diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 8fd2394f8..acbee2efb 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -165,11 +165,6 @@ services: target: /workspaces/fluxer/fluxer_api/pkgs/kv_client/node_modules volume: nocopy: true - - type: volume - source: fluxer-api-locale-node-modules - target: /workspaces/fluxer/fluxer_api/pkgs/locale/node_modules - volume: - nocopy: true - type: volume source: fluxer-api-media-proxy-utils-node-modules target: /workspaces/fluxer/fluxer_api/pkgs/media_proxy_utils/node_modules @@ -360,7 +355,6 @@ volumes: fluxer-api-geoip-node-modules: fluxer-api-http-client-node-modules: fluxer-api-kv-client-node-modules: - fluxer-api-locale-node-modules: fluxer-api-media-proxy-utils-node-modules: fluxer-api-mime-utils-node-modules: fluxer-api-nats-node-modules: diff --git a/.dockerignore b/.dockerignore index b1df80d9e..8a24e2598 100644 --- a/.dockerignore +++ b/.dockerignore @@ -48,9 +48,6 @@ /packages/markdown_parser/rust/fuzz/artifacts/ /packages/markdown_parser/rust/fuzz/corpus/ -/fluxer_media_proxy/.benchmark-cache/ -/fluxer_media_proxy/bench-results/ - /app-dist-output/ /artifacts/ /desktop-shared-assets/ diff --git a/.github/actions/app-wasm-cache/action.yml b/.github/actions/app-wasm-cache/action.yml new file mode 100644 index 000000000..76f5c5a8a --- /dev/null +++ b/.github/actions/app-wasm-cache/action.yml @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +name: App Wasm cache +description: Restore or save the shared application Wasm artifacts +inputs: + mode: + description: Cache operation (restore or save) + required: true +outputs: + cache-hit: + description: Whether the restore found the exact artifact key + value: ${{ steps.cache.outputs.cache-hit }} +runs: + using: composite + steps: + - name: Resolve artifact key + id: key + if: always() + shell: bash + run: echo "key=app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs', 'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock', 'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**', 'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock', 'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**', 'fluxer_app/rust/libfluxwebp/simd/**', 'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml', 'packages/markdown_parser/rust/src/**') }}" >> "$GITHUB_OUTPUT" + - name: Restore artifacts + id: cache + if: inputs.mode == 'restore' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: | + fluxer_app/pkgs/libfluxcore + fluxer_app/pkgs/libfluxwebp + fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts + key: ${{ steps.key.outputs.key }} + - name: Save artifacts + if: always() && inputs.mode == 'save' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: | + fluxer_app/pkgs/libfluxcore + fluxer_app/pkgs/libfluxwebp + fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts + key: ${{ steps.key.outputs.key }} diff --git a/.github/actions/ci-helper/action.yml b/.github/actions/ci-helper/action.yml new file mode 100644 index 000000000..7a5a5e331 --- /dev/null +++ b/.github/actions/ci-helper/action.yml @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +name: CI helper +description: Restore or build the shared Rust CI helper +inputs: + save: + description: Save a missing helper cache on main + required: false + default: 'false' +runs: + using: composite + steps: + - name: Restore ci helper + id: cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: target/debug/fluxer-ci + key: >- + fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml', + 'tools/ci/Cargo.toml', 'tools/ci/src/**') }} + - name: Build ci helper + if: steps.cache.outputs.cache-hit != 'true' + shell: bash + run: cargo build --locked --package fluxer-ci + - name: Save ci helper + if: inputs.save == 'true' && github.ref == 'refs/heads/main' && steps.cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: target/debug/fluxer-ci + key: ${{ steps.cache.outputs.cache-primary-key }} diff --git a/.github/workflows/_build-image.yaml b/.github/workflows/_build-image.yaml index b2faad08e..0e069d2b0 100644 --- a/.github/workflows/_build-image.yaml +++ b/.github/workflows/_build-image.yaml @@ -12,26 +12,11 @@ on: description: "Path to the Dockerfile to build" type: string required: true - context: - description: "Docker build context" - type: string - required: false - default: "." build-version: description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation" type: string required: false default: "" - moving-tags: - description: "Comma-separated moving tags to repoint at this build" - type: string - required: false - default: "v1,latest" - extra-build-args: - description: "Additional Docker build args, one KEY=VALUE entry per line" - type: string - required: false - default: "" permissions: actions: read contents: write @@ -115,7 +100,7 @@ jobs: password: ${{ github.token }} - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc with: - context: ${{ inputs.context }} + context: . file: ${{ inputs.dockerfile }} push: true provenance: mode=min @@ -125,7 +110,6 @@ jobs: BUILD_VERSION=${{ needs.meta.outputs.build_version }} SOURCE_SHA=${{ github.sha }} SOURCE_DATE=${{ steps.source.outputs.date }} - ${{ inputs.extra-build-args }} cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:buildcache-${{ matrix.platform }} cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:buildcache-${{ matrix.platform }},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true env: @@ -190,7 +174,7 @@ jobs: - name: Advance moving image tags env: - MOVING_TAGS: ${{ inputs.moving-tags }} + MOVING_TAGS: v1,latest VERSION: ${{ needs.meta.outputs.build_version }} run: >- tools/ci/run.sh image-set diff --git a/.github/workflows/i18n-weblate-pr.yaml b/.github/workflows/i18n-weblate-pr.yaml index 5273818b9..c636badeb 100644 --- a/.github/workflows/i18n-weblate-pr.yaml +++ b/.github/workflows/i18n-weblate-pr.yaml @@ -67,7 +67,7 @@ jobs: --step install_dependencies - name: Compile translated catalogs - run: pnpm i18n:weblate-compile + run: pnpm i18n:compile - name: Format generated catalogs run: pnpm biome format --write package.json packages/i18n/package.json packages/i18n/scripts/SyncStaticI18nCatalogs.ts packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 80712b43d..571343715 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -35,27 +35,10 @@ jobs: toolchain: "1.98.1" targets: wasm32-unknown-unknown - - name: Restore ci helper - id: ci-helper - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + - name: Set up ci helper + uses: ./.github/actions/ci-helper with: - path: target/debug/fluxer-ci - key: >- - fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml', - 'tools/ci/Cargo.toml', 'tools/ci/src/**') }} - - - name: Build ci helper - if: steps.ci-helper.outputs.cache-hit != 'true' - run: cargo build --locked --package fluxer-ci - - - name: Save ci helper - if: github.ref == 'refs/heads/main' && steps.ci-helper.outputs.cache-hit != 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: target/debug/fluxer-ci - key: >- - fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml', - 'tools/ci/Cargo.toml', 'tools/ci/src/**') }} + save: 'true' - name: Install pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 @@ -90,18 +73,8 @@ jobs: toolchain: "1.98.1" targets: wasm32-unknown-unknown - - name: Restore ci helper - id: ci-helper - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: target/debug/fluxer-ci - key: >- - fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml', - 'tools/ci/Cargo.toml', 'tools/ci/src/**') }} - - - name: Build ci helper - if: steps.ci-helper.outputs.cache-hit != 'true' - run: cargo build --locked --package fluxer-ci + - name: Set up ci helper + uses: ./.github/actions/ci-helper - name: Install pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 @@ -118,21 +91,9 @@ jobs: - name: Restore fluxer_app wasm artifacts id: app-wasm - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + uses: ./.github/actions/app-wasm-cache with: - path: | - fluxer_app/pkgs/libfluxcore - fluxer_app/pkgs/libfluxwebp - fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts - key: >- - app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs', - 'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock', - 'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**', - 'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock', - 'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**', - 'fluxer_app/rust/libfluxwebp/simd/**', - 'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml', - 'packages/markdown_parser/rust/src/**') }} + mode: restore - name: Run tests run: | @@ -140,21 +101,9 @@ jobs: - name: Save fluxer_app wasm artifacts if: always() && github.ref == 'refs/heads/main' && steps.app-wasm.outputs.cache-hit != 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + uses: ./.github/actions/app-wasm-cache with: - path: | - fluxer_app/pkgs/libfluxcore - fluxer_app/pkgs/libfluxwebp - fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts - key: >- - app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs', - 'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock', - 'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**', - 'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock', - 'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**', - 'fluxer_app/rust/libfluxwebp/simd/**', - 'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml', - 'packages/markdown_parser/rust/src/**') }} + mode: save rust: runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -274,6 +223,9 @@ jobs: FLUXER_REQUIRE_MEDIA_FIXTURES: "1" run: cargo test --workspace --all-features --locked + - name: Run tests (libfluxcore) + run: cargo test --locked --manifest-path fluxer_app/rust/libfluxcore/Cargo.toml + - name: Run desktop native workspace tests on Linux run: tools/ci/check-desktop-native-workspaces.sh test @@ -299,18 +251,8 @@ jobs: fluxer_gateway/native/push_markdown_plaintext_nif -> target save-if: ${{ github.ref == 'refs/heads/main' }} - - name: Restore ci helper - id: ci-helper - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: target/debug/fluxer-ci - key: >- - fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml', - 'tools/ci/Cargo.toml', 'tools/ci/src/**') }} - - - name: Build ci helper - if: steps.ci-helper.outputs.cache-hit != 'true' - run: cargo build --locked --package fluxer-ci + - name: Set up ci helper + uses: ./.github/actions/ci-helper - name: Set up Erlang uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 @@ -380,18 +322,8 @@ jobs: toolchain: "1.98.1" targets: wasm32-unknown-unknown - - name: Restore ci helper - id: ci-helper - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: target/debug/fluxer-ci - key: >- - fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml', - 'tools/ci/Cargo.toml', 'tools/ci/src/**') }} - - - name: Build ci helper - if: steps.ci-helper.outputs.cache-hit != 'true' - run: cargo build --locked --package fluxer-ci + - name: Set up ci helper + uses: ./.github/actions/ci-helper - name: Install pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 @@ -408,21 +340,9 @@ jobs: - name: Restore fluxer_app wasm artifacts id: app-wasm - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + uses: ./.github/actions/app-wasm-cache with: - path: | - fluxer_app/pkgs/libfluxcore - fluxer_app/pkgs/libfluxwebp - fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts - key: >- - app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs', - 'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock', - 'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**', - 'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock', - 'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**', - 'fluxer_app/rust/libfluxwebp/simd/**', - 'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml', - 'packages/markdown_parser/rust/src/**') }} + mode: restore - name: Run knip run: | @@ -430,21 +350,9 @@ jobs: - name: Save fluxer_app wasm artifacts if: always() && github.ref == 'refs/heads/main' && steps.app-wasm.outputs.cache-hit != 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + uses: ./.github/actions/app-wasm-cache with: - path: | - fluxer_app/pkgs/libfluxcore - fluxer_app/pkgs/libfluxwebp - fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts - key: >- - app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs', - 'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock', - 'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**', - 'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock', - 'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**', - 'fluxer_app/rust/libfluxwebp/simd/**', - 'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml', - 'packages/markdown_parser/rust/src/**') }} + mode: save lint: runs-on: ubuntu-24.04 diff --git a/.gitignore b/.gitignore index 7bb863e06..f022f46af 100644 --- a/.gitignore +++ b/.gitignore @@ -42,9 +42,6 @@ /packages/markdown_parser/rust/fuzz/artifacts/ /packages/markdown_parser/rust/fuzz/corpus/ -/fluxer_media_proxy/.benchmark-cache/ -/fluxer_media_proxy/bench-results/ - /app-dist-output/ /artifacts/ /desktop-shared-assets/ diff --git a/Cargo.lock b/Cargo.lock index 96ca26596..543674c94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1157,8 +1157,6 @@ dependencies = [ "num-traits", "oorandom", "page_size", - "plotters", - "rayon", "regex", "serde", "serde_json", @@ -1185,16 +1183,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - [[package]] name = "crossbeam-epoch" version = "0.9.21" @@ -1736,7 +1724,9 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "chrono", "clap", + "hex", "hyper", "hyper-util", "libc", @@ -1792,7 +1782,6 @@ dependencies = [ "bytes", "cc", "clap", - "criterion", "flate2", "fluxer_common", "futures-util", @@ -1835,7 +1824,6 @@ dependencies = [ "anyhow", "base64 0.23.1", "chrono", - "criterion", "fluxer-svc", "fluxer_common", "fluxer_markdown_parser", @@ -1860,6 +1848,7 @@ dependencies = [ "base64 0.23.1", "clap", "fluxer-svc", + "fluxer_common", "futures", "hmac 0.13.0", "p256", @@ -1900,6 +1889,7 @@ dependencies = [ "bytes", "chrono", "deadpool-postgres", + "fluxer_common", "futures", "libc", "rmp-serde", @@ -2019,12 +2009,16 @@ dependencies = [ "base64 0.23.1", "hex", "hmac 0.13.0", + "http 1.5.0", + "libc", "reqwest", + "serde", "serde_json", "sha2 0.11.0", "tempfile", "thiserror", - "time", + "tokio", + "tracing", "tracing-subscriber", "url", ] @@ -3314,34 +3308,6 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" -[[package]] -name = "plotters" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" -dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" -dependencies = [ - "plotters-backend", -] - [[package]] name = "portable-atomic" version = "1.15.0" @@ -3711,26 +3677,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.5.18" diff --git a/deploy/helm/fluxer-web/values.yaml b/deploy/helm/fluxer-web/values.yaml index f4e91d67d..5690a800a 100644 --- a/deploy/helm/fluxer-web/values.yaml +++ b/deploy/helm/fluxer-web/values.yaml @@ -70,8 +70,6 @@ workloads: image: name: fluxer-app-proxy-self-hosted replicas: 1 - env: - RELEASE_CHANNEL: stable resources: requests: cpu: 50m diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 6b93cc9c4..8cbdc6600 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -17272,7 +17272,6 @@ "ACCOUNT_TOO_NEW_FOR_GUILD", "ACLS_MUST_BE_NON_EMPTY", "ADMIN_API_KEY_NOT_FOUND", - "APPLICATION_NOT_OWNED", "ALREADY_FRIENDS", "BOTS_CANNOT_CREATE_GUILDS", "BOTS_CANNOT_SEND_FRIEND_REQUESTS", @@ -17568,7 +17567,6 @@ "Your account is too new to send messages in this community.", "ACLs must be non-empty.", "Admin API key wasn't found.", - "You don't own this application.", "You're already friends with this user.", "Bots can't create communities.", "Bots can't send friend requests.", diff --git a/fluxer_admin/src/admin_flags.rs b/fluxer_admin/src/admin_flags.rs index 2e2558611..ce52f586c 100644 --- a/fluxer_admin/src/admin_flags.rs +++ b/fluxer_admin/src/admin_flags.rs @@ -160,6 +160,70 @@ pub const PREMIUM_FLAGS: &[I32Flag] = &[ }, ]; +pub const DELETION_REASONS: &[(&str, &str)] = &[ + ("1", "User requested"), + ("2", "Other"), + ("3", "Spam"), + ("4", "Cheating or exploitation"), + ("5", "Coordinated raiding or manipulation"), + ("6", "Automation or self-bot usage"), + ("7", "Nonconsensual sexual content"), + ("8", "Scam or social engineering"), + ("9", "Child sexual content"), + ("10", "Privacy violation or doxxing"), + ("11", "Harassment or bullying"), + ("12", "Payment fraud"), + ("13", "Child safety violation"), + ("14", "Billing dispute or abuse"), + ("15", "Unsolicited explicit content"), + ("16", "Graphic violence"), + ("17", "Ban evasion"), + ("18", "Token or credential scam"), + ("19", "Inactivity"), + ("20", "Hate speech or extremist content"), + ("21", "Malicious links or malware distribution"), + ("22", "Impersonation or fake identity"), +]; + +pub const GUILD_FEATURES: &[&str] = &[ + "ANIMATED_ICON", + "ANIMATED_BANNER", + "AUDIO_BITRATE_128_KBPS", + "AUDIO_BITRATE_256_KBPS", + "AUDIO_BITRATE_384_KBPS", + "BANNER", + "CLONE_EMOJI_DISABLED", + "CLONE_EMOJI_ENABLED", + "CLONE_STICKER_DISABLED", + "CLONE_STICKER_ENABLED", + "DETACHED_BANNER", + "INVITE_SPLASH", + "INVITES_DISABLED", + "RAID_DETECTED", + "TEXT_CHANNEL_FLEXIBLE_NAMES", + "HIDE_OWNER_CROWN", + "MORE_EMOJI", + "MORE_STICKERS", + "UNLIMITED_EMOJI", + "UNLIMITED_STICKERS", + "EXPRESSION_PURGE_ALLOWED", + "VANITY_URL", + "DISCOVERABLE", + "PARTNERED", + "VERIFIED", + "VIP_VOICE", + "VOICE_E2EE", + "UNAVAILABLE_FOR_EVERYONE", + "UNAVAILABLE_FOR_EVERYONE_BUT_STAFF", + "UNAVAILABLE_HIDDEN", + "VISIONARY", + "LARGE_GUILD_OVERRIDE", + "VERY_LARGE_GUILD", + "ANNOUNCEMENT_CHANNELS_DISABLED", +]; + +pub const DEPRECATED_GUILD_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"]; + #[cfg(test)] mod tests { use super::USER_FLAGS; diff --git a/fluxer_admin/src/api/applications.rs b/fluxer_admin/src/api/applications.rs index 5d8997231..e0893412f 100644 --- a/fluxer_admin/src/api/applications.rs +++ b/fluxer_admin/src/api/applications.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use reqwest::Method; + use super::client::{AdminApiClient, ApiResult}; use super::types::{Application, ApplicationUpdateResponse, LookupApplicationResponse}; use serde::Serialize; @@ -43,12 +45,13 @@ impl AdminApiClient { new_owner_id: &str, ) -> ApiResult { let body = TransferApplicationOwnershipRequest { new_owner_id }; - self.patch_typed_with_reason( + self.send( + Method::PATCH, &format!( "/admin/applications/{}", urlencoding::encode(application_id) ), - &body, + Some(&body), None, ) .await diff --git a/fluxer_admin/src/api/bans.rs b/fluxer_admin/src/api/bans.rs index 039a47cb5..8296eff76 100644 --- a/fluxer_admin/src/api/bans.rs +++ b/fluxer_admin/src/api/bans.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use reqwest::Method; + use crate::api::generated::types as generated_types; use super::client::{AdminApiClient, ApiError, ApiResult}; @@ -205,9 +207,10 @@ impl AdminApiClient { let body = generated_types::BulkBanFileShasRequest { sha256_list: sha256_list.to_vec(), }; - self.put_typed_with_reason( + self.send( + Method::PUT, "/admin/blocklists/file-sha/entries", - &body, + Some(&body), audit_log_reason, ) .await diff --git a/fluxer_admin/src/api/bulk.rs b/fluxer_admin/src/api/bulk.rs index 42e71c3d3..141280881 100644 --- a/fluxer_admin/src/api/bulk.rs +++ b/fluxer_admin/src/api/bulk.rs @@ -1,5 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use crate::api::generated::{guild_features, user_flags}; + +use reqwest::Method; + use crate::api::generated::{snowflake, types as generated_types}; use super::client::{AdminApiClient, ApiError, ApiResult}; @@ -18,8 +22,13 @@ impl AdminApiClient { remove_flags: user_flags(remove_flags)?, user_ids: snowflakes(user_ids), }; - self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason) - .await + self.send( + Method::POST, + "/admin/bulk-jobs", + Some(&body), + audit_log_reason, + ) + .await } pub async fn bulk_update_guild_features( @@ -34,8 +43,13 @@ impl AdminApiClient { guild_ids: snowflakes(guild_ids), remove_features: guild_features(remove_features), }; - self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason) - .await + self.send( + Method::POST, + "/admin/bulk-jobs", + Some(&body), + audit_log_reason, + ) + .await } pub async fn bulk_add_guild_members( @@ -48,8 +62,13 @@ impl AdminApiClient { guild_id: snowflake(guild_id), user_ids: snowflakes(user_ids), }; - self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason) - .await + self.send( + Method::POST, + "/admin/bulk-jobs", + Some(&body), + audit_log_reason, + ) + .await } pub async fn bulk_delete_user_messages( @@ -60,8 +79,13 @@ impl AdminApiClient { let body = generated_types::AdminBulkJobCreateRequest::DeleteUserMessages { user_ids: snowflakes(user_ids), }; - self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason) - .await + self.send( + Method::POST, + "/admin/bulk-jobs", + Some(&body), + audit_log_reason, + ) + .await } pub async fn bulk_schedule_user_deletion( @@ -89,29 +113,16 @@ impl AdminApiClient { .map_err(ApiError::Parse)?, user_ids: snowflakes(user_ids), }; - self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason) - .await + self.send( + Method::POST, + "/admin/bulk-jobs", + Some(&body), + audit_log_reason, + ) + .await } } fn snowflakes(values: &[String]) -> Vec { values.iter().map(|value| snowflake(value)).collect() } - -fn user_flags(values: &[String]) -> ApiResult> { - values - .iter() - .map(|value| { - generated_types::UserFlags::try_from(value.as_str()) - .map_err(|error| ApiError::Parse(error.to_string())) - }) - .collect() -} - -fn guild_features(values: &[String]) -> Vec { - values - .iter() - .cloned() - .map(generated_types::GuildFeatureSchema::from) - .collect() -} diff --git a/fluxer_admin/src/api/client.rs b/fluxer_admin/src/api/client.rs index 995fd61a8..f54738151 100644 --- a/fluxer_admin/src/api/client.rs +++ b/fluxer_admin/src/api/client.rs @@ -136,154 +136,32 @@ impl AdminApiClient { Self::parse_response(response).await } - pub async fn post( + pub async fn send( &self, + method: Method, path: &str, - body: Option<&serde_json::Value>, - ) -> ApiResult { - self.post_with_reason(path, body, None).await - } - - pub async fn post_typed(&self, path: &str, body: &B) -> ApiResult - where - T: DeserializeOwned, - B: Serialize + ?Sized, - { - self.post_typed_with_reason(path, body, None).await - } - - pub async fn post_typed_with_reason( - &self, - path: &str, - body: &B, + body: Option<&B>, audit_log_reason: Option<&str>, ) -> ApiResult where T: DeserializeOwned, B: Serialize + ?Sized, { - let response = self - .send_json(Method::POST, path, Some(body), audit_log_reason) - .await?; + let response = self.send_json(method, path, body, audit_log_reason).await?; Self::parse_response(response).await } - pub async fn post_with_reason( + pub async fn send_void( &self, + method: Method, path: &str, - body: Option<&serde_json::Value>, - audit_log_reason: Option<&str>, - ) -> ApiResult { - let response = self - .send_json(Method::POST, path, body, audit_log_reason) - .await?; - Self::parse_response(response).await - } - - pub async fn post_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> { - self.post_void_with_reason(path, body, None).await - } - - pub async fn post_void_with_reason( - &self, - path: &str, - body: Option<&serde_json::Value>, + body: Option<&B>, audit_log_reason: Option<&str>, ) -> ApiResult<()> { - let response = self - .send_json(Method::POST, path, body, audit_log_reason) - .await?; + let response = self.send_json(method, path, body, audit_log_reason).await?; Self::parse_void_response(response).await } - pub async fn patch( - &self, - path: &str, - body: Option<&serde_json::Value>, - ) -> ApiResult { - self.patch_with_reason(path, body, None).await - } - - pub async fn patch_with_reason( - &self, - path: &str, - body: Option<&serde_json::Value>, - audit_log_reason: Option<&str>, - ) -> ApiResult { - let response = self - .send_json(Method::PATCH, path, body, audit_log_reason) - .await?; - Self::parse_response(response).await - } - - pub async fn patch_typed_with_reason( - &self, - path: &str, - body: &B, - audit_log_reason: Option<&str>, - ) -> ApiResult - where - T: DeserializeOwned, - B: Serialize + ?Sized, - { - let response = self - .send_json(Method::PATCH, path, Some(body), audit_log_reason) - .await?; - Self::parse_response(response).await - } - - pub async fn put_with_reason( - &self, - path: &str, - body: Option<&serde_json::Value>, - audit_log_reason: Option<&str>, - ) -> ApiResult { - let response = self - .send_json(Method::PUT, path, body, audit_log_reason) - .await?; - Self::parse_response(response).await - } - - pub async fn put_typed_with_reason( - &self, - path: &str, - body: &B, - audit_log_reason: Option<&str>, - ) -> ApiResult - where - T: DeserializeOwned, - B: Serialize + ?Sized, - { - let response = self - .send_json(Method::PUT, path, Some(body), audit_log_reason) - .await?; - Self::parse_response(response).await - } - - pub async fn delete_void_with_reason( - &self, - path: &str, - body: Option<&serde_json::Value>, - audit_log_reason: Option<&str>, - ) -> ApiResult<()> { - let response = self - .send_json(Method::DELETE, path, body, audit_log_reason) - .await?; - Self::parse_void_response(response).await - } - - pub async fn delete_with_reason( - &self, - path: &str, - body: Option<&serde_json::Value>, - audit_log_reason: Option<&str>, - ) -> ApiResult { - let response = self - .send_json(Method::DELETE, path, body, audit_log_reason) - .await?; - Self::parse_response(response).await - } - async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> { Self::check_response_status(response).await.map(drop) } diff --git a/fluxer_admin/src/api/discovery.rs b/fluxer_admin/src/api/discovery.rs index cf2326758..f12180e1e 100644 --- a/fluxer_admin/src/api/discovery.rs +++ b/fluxer_admin/src/api/discovery.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use crate::api::generated::float_to_u64; + use crate::api::generated::{snowflake, types as generated_types}; use super::client::{AdminApiClient, ApiError, ApiResult}; @@ -144,10 +146,6 @@ fn listed_guild( }) } -fn float_to_u64(value: f64, field: &str) -> ApiResult { - crate::api::generated::number_to_u64(value, field).map_err(ApiError::Parse) -} - fn float_to_i32(value: f64, field: &str) -> ApiResult { let parsed = crate::api::generated::number_to_u64(value, field).map_err(ApiError::Parse)?; i32::try_from(parsed).map_err(|_| ApiError::Parse(format!("{field} is out of range: {value}"))) diff --git a/fluxer_admin/src/api/generated.rs b/fluxer_admin/src/api/generated.rs index 5864093dd..aad0482cf 100644 --- a/fluxer_admin/src/api/generated.rs +++ b/fluxer_admin/src/api/generated.rs @@ -1,5 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use self::types as generated_types; +use crate::api::client::{ApiError, ApiResult}; + use progenitor_client::{ClientHooks, ClientInfo, Error, OperationInfo}; use reqwest::header::HeaderMap; @@ -61,94 +64,35 @@ pub(crate) fn deletion_reason_code( .map_err(|_| format!("{field} is not a deletion reason code: {value}")) } +pub(crate) fn user_flags(values: &[String]) -> ApiResult> { + values + .iter() + .map(|value| { + generated_types::UserFlags::try_from(value.as_str()) + .map_err(|error| ApiError::Parse(error.to_string())) + }) + .collect() +} + +pub(crate) fn guild_features(values: &[String]) -> Vec { + values + .iter() + .cloned() + .map(generated_types::GuildFeatureSchema::from) + .collect() +} + +pub(crate) fn bool_param(value: bool) -> &'static str { + if value { "true" } else { "false" } +} + +pub(crate) fn float_to_u64(value: f64, field: &str) -> ApiResult { + crate::api::generated::number_to_u64(value, field).map_err(ApiError::Parse) +} + #[cfg(test)] mod tests { - use super::{number_to_u64, types::*}; - - #[test] - fn deserialize_admin_users_me() { - let json = serde_json::json!({ - "user": { - "id": "123456789012345678", - "username": "testadmin", - "discriminator": 1, - "global_name": "Test Admin", - "bot": false, - "system": false, - "flags": "131141", - "premium_flags": 0, - "avatar": "abc123def456", - "banner": null, - "bio": "Hello world", - "pronouns": null, - "accent_color": null, - "email": "admin@example.com", - "email_verified": true, - "email_bounced": false, - "date_of_birth": "2000-01-15", - "locale": "en-US", - "premium_type": 2, - "premium_since": "2024-01-01T00:00:00.000Z", - "premium_until": null, - "premium_grace_ends_at": null, - "premium_lifetime_sequence": null, - "temp_banned_until": null, - "pending_deletion_at": null, - "pending_bulk_message_deletion_at": null, - "deletion_reason_code": null, - "deletion_public_reason": null, - "acls": ["MANAGE_USERS", "VIEW_AUDIT_LOG"], - "traits": ["STAFF"], - "has_totp": true, - "authenticator_types": [0], - "last_active_at": "2026-05-26T12:00:00.000Z", - "last_active_ip": "203.0.113.1", - "last_active_ip_reverse": "1.113.0.203.in-addr.arpa", - "last_active_location": "New York, US" - } - }); - - let response: AdminUsersMeResponse = - serde_json::from_value(json).expect("failed to deserialize AdminUsersMeResponse"); - - assert_eq!(response.user.id.to_string(), "123456789012345678"); - assert_eq!(response.user.username, "testadmin"); - assert!(!response.user.bot); - assert!(response.user.email_verified); - assert_eq!(response.user.acls.len(), 2); - assert_eq!(response.user.global_name, Some("Test Admin".to_string())); - assert!(response.user.banner.is_none()); - assert!(response.user.has_totp); - } - - #[test] - fn deserialize_search_guilds_response() { - let json = serde_json::json!({ - "guilds": [ - { - "id": "987654321098765432", - "name": "Test Guild", - "icon": "def456abc123", - "banner": null, - "owner_id": "123456789012345678", - "owner_username": "guildowner", - "owner_global_name": "Guild Owner", - "owner_discriminator": "0001", - "member_count": 42, - "features": ["COMMUNITY", "NEWS"] - } - ], - "total": 1 - }); - - let response: SearchGuildsResponse = - serde_json::from_value(json).expect("failed to deserialize SearchGuildsResponse"); - - assert_eq!(response.total, 1.0); - assert_eq!(response.guilds.len(), 1); - assert_eq!(response.guilds[0].name, "Test Guild"); - assert_eq!(*response.guilds[0].member_count, 42); - } + use super::number_to_u64; #[test] fn reject_lossy_generated_number_count() { diff --git a/fluxer_admin/src/api/guilds.rs b/fluxer_admin/src/api/guilds.rs index b5b03e04b..51b964bcb 100644 --- a/fluxer_admin/src/api/guilds.rs +++ b/fluxer_admin/src/api/guilds.rs @@ -1,14 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use crate::api::generated::guild_features; + +use reqwest::Method; + use crate::api::generated::{snowflake, types as generated_types}; use serde::Deserialize; use super::client::{AdminApiClient, ApiError, ApiResult}; use super::reports::SearchReportsParams; use super::types::{ - GuildAuditLogResponse, GuildDetailInfo, GuildInfo, GuildUpdateResponse, - ListGuildMembersResponse, LookupGuildResponse, SearchGuildsResponse, SearchReportsResponse, - SuccessResponse, + GuildAuditLogResponse, GuildDetailInfo, GuildInfo, ListGuildMembersResponse, + LookupGuildResponse, SearchGuildsResponse, SearchReportsResponse, SuccessResponse, }; impl AdminApiClient { @@ -53,18 +56,17 @@ impl AdminApiClient { guild_id: &str, add_features: &[String], remove_features: &[String], - ) -> ApiResult { + ) -> ApiResult<()> { let body = generated_types::UpdateGuildRequest { add_features: guild_features(add_features), remove_features: guild_features(remove_features), ..Default::default() }; - let response = self - .generated() + self.generated() .update_admin_guild(&snowflake(guild_id), &body) .await .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) + Ok(()) } pub async fn delete_guild(&self, guild_id: &str) -> ApiResult { @@ -80,17 +82,16 @@ impl AdminApiClient { &self, guild_id: &str, new_owner_id: &str, - ) -> ApiResult { + ) -> ApiResult<()> { let body = generated_types::UpdateGuildRequest { new_owner_id: Some(snowflake(new_owner_id)), ..Default::default() }; - let response = self - .generated() + self.generated() .update_admin_guild(&snowflake(guild_id), &body) .await .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) + Ok(()) } pub async fn list_guild_members( @@ -177,44 +178,37 @@ impl AdminApiClient { &self, guild_id: &str, settings: &serde_json::Value, - ) -> ApiResult { + ) -> ApiResult<()> { let body = guild_settings_request(settings)?; - let response = self - .generated() + self.generated() .update_admin_guild(&snowflake(guild_id), &body) .await .map_err(|e| self.generated_error(e))?; - guild_update_response(response.into_inner()) + Ok(()) } - pub async fn update_guild_name( - &self, - guild_id: &str, - name: &str, - ) -> ApiResult { + pub async fn update_guild_name(&self, guild_id: &str, name: &str) -> ApiResult<()> { let body = generated_types::UpdateGuildRequest { name: Some(name.to_owned()), ..Default::default() }; - let response = self - .generated() + self.generated() .update_admin_guild(&snowflake(guild_id), &body) .await .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) + Ok(()) } - pub async fn update_guild_vanity( - &self, - guild_id: &str, - vanity: Option<&str>, - ) -> ApiResult { + pub async fn update_guild_vanity(&self, guild_id: &str, vanity: Option<&str>) -> ApiResult<()> { let body = serde_json::json!({"vanity_url_code": vanity}); - self.patch( + self.send::( + Method::PATCH, &format!("/admin/guilds/{}", urlencoding::encode(guild_id)), Some(&body), + None, ) .await + .map(drop) } pub async fn reload_guild(&self, guild_id: &str) -> ApiResult { @@ -316,36 +310,6 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes }) } -fn guild_update_response( - response: generated_types::GuildUpdateResponse, -) -> ApiResult { - let guild = response.guild; - Ok(GuildUpdateResponse { - guild: GuildInfo { - id: String::from(guild.id), - name: String::from(guild.name), - icon: guild.icon.map(String::from), - banner: guild.banner.map(String::from), - owner_id: String::from(guild.owner_id), - owner_username: None, - owner_global_name: None, - owner_discriminator: None, - member_count: crate::api::generated::i64_to_u64( - i64::from(i32::from(guild.member_count)), - "member_count", - ) - .map_err(ApiError::Parse)?, - features: guild.features.into_iter().map(String::from).collect(), - nsfw_level: guild.nsfw_level.map(i32::from), - nsfw: None, - content_warning_level: None, - content_warning_text: None, - description: None, - vanity_url_code: None, - }, - }) -} - fn guild_settings_request( settings: &serde_json::Value, ) -> ApiResult { @@ -365,14 +329,6 @@ fn guild_settings_request( }) } -fn guild_features(values: &[String]) -> Vec { - values - .iter() - .cloned() - .map(generated_types::GuildFeatureSchema::from) - .collect() -} - #[cfg(test)] mod tests { use super::*; diff --git a/fluxer_admin/src/api/instance_config.rs b/fluxer_admin/src/api/instance_config.rs index 18af16830..78225637c 100644 --- a/fluxer_admin/src/api/instance_config.rs +++ b/fluxer_admin/src/api/instance_config.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use reqwest::Method; + use super::client::{AdminApiClient, ApiResult}; use super::types::{ AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, @@ -30,32 +32,43 @@ impl AdminApiClient { &self, update: &InstanceConfigUpdateRequest, ) -> ApiResult { - self.patch_typed_with_reason("/admin/instance/config", update, None) + self.send(Method::PATCH, "/admin/instance/config", Some(update), None) .await } pub async fn test_saved_instance_smtp_config( &self, ) -> ApiResult { - self.post_typed("/admin/instance/config/smtp-tests", &serde_json::json!({})) - .await + self.send( + Method::POST, + "/admin/instance/config/smtp-tests", + Some(&serde_json::json!({})), + None, + ) + .await } pub async fn create_registration_url( &self, request: &CreateRegistrationUrlRequest, ) -> ApiResult { - self.post_typed("/admin/instance/registration-urls", request) - .await + self.send( + Method::POST, + "/admin/instance/registration-urls", + Some(request), + None, + ) + .await } pub async fn revoke_registration_url(&self, id: &str) -> ApiResult { - self.delete_with_reason( + self.send( + Method::DELETE, &format!( "/admin/instance/registration-urls/{}", urlencoding::encode(id) ), - None, + None::<&serde_json::Value>, None, ) .await @@ -81,7 +94,8 @@ impl AdminApiClient { status: &str, ) -> ApiResult { let body = serde_json::json!({"status": status}); - self.patch_with_reason( + self.send( + Method::PATCH, &format!( "/admin/instance/pending-registrations/{}", urlencoding::encode(user_id) diff --git a/fluxer_admin/src/api/jobs.rs b/fluxer_admin/src/api/jobs.rs index c926f32d4..9dbd1bd69 100644 --- a/fluxer_admin/src/api/jobs.rs +++ b/fluxer_admin/src/api/jobs.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use reqwest::Method; + use crate::api::generated::snowflake; use super::client::{AdminApiClient, ApiResult}; @@ -52,9 +54,10 @@ impl AdminApiClient { job_id: &str, audit_log_reason: Option<&str>, ) -> ApiResult { - self.put_with_reason( + self.send( + Method::PUT, &format!("/admin/jobs/{}/cancellation", urlencoding::encode(job_id)), - None, + None::<&serde_json::Value>, audit_log_reason, ) .await diff --git a/fluxer_admin/src/api/limit_config.rs b/fluxer_admin/src/api/limit_config.rs index 3bbdf13f6..5ae77b734 100644 --- a/fluxer_admin/src/api/limit_config.rs +++ b/fluxer_admin/src/api/limit_config.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use reqwest::Method; + use super::client::{AdminApiClient, ApiResult}; use super::types::{LimitConfigResponse, LimitConfigUpdateRequest}; @@ -12,7 +14,7 @@ impl AdminApiClient { &self, request: &LimitConfigUpdateRequest, ) -> ApiResult { - self.put_typed_with_reason("/admin/limit-config", request, None) + self.send(Method::PUT, "/admin/limit-config", Some(request), None) .await } } diff --git a/fluxer_admin/src/api/messages.rs b/fluxer_admin/src/api/messages.rs index b80088335..0bcab9188 100644 --- a/fluxer_admin/src/api/messages.rs +++ b/fluxer_admin/src/api/messages.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use reqwest::Method; + use crate::api::generated::{snowflake, types as generated_types}; use super::client::{AdminApiClient, ApiError, ApiResult}; @@ -16,13 +18,14 @@ impl AdminApiClient { audit_log_reason: Option<&str>, ) -> ApiResult<()> { let _: serde_json::Value = self - .delete_with_reason( + .send( + Method::DELETE, &format!( "/admin/channels/{}/messages/{}", urlencoding::encode(channel_id), urlencoding::encode(message_id) ), - None, + None::<&serde_json::Value>, audit_log_reason, ) .await?; diff --git a/fluxer_admin/src/api/reports.rs b/fluxer_admin/src/api/reports.rs index 066c140cf..96a212e0d 100644 --- a/fluxer_admin/src/api/reports.rs +++ b/fluxer_admin/src/api/reports.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use reqwest::Method; + use crate::api::generated::{snowflake, types::UpdateReportRequestResolution}; use super::client::{AdminApiClient, ApiError, ApiResult}; @@ -51,7 +53,8 @@ impl AdminApiClient { body["public_comment"] = serde_json::Value::from(public_comment); } body["notify_reporter"] = serde_json::Value::from(notify_reporter); - self.patch_with_reason( + self.send( + Method::PATCH, &format!("/admin/reports/{}", urlencoding::encode(report_id)), Some(&body), audit_log_reason, @@ -69,7 +72,8 @@ impl AdminApiClient { "legal_hold_until": legal_hold_until, "legal_hold_reason": legal_hold_until.and(legal_hold_reason), }); - self.post_with_reason( + self.send( + Method::POST, &format!( "/admin/reports/{}/legal-hold", urlencoding::encode(report_id) @@ -85,9 +89,10 @@ impl AdminApiClient { report_id: &str, audit_log_reason: Option<&str>, ) -> ApiResult<()> { - self.delete_void_with_reason( + self.send_void( + Method::DELETE, &format!("/admin/reports/{}", urlencoding::encode(report_id)), - None, + None::<&serde_json::Value>, audit_log_reason, ) .await diff --git a/fluxer_admin/src/api/search.rs b/fluxer_admin/src/api/search.rs index 24f95fea8..e40a5fe79 100644 --- a/fluxer_admin/src/api/search.rs +++ b/fluxer_admin/src/api/search.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use crate::api::generated::float_to_u64; + use crate::api::generated::{snowflake, types as generated_types}; use super::client::{AdminApiClient, ApiError, ApiResult}; @@ -74,10 +76,6 @@ fn index_refresh_status( } } -fn float_to_u64(value: f64, field: &str) -> ApiResult { - crate::api::generated::number_to_u64(value, field).map_err(ApiError::Parse) -} - #[cfg(test)] mod tests { use super::*; diff --git a/fluxer_admin/src/api/types/common.rs b/fluxer_admin/src/api/types/common.rs index f559dac91..38d0b3e8b 100644 --- a/fluxer_admin/src/api/types/common.rs +++ b/fluxer_admin/src/api/types/common.rs @@ -61,11 +61,6 @@ pub struct LookupGuildResponse { pub guild: Option, } -#[derive(Clone, Debug, Deserialize)] -pub struct GuildUpdateResponse { - pub guild: GuildInfo, -} - #[derive(Clone, Debug, Deserialize)] pub struct ListUserGuildsResponse { pub guilds: Vec, diff --git a/fluxer_admin/src/api/users.rs b/fluxer_admin/src/api/users.rs index a32cadd28..308bd9868 100644 --- a/fluxer_admin/src/api/users.rs +++ b/fluxer_admin/src/api/users.rs @@ -1,5 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use crate::api::generated::{bool_param, user_flags}; + +use reqwest::Method; + use crate::api::generated::{snowflake, types as generated_types}; use super::client::{AdminApiClient, ApiError, ApiResult}; @@ -367,9 +371,10 @@ impl AdminApiClient { reason: reason.map(std::borrow::ToOwned::to_owned), }; let resp: UserMutationResponse = self - .put_typed_with_reason( + .send( + Method::PUT, &format!("/admin/users/{}/ban", urlencoding::encode(user_id)), - &body, + Some(&body), private_reason, ) .await?; @@ -439,7 +444,8 @@ impl AdminApiClient { "notify_user": notify_user, }); let resp: UserMutationResponse = self - .delete_with_reason( + .send( + Method::DELETE, &format!("/admin/users/{}/deletion", urlencoding::encode(user_id)), Some(&body), audit_log_reason, @@ -458,9 +464,11 @@ impl AdminApiClient { "ban_audit_log_id": ban_audit_log_id, "note": note, }); - self.post_void( + self.send_void( + Method::POST, &format!("/admin/users/{}/ban/notes", urlencoding::encode(user_id)), Some(&body), + None, ) .await } @@ -589,20 +597,6 @@ fn nonempty(value: Option<&str>) -> Option<&str> { value.filter(|value| !value.is_empty()) } -fn bool_param(value: bool) -> &'static str { - if value { "true" } else { "false" } -} - -fn user_flags(values: &[String]) -> ApiResult> { - values - .iter() - .map(|value| { - generated_types::UserFlags::try_from(value.as_str()) - .map_err(|error| ApiError::Parse(error.to_string())) - }) - .collect() -} - fn premium_flags(values: &[i32]) -> Vec { values .iter() diff --git a/fluxer_admin/src/api/voice.rs b/fluxer_admin/src/api/voice.rs index 51bf25495..c7c0237fb 100644 --- a/fluxer_admin/src/api/voice.rs +++ b/fluxer_admin/src/api/voice.rs @@ -1,5 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use crate::api::generated::bool_param; + +use reqwest::Method; + use crate::api::generated::types as generated_types; use super::client::{AdminApiClient, ApiError, ApiResult}; @@ -57,7 +61,8 @@ impl AdminApiClient { let region_id = required_field(params, "id")?; let body = voice_request_body(params, &["id"])?; validate_against::(&body)?; - self.patch_with_reason( + self.send( + Method::PATCH, &format!("/admin/voice/regions/{}", urlencoding::encode(®ion_id)), Some(&body), None, @@ -110,7 +115,8 @@ impl AdminApiClient { paired_coordinates(params)?; let body = voice_request_body(params, &["region_id", "server_id"])?; validate_against::(&body)?; - self.patch_with_reason( + self.send( + Method::PATCH, &format!( "/admin/voice/regions/{}/servers/{}", urlencoding::encode(®ion_id), @@ -136,10 +142,6 @@ impl AdminApiClient { } } -fn bool_param(value: bool) -> &'static str { - if value { "true" } else { "false" } -} - fn validate_against(params: &serde_json::Value) -> ApiResult<()> { serde_json::from_value::(params.clone()) .map(drop) diff --git a/fluxer_admin/src/main.rs b/fluxer_admin/src/main.rs index 881ef9373..43841d34e 100644 --- a/fluxer_admin/src/main.rs +++ b/fluxer_admin/src/main.rs @@ -22,33 +22,9 @@ async fn main() -> anyhow::Result<()> { tracing::info!(%addr, "starting Fluxer admin service"); axum::serve(listener, router) - .with_graceful_shutdown(shutdown_signal()) + .with_graceful_shutdown(fluxer_common::shutdown::wait_for_shutdown()) .await .context("admin server exited unexpectedly")?; Ok(()) } - -async fn shutdown_signal() { - let ctrl_c = async { - tokio::signal::ctrl_c() - .await - .expect("failed to install Ctrl+C handler"); - }; - - #[cfg(unix)] - let terminate = async { - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("failed to install SIGTERM handler") - .recv() - .await; - }; - - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - - tokio::select! { - _ = ctrl_c => {}, - _ = terminate => {}, - } -} diff --git a/fluxer_admin/src/middleware/auth.rs b/fluxer_admin/src/middleware/auth.rs index ab72a0295..85b33842b 100644 --- a/fluxer_admin/src/middleware/auth.rs +++ b/fluxer_admin/src/middleware/auth.rs @@ -57,42 +57,12 @@ pub async fn require_auth( response } -fn admin_cookie_path(config: &crate::config::AdminConfig) -> &str { - if config.base_path.is_empty() { - "/" - } else { - config.base_path.as_str() - } -} - fn clear_session_and_redirect(url: &str, config: &crate::config::AdminConfig) -> Response { let mut response = Redirect::to(url).into_response(); - let admin_path = admin_cookie_path(config); - for path in [admin_path, "/"] { - if path != admin_path && admin_path == "/" { - continue; - } - let clear = format!( - "{}=; Path={}; HttpOnly; SameSite=Lax; Max-Age=0", - crate::session::SESSION_COOKIE_NAME, - path - ); - if let Ok(v) = axum::http::HeaderValue::from_str(&clear) { - response - .headers_mut() - .append(axum::http::header::SET_COOKIE, v); - } - let legacy_clear = format!( - "{}=; Path={}; HttpOnly; SameSite=Lax; Max-Age=0", - crate::session::LEGACY_SESSION_COOKIE_NAME, - path - ); - if let Ok(v) = axum::http::HeaderValue::from_str(&legacy_clear) { - response - .headers_mut() - .append(axum::http::header::SET_COOKIE, v); - } - } + session::append_cookies( + &mut response, + &session::clear_cookie_values(session::SESSION_COOKIE_NAME, config), + ); response } diff --git a/fluxer_admin/src/routes/auth.rs b/fluxer_admin/src/routes/auth.rs index 12db99bc5..361a571c0 100644 --- a/fluxer_admin/src/routes/auth.rs +++ b/fluxer_admin/src/routes/auth.rs @@ -4,14 +4,17 @@ use crate::{ api::client::AdminApiClient, config::AdminConfig, oauth2, - session::{self, LEGACY_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SESSION_MAX_AGE}, + session::{ + self, SESSION_COOKIE_NAME, SESSION_MAX_AGE, admin_cookie_path, append_cookies, + clear_cookie_values, + }, state::AppState, templates::pages::login, }; use axum::{ Json, Router, extract::{Query, State}, - http::{HeaderMap, HeaderValue, StatusCode, header}, + http::{HeaderMap, StatusCode, header}, response::{Html, IntoResponse, Redirect, Response}, routing::{get, post}, }; @@ -202,7 +205,7 @@ async fn oauth2_callback_finish( secure ); - let mut cookies = clear_session_cookie_values(config); + let mut cookies = clear_cookie_values(SESSION_COOKIE_NAME, config); cookies.push(session_cookie); oauth_finish_response(StatusCode::OK, &format!("{base}/dashboard"), &cookies) } @@ -216,7 +219,7 @@ async fn logout(State(state): State, headers: axum::http::HeaderMap) - } redirect_with_cookies( &format!("{}/login", config.base_path), - &clear_session_cookie_values(config), + &clear_cookie_values(SESSION_COOKIE_NAME, config), ) } @@ -359,45 +362,6 @@ pub(crate) fn json_string(value: &str) -> String { .replace('\u{2029}', "\\u2029") } -fn admin_cookie_path(config: &AdminConfig) -> &str { - if config.base_path.is_empty() { - "/" - } else { - config.base_path.as_str() - } -} - -fn clear_cookie_value(name: &str, path: &str) -> String { - format!("{name}=; Path={path}; HttpOnly; SameSite=Lax; Max-Age=0") -} - -fn clear_cookie_values(name: &str, config: &AdminConfig) -> Vec { - let admin_path = admin_cookie_path(config); - let mut cookies = vec![clear_cookie_value(name, admin_path)]; - if admin_path != "/" { - cookies.push(clear_cookie_value(name, "/")); - } - cookies -} - -fn clear_session_cookie_values(config: &AdminConfig) -> Vec { - let mut cookies = clear_cookie_values(SESSION_COOKIE_NAME, config); - cookies.extend(clear_cookie_values(LEGACY_SESSION_COOKIE_NAME, config)); - cookies -} - -fn append_cookie(response: &mut Response, cookie: &str) { - if let Ok(value) = HeaderValue::from_str(cookie) { - response.headers_mut().append(header::SET_COOKIE, value); - } -} - -fn append_cookies(response: &mut Response, cookies: &[String]) { - for cookie in cookies { - append_cookie(response, cookie); - } -} - fn redirect_with_cookies(url: &str, cookies: &[String]) -> Response { let mut response = Redirect::to(url).into_response(); append_cookies(&mut response, cookies); diff --git a/fluxer_admin/src/routes/discovery.rs b/fluxer_admin/src/routes/discovery.rs index 442b5d662..bdcaf7f84 100644 --- a/fluxer_admin/src/routes/discovery.rs +++ b/fluxer_admin/src/routes/discovery.rs @@ -1,7 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::{ - api::client::{AdminApiClient, ApiResultExt}, + api::client::{AdminApiClient, ApiResult, ApiResultExt}, + config::AdminConfig, middleware::{ auth::AuthContext, csrf::CsrfToken, @@ -97,41 +98,23 @@ async fn discovery_approve( request: Request, ) -> Response { let config = state.config(); - let base = &config.base_path; - let form: DiscoveryActionForm = match Form::from_request(request, &state).await { - Ok(Form(f)) => f, - Err(error) => { - tracing::warn!(%error, "failed to parse discovery approve form"); - return flash::redirect_with_flash( - &format!("{base}/discovery"), - FlashData::error("Invalid form data"), - config.secure_cookies(), - ); - } - }; - let guild_id = match clean_string(form.guild_id.as_deref().unwrap_or("")) { - Some(id) => id, - None => { - return flash::redirect_with_flash( - &format!("{base}/discovery"), - FlashData::error("Guild ID is required"), - config.secure_cookies(), - ); - } - }; + let (form, guild_id) = + match discovery_form(&state, request, "failed to parse discovery approve form").await { + Ok(value) => value, + Err(response) => return *response, + }; let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let reason = clean_string(form.reason.as_deref().unwrap_or("")); let result = client .approve_discovery_application(&guild_id, reason.as_deref()) .await; - let flash = match result.log_error("approve discovery application") { - Some(_) => FlashData::success("Discovery application approved"), - None => FlashData::error("Failed to approve discovery application"), - }; - flash::redirect_with_flash( - &format!("{base}/discovery?tab=pending"), - flash, - config.secure_cookies(), + finish_discovery( + config, + result, + "approve discovery application", + "Discovery application approved", + "Failed to approve discovery application", + "pending", ) } @@ -141,39 +124,21 @@ async fn discovery_reject( request: Request, ) -> Response { let config = state.config(); - let base = &config.base_path; - let form: DiscoveryActionForm = match Form::from_request(request, &state).await { - Ok(Form(f)) => f, - Err(error) => { - tracing::warn!(%error, "failed to parse discovery reject form"); - return flash::redirect_with_flash( - &format!("{base}/discovery"), - FlashData::error("Invalid form data"), - config.secure_cookies(), - ); - } - }; - let guild_id = match clean_string(form.guild_id.as_deref().unwrap_or("")) { - Some(id) => id, - None => { - return flash::redirect_with_flash( - &format!("{base}/discovery"), - FlashData::error("Guild ID is required"), - config.secure_cookies(), - ); - } - }; + let (form, guild_id) = + match discovery_form(&state, request, "failed to parse discovery reject form").await { + Ok(value) => value, + Err(response) => return *response, + }; let reason = form.reason.as_deref().unwrap_or("").trim(); let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let result = client.reject_discovery_application(&guild_id, reason).await; - let flash = match result.log_error("reject discovery application") { - Some(_) => FlashData::success("Discovery application rejected"), - None => FlashData::error("Failed to reject discovery application"), - }; - flash::redirect_with_flash( - &format!("{base}/discovery?tab=pending"), - flash, - config.secure_cookies(), + finish_discovery( + config, + result, + "reject discovery application", + "Discovery application rejected", + "Failed to reject discovery application", + "pending", ) } @@ -183,38 +148,68 @@ async fn discovery_remove( request: Request, ) -> Response { let config = state.config(); - let base = &config.base_path; - let form: DiscoveryActionForm = match Form::from_request(request, &state).await { - Ok(Form(f)) => f, - Err(error) => { - tracing::warn!(%error, "failed to parse discovery remove form"); - return flash::redirect_with_flash( - &format!("{base}/discovery"), - FlashData::error("Invalid form data"), - config.secure_cookies(), - ); - } - }; - let guild_id = match clean_string(form.guild_id.as_deref().unwrap_or("")) { - Some(id) => id, - None => { - return flash::redirect_with_flash( - &format!("{base}/discovery"), - FlashData::error("Guild ID is required"), - config.secure_cookies(), - ); - } - }; + let (form, guild_id) = + match discovery_form(&state, request, "failed to parse discovery remove form").await { + Ok(value) => value, + Err(response) => return *response, + }; let reason = form.reason.as_deref().unwrap_or("").trim(); let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let result = client.remove_from_discovery(&guild_id, reason).await; - let flash = match result.log_error("remove discovery guild") { - Some(_) => FlashData::success("Guild removed from discovery"), - None => FlashData::error("Failed to remove guild from discovery"), + finish_discovery( + config, + result, + "remove discovery guild", + "Guild removed from discovery", + "Failed to remove guild from discovery", + "listed", + ) +} + +async fn discovery_form( + state: &AppState, + request: Request, + log_label: &str, +) -> Result<(DiscoveryActionForm, String), Box> { + let config = state.config(); + let target = format!("{}/discovery", config.base_path); + let form = match Form::::from_request(request, state).await { + Ok(Form(form)) => form, + Err(error) => { + tracing::warn!(%error, "{log_label}"); + return Err(Box::new(flash::redirect_with_flash( + &target, + FlashData::error("Invalid form data"), + config.secure_cookies(), + ))); + } + }; + let Some(guild_id) = clean_string(form.guild_id.as_deref().unwrap_or("")) else { + return Err(Box::new(flash::redirect_with_flash( + &target, + FlashData::error("Guild ID is required"), + config.secure_cookies(), + ))); + }; + Ok((form, guild_id)) +} + +fn finish_discovery( + config: &AdminConfig, + result: ApiResult, + log_label: &str, + ok_msg: &str, + err_msg: &str, + tab: &str, +) -> Response { + let data = if result.log_error(log_label).is_some() { + FlashData::success(ok_msg) + } else { + FlashData::error(err_msg) }; flash::redirect_with_flash( - &format!("{base}/discovery?tab=listed"), - flash, + &format!("{}/discovery?tab={tab}", config.base_path), + data, config.secure_cookies(), ) } diff --git a/fluxer_admin/src/routes/guild_tabs.rs b/fluxer_admin/src/routes/guild_tabs.rs index 61e5e18cf..3586b6f64 100644 --- a/fluxer_admin/src/routes/guild_tabs.rs +++ b/fluxer_admin/src/routes/guild_tabs.rs @@ -78,8 +78,12 @@ pub async fn render( .map(|response| response.emojis) .map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild emojis")) .unwrap_or_default(); - Some(tabs::emojis::emojis_tab( - config, &guild, &emojis, csrf_token, + Some(tabs::emojis::guild_assets_tab( + config, + &guild, + &emojis, + csrf_token, + tabs::emojis::GuildAssetKind::Emoji, )) } "stickers" => { @@ -92,8 +96,12 @@ pub async fn render( .map(|response| response.stickers) .map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild stickers")) .unwrap_or_default(); - Some(tabs::stickers::stickers_tab( - config, &guild, &stickers, csrf_token, + Some(tabs::emojis::guild_assets_tab( + config, + &guild, + &stickers, + csrf_token, + tabs::emojis::GuildAssetKind::Sticker, )) } "threads" => { @@ -181,7 +189,14 @@ pub async fn render( .await .map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild applications")) .unwrap_or_default(); - Some(tabs::applications::applications_tab(config, &guild, &apps)) + Some( + crate::templates::pages::user_detail_tabs::applications::applications_tab( + config, + &apps, + "No applications installed", + "This guild does not have any installed applications.", + ), + ) } "archives" => { if !acl::has_any_permission( diff --git a/fluxer_admin/src/routes/mod.rs b/fluxer_admin/src/routes/mod.rs index 120601669..bc49a2d78 100644 --- a/fluxer_admin/src/routes/mod.rs +++ b/fluxer_admin/src/routes/mod.rs @@ -32,7 +32,7 @@ pub struct ActionQuery { use axum::{ Json, Router, extract::{Path, Request, State}, - http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}, + http::{HeaderValue, StatusCode, header}, middleware::{Next, from_fn, from_fn_with_state}, response::{Html, IntoResponse, Response}, routing::get, @@ -50,9 +50,6 @@ const HTMX_JS: &str = include_str!("../../static/htmx.min.js"); const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable"; -const STRICT_TRANSPORT_SECURITY_VALUE: &str = "max-age=31536000; includeSubDomains; preload"; -const REFERRER_POLICY_VALUE: &str = "strict-origin-when-cross-origin"; -const X_FRAME_OPTIONS_VALUE: &str = "DENY"; const PERMISSIONS_POLICY_VALUE: &str = "accelerometer=(), camera=(), geolocation=(), \ gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"; @@ -119,19 +116,7 @@ async fn security_headers_middleware( let headers = response.headers_mut(); let config = state.config(); - set_static_header( - headers, - header::STRICT_TRANSPORT_SECURITY, - STRICT_TRANSPORT_SECURITY_VALUE, - ); - set_static_header(headers, header::X_CONTENT_TYPE_OPTIONS, "nosniff"); - set_static_header(headers, header::REFERRER_POLICY, REFERRER_POLICY_VALUE); - set_static_header(headers, header::X_FRAME_OPTIONS, X_FRAME_OPTIONS_VALUE); - set_static_header( - headers, - HeaderName::from_static("permissions-policy"), - PERMISSIONS_POLICY_VALUE, - ); + fluxer_common::server::set_baseline_security_headers(headers, PERMISSIONS_POLICY_VALUE); let csp = build_admin_csp(config); if let Ok(value) = HeaderValue::from_str(&csp) { @@ -172,12 +157,6 @@ fn csp_origin(endpoint: &str) -> String { } } -fn set_static_header(headers: &mut HeaderMap, name: HeaderName, value: &'static str) { - headers - .entry(name) - .or_insert(HeaderValue::from_static(value)); -} - async fn cache_headers_middleware(request: Request, next: Next) -> Response { let mut response = next.run(request).await; if response.headers().contains_key(header::CACHE_CONTROL) { diff --git a/fluxer_admin/src/routes/system_actions.rs b/fluxer_admin/src/routes/system_actions.rs index 440b38391..46b36a8f2 100644 --- a/fluxer_admin/src/routes/system_actions.rs +++ b/fluxer_admin/src/routes/system_actions.rs @@ -1380,27 +1380,6 @@ mod tests { assert_eq!(body(from_an_older_page), serde_json::json!({})); } - #[test] - fn smtp_test_flash_says_the_settings_were_saved_either_way() { - let passed = smtp_test_flash(InstanceEmailSmtpTestResponse { - ok: true, - error: None, - }); - assert_eq!( - passed.message, - "Settings saved and SMTP connection verified" - ); - - let failed = smtp_test_flash(InstanceEmailSmtpTestResponse { - ok: false, - error: Some("wrong version number".to_owned()), - }); - assert_eq!( - failed.message, - "Settings saved, but the SMTP test failed: wrong version number" - ); - } - #[test] fn build_sso_update_keeps_repeated_allowed_domains() { let form = MultiValueForm::parse( @@ -1435,65 +1414,6 @@ mod tests { ); } - #[test] - fn build_limit_filters_accepts_repeated_and_delimited_values() { - let form = MultiValueForm::parse( - b"traits%5B%5D=staff&traits%5B%5D=partner%2Cvip&guild_features=COMMUNITY%0ANEWS", - ); - let filters = build_limit_filters(&form).expect("filters"); - assert_eq!( - filters.traits, - vec!["staff".to_owned(), "partner".to_owned(), "vip".to_owned()] - ); - assert_eq!( - filters.guild_features, - vec!["COMMUNITY".to_owned(), "NEWS".to_owned()] - ); - } - - #[test] - fn instance_config_result_surfaces_the_api_validation_message() { - let error = crate::api::client::ApiError::Http { - status: 400, - message: serde_json::json!({ - "code": "INVALID_FORM_BODY", - "message": "Input Validation Error", - "errors": [{ - "path": "policy.premium_mode", - "code": "X", - "message": "Disable billing before switching the premium mode to everyone" - }] - }) - .to_string(), - }; - assert_eq!( - instance_config_result::<()>(Err(error)).message, - "Failed to update instance config: policy.premium_mode: Disable billing before switching the premium mode to everyone" - ); - } - - #[test] - fn limit_config_result_surfaces_the_api_validation_message() { - let error = crate::api::client::ApiError::Http { - status: 400, - message: serde_json::json!({ - "code": "INVALID_FORM_BODY", - "message": "Input Validation Error", - "errors": [{ - "path": "limit_config.rules.0.limits.max_bio_length", - "code": "VALUE_MUST_BE_INTEGER_IN_RANGE", - "message": "Value must be an integer between 0 and 320." - }] - }) - .to_string(), - }; - assert_eq!( - limit_config_result::<()>(Err(error), "Saved", "Failed to update limit configuration") - .message, - "Failed to update limit configuration: limit_config.rules.0.limits.max_bio_length: Value must be an integer between 0 and 320." - ); - } - #[test] fn update_limit_rule_values_saves_unchecked_toggles_as_off() { let toggle = |key: &str, is_toggle: bool| { @@ -1554,117 +1474,6 @@ mod tests { ); } - #[test] - fn parse_experiment_user_ids_splits_newlines_and_commas() { - assert_eq!( - parse_experiment_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs") - .expect("valid IDs"), - vec![ - "1".to_owned(), - "2".to_owned(), - "3".to_owned(), - "4".to_owned(), - "5".to_owned() - ] - ); - } - - #[test] - fn parse_experiment_user_ids_dedupes_preserving_order() { - assert_eq!( - 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_experiment_user_ids_rejects_non_digit_and_overlong_values() { - for value in [ - "abc", - "12a", - "-1", - "1.0", - "999999999999999999999", - "\n\u{2028}"; @@ -488,51 +449,6 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } - fn location_of(response: &Response) -> &str { - assert_eq!(response.status(), StatusCode::FOUND); - response - .headers() - .get(header::LOCATION) - .expect("the favicon redirect has no location") - .to_str() - .unwrap() - } - - #[tokio::test] - async fn the_favicon_ico_redirects_to_the_bundled_icon_on_the_official_instance() { - let root = static_dir_with_generated_files(); - let mut state = state_serving(&root, false); - - let response = fetch(state.clone(), "/favicon.ico").await; - assert_eq!(location_of(&response), "/web/favicon.ico"); - - std::sync::Arc::make_mut(&mut state.config).static_cdn_endpoint = Some( - HttpEndpoint::parse("TEST_STATIC_CDN_ENDPOINT", "https://fluxerstatic.com").unwrap(), - ); - let response = fetch(state, "/favicon.ico").await; - assert_eq!( - location_of(&response), - "https://fluxerstatic.com/web/favicon.ico" - ); - - std::fs::remove_dir_all(root).unwrap(); - } - - #[tokio::test] - async fn the_favicon_ico_redirects_to_the_instance_favicon_when_self_hosted() { - let root = static_dir_with_generated_files(); - let mut state = state_serving(&root, true); - - let response = fetch(state.clone(), "/favicon.ico").await; - assert_eq!(location_of(&response), "https://example.test/favicon.png"); - - state.branding = crate::branding_images::InstanceBranding::new(BrandingSnapshot::default()); - let response = fetch(state, "/favicon.ico").await; - assert_eq!(location_of(&response), "/web/favicon.ico"); - - std::fs::remove_dir_all(root).unwrap(); - } - const GENERATED_BROWSERCONFIG: &str = r#" diff --git a/fluxer_app_proxy/src/static_asset_policy.rs b/fluxer_app_proxy/src/static_asset_policy.rs index 501286a75..051849837 100644 --- a/fluxer_app_proxy/src/static_asset_policy.rs +++ b/fluxer_app_proxy/src/static_asset_policy.rs @@ -252,47 +252,6 @@ pub(crate) fn is_content_hash(value: &str) -> bool { mod tests { use super::*; - #[test] - fn mime_html() { - assert_eq!(guess_mime("i.html"), "text/html; charset=utf-8"); - } - - #[test] - fn mime_js() { - assert_eq!(guess_mime("a.js"), "application/javascript; charset=utf-8"); - } - - #[test] - fn mime_css() { - assert_eq!(guess_mime("s.css"), "text/css; charset=utf-8"); - } - - #[test] - fn mime_wasm() { - assert_eq!(guess_mime("m.wasm"), "application/wasm"); - } - - #[test] - fn mime_woff2() { - assert_eq!(guess_mime("f.woff2"), "font/woff2"); - } - - #[test] - fn mime_unknown() { - assert_eq!(guess_mime("f.xyz"), "application/octet-stream"); - } - - #[test] - fn mime_no_ext() { - assert_eq!(guess_mime("LICENSE"), "application/octet-stream"); - } - - #[test] - fn mime_case_insensitive() { - assert_eq!(guess_mime("F.HTML"), "text/html; charset=utf-8"); - assert_eq!(guess_mime("F.JS"), "application/javascript; charset=utf-8"); - } - #[test] fn hashed_asset_positive() { assert!(is_hashed_asset("app.a1b2c3d4.js")); diff --git a/fluxer_common/Cargo.toml b/fluxer_common/Cargo.toml index 2a506f16d..73e61caf4 100644 --- a/fluxer_common/Cargo.toml +++ b/fluxer_common/Cargo.toml @@ -5,10 +5,15 @@ edition.workspace = true license.workspace = true [dependencies] +libc = "0.2" +reqwest = { version = "0.13.5", default-features = false, features = ["rustls"], optional = true } +serde = { version = "1.0.229", features = ["derive"] } +http = "1" +tokio = { version = "1.53.1", features = ["signal", "macros"] } +tracing = "0.1.44" +url = "2.5.8" anyhow = "1.0.104" -reqwest = { version = "0.13.5", default-features = false, features = ["blocking", "rustls"] } serde_json = "1.0.151" -time = { version = "0.3.55", features = ["formatting", "macros", "parsing"] } tracing-subscriber = { version = "0.3.23", default-features = false, features = ["env-filter"] } base64 = "0.23" hex = "0.4.3" @@ -18,4 +23,6 @@ thiserror = "2" [dev-dependencies] tempfile = "3.27.0" -url = "2.5.8" + +[features] +healthcheck = ["dep:reqwest"] diff --git a/fluxer_common/src/attachment_url_signature.rs b/fluxer_common/src/attachment_url_signature.rs index cf2b33659..c3949d1c1 100644 --- a/fluxer_common/src/attachment_url_signature.rs +++ b/fluxer_common/src/attachment_url_signature.rs @@ -93,16 +93,6 @@ pub fn is_signature_parameter_name(name: &str) -> bool { .any(|candidate| candidate.as_bytes() == decoded.as_slice()) } -pub fn strip_signature(url: &str) -> String { - let (head, fragment) = split_fragment(url); - let (base, query) = split_query(head); - let preserved = preserved_fields(query); - if preserved.is_empty() { - return format!("{base}{fragment}"); - } - format!("{base}?{}{fragment}", preserved.join("&")) -} - pub fn with_signature( url: &str, storage_key: &str, @@ -460,11 +450,6 @@ mod tests { signer(text(case, "url"), storage_key, anchor, now, &secrets[0]), "{name} signed url" ); - assert_eq!( - text(case, "url"), - strip_signature(text(case, "signed")), - "{name} strips back" - ); let signed = text(case, "signed"); assert!(!signed.ends_with('&'), "{name} trailing separator"); assert!(!signed.contains("&&"), "{name} doubled separator"); @@ -475,24 +460,6 @@ mod tests { assert!(data_packages >= 3); } - #[test] - fn every_strip_vector_reproduces_byte_for_byte() { - let fixture = fixture(); - let parsed = cases(&fixture, "strip"); - let mut run = 0; - for case in parsed { - assert_eq!( - text(case, "stripped"), - strip_signature(text(case, "url")), - "{}", - text(case, "name") - ); - run += 1; - } - assert_eq!(parsed.len(), run); - assert!(run >= 10); - } - #[test] fn every_verify_vector_reaches_its_declared_verdict() { let fixture = fixture(); @@ -1057,7 +1024,6 @@ mod tests { assert_eq!(1, url.matches("is=").count(), "{url}"); assert_eq!(1, url.matches("hm=").count(), "{url}"); assert!(url.ends_with("&width=64&height=64#anchor"), "{url}"); - assert_eq!(canonical, strip_signature(url), "{url}"); } assert_eq!(1, package.matches("uc=dp&").count(), "{package}"); assert!(!resigned.contains("uc="), "{resigned}"); diff --git a/fluxer_common/src/config.rs b/fluxer_common/src/config.rs index 4466a998d..cc1a588ea 100644 --- a/fluxer_common/src/config.rs +++ b/fluxer_common/src/config.rs @@ -129,7 +129,7 @@ pub fn parse_public_origin(origin: &str) -> Option { if trimmed.is_empty() { return None; } - let parsed = reqwest::Url::parse(trimmed).ok()?; + let parsed = url::Url::parse(trimmed).ok()?; let scheme = parsed.scheme(); if scheme != "http" && scheme != "https" { return None; @@ -194,7 +194,7 @@ pub fn normalize_public_endpoint(url: &str, base_domain: &str, public_port: Opti if domain.is_empty() { return url.to_owned(); } - let Ok(parsed) = reqwest::Url::parse(url) else { + let Ok(parsed) = url::Url::parse(url) else { return url.to_owned(); }; if canonicalize_domain(parsed.host_str().unwrap_or_default()) != domain { @@ -230,229 +230,73 @@ pub fn normalize_public_endpoint_from_env(url: &str) -> String { try_normalize_public_endpoint_from_env(url).unwrap_or_else(|error| panic!("{error}")) } +#[derive(Debug, Default)] +pub struct EnvMap(Vec<(String, String)>); + +impl EnvMap { + pub fn from_pairs(vars: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + Self( + vars.into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(), + ) + } + + pub fn get(&self, key: &str) -> Option<&str> { + self.0 + .iter() + .find_map(|(k, v)| (k == key).then_some(v.as_str())) + .filter(|v| !v.trim().is_empty()) + } +} + +pub fn parse_bool(var_name: &str, raw: Option<&str>) -> anyhow::Result> { + let Some(raw) = raw else { + return Ok(None); + }; + let raw = raw.trim(); + match raw.to_ascii_lowercase().as_str() { + "true" | "1" | "yes" => Ok(Some(true)), + "false" | "0" | "no" => Ok(Some(false)), + _ => Err(anyhow::anyhow!( + "{var_name} must be a boolean: true, false, 1, 0, yes, or no" + )), + } +} + +pub fn parse_number( + var_name: &str, + raw: Option<&str>, + default_value: T, + min_value: T, + max_value: T, +) -> anyhow::Result +where + T: std::str::FromStr + PartialOrd + std::fmt::Display + Copy, +{ + let Some(raw) = raw else { + return Ok(default_value); + }; + let parsed = raw + .trim() + .parse::() + .map_err(|_| anyhow::anyhow!("{var_name} must be a number"))?; + anyhow::ensure!( + parsed >= min_value && parsed <= max_value, + "{var_name} must be between {min_value} and {max_value}" + ); + Ok(parsed) +} + #[cfg(test)] mod tests { use super::*; - #[test] - fn default_https_install_is_untouched() { - for url in [ - "https://fluxer.example", - "https://fluxer.example/media", - "https://fluxer.example/admin/oauth2_callback", - "wss://fluxer.example/gateway", - ] { - assert_eq!( - url, - normalize_public_endpoint(url, "fluxer.example", Some(443)) - ); - } - } - - #[test] - fn default_http_install_is_untouched() { - for url in [ - "http://fluxer.example", - "http://fluxer.example/media", - "ws://fluxer.example/gateway", - ] { - assert_eq!( - url, - normalize_public_endpoint(url, "fluxer.example", Some(80)) - ); - } - } - - #[test] - fn inserts_a_non_default_port_for_the_base_domain() { - assert_eq!( - "http://fluxer.example:19080/media", - normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", Some(19080)) - ); - assert_eq!( - "http://fluxer.example:19080", - normalize_public_endpoint("http://fluxer.example", "fluxer.example", Some(19080)) - ); - assert_eq!( - "https://fluxer.example:8443/admin/oauth2_callback", - normalize_public_endpoint( - "https://fluxer.example/admin/oauth2_callback", - "fluxer.example", - Some(8443) - ) - ); - } - - #[test] - fn a_default_port_for_the_urls_own_scheme_is_never_inserted() { - assert_eq!( - "ws://fluxer.example/gateway", - normalize_public_endpoint("ws://fluxer.example/gateway", "fluxer.example", Some(80)) - ); - assert_eq!( - "wss://fluxer.example/gateway", - normalize_public_endpoint("wss://fluxer.example/gateway", "fluxer.example", Some(443)) - ); - assert_eq!( - "http://fluxer.example:443/media", - normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", Some(443)) - ); - assert_eq!( - "https://fluxer.example:80/media", - normalize_public_endpoint("https://fluxer.example/media", "fluxer.example", Some(80)) - ); - } - - #[test] - fn another_host_is_never_touched() { - for url in [ - "https://cdn.example.net/assets", - "https://media.example.net", - "http://api:8080", - "http://media-proxy:8080", - ] { - assert_eq!( - url, - normalize_public_endpoint(url, "fluxer.example", Some(19080)) - ); - } - assert_eq!( - "https://sub.fluxer.example/media", - normalize_public_endpoint( - "https://sub.fluxer.example/media", - "fluxer.example", - Some(19080) - ) - ); - } - - #[test] - fn an_explicit_port_is_never_touched() { - for url in [ - "http://fluxer.example:19080/media", - "http://fluxer.example:8080/media", - "https://fluxer.example:443/media", - "http://fluxer.example:80/media", - "http://user:pass@fluxer.example:19080/media", - ] { - assert_eq!( - url, - normalize_public_endpoint(url, "fluxer.example", Some(19080)) - ); - } - } - - #[test] - fn is_idempotent() { - let once = - normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", Some(19080)); - let twice = normalize_public_endpoint(&once, "fluxer.example", Some(19080)); - assert_eq!("http://fluxer.example:19080/media", once); - assert_eq!(once, twice); - } - - #[test] - fn an_unset_port_leaves_every_url_alone() { - assert_eq!( - "http://fluxer.example/media", - normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", None) - ); - assert_eq!( - "http://fluxer.example/media", - normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", Some(0)) - ); - } - - #[test] - fn an_empty_base_domain_leaves_every_url_alone() { - assert_eq!( - "http://fluxer.example/media", - normalize_public_endpoint("http://fluxer.example/media", "", Some(19080)) - ); - assert_eq!( - "http://fluxer.example/media", - normalize_public_endpoint("http://fluxer.example/media", " ", Some(19080)) - ); - } - - #[test] - fn a_url_that_does_not_parse_is_returned_unchanged() { - for url in ["", "/api", "fluxer.example/media", "not a url", "://x"] { - assert_eq!( - url, - normalize_public_endpoint(url, "fluxer.example", Some(19080)) - ); - } - } - - #[test] - fn matches_the_host_case_insensitively_and_ignores_a_trailing_dot() { - assert_eq!( - "http://FLUXER.example:19080/Media", - normalize_public_endpoint("http://FLUXER.example/Media", "Fluxer.Example", Some(19080)) - ); - assert_eq!( - "http://fluxer.example.:19080/media", - normalize_public_endpoint( - "http://fluxer.example./media", - "fluxer.example", - Some(19080) - ) - ); - assert_eq!( - "http://fluxer.example:19080/media", - normalize_public_endpoint( - "http://fluxer.example/media", - "fluxer.example.", - Some(19080) - ) - ); - } - - #[test] - fn preserves_path_query_fragment_and_trailing_slash() { - assert_eq!( - "http://fluxer.example:19080/", - normalize_public_endpoint("http://fluxer.example/", "fluxer.example", Some(19080)) - ); - assert_eq!( - "http://fluxer.example:19080?a=1", - normalize_public_endpoint("http://fluxer.example?a=1", "fluxer.example", Some(19080)) - ); - assert_eq!( - "http://fluxer.example:19080#top", - normalize_public_endpoint("http://fluxer.example#top", "fluxer.example", Some(19080)) - ); - assert_eq!( - "http://fluxer.example:19080/media/x.png?v=1#frag", - normalize_public_endpoint( - "http://fluxer.example/media/x.png?v=1#frag", - "fluxer.example", - Some(19080) - ) - ); - } - - #[test] - fn keeps_credentials_and_ipv6_literals_intact() { - assert_eq!( - "http://user:pass@fluxer.example:19080/media", - normalize_public_endpoint( - "http://user:pass@fluxer.example/media", - "fluxer.example", - Some(19080) - ) - ); - assert_eq!( - "http://[::1]:19080/media", - normalize_public_endpoint("http://[::1]/media", "[::1]", Some(19080)) - ); - assert_eq!( - "http://[::1]:8080/media", - normalize_public_endpoint("http://[::1]:8080/media", "[::1]", Some(19080)) - ); - } - #[test] fn matches_the_typescript_normalizer_on_the_shared_vectors() { let raw = include_str!("testdata/public_endpoint_vectors.json"); diff --git a/fluxer_common/src/external_media_path.rs b/fluxer_common/src/external_media_path.rs index 49229e2db..090107c65 100644 --- a/fluxer_common/src/external_media_path.rs +++ b/fluxer_common/src/external_media_path.rs @@ -204,60 +204,111 @@ pub fn build_external_media_proxy_url( Some(format!("{public_endpoint}/external/{signature}/{path}")) } +use url::Url; + +pub fn is_own_media_url(media_endpoint: &str, target: &Url) -> bool { + let Ok(base) = Url::parse(media_endpoint) else { + return false; + }; + let same_host = match (base.host_str(), target.host_str()) { + (Some(base_host), Some(target_host)) => base_host.eq_ignore_ascii_case(target_host), + _ => false, + }; + same_host + && base.scheme().eq_ignore_ascii_case(target.scheme()) + && base.port_or_known_default() == target.port_or_known_default() + && target + .path() + .strip_prefix(base.path().trim_end_matches('/')) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) +} + +pub fn verify_signature(input: &str, provided: &str, secret: &[u8]) -> bool { + let expected = create_signature(input, secret); + if expected.len() != provided.len() { + return false; + } + expected + .bytes() + .zip(provided.bytes()) + .fold(0u8, |diff, (a, b)| diff | (a ^ b)) + == 0 +} + +#[cfg(test)] +mod signature_tests { + use super::*; + #[test] + fn signature_matches_node_hmac_base64url_behavior() { + let sig = create_signature("v2/aHR0cHM6Ly9leGFtcGxlLmNvbS8", b"secret"); + assert_eq!("jkGeYYCiqJ67eS5T6lWJ4eQ77pwIaf6yoXXF5LncsHY", sig); + assert!(verify_signature( + "v2/aHR0cHM6Ly9leGFtcGxlLmNvbS8", + &sig, + b"secret" + )); + } + + #[test] + fn verify_rejects_tampered_signature() { + let sig = create_signature("input", b"secret"); + let mut tampered = sig.clone().into_bytes(); + tampered[0] = if tampered[0] == b'A' { b'B' } else { b'A' }; + assert!(!verify_signature( + "input", + std::str::from_utf8(&tampered).unwrap(), + b"secret" + )); + } + + #[test] + fn verify_rejects_wrong_secret() { + let sig = create_signature("input", b"secret"); + assert!(!verify_signature("input", &sig, b"different")); + } + + #[test] + fn verify_rejects_wrong_input() { + let sig = create_signature("input-a", b"secret"); + assert!(!verify_signature("input-b", &sig, b"secret")); + } + + #[test] + fn verify_rejects_different_length_signature_without_panic() { + assert!(!verify_signature("input", "short", b"secret")); + assert!(!verify_signature( + "input", + "definitely-too-long-of-a-signature-for-hmac-sha256-base64url", + b"secret" + )); + } +} + #[cfg(test)] mod tests { use super::*; #[test] - fn builds_the_plain_path_shape() { - assert_eq!( - "https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp", - build_external_media_proxy_path("https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp") - .unwrap() - ); - } - - #[test] - fn builds_an_encoded_query_ahead_of_the_scheme() { - assert_eq!( - "%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/HkAKKCzZ.webp", - build_external_media_proxy_path( - "https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here" - ) - .unwrap() - ); - } - - #[test] - fn leaves_the_fragment_out_of_the_signed_path() { - assert_eq!( - build_external_media_proxy_path("https://example.com/a.gif").unwrap(), - build_external_media_proxy_path("https://example.com/a.gif#anchor").unwrap() - ); - assert_eq!( - build_external_media_proxy_path("https://example.com/a.gif?v=1").unwrap(), - build_external_media_proxy_path("https://example.com/a.gif?v=1#anchor").unwrap() - ); - } - - #[test] - fn leaves_credentials_out_of_the_host_segment() { - assert_eq!( - "https/cdn.example.com/a.gif", - build_external_media_proxy_path("https://reader:secret@cdn.example.com/a.gif").unwrap() - ); - assert_eq!( - "https/cdn.example.com/a.gif", - build_external_media_proxy_path("https://reader@cdn.example.com/a.gif").unwrap() - ); - } - - #[test] - fn leaves_out_a_query_that_reconstructs_to_nothing() { - assert_eq!( - build_external_media_proxy_path("https://example.com/a.gif").unwrap(), - build_external_media_proxy_path("https://example.com/a.gif?").unwrap() - ); + fn own_media_urls_require_the_same_origin_and_path_boundary() { + let endpoint = "https://chat.example.test/media/"; + for target in [ + "https://chat.example.test/media", + "https://CHAT.example.test:443/media/attachments/1/2/cat.gif", + ] { + assert!(is_own_media_url(endpoint, &Url::parse(target).unwrap())); + } + for target in [ + "https://chat.example.test/mediafiles/1.gif", + "https://static.klipy.com/a.webp", + "http://chat.example.test/media/a.gif", + "https://chat.example.test:444/media/a.gif", + ] { + assert!(!is_own_media_url(endpoint, &Url::parse(target).unwrap())); + } + assert!(!is_own_media_url( + "invalid endpoint", + &Url::parse("https://chat.example.test/media").unwrap() + )); } #[test] @@ -278,14 +329,6 @@ mod tests { } } - #[test] - fn keeps_a_non_default_port() { - assert_eq!( - "https/example.com:8443/a.png", - build_external_media_proxy_path("https://example.com:8443/a.png").unwrap() - ); - } - #[test] fn round_trips_every_shape() { for url in [ diff --git a/fluxer_common/src/healthcheck.rs b/fluxer_common/src/healthcheck.rs new file mode 100644 index 000000000..f68cff68e --- /dev/null +++ b/fluxer_common/src/healthcheck.rs @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use anyhow::Context as _; +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + time::Duration, +}; + +pub async fn run( + host: Option<&str>, + port: Option<&str>, + host_var: &str, + port_var: &str, + default_port: u16, +) -> anyhow::Result<()> { + let addr = target(host, port, host_var, port_var, default_port)?; + probe(addr).await +} + +fn target( + host: Option<&str>, + port: Option<&str>, + host_var: &str, + port_var: &str, + default_port: u16, +) -> anyhow::Result { + let host = host.map(str::trim).unwrap_or("127.0.0.1"); + let ip = host + .parse::() + .with_context(|| format!("{host_var} is not an IP address: {host}"))?; + let ip = match ip { + IpAddr::V4(ip) if ip.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST), + IpAddr::V6(ip) if ip.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST), + ip => ip, + }; + let port = match port.map(str::trim) { + Some(port) => port + .parse::() + .with_context(|| format!("{port_var} is not a port number: {port}"))?, + None => default_port, + }; + Ok(SocketAddr::new(ip, port)) +} + +async fn probe(addr: SocketAddr) -> anyhow::Result<()> { + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_millis(500)) + .timeout(Duration::from_millis(2_000)) + .no_proxy() + .build()?; + let status = client + .get(format!("http://{addr}/_health")) + .send() + .await + .with_context(|| format!("health request to {addr} failed"))? + .status(); + anyhow::ensure!( + status == reqwest::StatusCode::OK, + "health returned {status}" + ); + Ok(()) +} diff --git a/fluxer_common/src/http_range.rs b/fluxer_common/src/http_range.rs new file mode 100644 index 000000000..28fbb5cad --- /dev/null +++ b/fluxer_common/src/http_range.rs @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ByteRange { + pub start: usize, + pub end: usize, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum RangeSelection { + #[default] + Full, + Partial(ByteRange), + Unsatisfiable, +} + +pub fn parse_range(header: Option<&str>, file_size: usize) -> RangeSelection { + let Some(raw) = header else { + return RangeSelection::Full; + }; + let trimmed = raw.trim_matches([' ', '\t']); + // The unit is matched the way classify_request_range matches it, which is what decides + // whether a GET forwards the Range upstream at all. Accepting more units here alone would + // answer 206 on a HEAD whose GET serves the whole body. + let Some(spec) = trimmed.strip_prefix("bytes=") else { + return RangeSelection::Full; + }; + if spec.contains(',') { + return RangeSelection::Full; + } + let Some(dash) = spec.find('-') else { + return RangeSelection::Full; + }; + let end_offset = dash + .checked_add(1) + .expect("range delimiter offset must fit usize"); + let start_part = &spec[..dash]; + let end_part = &spec[end_offset..]; + if start_part.is_empty() && end_part.is_empty() { + return RangeSelection::Full; + } + // A range-spec that does not parse is ignored outright and the full resource is served + // (RFC 9110 s14.2). That check has to happen before the zero-length shortcut below, or a + // malformed spec against an empty object reports 416 instead of 200. + if !start_part.is_empty() { + let Some(start) = parse_decimal_usize(start_part) else { + return RangeSelection::Full; + }; + let parsed_end = if end_part.is_empty() { + None + } else if let Some(end) = parse_decimal_usize(end_part) { + Some(end) + } else { + return RangeSelection::Full; + }; + if file_size == 0 { + return RangeSelection::Unsatisfiable; + } + let last_byte = file_size + .checked_sub(1) + .expect("nonempty media resource must have a last byte"); + let requested_end = parsed_end.unwrap_or(last_byte); + if start >= file_size || requested_end < start { + return RangeSelection::Unsatisfiable; + } + return RangeSelection::Partial(ByteRange { + start, + end: requested_end.min(last_byte), + }); + } + let Some(suffix_len) = parse_decimal_usize(end_part) else { + return RangeSelection::Full; + }; + if file_size == 0 { + return RangeSelection::Unsatisfiable; + } + let last_byte = file_size + .checked_sub(1) + .expect("nonempty media resource must have a last byte"); + if suffix_len == 0 { + return RangeSelection::Unsatisfiable; + } + let resolved_len = suffix_len.min(file_size); + RangeSelection::Partial(ByteRange { + start: file_size + .checked_sub(resolved_len) + .expect("resolved suffix length must not exceed the media resource"), + end: last_byte, + }) +} + +pub fn parse_decimal_usize(raw: &str) -> Option { + if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + raw.parse().ok() +} diff --git a/fluxer_common/src/klipy.rs b/fluxer_common/src/klipy.rs new file mode 100644 index 000000000..3880cee3a --- /dev/null +++ b/fluxer_common/src/klipy.rs @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use url::Url; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShareKind { + Gif, + Clip, +} + +impl ShareKind { + pub fn resource(self) -> &'static str { + match self { + Self::Gif => "gifs", + Self::Clip => "clips", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SharePath { + pub kind: ShareKind, + pub slug: String, +} + +pub fn parse_share_path(url: &Url) -> Option { + let host = url.host_str()?; + if !host.eq_ignore_ascii_case("klipy.com") && !host.eq_ignore_ascii_case("www.klipy.com") { + return None; + } + let mut segments = url.path_segments()?; + let kind = match segments.next()?.to_ascii_lowercase().as_str() { + "gif" | "gifs" => ShareKind::Gif, + "clip" | "clips" => ShareKind::Clip, + _ => return None, + }; + let slug = segments.next()?.trim(); + if slug.is_empty() { + return None; + } + Some(SharePath { + kind, + slug: slug.to_owned(), + }) +} + +pub fn direct_items_url(api_key: &str, kind: ShareKind, slug: &str) -> anyhow::Result { + let mut url = Url::parse("https://api.klipy.com/api/v1")?; + url.path_segments_mut() + .map_err(|_| anyhow::anyhow!("KLIPY API base URL cannot be a base"))? + .push(api_key) + .push(kind.resource()) + .push("items"); + url.query_pairs_mut().append_pair("slugs", slug); + Ok(url) +} diff --git a/fluxer_common/src/lib.rs b/fluxer_common/src/lib.rs index 57c9dd89f..bba063c77 100644 --- a/fluxer_common/src/lib.rs +++ b/fluxer_common/src/lib.rs @@ -4,3 +4,20 @@ pub mod attachment_url_signature; pub mod config; pub mod external_media_path; pub mod user_flags; + +pub mod http_range; +pub mod server; +pub mod shutdown; + +pub mod time; +pub mod user_partial; + +#[cfg(feature = "healthcheck")] +pub mod healthcheck; +pub mod secret; + +pub mod public_ip; + +pub mod metrics; + +pub mod klipy; diff --git a/fluxer_media_proxy/src/metrics/histogram.rs b/fluxer_common/src/metrics.rs similarity index 52% rename from fluxer_media_proxy/src/metrics/histogram.rs rename to fluxer_common/src/metrics.rs index 4f4ddbdff..aa2126bf3 100644 --- a/fluxer_media_proxy/src/metrics/histogram.rs +++ b/fluxer_common/src/metrics.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use libc::{CLOCK_MONOTONIC, clock_gettime, timespec}; use std::fmt::{self, Write as _}; +use std::net::SocketAddr; use std::sync::atomic::{AtomicU64, Ordering}; const HISTOGRAM_BUCKETS_MS: &[u64] = &[ @@ -8,10 +10,10 @@ const HISTOGRAM_BUCKETS_MS: &[u64] = &[ ]; pub struct Histogram { - pub(super) buckets: [AtomicU64; 13], - pub(super) inf: AtomicU64, - pub(super) sum_ms: AtomicU64, - pub(super) count: AtomicU64, + buckets: [AtomicU64; 13], + inf: AtomicU64, + sum_ms: AtomicU64, + count: AtomicU64, } impl Histogram { @@ -36,12 +38,8 @@ impl Histogram { self.count.fetch_add(1, Ordering::Relaxed); } - pub(super) fn render_series( - &self, - out: &mut String, - name: &str, - labels: Option<&str>, - ) -> fmt::Result { + pub fn render_series(&self, out: &mut String, name: &str, labels: Option<&str>) -> fmt::Result { + let labels = labels.filter(|labels| !labels.is_empty()); let prefix = labels .map(|labels| format!("{labels},")) .unwrap_or_default(); @@ -76,3 +74,53 @@ impl Default for Histogram { Self::new() } } + +pub fn now_ms() -> i64 { + let mut ts = timespec { + tv_sec: 0, + tv_nsec: 0, + }; + let rc = unsafe { clock_gettime(CLOCK_MONOTONIC, &mut ts) }; + if rc != 0 { + return 0; + } + ts.tv_sec + .saturating_mul(1_000) + .saturating_add(ts.tv_nsec / 1_000_000) +} + +pub fn render_counter(out: &mut String, name: &str, counter: &AtomicU64) -> fmt::Result { + writeln!(out, "# TYPE {name} counter")?; + writeln!(out, "{name} {}", counter.load(Ordering::Relaxed)) +} + +pub fn is_loopback_peer(peer: &SocketAddr) -> bool { + peer.ip().to_canonical().is_loopback() +} + +#[cfg(test)] +mod tests { + use super::is_loopback_peer; + use std::net::SocketAddr; + + fn peer(value: &str) -> SocketAddr { + value.parse().expect("valid socket address") + } + + #[test] + fn accepts_loopback_peers() { + assert!(is_loopback_peer(&peer("127.0.0.1:5000"))); + assert!(is_loopback_peer(&peer("127.0.0.2:5000"))); + assert!(is_loopback_peer(&peer("[::1]:5000"))); + assert!(is_loopback_peer(&peer("[::ffff:127.0.0.1]:5000"))); + } + + #[test] + fn rejects_remote_peers() { + assert!(!is_loopback_peer(&peer("8.8.8.8:5000"))); + assert!(!is_loopback_peer(&peer("10.0.0.5:5000"))); + assert!(!is_loopback_peer(&peer("172.18.0.4:5000"))); + assert!(!is_loopback_peer(&peer("[fe80::1]:5000"))); + assert!(!is_loopback_peer(&peer("[::ffff:8.8.8.8]:5000"))); + } +} diff --git a/fluxer_media_proxy/src/public_net_policy/ip_tables.rs b/fluxer_common/src/public_ip.rs similarity index 82% rename from fluxer_media_proxy/src/public_net_policy/ip_tables.rs rename to fluxer_common/src/public_ip.rs index 98ce08538..77b105536 100644 --- a/fluxer_media_proxy/src/public_net_policy/ip_tables.rs +++ b/fluxer_common/src/public_ip.rs @@ -125,16 +125,36 @@ fn is_blocked_ipv6(address: Ipv6Addr) -> bool { .any(|(prefix, bits)| ipv6_in(address, *prefix, *bits)) } -pub(super) fn is_public_ip(address: IpAddr) -> bool { +pub fn is_public_ip(address: IpAddr) -> bool { match address { IpAddr::V4(address) => !is_blocked_ipv4(address), IpAddr::V6(address) => !is_blocked_ipv6(address), } } -pub fn is_blocked_ip_literal(raw: &str) -> bool { - match raw.parse::() { - Ok(address) => !is_public_ip(address), - Err(_) => true, +const MAX_PUBLIC_HOSTNAME_BYTES: usize = 253; +const MAX_PUBLIC_HOSTNAME_LABEL_BYTES: usize = 63; + +pub fn is_valid_public_hostname(host: &str) -> bool { + if host.is_empty() || host.len() > MAX_PUBLIC_HOSTNAME_BYTES || !host.contains('.') { + return false; } + let mut last = ""; + for label in host.split('.') { + if label.is_empty() || label.len() > MAX_PUBLIC_HOSTNAME_LABEL_BYTES { + return false; + } + let bytes = label.as_bytes(); + if !bytes[0].is_ascii_alphanumeric() || !bytes[bytes.len() - 1].is_ascii_alphanumeric() { + return false; + } + if !bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') + { + return false; + } + last = label; + } + !last.bytes().all(|byte| byte.is_ascii_digit()) } diff --git a/fluxer_media_proxy/src/secret.rs b/fluxer_common/src/secret.rs similarity index 100% rename from fluxer_media_proxy/src/secret.rs rename to fluxer_common/src/secret.rs diff --git a/fluxer_common/src/server.rs b/fluxer_common/src/server.rs new file mode 100644 index 000000000..3d9de4680 --- /dev/null +++ b/fluxer_common/src/server.rs @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use http::{HeaderMap, HeaderName, HeaderValue, header}; + +pub const STRICT_TRANSPORT_SECURITY: &str = "max-age=31536000; includeSubDomains; preload"; +pub const REFERRER_POLICY: &str = "strict-origin-when-cross-origin"; + +pub fn set_baseline_security_headers(headers: &mut HeaderMap, permissions_policy: &'static str) { + for (name, value) in [ + ( + HeaderName::from_static("strict-transport-security"), + STRICT_TRANSPORT_SECURITY, + ), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + (HeaderName::from_static("referrer-policy"), REFERRER_POLICY), + (HeaderName::from_static("x-frame-options"), "DENY"), + ( + HeaderName::from_static("permissions-policy"), + permissions_policy, + ), + ] { + headers + .entry(name) + .or_insert(HeaderValue::from_static(value)); + } +} diff --git a/fluxer_common/src/shutdown.rs b/fluxer_common/src/shutdown.rs new file mode 100644 index 000000000..b4cc15b71 --- /dev/null +++ b/fluxer_common/src/shutdown.rs @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub async fn wait_for_shutdown() { + let ctrl_c = tokio::signal::ctrl_c(); + + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + let mut sigterm = + signal(SignalKind::terminate()).expect("failed to install SIGTERM handler"); + tokio::select! { + _ = ctrl_c => { + tracing::info!("received SIGINT, beginning shutdown"); + } + _ = sigterm.recv() => { + tracing::info!("received SIGTERM, beginning shutdown"); + } + } + } + + #[cfg(not(unix))] + { + ctrl_c.await.ok(); + tracing::info!("received SIGINT, beginning shutdown"); + } +} diff --git a/fluxer_common/src/time.rs b/fluxer_common/src/time.rs new file mode 100644 index 000000000..170524908 --- /dev/null +++ b/fluxer_common/src/time.rs @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn unix_millis() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +pub fn unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/fluxer_common/src/user_partial.rs b/fluxer_common/src/user_partial.rs new file mode 100644 index 000000000..5acd38cbd --- /dev/null +++ b/fluxer_common/src/user_partial.rs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::user_flags::{USER_FLAG_STAFF, visible_user_flags}; +use serde::{Deserialize, Serialize}; + +pub const FLUXER_SYSTEM_USER_ID: i64 = 0; +pub const FLUXER_SYSTEM_USERNAME: &str = "Fluxer"; +pub const FLUXER_SYSTEM_DISCRIMINATOR: i32 = 0; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiUserPartial { + pub id: String, + pub username: String, + pub discriminator: String, + pub global_name: Option, + pub avatar: Option, + pub avatar_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bot: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system: Option, + pub flags: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub mention_flags: Option, +} + +pub struct UserPartialFields { + pub user_id: i64, + pub username: String, + pub discriminator: i32, + pub global_name: Option, + pub avatar_hash: Option, + pub avatar_color: Option, + pub bot: Option, + pub system: Option, + pub flags: Option, + pub mention_flags: Option, +} + +impl From for ApiUserPartial { + fn from(partial: UserPartialFields) -> Self { + if partial.user_id == FLUXER_SYSTEM_USER_ID { + return fluxer_system_user(); + } + Self { + id: partial.user_id.to_string(), + username: partial.username, + discriminator: format!("{:04}", partial.discriminator), + global_name: partial.global_name, + avatar: partial.avatar_hash, + avatar_color: partial.avatar_color, + bot: partial.bot.filter(|bot| *bot), + system: partial.system.filter(|system| *system), + flags: visible_user_flags(partial.flags.unwrap_or_default()), + mention_flags: partial.mention_flags.filter(|flags| *flags != 0), + } + } +} + +pub fn fluxer_system_user() -> ApiUserPartial { + ApiUserPartial { + id: FLUXER_SYSTEM_USER_ID.to_string(), + username: FLUXER_SYSTEM_USERNAME.to_owned(), + discriminator: format!("{:04}", FLUXER_SYSTEM_DISCRIMINATOR), + global_name: None, + avatar: None, + avatar_color: None, + bot: Some(true), + system: Some(true), + flags: USER_FLAG_STAFF as i32, + mention_flags: None, + } +} diff --git a/fluxer_desktop/electron-builder.config.cjs b/fluxer_desktop/electron-builder.config.cjs index 23d482722..d5ca80c6e 100644 --- a/fluxer_desktop/electron-builder.config.cjs +++ b/fluxer_desktop/electron-builder.config.cjs @@ -278,7 +278,6 @@ const nativeRuntimeFilePatterns = [ 'node_modules/@fluxer/linux-input-hook/*.node', 'node_modules/@fluxer/platform-info/package.json', 'node_modules/@fluxer/platform-info/index.js', - 'node_modules/@fluxer/platform-info/pure.cjs', 'node_modules/@fluxer/platform-info/loader-diagnostics.cjs', 'node_modules/@fluxer/platform-info/*.node', 'node_modules/@fluxer/webauthn/package.json', @@ -294,12 +293,10 @@ const nativeRuntimeFilePatterns = [ 'node_modules/@fluxer/hardware-encoder/*.node', 'node_modules/@fluxer/app-store/package.json', 'node_modules/@fluxer/app-store/index.js', - 'node_modules/@fluxer/app-store/pure.cjs', 'node_modules/@fluxer/app-store/loader-diagnostics.cjs', 'node_modules/@fluxer/app-store/*.node', 'node_modules/@fluxer/gateway-socket/package.json', 'node_modules/@fluxer/gateway-socket/index.js', - 'node_modules/@fluxer/gateway-socket/pure.cjs', 'node_modules/@fluxer/gateway-socket/loader-diagnostics.cjs', 'node_modules/@fluxer/gateway-socket/*.node', 'node_modules/.pnpm/@fluxer+*/node_modules/@fluxer/*/loader-diagnostics.cjs', diff --git a/fluxer_desktop/native/app-store/index.js b/fluxer_desktop/native/app-store/index.js index 60adff85e..0155331bf 100644 --- a/fluxer_desktop/native/app-store/index.js +++ b/fluxer_desktop/native/app-store/index.js @@ -3,7 +3,20 @@ const {existsSync} = require('node:fs'); const {join, sep} = require('node:path'); const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs'); -const {nativeFileName} = require('./pure.cjs'); +function nativeFileName(platform = process.platform, arch = process.arch) { + switch (platform) { + case 'darwin': + if (arch === 'x64' || arch === 'arm64') return `app-store.darwin-${arch}.node`; + break; + case 'win32': + if (arch === 'x64' || arch === 'arm64') return `app-store.win32-${arch}-msvc.node`; + break; + case 'linux': + if (arch === 'x64' || arch === 'arm64') return `app-store.linux-${arch}-gnu.node`; + break; + } + throw new Error(`Unsupported app-store target: ${platform}/${arch}`); +} const MODULE_NAME = '@fluxer/app-store'; const SKIP_NATIVE_PROBE_ENV = 'FLUXER_APP_STORE_SKIP_NATIVE_PROBE'; @@ -45,7 +58,4 @@ try { module.exports = { AppStore: binding ? binding.AppStore : null, loadError, - _private: { - nativeFileName, - }, }; diff --git a/fluxer_desktop/native/app-store/package.json b/fluxer_desktop/native/app-store/package.json index 280e7a4c5..7ac1f8295 100644 --- a/fluxer_desktop/native/app-store/package.json +++ b/fluxer_desktop/native/app-store/package.json @@ -14,7 +14,6 @@ "index.js", "index.d.ts", "loader-diagnostics.cjs", - "pure.cjs", "app-store.darwin-x64.node", "app-store.darwin-arm64.node", "app-store.win32-x64-msvc.node", diff --git a/fluxer_desktop/native/app-store/pure.cjs b/fluxer_desktop/native/app-store/pure.cjs deleted file mode 100644 index 22b08ec43..000000000 --- a/fluxer_desktop/native/app-store/pure.cjs +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -function nativeFileName(platform = process.platform, arch = process.arch) { - switch (platform) { - case 'darwin': - if (arch === 'x64' || arch === 'arm64') return `app-store.darwin-${arch}.node`; - break; - case 'win32': - if (arch === 'x64' || arch === 'arm64') return `app-store.win32-${arch}-msvc.node`; - break; - case 'linux': - if (arch === 'x64' || arch === 'arm64') return `app-store.linux-${arch}-gnu.node`; - break; - } - throw new Error(`Unsupported app-store target: ${platform}/${arch}`); -} - -module.exports = { - nativeFileName, -}; diff --git a/fluxer_desktop/native/audio-apm/src/lib.rs b/fluxer_desktop/native/audio-apm/src/lib.rs deleted file mode 100644 index becfc3511..000000000 --- a/fluxer_desktop/native/audio-apm/src/lib.rs +++ /dev/null @@ -1,427 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -#![deny(clippy::too_many_lines)] -#![deny(clippy::unwrap_used)] - -pub const APM_FRAME_MS: u32 = 10; -pub const APM_MAX_SAMPLE_RATE: u32 = 48_000; -pub const APM_MAX_CHANNELS: u16 = 2; -pub const APM_MAX_FRAME_SAMPLES: usize = - (APM_FRAME_MS as usize) * (APM_MAX_SAMPLE_RATE as usize) / 1000; - -pub const APM_MIN_SAMPLE_RATE: u32 = 8_000; -pub const APM_MIN_CHANNELS: u16 = 1; - -const _: () = assert!(APM_MAX_FRAME_SAMPLES == 480); -const _: () = assert!(APM_MIN_SAMPLE_RATE <= APM_MAX_SAMPLE_RATE); -const _: () = assert!(APM_MIN_CHANNELS <= APM_MAX_CHANNELS); - -#[derive(Debug, PartialEq, Eq, Clone)] -pub enum ApmError { - SampleRateOutOfRange { - sample_rate_hz: u32, - }, - SampleRateMismatch { - expected_hz: u32, - observed_hz: u32, - }, - ChannelsOutOfRange { - channels: u16, - }, - ChannelsMismatch { - expected: u16, - observed: u16, - }, - FrameLengthMismatch { - expected_samples: usize, - observed_samples: usize, - }, - NotInitialized, -} - -impl core::fmt::Display for ApmError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - ApmError::SampleRateOutOfRange { sample_rate_hz } => write!( - f, - "sample rate {sample_rate_hz} hz outside [{APM_MIN_SAMPLE_RATE}, {APM_MAX_SAMPLE_RATE}]", - ), - ApmError::SampleRateMismatch { - expected_hz, - observed_hz, - } => write!( - f, - "sample rate mismatch: expected={expected_hz} observed={observed_hz}", - ), - ApmError::ChannelsOutOfRange { channels } => write!( - f, - "channel count {channels} outside [{APM_MIN_CHANNELS}, {APM_MAX_CHANNELS}]", - ), - ApmError::ChannelsMismatch { expected, observed } => write!( - f, - "channels mismatch: expected={expected} observed={observed}", - ), - ApmError::FrameLengthMismatch { - expected_samples, - observed_samples, - } => write!( - f, - "frame length mismatch: expected={expected_samples} observed={observed_samples}", - ), - ApmError::NotInitialized => write!(f, "audio processor not initialized"), - } - } -} - -impl std::error::Error for ApmError {} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct AecMetrics { - pub echo_return_loss_db: f32, - pub echo_return_loss_enhancement_db: f32, - pub delay_ms: i32, -} - -impl AecMetrics { - pub const NEUTRAL: AecMetrics = AecMetrics { - echo_return_loss_db: 0.0, - echo_return_loss_enhancement_db: 0.0, - delay_ms: 0, - }; -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct ApmReport { - pub aec_metrics: AecMetrics, - pub voice_detected: bool, - pub level_dbfs: f32, -} - -impl ApmReport { - pub const NEUTRAL: ApmReport = ApmReport { - aec_metrics: AecMetrics::NEUTRAL, - voice_detected: false, - level_dbfs: -120.0, - }; -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct ApmConfig { - pub aec_enabled: bool, - pub ns_enabled: bool, - pub agc_enabled: bool, - pub aec_mobile_mode: bool, - pub target_level_dbfs: i32, -} - -impl Default for ApmConfig { - fn default() -> Self { - ApmConfig { - aec_enabled: true, - ns_enabled: true, - agc_enabled: true, - aec_mobile_mode: false, - target_level_dbfs: -3, - } - } -} - -pub struct ApmConfigBuilder { - config: ApmConfig, -} - -impl ApmConfigBuilder { - pub fn new() -> Self { - ApmConfigBuilder { - config: ApmConfig::default(), - } - } - - pub fn aec(mut self, enabled: bool) -> Self { - self.config.aec_enabled = enabled; - self - } - - pub fn ns(mut self, enabled: bool) -> Self { - self.config.ns_enabled = enabled; - self - } - - pub fn agc(mut self, enabled: bool) -> Self { - self.config.agc_enabled = enabled; - self - } - - pub fn aec_mobile_mode(mut self, enabled: bool) -> Self { - self.config.aec_mobile_mode = enabled; - self - } - - pub fn target_level_dbfs(mut self, target: i32) -> Self { - self.config.target_level_dbfs = target; - self - } - - pub fn build(self) -> ApmConfig { - assert!(self.config.target_level_dbfs <= 0); - assert!(self.config.target_level_dbfs >= -60); - self.config - } -} - -impl Default for ApmConfigBuilder { - fn default() -> Self { - ApmConfigBuilder::new() - } -} - -pub trait AudioProcessor: Send { - fn process_capture_frame( - &mut self, - samples: &mut [i16], - sample_rate_hz: u32, - channels: u16, - ) -> Result; - - fn process_render_frame( - &mut self, - samples: &[i16], - sample_rate_hz: u32, - channels: u16, - ) -> Result<(), ApmError>; - - fn reset(&mut self) -> Result<(), ApmError>; -} - -pub fn expected_frame_samples(sample_rate_hz: u32, channels: u16) -> usize { - assert!(sample_rate_hz >= APM_MIN_SAMPLE_RATE); - assert!(sample_rate_hz <= APM_MAX_SAMPLE_RATE); - assert!(channels >= APM_MIN_CHANNELS); - assert!(channels <= APM_MAX_CHANNELS); - let per_channel = (APM_FRAME_MS as usize) * (sample_rate_hz as usize) / 1000; - per_channel * (channels as usize) -} - -pub(crate) fn validate_frame_shape( - samples_len: usize, - sample_rate_hz: u32, - channels: u16, - expected_sample_rate_hz: u32, - expected_channels: u16, -) -> Result<(), ApmError> { - assert!(expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE); - assert!(expected_sample_rate_hz <= APM_MAX_SAMPLE_RATE); - if !(APM_MIN_SAMPLE_RATE..=APM_MAX_SAMPLE_RATE).contains(&sample_rate_hz) { - return Err(ApmError::SampleRateOutOfRange { sample_rate_hz }); - } - if !(APM_MIN_CHANNELS..=APM_MAX_CHANNELS).contains(&channels) { - return Err(ApmError::ChannelsOutOfRange { channels }); - } - if sample_rate_hz != expected_sample_rate_hz { - return Err(ApmError::SampleRateMismatch { - expected_hz: expected_sample_rate_hz, - observed_hz: sample_rate_hz, - }); - } - if channels != expected_channels { - return Err(ApmError::ChannelsMismatch { - expected: expected_channels, - observed: channels, - }); - } - let expected_samples = expected_frame_samples(sample_rate_hz, channels); - if samples_len != expected_samples { - return Err(ApmError::FrameLengthMismatch { - expected_samples, - observed_samples: samples_len, - }); - } - Ok(()) -} - -#[derive(Debug)] -pub struct StubAudioProcessor { - config: ApmConfig, - expected_sample_rate_hz: u32, - expected_channels: u16, - capture_frames_processed: u64, - render_frames_processed: u64, -} - -impl StubAudioProcessor { - pub fn new(config: ApmConfig, sample_rate_hz: u32, channels: u16) -> Result { - if !(APM_MIN_SAMPLE_RATE..=APM_MAX_SAMPLE_RATE).contains(&sample_rate_hz) { - return Err(ApmError::SampleRateOutOfRange { sample_rate_hz }); - } - if !(APM_MIN_CHANNELS..=APM_MAX_CHANNELS).contains(&channels) { - return Err(ApmError::ChannelsOutOfRange { channels }); - } - assert!(sample_rate_hz >= APM_MIN_SAMPLE_RATE); - assert!(channels >= APM_MIN_CHANNELS); - Ok(StubAudioProcessor { - config, - expected_sample_rate_hz: sample_rate_hz, - expected_channels: channels, - capture_frames_processed: 0, - render_frames_processed: 0, - }) - } - - pub fn config(&self) -> ApmConfig { - self.config - } - - pub fn capture_frames_processed(&self) -> u64 { - self.capture_frames_processed - } - - pub fn render_frames_processed(&self) -> u64 { - self.render_frames_processed - } -} - -impl AudioProcessor for StubAudioProcessor { - fn process_capture_frame( - &mut self, - samples: &mut [i16], - sample_rate_hz: u32, - channels: u16, - ) -> Result { - assert!(self.expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE); - assert!(self.expected_channels >= APM_MIN_CHANNELS); - validate_frame_shape( - samples.len(), - sample_rate_hz, - channels, - self.expected_sample_rate_hz, - self.expected_channels, - )?; - assert!(self.capture_frames_processed < u64::MAX); - self.capture_frames_processed = self.capture_frames_processed.saturating_add(1); - Ok(ApmReport::NEUTRAL) - } - - fn process_render_frame( - &mut self, - samples: &[i16], - sample_rate_hz: u32, - channels: u16, - ) -> Result<(), ApmError> { - assert!(self.expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE); - assert!(self.expected_channels >= APM_MIN_CHANNELS); - validate_frame_shape( - samples.len(), - sample_rate_hz, - channels, - self.expected_sample_rate_hz, - self.expected_channels, - )?; - assert!(self.render_frames_processed < u64::MAX); - self.render_frames_processed = self.render_frames_processed.saturating_add(1); - Ok(()) - } - - fn reset(&mut self) -> Result<(), ApmError> { - assert!(self.expected_sample_rate_hz >= APM_MIN_SAMPLE_RATE); - assert!(self.expected_channels >= APM_MIN_CHANNELS); - self.capture_frames_processed = 0; - self.render_frames_processed = 0; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_frame(sample_rate_hz: u32, channels: u16) -> Vec { - let n = expected_frame_samples(sample_rate_hz, channels); - (0..n).map(|i| (i as i16).wrapping_mul(7)).collect() - } - - #[test] - fn rejects_mismatched_sample_rate() { - let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor"); - let mut samples = make_frame(16_000, 1); - let err = stub - .process_capture_frame(&mut samples, 16_000, 1) - .expect_err("err"); - assert!(matches!(err, ApmError::SampleRateMismatch { .. })); - } - - #[test] - fn rejects_mismatched_channels() { - let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor"); - let mut samples = make_frame(48_000, 2); - let err = stub - .process_capture_frame(&mut samples, 48_000, 2) - .expect_err("err"); - assert!(matches!(err, ApmError::ChannelsMismatch { .. })); - } - - #[test] - fn rejects_wrong_frame_length() { - let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor"); - let mut samples = vec![0i16; 100]; - let err = stub - .process_capture_frame(&mut samples, 48_000, 1) - .expect_err("err"); - assert!(matches!(err, ApmError::FrameLengthMismatch { .. })); - } - - #[test] - fn rejects_render_frame_length() { - let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor"); - let samples = vec![0i16; 99]; - let err = stub - .process_render_frame(&samples, 48_000, 1) - .expect_err("err"); - assert!(matches!(err, ApmError::FrameLengthMismatch { .. })); - } - - #[test] - fn rejects_sample_rate_out_of_range_on_construct() { - let err = StubAudioProcessor::new(ApmConfig::default(), 4_000, 1).expect_err("err"); - assert!(matches!(err, ApmError::SampleRateOutOfRange { .. })); - } - - #[test] - fn rejects_channels_out_of_range_on_construct() { - let err = StubAudioProcessor::new(ApmConfig::default(), 48_000, 4).expect_err("err"); - assert!(matches!(err, ApmError::ChannelsOutOfRange { .. })); - } - - #[test] - fn state_preserved_across_many_frames() { - let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 1).expect("ctor"); - let mut samples = make_frame(48_000, 1); - const N: u64 = 100; - for _ in 0..N { - stub.process_capture_frame(&mut samples, 48_000, 1) - .expect("ok"); - } - assert_eq!(stub.capture_frames_processed(), N); - let render = vec![0i16; expected_frame_samples(48_000, 1)]; - for _ in 0..N { - stub.process_render_frame(&render, 48_000, 1).expect("ok"); - } - assert_eq!(stub.render_frames_processed(), N); - } - - #[test] - fn stereo_round_trip_capture_succeeds() { - let mut stub = StubAudioProcessor::new(ApmConfig::default(), 48_000, 2).expect("ctor"); - let mut samples = make_frame(48_000, 2); - let original = samples.clone(); - let report = stub - .process_capture_frame(&mut samples, 48_000, 2) - .expect("ok"); - assert_eq!(samples, original); - assert_eq!(report, ApmReport::NEUTRAL); - } - - #[test] - fn validate_frame_shape_accepts_canonical_48k_mono() { - validate_frame_shape(480, 48_000, 1, 48_000, 1).expect("ok"); - } -} diff --git a/fluxer_desktop/native/encoder-ring/Cargo.lock b/fluxer_desktop/native/encoder-ring/Cargo.lock deleted file mode 100644 index 2d0e3a173..000000000 --- a/fluxer_desktop/native/encoder-ring/Cargo.lock +++ /dev/null @@ -1,870 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloca" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" -dependencies = [ - "cc", -] - -[[package]] -name = "anes" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "bindgen" -version = "0.73.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "787ef8ef523575546b106a58213d6e6b06198a05c2f757258c68a74273670cfa" -dependencies = [ - "bitflags", - "cexpr", - "clang-sys", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", - "syn 3.0.6", -] - -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" - -[[package]] -name = "cc" -version = "1.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - -[[package]] -name = "clang-sys" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" -dependencies = [ - "glob", - "libc", - "libloading 0.8.9", -] - -[[package]] -name = "clap" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" -dependencies = [ - "clap_builder", -] - -[[package]] -name = "clap_builder" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" -dependencies = [ - "anstyle", - "clap_lex", -] - -[[package]] -name = "clap_lex" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" - -[[package]] -name = "criterion" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" -dependencies = [ - "alloca", - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "itertools", - "num-traits", - "oorandom", - "page_size", - "plotters", - "rayon", - "regex", - "serde", - "serde_json", - "tinytemplate", - "walkdir", -] - -[[package]] -name = "criterion-plot" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" -dependencies = [ - "cast", - "itertools", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" - -[[package]] -name = "find-msvc-tools" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" - -[[package]] -name = "fluxer_encoder_ring" -version = "0.0.0" -dependencies = [ - "bindgen", - "criterion", - "libloading 0.9.0", - "windows", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "glob" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "libloading" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - -[[package]] -name = "page_size" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "plotters" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" -dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" -dependencies = [ - "plotters-backend", -] - -[[package]] -name = "prettyplease" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" -dependencies = [ - "proc-macro2", - "syn 3.0.6", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "unicode-ident" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.6", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[package]] -name = "zerocopy" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/fluxer_desktop/native/encoder-ring/Cargo.toml b/fluxer_desktop/native/encoder-ring/Cargo.toml deleted file mode 100644 index eda52ce9c..000000000 --- a/fluxer_desktop/native/encoder-ring/Cargo.toml +++ /dev/null @@ -1,45 +0,0 @@ -[package] -name = "fluxer_encoder_ring" -version = "0.0.0" -edition = "2024" -license = "AGPL-3.0-or-later" -publish = false - -[workspace] -resolver = "2" - -[lib] -crate-type = ["rlib"] - -[dependencies] - -[target.'cfg(target_os = "windows")'.dependencies] -windows = {version = "0.62.2", features = [ - "Win32_Foundation", - "Win32_Graphics_Direct3D", - "Win32_Graphics_Direct3D11", - "Win32_Graphics_Dxgi", - "Win32_Graphics_Dxgi_Common", - "Win32_Security", - "Win32_System_Threading", - "Win32_System_LibraryLoader", -]} -libloading = "0.9" - -[build-dependencies] -bindgen = {version = "0.73", optional = true} - -[features] -default = [] -bindgen-sdk = ["dep:bindgen"] - -[dev-dependencies] -criterion = "0.8" - -[[bench]] -name = "ring" -harness = false - -[[bench]] -name = "encoder_handoff" -harness = false diff --git a/fluxer_desktop/native/encoder-ring/benches/baseline.json b/fluxer_desktop/native/encoder-ring/benches/baseline.json deleted file mode 100644 index de7958464..000000000 --- a/fluxer_desktop/native/encoder-ring/benches/baseline.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "measured_at": "85e057a273fd", - "host": "darwin-arm64-apple-silicon", - "regression_budget_percent": 5.0, - "criterion_args": { - "warm_up_time_sec": 2, - "measurement_time_sec": 5 - }, - "benches": { - "encoder_input_ring/submit_poll_release_1080p_nv12": { - "median_ns": 16.806, - "low_ns": 16.663, - "high_ns": 16.951, - "budget_percent_override": 10.0, - "note": "~17ns hot loop; same noise-floor reasoning as other sub-50ns benches." - }, - "encoder_input_ring/submit_then_drain_eight_1080p_nv12": { - "median_ns": 112590.0, - "low_ns": 108530.0, - "high_ns": 117050.0, - "budget_percent_override": 10.0, - "note": "8x submit + 8x drain. Allocator-touchy; observed cross-run sigma ~5-7% on a loaded host." - } - } -} diff --git a/fluxer_desktop/native/encoder-ring/benches/encoder_handoff.rs b/fluxer_desktop/native/encoder-ring/benches/encoder_handoff.rs deleted file mode 100644 index 3f04f010b..000000000 --- a/fluxer_desktop/native/encoder-ring/benches/encoder_handoff.rs +++ /dev/null @@ -1,143 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::hint::black_box; - -use criterion::{Criterion, criterion_group, criterion_main}; -use fluxer_encoder_ring::{ - EncoderDims, EncoderSubmission, NotImplementedHandoff, NvencHandoff, RingError, - apply_dts_offset, compute_dts_offset_us, -}; - -struct NoopCallback; -impl fluxer_encoder_ring::encoder_handoff::EncoderCompletionCallback for NoopCallback { - fn on_complete(&mut self, _sequence: u64, _encoded_bytes: u32) {} -} - -fn bench_stub_encode_round_trip(c: &mut Criterion) { - let mut group = c.benchmark_group("encoder_handoff"); - group.sample_size(60); - group.bench_function("stub_encode_returns_not_implemented", |b| { - let mut handoff = NotImplementedHandoff::nvenc(); - let mut cb = NoopCallback; - let dims = EncoderDims::new(1920, 1080); - let submission = EncoderSubmission::new(0xfeed_face, 0, dims, 1); - b.iter(|| { - let result: Result<(), RingError> = - NvencHandoff::encode_shared(&mut handoff, submission, &mut cb); - black_box(result.err()); - }); - }); - group.finish(); -} - -fn bench_dts_offset_computation(c: &mut Criterion) { - let mut group = c.benchmark_group("encoder_handoff"); - group.sample_size(120); - group.bench_function("dts_offset_compute_apply", |b| { - b.iter(|| { - let offset = compute_dts_offset_us(0, 2, 16_666); - let dts = apply_dts_offset(black_box(100_000), offset); - black_box(dts); - }); - }); - group.finish(); -} - -#[cfg(target_os = "windows")] -fn bench_nvenc_single_frame_round_trip(c: &mut Criterion) { - use fluxer_encoder_ring::{NvencD3D11Handoff, PicParams}; - use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL_11_0}; - use windows::Win32::Graphics::Direct3D11::{ - D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_CREATE_DEVICE_BGRA_SUPPORT, - D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, D3D11_RESOURCE_MISC_SHARED_NTHANDLE, - D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, D3D11CreateDevice, - ID3D11Device, - }; - use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_NV12, DXGI_SAMPLE_DESC}; - use windows::Win32::Graphics::Dxgi::IDXGIResource1; - use windows::core::Interface; - let mut device: Option = None; - let feature_levels = [D3D_FEATURE_LEVEL_11_0]; - let _ = unsafe { - D3D11CreateDevice( - None, - D3D_DRIVER_TYPE_HARDWARE, - Default::default(), - D3D11_CREATE_DEVICE_BGRA_SUPPORT, - Some(&feature_levels), - D3D11_SDK_VERSION, - Some(&mut device), - None, - None, - ) - }; - let device = match device { - Some(d) => d, - None => return, - }; - let dims = EncoderDims::new(1920, 1080); - let handoff_result = NvencD3D11Handoff::new(device.clone(), dims, 5_000_000); - let mut handoff = match handoff_result { - Ok(h) => h, - Err(_) => return, - }; - let desc = D3D11_TEXTURE2D_DESC { - Width: 1920, - Height: 1080, - MipLevels: 1, - ArraySize: 1, - Format: DXGI_FORMAT_NV12, - SampleDesc: DXGI_SAMPLE_DESC { - Count: 1, - Quality: 0, - }, - Usage: D3D11_USAGE_DEFAULT, - BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, - CPUAccessFlags: 0, - MiscFlags: (D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX.0 | D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0) - as u32, - }; - let mut texture = None; - let _ = unsafe { device.CreateTexture2D(&desc, None, Some(&mut texture)) }; - let texture = match texture { - Some(t) => t, - None => return, - }; - let resource: IDXGIResource1 = match texture.cast() { - Ok(r) => r, - Err(_) => return, - }; - let shared = - match unsafe { resource.CreateSharedHandle(None, 0x3, windows::core::PCWSTR::null()) } { - Ok(h) => h.0 as u64, - Err(_) => return, - }; - let slot = match handoff.register_slot(shared, 0, dims) { - Ok(s) => s, - Err(_) => return, - }; - let mut counter: u64 = 0; - let mut group = c.benchmark_group("encoder_handoff"); - group.sample_size(20); - group.bench_function("nvenc_1080p_nv12_single_frame", |b| { - b.iter(|| { - let pic = PicParams::new(counter * 16_666, counter == 0); - counter += 1; - let _ = handoff.encode_shared_async(slot, 0, dims, pic); - let _ = handoff.poll_completed(slot); - }); - }); - group.finish(); - handoff.unregister_slot(slot); -} - -#[cfg(not(target_os = "windows"))] -fn bench_nvenc_single_frame_round_trip(_c: &mut Criterion) {} - -criterion_group!( - benches, - bench_stub_encode_round_trip, - bench_dts_offset_computation, - bench_nvenc_single_frame_round_trip, -); -criterion_main!(benches); diff --git a/fluxer_desktop/native/encoder-ring/benches/ring.rs b/fluxer_desktop/native/encoder-ring/benches/ring.rs deleted file mode 100644 index 95314d17c..000000000 --- a/fluxer_desktop/native/encoder-ring/benches/ring.rs +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::hint::black_box; - -use criterion::{Criterion, criterion_group, criterion_main}; -use fluxer_encoder_ring::{CpuMemcpyBackend, EncoderInputRing, TextureFormat}; - -fn bench_submit_poll_release_cycle(c: &mut Criterion) { - let mut group = c.benchmark_group("encoder_input_ring"); - group.sample_size(60); - group.bench_function("submit_poll_release_1080p_nv12", |b| { - let mut ring = EncoderInputRing::new(CpuMemcpyBackend::new()); - ring.initialise(1920, 1080, TextureFormat::Nv12) - .expect("init"); - b.iter(|| { - ring.submit(|_h| {}).expect("submit"); - let ready = ring.poll_next_ready().expect("ready"); - black_box(&ready); - ring.release_completed(ready).expect("release"); - }); - }); - group.finish(); -} - -fn bench_submit_only_drained_after(c: &mut Criterion) { - let mut group = c.benchmark_group("encoder_input_ring"); - group.sample_size(60); - group.bench_function("submit_then_drain_eight_1080p_nv12", |b| { - b.iter(|| { - let mut ring = EncoderInputRing::new(CpuMemcpyBackend::new()); - ring.initialise(1920, 1080, TextureFormat::Nv12) - .expect("init"); - for _ in 0..8 { - ring.submit(|_h| {}).expect("submit"); - } - for _ in 0..8 { - let ready = ring.poll_next_ready().expect("ready"); - ring.release_completed(ready).expect("release"); - } - black_box(ring.metrics()); - }); - }); - group.finish(); -} - -criterion_group!( - benches, - bench_submit_poll_release_cycle, - bench_submit_only_drained_after -); -criterion_main!(benches); diff --git a/fluxer_desktop/native/encoder-ring/build.rs b/fluxer_desktop/native/encoder-ring/build.rs deleted file mode 100644 index f99d32093..000000000 --- a/fluxer_desktop/native/encoder-ring/build.rs +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::env; -use std::path::PathBuf; - -fn main() { - let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); - if target_os != "windows" { - println!("cargo:rerun-if-changed=build.rs"); - return; - } - println!("cargo:rerun-if-env-changed=BINDGEN_NVENC_HEADERS"); - println!("cargo:rerun-if-env-changed=BINDGEN_AMF_HEADERS"); - println!("cargo:rerun-if-env-changed=BINDGEN_QSV_HEADERS"); - println!("cargo:rerun-if-changed=build.rs"); - #[cfg(feature = "bindgen-sdk")] - { - if let Some(path) = sdk_path("BINDGEN_NVENC_HEADERS", DEFAULT_NVENC_INCLUDE) { - generate_nvenc_bindings(path); - } - if let Some(path) = sdk_path("BINDGEN_AMF_HEADERS", DEFAULT_AMF_INCLUDE) { - generate_amf_bindings(path); - } - if let Some(path) = sdk_path("BINDGEN_QSV_HEADERS", DEFAULT_QSV_INCLUDE) { - generate_qsv_bindings(path); - } - } -} - -#[allow(dead_code)] -const DEFAULT_NVENC_INCLUDE: &str = r"C:\Users\Hampus\sdk\nv-codec-headers\include"; -#[allow(dead_code)] -const DEFAULT_AMF_INCLUDE: &str = r"C:\Users\Hampus\sdk\AMF\amf\public\include"; -#[allow(dead_code)] -const DEFAULT_QSV_INCLUDE: &str = r"C:\Users\Hampus\sdk\libvpl\api\vpl"; - -#[allow(dead_code)] -fn sdk_path(env_var: &str, default: &str) -> Option { - let value = env::var(env_var).unwrap_or_else(|_| default.to_string()); - let path = PathBuf::from(&value); - if path.exists() { Some(path) } else { None } -} - -#[cfg(feature = "bindgen-sdk")] -fn generate_nvenc_bindings(include_root: PathBuf) { - let header = include_root.join("ffnvcodec").join("nvEncodeAPI.h"); - if !header.exists() { - println!("cargo:warning=NVENC header missing at {}", header.display()); - return; - } - let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); - let bindings = bindgen::Builder::default() - .header(header.to_string_lossy()) - .clang_arg(format!("-I{}", include_root.display())) - .allowlist_type("NV_ENC.*") - .allowlist_function("NvEncodeAPI.*") - .allowlist_var("NV_ENC.*") - .layout_tests(false) - .generate() - .expect("nvenc bindgen"); - bindings - .write_to_file(out_dir.join("nvenc_bindings.rs")) - .expect("write nvenc bindings"); -} - -#[cfg(feature = "bindgen-sdk")] -fn generate_amf_bindings(include_root: PathBuf) { - let header = include_root.join("components").join("VideoEncoderVCE.h"); - if !header.exists() { - println!("cargo:warning=AMF header missing at {}", header.display()); - return; - } - let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); - let bindings = bindgen::Builder::default() - .header(header.to_string_lossy()) - .clang_arg(format!("-I{}", include_root.display())) - .allowlist_type("AMF.*") - .layout_tests(false) - .generate() - .expect("amf bindgen"); - bindings - .write_to_file(out_dir.join("amf_bindings.rs")) - .expect("write amf bindings"); -} - -#[cfg(feature = "bindgen-sdk")] -fn generate_qsv_bindings(include_root: PathBuf) { - let header = include_root.join("mfxvideo.h"); - if !header.exists() { - println!("cargo:warning=QSV header missing at {}", header.display()); - return; - } - let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); - let bindings = bindgen::Builder::default() - .header(header.to_string_lossy()) - .clang_arg(format!("-I{}", include_root.display())) - .allowlist_type("mfx.*") - .allowlist_function("MFX.*") - .layout_tests(false) - .generate() - .expect("qsv bindgen"); - bindings - .write_to_file(out_dir.join("qsv_bindings.rs")) - .expect("write qsv bindings"); -} diff --git a/fluxer_desktop/native/encoder-ring/src/amf.rs b/fluxer_desktop/native/encoder-ring/src/amf.rs deleted file mode 100644 index 1d6d9b29f..000000000 --- a/fluxer_desktop/native/encoder-ring/src/amf.rs +++ /dev/null @@ -1,357 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::collections::HashMap; -use std::ffi::c_void; -use std::ptr; -use std::sync::Arc; - -use libloading::{Library, Symbol}; -use windows::Win32::Graphics::Direct3D11::ID3D11Device; -use windows::core::Interface; - -use crate::encoder_handoff::{ - AmfHandoff, EncodedBitstream, EncoderCompletionCallback, EncoderDims, EncoderError, - EncoderFrameRate, EncoderSubmission, HandoffSlot, PicParams, apply_dts_offset, - compute_dts_offset_us, -}; -use crate::ring::RingError; - -pub const AMF_DLL_NAME: &str = "amfrt64.dll"; - -const AMF_OK: i32 = 0; -#[allow( - dead_code, - reason = "documented as a possible AMF QueryOutput status code per the SDK" -)] -const AMF_REPEAT: i32 = 5; -#[allow( - dead_code, - reason = "documented as a possible AMF status code per the SDK" -)] -const AMF_NOT_READY: i32 = 1; - -type AmfStatus = i32; -type AmfInitFn = unsafe extern "C" fn(version: u64, factory: *mut *mut c_void) -> AmfStatus; - -#[repr(C)] -struct AmfFactoryVtbl { - query_interface: - unsafe extern "system" fn(*mut c_void, *const u128, *mut *mut c_void) -> AmfStatus, - acquire: unsafe extern "system" fn(*mut c_void) -> u32, - release: unsafe extern "system" fn(*mut c_void) -> u32, - create_context: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> AmfStatus, - create_component: unsafe extern "system" fn( - *mut c_void, - *mut c_void, - *const u16, - *mut *mut c_void, - ) -> AmfStatus, - set_cache_folder: unsafe extern "system" fn(*mut c_void, *const u16) -> AmfStatus, - get_cache_folder: unsafe extern "system" fn(*mut c_void) -> *const u16, - get_debug: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> AmfStatus, - get_trace: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> AmfStatus, - get_program_versions: - unsafe extern "system" fn(*mut c_void, *mut u32, *mut u32, *mut u32, *mut u32) -> AmfStatus, -} - -#[repr(C)] -struct AmfFactoryObject { - vtbl: *const AmfFactoryVtbl, -} - -struct SlotState { - pending_pts_us: u64, - pending_force_keyframe: bool, - in_flight: bool, -} - -pub struct AmfD3D11Handoff { - _library: Arc, - factory: *mut c_void, - context: *mut c_void, - encoder: *mut c_void, - slots: HashMap, - next_slot_index: u32, - dts_offset_us: i64, - completed_count: u64, - frame_interval_us: u64, -} - -unsafe impl Send for AmfD3D11Handoff {} - -impl AmfD3D11Handoff { - pub fn new( - device: ID3D11Device, - dims: EncoderDims, - bitrate_bps: u32, - ) -> Result { - Self::new_with_frame_rate(device, dims, bitrate_bps, EncoderFrameRate::default()) - } - - pub fn new_with_frame_rate( - device: ID3D11Device, - dims: EncoderDims, - bitrate_bps: u32, - frame_rate: EncoderFrameRate, - ) -> Result { - assert!(dims.width > 0, "width positive"); - assert!(dims.height > 0, "height positive"); - assert!(frame_rate.numerator > 0, "frame rate numerator positive"); - assert!( - frame_rate.denominator > 0, - "frame rate denominator positive" - ); - if dims.width > 7680 || dims.height > 4320 { - return Err(EncoderError::DimensionsOutOfRange { - width: dims.width, - height: dims.height, - }); - } - let library = load_runtime()?; - let factory = init_factory(&library)?; - let context = create_context(factory)?; - init_dx11_context(context, &device)?; - let encoder = create_video_encoder(factory, context, dims, bitrate_bps)?; - let frame_interval_us = frame_rate.frame_interval_us(); - let dts_offset_us = compute_dts_offset_us(0, 0, frame_interval_us); - let handoff = Self { - _library: Arc::new(library), - factory, - context, - encoder, - slots: HashMap::new(), - next_slot_index: 0, - dts_offset_us, - completed_count: 0, - frame_interval_us, - }; - assert!(!handoff.factory.is_null(), "factory non-null"); - assert!(handoff.completed_count == 0, "fresh state"); - Ok(handoff) - } -} - -fn load_runtime() -> Result { - let library = unsafe { Library::new(AMF_DLL_NAME) }.map_err(|_| EncoderError::SdkNotFound { - vendor: "amf", - dll: AMF_DLL_NAME, - })?; - Ok(library) -} - -fn init_factory(library: &Library) -> Result<*mut c_void, EncoderError> { - let init: Symbol<'_, AmfInitFn> = - unsafe { library.get(b"AMFInit\0") }.map_err(|_| EncoderError::SymbolMissing { - vendor: "amf", - symbol: "AMFInit", - })?; - let mut factory: *mut c_void = ptr::null_mut(); - const AMF_FULL_VERSION: u64 = (1_u64 << 48) | (4_u64 << 32) | (30_u64 << 16); - let status = unsafe { init(AMF_FULL_VERSION, &mut factory) }; - if status != AMF_OK { - return Err(EncoderError::SessionInitFailed { - vendor: "amf", - status: status as i64, - }); - } - if factory.is_null() { - return Err(EncoderError::SessionInitFailed { - vendor: "amf", - status: -1, - }); - } - Ok(factory) -} - -fn create_context(factory: *mut c_void) -> Result<*mut c_void, EncoderError> { - assert!(!factory.is_null(), "factory ptr non-null"); - let object = factory as *mut AmfFactoryObject; - let vtbl = unsafe { (*object).vtbl }; - let mut context: *mut c_void = ptr::null_mut(); - let status = unsafe { ((*vtbl).create_context)(factory, &mut context) }; - if status != AMF_OK { - return Err(EncoderError::SessionInitFailed { - vendor: "amf", - status: status as i64, - }); - } - if context.is_null() { - return Err(EncoderError::SessionInitFailed { - vendor: "amf", - status: -2, - }); - } - Ok(context) -} - -fn init_dx11_context(context: *mut c_void, device: &ID3D11Device) -> Result<(), EncoderError> { - assert!(!context.is_null(), "context non-null"); - let _ = device.as_raw(); - Ok(()) -} - -fn create_video_encoder( - factory: *mut c_void, - context: *mut c_void, - dims: EncoderDims, - bitrate_bps: u32, -) -> Result<*mut c_void, EncoderError> { - assert!(!factory.is_null(), "factory non-null"); - assert!(!context.is_null(), "context non-null"); - assert!(dims.width > 0, "width positive"); - let _ = bitrate_bps; - let component_id: Vec = "AMFVideoEncoderVCE_AVC\0".encode_utf16().collect(); - let object = factory as *mut AmfFactoryObject; - let vtbl = unsafe { (*object).vtbl }; - let mut encoder: *mut c_void = ptr::null_mut(); - let status = unsafe { - ((*vtbl).create_component)(factory, context, component_id.as_ptr(), &mut encoder) - }; - if status != AMF_OK { - return Err(EncoderError::SessionInitFailed { - vendor: "amf", - status: status as i64, - }); - } - if encoder.is_null() { - return Err(EncoderError::SessionInitFailed { - vendor: "amf", - status: -3, - }); - } - Ok(encoder) -} - -impl Drop for AmfD3D11Handoff { - fn drop(&mut self) { - self.slots.clear(); - if !self.factory.is_null() { - let object = self.factory as *mut AmfFactoryObject; - unsafe { - let vtbl = (*object).vtbl; - if !self.encoder.is_null() { - let _ = ((*vtbl).release)(self.encoder); - self.encoder = ptr::null_mut(); - } - if !self.context.is_null() { - let _ = ((*vtbl).release)(self.context); - self.context = ptr::null_mut(); - } - let _ = ((*vtbl).release)(self.factory); - } - self.factory = ptr::null_mut(); - } - } -} - -impl AmfHandoff for AmfD3D11Handoff { - fn register_slot( - &mut self, - shared_handle: u64, - _key: u64, - dims: EncoderDims, - ) -> Result { - assert!(shared_handle != 0, "shared_handle non-zero"); - assert!(dims.width > 0, "width positive"); - let slot_index = self.next_slot_index; - self.next_slot_index = self.next_slot_index.saturating_add(1); - let slot = HandoffSlot::new(slot_index, shared_handle); - self.slots.insert( - slot_index, - SlotState { - pending_pts_us: 0, - pending_force_keyframe: false, - in_flight: false, - }, - ); - assert!(self.slots.contains_key(&slot_index), "slot stored"); - Ok(slot) - } - - fn encode_shared_async( - &mut self, - slot: HandoffSlot, - _key: u64, - dims: EncoderDims, - pic_params: PicParams, - ) -> Result<(), EncoderError> { - assert!(slot.shared_handle != 0, "slot handle non-zero"); - assert!(dims.width > 0, "width positive"); - let state = self - .slots - .get_mut(&slot.slot_index) - .ok_or(EncoderError::SlotUnknown { - slot_index: slot.slot_index, - })?; - state.pending_pts_us = pic_params.pts_us; - state.pending_force_keyframe = pic_params.force_keyframe; - state.in_flight = true; - Ok(()) - } - - fn poll_completed(&mut self, slot: HandoffSlot) -> Option { - let state = self.slots.get_mut(&slot.slot_index)?; - if !state.in_flight { - return None; - } - state.in_flight = false; - let pts = state.pending_pts_us; - let dts = apply_dts_offset(pts, self.dts_offset_us); - let _ = state.pending_force_keyframe || self.completed_count == 0; - self.completed_count = self.completed_count.saturating_add(1); - let _ = (pts, dts); - None - } - - fn unregister_slot(&mut self, slot: HandoffSlot) { - self.slots.remove(&slot.slot_index); - } - - fn encode_shared( - &mut self, - submission: EncoderSubmission, - callback: &mut dyn EncoderCompletionCallback, - ) -> Result<(), RingError> { - assert!(submission.shared_handle != 0, "submission handle non-zero"); - assert!(submission.dims.width > 0, "submission width positive"); - let slot = self - .register_slot( - submission.shared_handle, - submission.keyed_mutex_key, - submission.dims, - ) - .map_err(|_| RingError::NotImplemented { - what: "amf::register_slot in encode_shared", - })?; - let pts_us = submission - .capture_pts_us - .unwrap_or_else(|| submission.sequence.saturating_mul(self.frame_interval_us)); - let pic = PicParams::new(pts_us, false); - AmfHandoff::encode_shared_async( - self, - slot, - submission.keyed_mutex_key, - submission.dims, - pic, - ) - .map_err(|_| RingError::NotImplemented { - what: "amf::encode_shared_async", - })?; - if let Some(bs) = AmfHandoff::poll_completed(self, slot) { - callback.on_complete(submission.sequence, bs.data.len() as u32); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn amf_status_constants_match_spec() { - assert_eq!(AMF_OK, 0); - assert_eq!(AMF_REPEAT, 5); - assert_eq!(AMF_NOT_READY, 1); - } -} diff --git a/fluxer_desktop/native/encoder-ring/src/backend.rs b/fluxer_desktop/native/encoder-ring/src/backend.rs deleted file mode 100644 index 97d25e73b..000000000 --- a/fluxer_desktop/native/encoder-ring/src/backend.rs +++ /dev/null @@ -1,366 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; - -use crate::{MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH, nv12_byte_size}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TextureFormat { - Nv12, - P010, - Bgra8, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BackendError { - DimensionsOutOfRange { width: u32, height: u32 }, - UnsupportedFormat { format: TextureFormat }, - PlatformUnsupported { reason: &'static str }, - KeyMismatch { expected: u64, observed: u64 }, - AcquireWhileWriting { slot_index: u32 }, - ReleaseWithoutAcquire { slot_index: u32 }, - WouldBlock { slot_index: u32 }, -} - -impl std::fmt::Display for BackendError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::DimensionsOutOfRange { width, height } => { - write!(f, "dimensions out of range: {width}x{height}") - } - Self::UnsupportedFormat { format } => write!(f, "unsupported format: {format:?}"), - Self::PlatformUnsupported { reason } => write!(f, "platform unsupported: {reason}"), - Self::KeyMismatch { expected, observed } => { - write!( - f, - "keyed-mutex key mismatch: expected={expected} observed={observed}" - ) - } - Self::AcquireWhileWriting { slot_index } => { - write!(f, "acquire_write while slot {slot_index} already acquired") - } - Self::ReleaseWithoutAcquire { slot_index } => { - write!(f, "release_write without acquire on slot {slot_index}") - } - Self::WouldBlock { slot_index } => { - write!( - f, - "keyed mutex busy on slot {slot_index}; skipped without blocking" - ) - } - } - } -} - -impl std::error::Error for BackendError {} - -pub const NUM_SLOTS_DEFAULT: usize = 8; - -pub trait KeyedMutexBackend: Send { - type SlotHandle: Send + Clone; - const NUM_SLOTS: usize; - - fn create_slots( - &mut self, - width: u32, - height: u32, - format: TextureFormat, - ) -> Result, BackendError>; - - fn acquire_write(&mut self, slot: &Self::SlotHandle, key: u64) -> Result<(), BackendError>; - - fn release_write(&mut self, slot: &Self::SlotHandle, next_key: u64) - -> Result<(), BackendError>; - - fn poll_complete(&mut self, slot: &Self::SlotHandle) -> bool; - - fn mark_consumed(&mut self, slot: &Self::SlotHandle); - - fn fill_test_pattern(&mut self, _slot: &Self::SlotHandle, _value: u8) {} -} - -#[derive(Clone)] -pub struct CpuSlotHandle { - inner: Arc, -} - -impl CpuSlotHandle { - pub fn slot_index(&self) -> u32 { - let idx = self.inner.slot_index; - assert!((idx as usize) < NUM_SLOTS_DEFAULT, "slot_index in range"); - assert!( - self.inner.buffer.len() == self.inner.byte_size, - "buffer matches byte_size" - ); - idx - } - - pub fn current_key(&self) -> u64 { - let key = self.inner.current_key.load(Ordering::Acquire); - assert!( - self.inner.buffer.len() == self.inner.byte_size, - "buffer intact" - ); - assert!( - (self.inner.slot_index as usize) < NUM_SLOTS_DEFAULT, - "slot_index intact" - ); - key - } - - pub fn buffer_len(&self) -> usize { - let len = self.inner.byte_size; - assert!(len > 0, "byte_size positive"); - assert!(self.inner.buffer.len() == len, "buffer matches byte_size"); - len - } - - pub fn write_byte(&self, offset: usize, value: u8) { - assert!(offset < self.inner.byte_size, "offset within buffer"); - assert!( - self.inner.acquired.load(Ordering::Acquire), - "writes only while acquired", - ); - unsafe { - let ptr = self.inner.buffer.as_ptr().add(offset) as *mut u8; - ptr.write_volatile(value); - } - } -} - -struct CpuSlotInner { - slot_index: u32, - byte_size: usize, - buffer: Vec, - current_key: AtomicU64, - acquired: AtomicBool, - completed: AtomicBool, -} - -pub struct CpuMemcpyBackend { - width: u32, - height: u32, - format: TextureFormat, - slots_created: bool, - slot_count: u32, -} - -impl CpuMemcpyBackend { - pub fn new() -> Self { - let backend = Self { - width: 0, - height: 0, - format: TextureFormat::Nv12, - slots_created: false, - slot_count: 0, - }; - assert!(!backend.slots_created, "fresh backend has no slots"); - assert_eq!(backend.slot_count, 0, "fresh slot_count zero"); - backend - } - - pub fn width(&self) -> u32 { - assert!(self.width <= MAX_FRAME_WIDTH, "width within cap"); - self.width - } - - pub fn height(&self) -> u32 { - assert!(self.height <= MAX_FRAME_HEIGHT, "height within cap"); - self.height - } -} - -impl Default for CpuMemcpyBackend { - fn default() -> Self { - Self::new() - } -} - -impl KeyedMutexBackend for CpuMemcpyBackend { - type SlotHandle = CpuSlotHandle; - const NUM_SLOTS: usize = NUM_SLOTS_DEFAULT; - - fn create_slots( - &mut self, - width: u32, - height: u32, - format: TextureFormat, - ) -> Result, BackendError> { - if width == 0 || height == 0 || width > MAX_FRAME_WIDTH || height > MAX_FRAME_HEIGHT { - return Err(BackendError::DimensionsOutOfRange { width, height }); - } - if !matches!(format, TextureFormat::Nv12) { - return Err(BackendError::UnsupportedFormat { format }); - } - assert!(!self.slots_created, "slots created once"); - let byte_size = nv12_byte_size(width, height); - assert!(byte_size > 0, "byte_size positive"); - let mut out: Vec = Vec::with_capacity(Self::NUM_SLOTS); - for idx in 0..Self::NUM_SLOTS { - let inner = CpuSlotInner { - slot_index: idx as u32, - byte_size, - buffer: vec![0u8; byte_size], - current_key: AtomicU64::new(0), - acquired: AtomicBool::new(false), - completed: AtomicBool::new(false), - }; - out.push(CpuSlotHandle { - inner: Arc::new(inner), - }); - } - self.width = width; - self.height = height; - self.format = format; - self.slots_created = true; - self.slot_count = Self::NUM_SLOTS as u32; - assert_eq!(out.len(), Self::NUM_SLOTS, "slot vector length"); - assert!(self.slots_created, "slots_created flipped"); - Ok(out) - } - - fn acquire_write(&mut self, slot: &CpuSlotHandle, key: u64) -> Result<(), BackendError> { - assert!(self.slots_created, "slots must exist before acquire"); - let current = slot.inner.current_key.load(Ordering::Acquire); - if current != key { - return Err(BackendError::KeyMismatch { - expected: key, - observed: current, - }); - } - let was_acquired = slot.inner.acquired.swap(true, Ordering::AcqRel); - if was_acquired { - return Err(BackendError::AcquireWhileWriting { - slot_index: slot.inner.slot_index, - }); - } - slot.inner.completed.store(false, Ordering::Release); - assert!( - slot.inner.acquired.load(Ordering::Acquire), - "acquired flag set" - ); - assert!( - !slot.inner.completed.load(Ordering::Acquire), - "completed cleared" - ); - Ok(()) - } - - fn release_write(&mut self, slot: &CpuSlotHandle, next_key: u64) -> Result<(), BackendError> { - assert!(self.slots_created, "slots must exist before release"); - let was_acquired = slot.inner.acquired.swap(false, Ordering::AcqRel); - if !was_acquired { - return Err(BackendError::ReleaseWithoutAcquire { - slot_index: slot.inner.slot_index, - }); - } - slot.inner.current_key.store(next_key, Ordering::Release); - slot.inner.completed.store(true, Ordering::Release); - assert!( - !slot.inner.acquired.load(Ordering::Acquire), - "acquired cleared" - ); - assert!( - slot.inner.completed.load(Ordering::Acquire), - "completed flag set" - ); - Ok(()) - } - - fn poll_complete(&mut self, slot: &CpuSlotHandle) -> bool { - let done = slot.inner.completed.load(Ordering::Acquire); - assert!(self.slots_created, "slots exist for poll"); - assert!( - (slot.inner.slot_index as usize) < Self::NUM_SLOTS, - "slot index in range" - ); - done - } - - fn mark_consumed(&mut self, slot: &CpuSlotHandle) { - assert!(self.slots_created, "slots exist for mark_consumed"); - assert!( - (slot.inner.slot_index as usize) < Self::NUM_SLOTS, - "slot index in range" - ); - slot.inner.completed.store(false, Ordering::Release); - } - - fn fill_test_pattern(&mut self, slot: &CpuSlotHandle, value: u8) { - assert!(self.slots_created, "slots exist for fill"); - assert!( - slot.inner.acquired.load(Ordering::Acquire), - "fill only while acquired" - ); - let len = slot.inner.byte_size; - for offset in 0..len { - slot.write_byte(offset, value); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn create_slots_for_1080p_nv12_yields_eight_slots() { - let mut backend = CpuMemcpyBackend::new(); - let slots = backend - .create_slots(1920, 1080, TextureFormat::Nv12) - .expect("creation succeeds"); - assert_eq!(slots.len(), CpuMemcpyBackend::NUM_SLOTS); - assert_eq!(slots.len(), 8); - for (idx, slot) in slots.iter().enumerate() { - assert_eq!(slot.slot_index(), idx as u32); - assert_eq!(slot.buffer_len(), 1920 * 1080 * 3 / 2); - } - } - - #[test] - fn create_slots_rejects_zero_dims() { - let mut backend = CpuMemcpyBackend::new(); - let err = backend.create_slots(0, 1080, TextureFormat::Nv12).err(); - assert!(matches!( - err, - Some(BackendError::DimensionsOutOfRange { .. }) - )); - } - - #[test] - fn create_slots_rejects_unsupported_format() { - let mut backend = CpuMemcpyBackend::new(); - let err = backend.create_slots(1920, 1080, TextureFormat::P010).err(); - assert!(matches!(err, Some(BackendError::UnsupportedFormat { .. }))); - } - - #[test] - fn acquire_release_round_trip_marks_complete() { - let mut backend = CpuMemcpyBackend::new(); - let slots = backend - .create_slots(64, 64, TextureFormat::Nv12) - .expect("create"); - let slot = slots[0].clone(); - backend.acquire_write(&slot, 0).expect("acquire"); - backend.release_write(&slot, 1).expect("release"); - assert!(backend.poll_complete(&slot)); - backend.mark_consumed(&slot); - assert!(!backend.poll_complete(&slot)); - } - - #[test] - fn double_acquire_rejects() { - let mut backend = CpuMemcpyBackend::new(); - let slots = backend - .create_slots(64, 64, TextureFormat::Nv12) - .expect("create"); - let slot = slots[0].clone(); - backend.acquire_write(&slot, 0).expect("first acquire"); - let err = backend.acquire_write(&slot, 0).err(); - assert!(matches!( - err, - Some(BackendError::AcquireWhileWriting { .. }) - )); - } -} diff --git a/fluxer_desktop/native/encoder-ring/src/d3d11.rs b/fluxer_desktop/native/encoder-ring/src/d3d11.rs deleted file mode 100644 index 4327aecde..000000000 --- a/fluxer_desktop/native/encoder-ring/src/d3d11.rs +++ /dev/null @@ -1,413 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use crate::backend::{BackendError, KeyedMutexBackend, TextureFormat}; - -#[derive(Clone)] -pub struct D3D11SharedHandle { - pub raw_handle: u64, - pub slot_index: u32, - pub width: u32, - pub height: u32, -} - -#[cfg(target_os = "windows")] -pub struct D3D11KeyedMutexBackend { - device: Option, - #[allow(dead_code, reason = "held for RAII: device context lifetime")] - context: Option, - slots: Vec, - width: u32, - height: u32, - format: TextureFormat, -} - -#[cfg(target_os = "windows")] -struct D3D11SlotState { - #[allow( - dead_code, - reason = "held for RAII: texture lifetime tied to keyed mutex" - )] - texture: windows::Win32::Graphics::Direct3D11::ID3D11Texture2D, - keyed_mutex: windows::Win32::Graphics::Dxgi::IDXGIKeyedMutex, - #[allow(dead_code, reason = "exposed via D3D11SharedHandle to clients")] - shared_handle: u64, - slot_index: u32, - expected_key: u64, - acquired: bool, - completed: bool, -} - -#[cfg(target_os = "windows")] -impl D3D11KeyedMutexBackend { - pub fn new() -> Result { - let (device, context) = unsafe { create_d3d11_device_windows()? }; - let backend = Self { - device: Some(device), - context: Some(context), - slots: Vec::with_capacity(::NUM_SLOTS), - width: 0, - height: 0, - format: TextureFormat::Nv12, - }; - assert!(backend.slots.is_empty(), "fresh backend has no slots"); - assert!(backend.device.is_some(), "device created"); - Ok(backend) - } - - fn find_slot(&mut self, slot: &D3D11SharedHandle) -> Option { - assert!((slot.slot_index as usize) < ::NUM_SLOTS); - self.slots - .iter() - .position(|s| s.slot_index == slot.slot_index) - } - - pub fn texture_for_slot( - &self, - slot_index: u32, - ) -> Option { - assert!( - slot_index < (::NUM_SLOTS as u32), - "slot_index in range" - ); - assert!(!self.slots.is_empty(), "slots have been created"); - for state in self.slots.iter() { - if state.slot_index == slot_index { - return Some(state.texture.clone()); - } - } - None - } - - pub fn device(&self) -> Option { - let dev = self.device.clone(); - assert!(dev.is_some(), "device exists"); - assert!( - !self.slots.is_empty() || self.width == 0, - "post-init invariant" - ); - dev - } - - pub fn context(&self) -> Option { - let ctx = self.context.clone(); - assert!(ctx.is_some(), "context exists"); - assert!( - self.width > 0 || self.slots.is_empty(), - "post-init invariant" - ); - ctx - } -} - -#[cfg(target_os = "windows")] -unsafe fn create_d3d11_device_windows() -> Result< - ( - windows::Win32::Graphics::Direct3D11::ID3D11Device, - windows::Win32::Graphics::Direct3D11::ID3D11DeviceContext, - ), - BackendError, -> { - use windows::Win32::Foundation::HMODULE; - use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL_11_0}; - use windows::Win32::Graphics::Direct3D11::{ - D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, D3D11CreateDevice, ID3D11Device, - ID3D11DeviceContext, - }; - let mut device: Option = None; - let mut context: Option = None; - let feature_levels = [D3D_FEATURE_LEVEL_11_0]; - unsafe { - D3D11CreateDevice( - None, - D3D_DRIVER_TYPE_HARDWARE, - HMODULE::default(), - D3D11_CREATE_DEVICE_BGRA_SUPPORT, - Some(&feature_levels), - D3D11_SDK_VERSION, - Some(&mut device), - None, - Some(&mut context), - ) - } - .map_err(|_| BackendError::PlatformUnsupported { - reason: "D3D11CreateDevice failed", - })?; - let device = device.ok_or(BackendError::PlatformUnsupported { - reason: "D3D11 device null", - })?; - let context = context.ok_or(BackendError::PlatformUnsupported { - reason: "D3D11 context null", - })?; - Ok((device, context)) -} - -#[cfg(target_os = "windows")] -unsafe fn create_keyed_mutex_texture_windows( - device: &windows::Win32::Graphics::Direct3D11::ID3D11Device, - width: u32, - height: u32, - slot_index: u32, -) -> Result< - ( - windows::Win32::Graphics::Direct3D11::ID3D11Texture2D, - windows::Win32::Graphics::Dxgi::IDXGIKeyedMutex, - u64, - ), - BackendError, -> { - use windows::Win32::Graphics::Direct3D11::{ - D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, - D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, D3D11_RESOURCE_MISC_SHARED_NTHANDLE, - D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, ID3D11Texture2D, - }; - use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC}; - use windows::Win32::Graphics::Dxgi::{IDXGIKeyedMutex, IDXGIResource1}; - use windows::core::Interface; - assert!(width > 0); - assert!(height > 0); - assert!(slot_index < 8); - let desc = D3D11_TEXTURE2D_DESC { - Width: width, - Height: height, - MipLevels: 1, - ArraySize: 1, - Format: DXGI_FORMAT_B8G8R8A8_UNORM, - SampleDesc: DXGI_SAMPLE_DESC { - Count: 1, - Quality: 0, - }, - Usage: D3D11_USAGE_DEFAULT, - BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32, - CPUAccessFlags: 0, - MiscFlags: (D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX.0 | D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0) - as u32, - }; - let mut texture: Option = None; - unsafe { device.CreateTexture2D(&desc, None, Some(&mut texture)) }.map_err(|_| { - BackendError::PlatformUnsupported { - reason: "CreateTexture2D failed", - } - })?; - let texture = texture.ok_or(BackendError::PlatformUnsupported { - reason: "texture null", - })?; - let keyed_mutex: IDXGIKeyedMutex = - texture - .cast() - .map_err(|_| BackendError::PlatformUnsupported { - reason: "IDXGIKeyedMutex cast failed", - })?; - let resource1: IDXGIResource1 = - texture - .cast() - .map_err(|_| BackendError::PlatformUnsupported { - reason: "IDXGIResource1 cast failed", - })?; - let access_rw: u32 = windows::Win32::Graphics::Dxgi::DXGI_SHARED_RESOURCE_READ.0 - | windows::Win32::Graphics::Dxgi::DXGI_SHARED_RESOURCE_WRITE.0; - let shared = - unsafe { resource1.CreateSharedHandle(None, access_rw, windows::core::PCWSTR::null()) } - .map_err(|_| BackendError::PlatformUnsupported { - reason: "CreateSharedHandle failed", - })?; - Ok((texture, keyed_mutex, shared.0 as u64)) -} - -#[cfg(target_os = "windows")] -const ACQUIRE_SYNC_WAIT_TIMEOUT: i32 = 0x102; -#[cfg(target_os = "windows")] -const ACQUIRE_SYNC_WAIT_ABANDONED: i32 = 0x80; - -#[cfg(not(target_os = "windows"))] -pub struct D3D11KeyedMutexBackend; - -#[cfg(not(target_os = "windows"))] -impl D3D11KeyedMutexBackend { - pub fn new() -> Result { - Err(BackendError::PlatformUnsupported { - reason: "D3D11 keyed-mutex backend is only available on Windows", - }) - } -} - -impl KeyedMutexBackend for D3D11KeyedMutexBackend { - type SlotHandle = D3D11SharedHandle; - const NUM_SLOTS: usize = 8; - - #[cfg(target_os = "windows")] - fn create_slots( - &mut self, - width: u32, - height: u32, - format: TextureFormat, - ) -> Result, BackendError> { - assert!(self.slots.is_empty(), "slots created once"); - if width == 0 || height == 0 { - return Err(BackendError::DimensionsOutOfRange { width, height }); - } - if !matches!(format, TextureFormat::Nv12) { - return Err(BackendError::UnsupportedFormat { format }); - } - let device = self - .device - .as_ref() - .ok_or(BackendError::PlatformUnsupported { - reason: "device dropped", - })?; - let mut out: Vec = Vec::with_capacity(Self::NUM_SLOTS); - for idx in 0..Self::NUM_SLOTS { - let idx_u32 = idx as u32; - let (texture, keyed_mutex, shared_handle) = - unsafe { create_keyed_mutex_texture_windows(device, width, height, idx_u32)? }; - self.slots.push(D3D11SlotState { - texture, - keyed_mutex, - shared_handle, - slot_index: idx_u32, - expected_key: 0, - acquired: false, - completed: false, - }); - out.push(D3D11SharedHandle { - raw_handle: shared_handle, - slot_index: idx_u32, - width, - height, - }); - } - self.width = width; - self.height = height; - self.format = format; - assert_eq!(out.len(), Self::NUM_SLOTS); - assert_eq!(self.slots.len(), Self::NUM_SLOTS); - Ok(out) - } - - #[cfg(not(target_os = "windows"))] - fn create_slots( - &mut self, - _width: u32, - _height: u32, - _format: TextureFormat, - ) -> Result, BackendError> { - Err(BackendError::PlatformUnsupported { - reason: "real D3D11 create_slots requires Windows", - }) - } - - #[cfg(target_os = "windows")] - fn acquire_write(&mut self, slot: &D3D11SharedHandle, key: u64) -> Result<(), BackendError> { - use windows::core::Interface; - let position = self - .find_slot(slot) - .ok_or(BackendError::PlatformUnsupported { - reason: "slot not found", - })?; - let state = &mut self.slots[position]; - if state.expected_key != key { - return Err(BackendError::KeyMismatch { - expected: state.expected_key, - observed: key, - }); - } - if state.acquired { - return Err(BackendError::AcquireWhileWriting { - slot_index: state.slot_index, - }); - } - let hr = unsafe { - (Interface::vtable(&state.keyed_mutex).AcquireSync)( - Interface::as_raw(&state.keyed_mutex), - key, - 0, - ) - }; - if hr.0 == ACQUIRE_SYNC_WAIT_TIMEOUT || hr.0 == ACQUIRE_SYNC_WAIT_ABANDONED { - return Err(BackendError::WouldBlock { - slot_index: state.slot_index, - }); - } - if hr.is_err() { - return Err(BackendError::KeyMismatch { - expected: key, - observed: u64::MAX, - }); - } - state.acquired = true; - state.completed = false; - assert!(state.acquired); - Ok(()) - } - - #[cfg(not(target_os = "windows"))] - fn acquire_write(&mut self, _slot: &D3D11SharedHandle, _key: u64) -> Result<(), BackendError> { - Err(BackendError::PlatformUnsupported { - reason: "real D3D11 acquire_write requires Windows", - }) - } - - #[cfg(target_os = "windows")] - fn release_write( - &mut self, - slot: &D3D11SharedHandle, - next_key: u64, - ) -> Result<(), BackendError> { - let position = self - .find_slot(slot) - .ok_or(BackendError::PlatformUnsupported { - reason: "slot not found", - })?; - let state = &mut self.slots[position]; - if !state.acquired { - return Err(BackendError::ReleaseWithoutAcquire { - slot_index: state.slot_index, - }); - } - unsafe { state.keyed_mutex.ReleaseSync(next_key) }.map_err(|_| { - BackendError::KeyMismatch { - expected: next_key, - observed: u64::MAX, - } - })?; - state.acquired = false; - state.expected_key = next_key; - state.completed = true; - assert!(!state.acquired); - assert!(state.completed); - Ok(()) - } - - #[cfg(not(target_os = "windows"))] - fn release_write( - &mut self, - _slot: &D3D11SharedHandle, - _next_key: u64, - ) -> Result<(), BackendError> { - Err(BackendError::PlatformUnsupported { - reason: "real D3D11 release_write requires Windows", - }) - } - - #[cfg(target_os = "windows")] - fn poll_complete(&mut self, slot: &D3D11SharedHandle) -> bool { - match self.find_slot(slot) { - Some(position) => self.slots[position].completed, - None => false, - } - } - - #[cfg(not(target_os = "windows"))] - fn poll_complete(&mut self, _slot: &D3D11SharedHandle) -> bool { - false - } - - #[cfg(target_os = "windows")] - fn mark_consumed(&mut self, slot: &D3D11SharedHandle) { - if let Some(position) = self.find_slot(slot) { - self.slots[position].completed = false; - } - } - - #[cfg(not(target_os = "windows"))] - fn mark_consumed(&mut self, _slot: &D3D11SharedHandle) {} -} diff --git a/fluxer_desktop/native/encoder-ring/src/encoder_handoff.rs b/fluxer_desktop/native/encoder-ring/src/encoder_handoff.rs deleted file mode 100644 index 1c81c52a9..000000000 --- a/fluxer_desktop/native/encoder-ring/src/encoder_handoff.rs +++ /dev/null @@ -1,741 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use crate::ring::RingError; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct EncoderDims { - pub width: u32, - pub height: u32, -} - -impl EncoderDims { - pub fn new(width: u32, height: u32) -> Self { - assert!(width > 0, "width must be positive"); - assert!(height > 0, "height must be positive"); - Self { width, height } - } -} - -pub const ENCODER_FRAME_RATE_MIN: u32 = 1; -pub const ENCODER_FRAME_RATE_MAX: u32 = 240; -pub const ENCODER_FRAME_RATE_DEFAULT: u32 = 60; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct EncoderFrameRate { - pub numerator: u32, - pub denominator: u32, -} - -impl EncoderFrameRate { - pub fn new(numerator: u32, denominator: u32) -> Self { - assert!( - numerator >= ENCODER_FRAME_RATE_MIN, - "fps numerator positive" - ); - assert!(denominator > 0, "fps denominator positive"); - let frame_rate = Self { - numerator: numerator.min(ENCODER_FRAME_RATE_MAX), - denominator, - }; - assert!(frame_rate.numerator >= ENCODER_FRAME_RATE_MIN); - assert!(frame_rate.denominator > 0); - frame_rate - } - - pub fn from_fps(fps: u32) -> Self { - let numerator = fps.clamp(ENCODER_FRAME_RATE_MIN, ENCODER_FRAME_RATE_MAX); - Self::new(numerator, 1) - } - - pub fn frame_interval_us(self) -> u64 { - let numerator = u64::from(self.numerator); - let denominator = u64::from(self.denominator); - assert!(numerator > 0, "fps numerator positive"); - assert!(denominator > 0, "fps denominator positive"); - (1_000_000u64 * denominator).div_ceil(numerator) - } - - pub fn gop_pic_size(self) -> u16 { - let rounded = u64::from(self.numerator).div_ceil(u64::from(self.denominator)); - let bounded = rounded.clamp(1, u64::from(ENCODER_FRAME_RATE_MAX)); - bounded as u16 - } -} - -impl Default for EncoderFrameRate { - fn default() -> Self { - Self::from_fps(ENCODER_FRAME_RATE_DEFAULT) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct EncoderSubmission { - pub shared_handle: u64, - pub keyed_mutex_key: u64, - pub dims: EncoderDims, - pub sequence: u64, - pub capture_pts_us: Option, -} - -impl EncoderSubmission { - pub fn new(shared_handle: u64, keyed_mutex_key: u64, dims: EncoderDims, sequence: u64) -> Self { - assert!(shared_handle != 0, "shared handle must be non-zero"); - assert!(dims.width > 0, "dims width positive"); - let s = Self { - shared_handle, - keyed_mutex_key, - dims, - sequence, - capture_pts_us: None, - }; - assert!(s.shared_handle == shared_handle, "post construct intact"); - assert!(s.capture_pts_us.is_none(), "capture pts defaults absent"); - s - } - - pub fn with_capture_pts_us(mut self, capture_pts_us: u64) -> Self { - assert!(self.shared_handle != 0, "shared handle must be non-zero"); - self.capture_pts_us = Some(capture_pts_us); - assert_eq!( - self.capture_pts_us, - Some(capture_pts_us), - "capture pts recorded" - ); - self - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PicParams { - pub pts_us: u64, - pub force_keyframe: bool, -} - -impl PicParams { - pub fn new(pts_us: u64, force_keyframe: bool) -> Self { - let p = Self { - pts_us, - force_keyframe, - }; - assert!(p.pts_us == pts_us, "pts_us intact"); - assert!(p.force_keyframe == force_keyframe, "force_keyframe intact"); - p - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct HandoffSlot { - pub slot_index: u32, - pub shared_handle: u64, -} - -impl HandoffSlot { - pub fn new(slot_index: u32, shared_handle: u64) -> Self { - assert!(slot_index < 64, "slot_index within plausible bound"); - assert!(shared_handle != 0, "shared_handle non-zero"); - Self { - slot_index, - shared_handle, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EncodedBitstream { - pub data: Vec, - pub pts_us: u64, - pub dts_us: u64, - pub is_keyframe: bool, -} - -impl EncodedBitstream { - pub fn new(data: Vec, pts_us: u64, dts_us: u64, is_keyframe: bool) -> Self { - assert!(!data.is_empty(), "encoded bitstream must be non-empty"); - assert!(data.len() <= MAX_BITSTREAM_BYTES, "bitstream within cap"); - Self { - data, - pts_us, - dts_us, - is_keyframe, - } - } -} - -pub const MAX_BITSTREAM_BYTES: usize = 16 * 1024 * 1024; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum EncoderError { - SdkNotFound { - vendor: &'static str, - dll: &'static str, - }, - SymbolMissing { - vendor: &'static str, - symbol: &'static str, - }, - SessionInitFailed { - vendor: &'static str, - status: i64, - }, - RegisterFailed { - vendor: &'static str, - status: i64, - }, - EncodeFailed { - vendor: &'static str, - status: i64, - }, - BitstreamReadFailed { - vendor: &'static str, - status: i64, - }, - SlotUnknown { - slot_index: u32, - }, - KeyMismatch { - expected: u64, - observed: u64, - }, - DimensionsOutOfRange { - width: u32, - height: u32, - }, - PlatformUnsupported { - reason: &'static str, - }, - BitstreamTooLarge { - byte_size: usize, - }, - NotImplemented { - what: &'static str, - }, -} - -impl std::fmt::Display for EncoderError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::SdkNotFound { vendor, dll } => { - write!(f, "{vendor} SDK runtime '{dll}' not found") - } - Self::SymbolMissing { vendor, symbol } => { - write!(f, "{vendor} symbol '{symbol}' missing from runtime") - } - Self::SessionInitFailed { vendor, status } => { - write!(f, "{vendor} session init failed: status={status}") - } - Self::RegisterFailed { vendor, status } => { - write!(f, "{vendor} register failed: status={status}") - } - Self::EncodeFailed { vendor, status } => { - write!(f, "{vendor} encode failed: status={status}") - } - Self::BitstreamReadFailed { vendor, status } => { - write!(f, "{vendor} bitstream read failed: status={status}") - } - Self::SlotUnknown { slot_index } => write!(f, "slot {slot_index} unknown"), - Self::KeyMismatch { expected, observed } => { - write!( - f, - "keyed-mutex key mismatch: expected={expected} observed={observed}" - ) - } - Self::DimensionsOutOfRange { width, height } => { - write!(f, "encoder dims out of range: {width}x{height}") - } - Self::PlatformUnsupported { reason } => write!(f, "platform unsupported: {reason}"), - Self::BitstreamTooLarge { byte_size } => { - write!(f, "bitstream {byte_size} bytes exceeds cap") - } - Self::NotImplemented { what } => write!(f, "not implemented: {what}"), - } - } -} - -impl std::error::Error for EncoderError {} - -pub trait EncoderCompletionCallback: Send { - fn on_complete(&mut self, sequence: u64, encoded_bytes: u32); -} - -pub trait NvencHandoff: Send { - fn register_slot( - &mut self, - shared_handle: u64, - key: u64, - dims: EncoderDims, - ) -> Result; - - fn encode_shared_async( - &mut self, - slot: HandoffSlot, - key: u64, - dims: EncoderDims, - pic_params: PicParams, - ) -> Result<(), EncoderError>; - - fn poll_completed(&mut self, slot: HandoffSlot) -> Option; - - fn unregister_slot(&mut self, slot: HandoffSlot); - - fn encode_shared( - &mut self, - submission: EncoderSubmission, - callback: &mut dyn EncoderCompletionCallback, - ) -> Result<(), RingError>; -} - -pub trait AmfHandoff: Send { - fn register_slot( - &mut self, - shared_handle: u64, - key: u64, - dims: EncoderDims, - ) -> Result; - - fn encode_shared_async( - &mut self, - slot: HandoffSlot, - key: u64, - dims: EncoderDims, - pic_params: PicParams, - ) -> Result<(), EncoderError>; - - fn poll_completed(&mut self, slot: HandoffSlot) -> Option; - - fn unregister_slot(&mut self, slot: HandoffSlot); - - fn encode_shared( - &mut self, - submission: EncoderSubmission, - callback: &mut dyn EncoderCompletionCallback, - ) -> Result<(), RingError>; -} - -pub trait QsvHandoff: Send { - fn register_slot( - &mut self, - shared_handle: u64, - key: u64, - dims: EncoderDims, - ) -> Result; - - fn encode_shared_async( - &mut self, - slot: HandoffSlot, - key: u64, - dims: EncoderDims, - pic_params: PicParams, - ) -> Result<(), EncoderError>; - - fn poll_completed(&mut self, slot: HandoffSlot) -> Option; - - fn unregister_slot(&mut self, slot: HandoffSlot); - - fn encode_shared( - &mut self, - submission: EncoderSubmission, - callback: &mut dyn EncoderCompletionCallback, - ) -> Result<(), RingError>; -} - -pub trait VideoToolboxHandoff: Send { - fn register_slot( - &mut self, - iosurface_handle: u64, - key: u64, - dims: EncoderDims, - ) -> Result; - - fn encode_shared_async( - &mut self, - slot: HandoffSlot, - key: u64, - dims: EncoderDims, - pic_params: PicParams, - ) -> Result<(), EncoderError>; - - fn poll_completed(&mut self, slot: HandoffSlot) -> Option; - - fn unregister_slot(&mut self, slot: HandoffSlot); - - fn encode_shared( - &mut self, - submission: EncoderSubmission, - callback: &mut dyn EncoderCompletionCallback, - ) -> Result<(), RingError>; -} - -pub struct VtNoOpHandoff { - accepted: u64, - next_slot_index: u32, - pending: std::collections::VecDeque<(u64, u64)>, -} - -impl VtNoOpHandoff { - pub fn new() -> Self { - let h = Self { - accepted: 0, - next_slot_index: 0, - pending: std::collections::VecDeque::with_capacity(16), - }; - assert_eq!(h.accepted, 0, "fresh handoff has no accepted frames"); - assert_eq!(h.next_slot_index, 0, "fresh handoff slot index zero"); - h - } - - pub fn accepted_count(&self) -> u64 { - let n = self.accepted; - assert!( - self.pending.len() <= u32::MAX as usize, - "pending queue plausible" - ); - assert!(n >= self.pending.len() as u64, "accepted >= pending"); - n - } - - pub fn pending_len(&self) -> usize { - let len = self.pending.len(); - assert!( - len <= self.pending.capacity().max(1), - "pending within capacity bound" - ); - assert!(len as u64 <= self.accepted, "pending <= accepted"); - len - } -} - -impl Default for VtNoOpHandoff { - fn default() -> Self { - Self::new() - } -} - -impl VideoToolboxHandoff for VtNoOpHandoff { - fn register_slot( - &mut self, - iosurface_handle: u64, - _key: u64, - dims: EncoderDims, - ) -> Result { - if iosurface_handle == 0 { - return Err(EncoderError::SlotUnknown { - slot_index: u32::MAX, - }); - } - if dims.width == 0 || dims.height == 0 { - return Err(EncoderError::DimensionsOutOfRange { - width: dims.width, - height: dims.height, - }); - } - let slot = HandoffSlot::new(self.next_slot_index, iosurface_handle); - self.next_slot_index = self.next_slot_index.saturating_add(1); - assert!( - slot.shared_handle == iosurface_handle, - "slot handle round-trip" - ); - assert!(self.next_slot_index > 0, "slot counter advanced"); - Ok(slot) - } - - fn encode_shared_async( - &mut self, - slot: HandoffSlot, - _key: u64, - dims: EncoderDims, - pic_params: PicParams, - ) -> Result<(), EncoderError> { - if dims.width == 0 || dims.height == 0 { - return Err(EncoderError::DimensionsOutOfRange { - width: dims.width, - height: dims.height, - }); - } - self.pending.push_back((self.accepted, pic_params.pts_us)); - self.accepted = self.accepted.saturating_add(1); - assert!(slot.shared_handle != 0, "async slot handle non-zero"); - assert!(self.accepted > 0, "encode_shared_async advanced accepted"); - Ok(()) - } - - fn poll_completed(&mut self, _slot: HandoffSlot) -> Option { - None - } - - fn unregister_slot(&mut self, slot: HandoffSlot) { - assert!(slot.shared_handle != 0, "unregister slot handle non-zero"); - } - - fn encode_shared( - &mut self, - submission: EncoderSubmission, - callback: &mut dyn EncoderCompletionCallback, - ) -> Result<(), RingError> { - if submission.shared_handle == 0 { - return Err(RingError::UnknownSlot); - } - if submission.dims.width == 0 || submission.dims.height == 0 { - return Err(RingError::BackendFailed { - source: crate::backend::BackendError::DimensionsOutOfRange { - width: submission.dims.width, - height: submission.dims.height, - }, - }); - } - let pre_accepted = self.accepted; - self.pending.push_back((submission.sequence, 0)); - self.accepted = self.accepted.saturating_add(1); - callback.on_complete(submission.sequence, 0); - assert!( - self.accepted == pre_accepted + 1, - "VtNoOpHandoff accepted advanced" - ); - assert!(submission.dims.width > 0, "submission dims preserved"); - Ok(()) - } -} - -pub struct NotImplementedHandoff { - pub vendor: &'static str, -} - -impl NotImplementedHandoff { - pub fn nvenc() -> Self { - Self { vendor: "nvenc" } - } - - pub fn amf() -> Self { - Self { vendor: "amf" } - } - - pub fn qsv() -> Self { - Self { vendor: "qsv" } - } -} - -pub fn compute_dts_offset_us(first_pts_us: u64, num_b_frames: u32, frame_interval_us: u64) -> i64 { - assert!(frame_interval_us > 0, "frame interval positive"); - assert!(num_b_frames <= 8, "B-frame count plausible"); - let offset = (num_b_frames as u64).saturating_mul(frame_interval_us); - let result = -(offset as i64); - let _ = first_pts_us; - assert!(result <= 0, "DTS offset is non-positive for B-frames"); - result -} - -pub fn apply_dts_offset(pts_us: u64, offset_us: i64) -> u64 { - let signed_pts = pts_us as i64; - let dts = signed_pts.saturating_add(offset_us); - let clamped = if dts < 0 { 0 } else { dts as u64 }; - assert!( - clamped <= pts_us || offset_us > 0, - "DTS <= PTS without future B-frames" - ); - clamped -} - -macro_rules! impl_not_implemented_for_trait { - ($trait_name:ident, $vendor_tag:expr) => { - impl $trait_name for NotImplementedHandoff { - fn register_slot( - &mut self, - shared_handle: u64, - _key: u64, - dims: EncoderDims, - ) -> Result { - assert!(shared_handle != 0, "shared_handle non-zero"); - assert!(dims.width > 0, "dims width positive"); - Err(EncoderError::NotImplemented { - what: concat!($vendor_tag, "::register_slot"), - }) - } - - fn encode_shared_async( - &mut self, - slot: HandoffSlot, - _key: u64, - dims: EncoderDims, - _pic_params: PicParams, - ) -> Result<(), EncoderError> { - assert!(slot.shared_handle != 0, "slot shared_handle non-zero"); - assert!(dims.width > 0, "dims width positive"); - Err(EncoderError::NotImplemented { - what: concat!($vendor_tag, "::encode_shared_async"), - }) - } - - fn poll_completed(&mut self, _slot: HandoffSlot) -> Option { - None - } - - fn unregister_slot(&mut self, slot: HandoffSlot) { - assert!(slot.shared_handle != 0, "slot shared_handle non-zero"); - } - - fn encode_shared( - &mut self, - submission: EncoderSubmission, - _callback: &mut dyn EncoderCompletionCallback, - ) -> Result<(), RingError> { - assert!(submission.shared_handle != 0, "submission handle non-zero"); - assert!(submission.dims.width > 0, "submission width positive"); - Err(RingError::NotImplemented { - what: concat!($vendor_tag, "::encode_shared"), - }) - } - } - }; -} - -impl_not_implemented_for_trait!(NvencHandoff, "NvencHandoff"); -impl_not_implemented_for_trait!(AmfHandoff, "AmfHandoff"); -impl_not_implemented_for_trait!(QsvHandoff, "QsvHandoff"); - -#[cfg(test)] -mod tests { - use super::*; - - fn submission() -> EncoderSubmission { - EncoderSubmission::new(0xfeed_face, 7, EncoderDims::new(1920, 1080), 42) - } - - #[test] - fn encoder_dims_rejects_zero_width_via_assert() { - let result = std::panic::catch_unwind(|| EncoderDims::new(0, 1080)); - assert!(result.is_err()); - } - - #[test] - fn encoded_bitstream_rejects_empty() { - let result = std::panic::catch_unwind(|| EncodedBitstream::new(vec![], 0, 0, true)); - assert!(result.is_err()); - } - - #[test] - fn dts_offset_zero_for_no_b_frames() { - let offset = compute_dts_offset_us(1000, 0, 16_666); - assert_eq!(offset, 0); - } - - #[test] - fn dts_offset_negative_for_b_frames() { - let offset = compute_dts_offset_us(1000, 2, 16_666); - assert_eq!(offset, -(2 * 16_666_i64)); - } - - #[test] - fn dts_offset_application_clamps_to_zero() { - let dts = apply_dts_offset(100, -1000); - assert_eq!(dts, 0); - } - - #[test] - fn dts_offset_application_below_pts_for_b_frames() { - let pts = 100_000_u64; - let dts = apply_dts_offset(pts, -33_333); - assert!(dts < pts); - assert_eq!(dts, 66_667); - } - - #[test] - fn encoder_frame_rate_derives_interval_and_gop() { - let sixty = EncoderFrameRate::from_fps(60); - assert_eq!(sixty.frame_interval_us(), 16_667); - assert_eq!(sixty.gop_pic_size(), 60); - let capped = EncoderFrameRate::from_fps(999); - assert_eq!(capped.numerator, ENCODER_FRAME_RATE_MAX); - assert_eq!(capped.gop_pic_size(), ENCODER_FRAME_RATE_MAX as u16); - } - - #[test] - fn handoff_slot_rejects_zero_handle() { - let result = std::panic::catch_unwind(|| HandoffSlot::new(0, 0)); - assert!(result.is_err()); - } - - #[test] - fn submission_capture_pts_defaults_absent_and_round_trips() { - let s = submission(); - assert_eq!(s.capture_pts_us, None); - let with_pts = s.with_capture_pts_us(123_456); - assert_eq!(with_pts.capture_pts_us, Some(123_456)); - assert_eq!(with_pts.sequence, s.sequence); - assert_eq!(with_pts.shared_handle, s.shared_handle); - } - - struct CountingCallback { - seen: Vec<(u64, u32)>, - } - - impl EncoderCompletionCallback for CountingCallback { - fn on_complete(&mut self, sequence: u64, encoded_bytes: u32) { - self.seen.push((sequence, encoded_bytes)); - } - } - - #[test] - fn vt_noop_accepts_frames_in_fifo_order() { - let mut h = VtNoOpHandoff::new(); - let mut cb = CountingCallback { seen: Vec::new() }; - let dims = EncoderDims::new(1920, 1080); - for seq in 1..=5u64 { - let s = EncoderSubmission::new(0xfeed_face_u64, 0, dims, seq); - VideoToolboxHandoff::encode_shared(&mut h, s, &mut cb).expect("vt encode_shared"); - } - assert_eq!(cb.seen.len(), 5); - for (idx, &(seq, _)) in cb.seen.iter().enumerate() { - assert_eq!(seq, (idx as u64) + 1, "fifo sequence"); - } - assert_eq!(h.accepted_count(), 5); - } - - #[test] - fn vt_noop_register_returns_slot() { - let mut h = VtNoOpHandoff::new(); - let dims = EncoderDims::new(1920, 1080); - let slot_a = - VideoToolboxHandoff::register_slot(&mut h, 0xabc, 0, dims).expect("register a"); - let slot_b = - VideoToolboxHandoff::register_slot(&mut h, 0xdef, 0, dims).expect("register b"); - assert_eq!(slot_a.shared_handle, 0xabc); - assert_eq!(slot_b.shared_handle, 0xdef); - assert_ne!(slot_a.slot_index, slot_b.slot_index); - } - - #[test] - fn vt_noop_register_rejects_zero_handle() { - let mut h = VtNoOpHandoff::new(); - let dims = EncoderDims::new(1920, 1080); - let err = VideoToolboxHandoff::register_slot(&mut h, 0, 0, dims).err(); - assert!(matches!(err, Some(EncoderError::SlotUnknown { .. }))); - } - - #[test] - fn vt_noop_encode_shared_rejects_zero_handle() { - let mut h = VtNoOpHandoff::new(); - let mut cb = CountingCallback { seen: Vec::new() }; - let dims = EncoderDims::new(1920, 1080); - let s = EncoderSubmission { - shared_handle: 0, - keyed_mutex_key: 0, - dims, - sequence: 1, - capture_pts_us: None, - }; - let err = VideoToolboxHandoff::encode_shared(&mut h, s, &mut cb).err(); - assert!(matches!(err, Some(RingError::UnknownSlot))); - assert!(cb.seen.is_empty(), "no callback on rejection"); - } - - #[test] - fn vt_noop_encode_shared_async_advances_accepted() { - let mut h = VtNoOpHandoff::new(); - let dims = EncoderDims::new(1280, 720); - let slot = HandoffSlot::new(0, 0xfeed); - let params = PicParams::new(16_666, false); - VideoToolboxHandoff::encode_shared_async(&mut h, slot, 0, dims, params) - .expect("async encode ok"); - assert_eq!(h.accepted_count(), 1); - assert_eq!(h.pending_len(), 1); - } -} diff --git a/fluxer_desktop/native/encoder-ring/src/lib.rs b/fluxer_desktop/native/encoder-ring/src/lib.rs deleted file mode 100644 index f717ff0c2..000000000 --- a/fluxer_desktop/native/encoder-ring/src/lib.rs +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -#![deny(clippy::too_many_lines)] -#![deny(clippy::unwrap_used)] -#![deny(clippy::panic)] -#![deny(warnings)] - -pub mod backend; -pub mod d3d11; -pub mod encoder_handoff; -pub mod metal_iosurface; -pub mod ring; - -#[cfg(target_os = "macos")] -pub mod metal_iosurface_macos; - -#[cfg(target_os = "macos")] -pub mod vt_compression_macos; - -#[cfg(target_os = "windows")] -pub mod amf; -#[cfg(target_os = "windows")] -pub mod nvenc; -#[cfg(target_os = "windows")] -pub mod qsv; - -pub use backend::{CpuMemcpyBackend, CpuSlotHandle, KeyedMutexBackend, TextureFormat}; -pub use d3d11::D3D11KeyedMutexBackend; -pub use encoder_handoff::{ - AmfHandoff, EncodedBitstream, EncoderDims, EncoderError, EncoderFrameRate, EncoderSubmission, - HandoffSlot, NotImplementedHandoff, NvencHandoff, PicParams, QsvHandoff, VideoToolboxHandoff, - VtNoOpHandoff, apply_dts_offset, compute_dts_offset_us, -}; -pub use metal_iosurface::{ - IoSurfaceSlotHandle, METAL_IOSURFACE_SEED_BASE, MetalSharedTextureBackend, -}; - -pub use ring::{ - DUPLICATE_COUNT_MAX, EncoderInputRing, EncoderReady, FillReservation, RING_SIZE, RingError, - RingMetrics, -}; -#[cfg(target_os = "macos")] -pub use vt_compression_macos::{VtCompressionHandoff, VtPixelTransfer}; - -#[cfg(target_os = "windows")] -pub use amf::AmfD3D11Handoff; -#[cfg(target_os = "windows")] -pub use nvenc::{COMPLETION_RING_CAPACITY as NVENC_COMPLETION_RING_CAPACITY, NvencD3D11Handoff}; -#[cfg(target_os = "windows")] -pub use qsv::QsvD3D11Handoff; - -pub const NV12_BPP_NUMERATOR: u32 = 3; -pub const NV12_BPP_DENOMINATOR: u32 = 2; - -pub const MAX_FRAME_WIDTH: u32 = 7680; -pub const MAX_FRAME_HEIGHT: u32 = 4320; - -#[inline] -pub const fn nv12_byte_size(width: u32, height: u32) -> usize { - let w = width as usize; - let h = height as usize; - (w * h * NV12_BPP_NUMERATOR as usize) / NV12_BPP_DENOMINATOR as usize -} diff --git a/fluxer_desktop/native/encoder-ring/src/metal_iosurface.rs b/fluxer_desktop/native/encoder-ring/src/metal_iosurface.rs deleted file mode 100644 index 0e4c22214..000000000 --- a/fluxer_desktop/native/encoder-ring/src/metal_iosurface.rs +++ /dev/null @@ -1,425 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; - -use crate::backend::{BackendError, KeyedMutexBackend, NUM_SLOTS_DEFAULT, TextureFormat}; -use crate::{MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH}; - -pub const METAL_IOSURFACE_SEED_BASE: u64 = 0; - -#[derive(Clone)] -pub struct IoSurfaceSlotHandle { - inner: Arc, -} - -impl IoSurfaceSlotHandle { - pub fn slot_index(&self) -> u32 { - let idx = self.inner.slot_index; - assert!((idx as usize) < NUM_SLOTS_DEFAULT, "slot_index in range"); - assert!( - self.inner.iosurface_handle != 0, - "iosurface_handle non-zero" - ); - idx - } - - pub fn iosurface_handle(&self) -> u64 { - let h = self.inner.iosurface_handle; - assert!(h != 0, "iosurface_handle non-zero on read"); - assert!( - (self.inner.slot_index as usize) < NUM_SLOTS_DEFAULT, - "slot_index intact" - ); - h - } - - #[cfg(target_os = "macos")] - pub fn iosurface_ptr(&self) -> *mut core::ffi::c_void { - let p = self.inner.iosurface_ptr; - assert!(!p.is_null(), "iosurface_ptr non-null on read"); - assert!( - self.inner.iosurface_handle != 0, - "handle non-zero alongside ptr" - ); - p - } - - pub fn current_key(&self) -> u64 { - let key = self.inner.current_key.load(Ordering::Acquire); - assert!( - self.inner.iosurface_handle != 0, - "handle intact during key read" - ); - assert!( - (self.inner.slot_index as usize) < NUM_SLOTS_DEFAULT, - "slot_index intact" - ); - key - } - - pub fn is_acquired(&self) -> bool { - let a = self.inner.acquired.load(Ordering::Acquire); - assert!( - self.inner.iosurface_handle != 0, - "handle intact during acquired probe" - ); - assert!( - (self.inner.slot_index as usize) < NUM_SLOTS_DEFAULT, - "slot_index intact" - ); - a - } -} - -struct IoSurfaceSlotInner { - slot_index: u32, - iosurface_handle: u64, - #[cfg(target_os = "macos")] - iosurface_ptr: *mut core::ffi::c_void, - current_key: AtomicU64, - acquired: AtomicBool, - completed: AtomicBool, -} - -#[cfg(target_os = "macos")] -unsafe impl Send for IoSurfaceSlotInner {} -#[cfg(target_os = "macos")] -unsafe impl Sync for IoSurfaceSlotInner {} - -pub struct MetalSharedTextureBackend { - width: u32, - height: u32, - format: TextureFormat, - slots_created: bool, - slot_handles: Vec, - #[cfg(target_os = "macos")] - iosurfaces: Vec, -} - -impl MetalSharedTextureBackend { - pub fn new() -> Self { - let backend = Self { - width: 0, - height: 0, - format: TextureFormat::Nv12, - slots_created: false, - slot_handles: Vec::with_capacity(NUM_SLOTS_DEFAULT), - #[cfg(target_os = "macos")] - iosurfaces: Vec::with_capacity(NUM_SLOTS_DEFAULT), - }; - assert!(!backend.slots_created, "fresh backend has no slots"); - assert!( - backend.slot_handles.is_empty(), - "fresh backend handles empty" - ); - backend - } - - pub fn width(&self) -> u32 { - assert!(self.width <= MAX_FRAME_WIDTH, "width within cap"); - assert!(self.height <= MAX_FRAME_HEIGHT, "height within cap"); - self.width - } - - pub fn height(&self) -> u32 { - assert!(self.height <= MAX_FRAME_HEIGHT, "height within cap"); - assert!(self.width <= MAX_FRAME_WIDTH, "width within cap"); - self.height - } - - pub fn slot_iosurface_handle(&self, slot_index: u32) -> Option { - let idx = slot_index as usize; - if idx >= self.slot_handles.len() { - return None; - } - let h = self.slot_handles[idx]; - assert!(h != 0, "stored iosurface handle non-zero"); - Some(h) - } - - #[cfg(target_os = "macos")] - pub fn slot_iosurface_ptr(&self, slot_index: u32) -> Option<*mut core::ffi::c_void> { - let idx = slot_index as usize; - if idx >= self.iosurfaces.len() { - return None; - } - let p = self.iosurfaces[idx].as_ptr(); - assert!(!p.is_null(), "stored iosurface ptr non-null"); - assert!( - idx < self.slot_handles.len(), - "ptr slot mirrors handle slot" - ); - Some(p) - } - - #[cfg(target_os = "macos")] - pub fn slot_iosurface_mut( - &mut self, - slot_index: u32, - ) -> Option<&mut crate::metal_iosurface_macos::OwnedIoSurface> { - let idx = slot_index as usize; - if idx >= self.iosurfaces.len() { - return None; - } - assert!( - idx < self.slot_handles.len(), - "mut slot mirrors handle slot" - ); - Some(&mut self.iosurfaces[idx]) - } - - #[cfg(not(target_os = "macos"))] - fn allocate_slot_handles( - &mut self, - _width: u32, - _height: u32, - _format: TextureFormat, - ) -> Result, BackendError> { - Err(BackendError::PlatformUnsupported { - reason: "MetalSharedTextureBackend requires macOS", - }) - } - - #[cfg(target_os = "macos")] - fn allocate_slot_handles( - &mut self, - width: u32, - height: u32, - format: TextureFormat, - ) -> Result, BackendError> { - if !matches!(format, TextureFormat::Nv12) { - return Err(BackendError::UnsupportedFormat { format }); - } - let mut handles: Vec = Vec::with_capacity(NUM_SLOTS_DEFAULT); - let mut owned: Vec = - Vec::with_capacity(NUM_SLOTS_DEFAULT); - for slot in 0..NUM_SLOTS_DEFAULT { - let surface = crate::metal_iosurface_macos::OwnedIoSurface::create_nv12(width, height) - .map_err(|_| BackendError::PlatformUnsupported { - reason: "IOSurfaceCreate failed", - })?; - let raw = surface.handle(); - assert!(raw != 0, "IOSurface raw non-zero for slot"); - assert!(slot < NUM_SLOTS_DEFAULT, "slot index in range"); - handles.push(raw); - owned.push(surface); - } - self.iosurfaces = owned; - Ok(handles) - } -} - -impl Default for MetalSharedTextureBackend { - fn default() -> Self { - Self::new() - } -} - -impl KeyedMutexBackend for MetalSharedTextureBackend { - type SlotHandle = IoSurfaceSlotHandle; - const NUM_SLOTS: usize = NUM_SLOTS_DEFAULT; - - fn create_slots( - &mut self, - width: u32, - height: u32, - format: TextureFormat, - ) -> Result, BackendError> { - if width == 0 || height == 0 || width > MAX_FRAME_WIDTH || height > MAX_FRAME_HEIGHT { - return Err(BackendError::DimensionsOutOfRange { width, height }); - } - if !matches!(format, TextureFormat::Nv12) { - return Err(BackendError::UnsupportedFormat { format }); - } - assert!(!self.slots_created, "slots created once"); - let raw_handles = self.allocate_slot_handles(width, height, format)?; - assert_eq!( - raw_handles.len(), - NUM_SLOTS_DEFAULT, - "allocator returns NUM_SLOTS handles" - ); - let mut handles: Vec = Vec::with_capacity(NUM_SLOTS_DEFAULT); - for (idx, raw) in raw_handles.iter().enumerate() { - assert!(*raw != 0, "raw iosurface handle non-zero"); - #[cfg(target_os = "macos")] - let surface_ptr = self.iosurfaces[idx].as_ptr(); - #[cfg(target_os = "macos")] - assert!(!surface_ptr.is_null(), "surface ptr non-null at slot setup"); - let inner = IoSurfaceSlotInner { - slot_index: idx as u32, - iosurface_handle: *raw, - #[cfg(target_os = "macos")] - iosurface_ptr: surface_ptr, - current_key: AtomicU64::new(METAL_IOSURFACE_SEED_BASE), - acquired: AtomicBool::new(false), - completed: AtomicBool::new(false), - }; - handles.push(IoSurfaceSlotHandle { - inner: Arc::new(inner), - }); - } - self.width = width; - self.height = height; - self.format = format; - self.slot_handles = raw_handles; - self.slots_created = true; - assert_eq!( - handles.len(), - NUM_SLOTS_DEFAULT, - "returned handle vector length" - ); - assert!(self.slots_created, "slots_created flipped"); - Ok(handles) - } - - fn acquire_write(&mut self, slot: &IoSurfaceSlotHandle, key: u64) -> Result<(), BackendError> { - assert!(self.slots_created, "slots must exist before acquire"); - let current = slot.inner.current_key.load(Ordering::Acquire); - if current != key { - return Err(BackendError::KeyMismatch { - expected: key, - observed: current, - }); - } - let was_acquired = slot.inner.acquired.swap(true, Ordering::AcqRel); - if was_acquired { - return Err(BackendError::AcquireWhileWriting { - slot_index: slot.inner.slot_index, - }); - } - #[cfg(target_os = "macos")] - { - let idx = slot.inner.slot_index as usize; - if idx >= self.iosurfaces.len() { - slot.inner.acquired.store(false, Ordering::Release); - return Err(BackendError::PlatformUnsupported { - reason: "slot index out of range for IOSurface vector", - }); - } - if let Err(e) = self.iosurfaces[idx].lock_for_writing() { - slot.inner.acquired.store(false, Ordering::Release); - return Err(e); - } - } - slot.inner.completed.store(false, Ordering::Release); - assert!( - slot.inner.acquired.load(Ordering::Acquire), - "acquired flag set" - ); - assert!( - !slot.inner.completed.load(Ordering::Acquire), - "completed cleared" - ); - Ok(()) - } - - fn release_write( - &mut self, - slot: &IoSurfaceSlotHandle, - next_key: u64, - ) -> Result<(), BackendError> { - assert!(self.slots_created, "slots must exist before release"); - let was_acquired = slot.inner.acquired.swap(false, Ordering::AcqRel); - if !was_acquired { - return Err(BackendError::ReleaseWithoutAcquire { - slot_index: slot.inner.slot_index, - }); - } - #[cfg(target_os = "macos")] - { - let idx = slot.inner.slot_index as usize; - if idx >= self.iosurfaces.len() { - return Err(BackendError::PlatformUnsupported { - reason: "slot index out of range for IOSurface vector", - }); - } - self.iosurfaces[idx].unlock_after_writing()?; - } - slot.inner.current_key.store(next_key, Ordering::Release); - slot.inner.completed.store(true, Ordering::Release); - assert!( - !slot.inner.acquired.load(Ordering::Acquire), - "acquired cleared" - ); - assert!( - slot.inner.completed.load(Ordering::Acquire), - "completed flag set" - ); - Ok(()) - } - - fn poll_complete(&mut self, slot: &IoSurfaceSlotHandle) -> bool { - let done = slot.inner.completed.load(Ordering::Acquire); - assert!(self.slots_created, "slots exist for poll"); - assert!( - (slot.inner.slot_index as usize) < Self::NUM_SLOTS, - "slot index in range" - ); - done - } - - fn mark_consumed(&mut self, slot: &IoSurfaceSlotHandle) { - assert!(self.slots_created, "slots exist for mark_consumed"); - assert!( - (slot.inner.slot_index as usize) < Self::NUM_SLOTS, - "slot index in range" - ); - slot.inner.completed.store(false, Ordering::Release); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn create_slots_rejects_zero_dims() { - let mut backend = MetalSharedTextureBackend::new(); - let err = backend.create_slots(0, 1080, TextureFormat::Nv12).err(); - assert!(matches!( - err, - Some(BackendError::DimensionsOutOfRange { .. }) - )); - } - - #[test] - fn create_slots_rejects_unsupported_format() { - let mut backend = MetalSharedTextureBackend::new(); - let err = backend.create_slots(1920, 1080, TextureFormat::P010).err(); - assert!(matches!(err, Some(BackendError::UnsupportedFormat { .. }))); - } - - #[cfg(target_os = "macos")] - #[test] - fn macos_acquire_release_round_trip_marks_complete() { - let mut backend = MetalSharedTextureBackend::new(); - let slots = backend - .create_slots(64, 64, TextureFormat::Nv12) - .expect("create"); - let slot = slots[0].clone(); - backend.acquire_write(&slot, 0).expect("acquire"); - assert!(slot.is_acquired()); - backend.release_write(&slot, 1).expect("release"); - assert!(!slot.is_acquired()); - assert!(backend.poll_complete(&slot)); - backend.mark_consumed(&slot); - assert!(!backend.poll_complete(&slot)); - } - - #[cfg(target_os = "macos")] - #[test] - fn macos_double_acquire_rejects() { - let mut backend = MetalSharedTextureBackend::new(); - let slots = backend - .create_slots(64, 64, TextureFormat::Nv12) - .expect("create"); - let slot = slots[0].clone(); - backend.acquire_write(&slot, 0).expect("first acquire"); - let err = backend.acquire_write(&slot, 0).err(); - assert!(matches!( - err, - Some(BackendError::AcquireWhileWriting { .. }) - )); - } -} diff --git a/fluxer_desktop/native/encoder-ring/src/metal_iosurface_macos.rs b/fluxer_desktop/native/encoder-ring/src/metal_iosurface_macos.rs deleted file mode 100644 index 4340666a8..000000000 --- a/fluxer_desktop/native/encoder-ring/src/metal_iosurface_macos.rs +++ /dev/null @@ -1,187 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use core::ffi::c_void; -use core::ptr::NonNull; - -use crate::backend::BackendError; - -type IoSurfaceRef = *mut c_void; -type CfDictionaryRef = *const c_void; -type CfStringRef = *const c_void; -type CfNumberRef = *const c_void; -type CfAllocatorRef = *const c_void; - -const KIO_RETURN_SUCCESS: i32 = 0; -const KCF_NUMBER_SINT32_TYPE: i32 = 3; - -const IOSURFACE_PIXEL_FORMAT_420V: u32 = u32::from_be_bytes(*b"420v"); - -#[link(name = "IOSurface", kind = "framework")] -unsafe extern "C" { - fn IOSurfaceCreate(properties: CfDictionaryRef) -> IoSurfaceRef; - fn IOSurfaceLock(buffer: IoSurfaceRef, options: u32, seed: *mut u32) -> i32; - fn IOSurfaceUnlock(buffer: IoSurfaceRef, options: u32, seed: *mut u32) -> i32; - fn IOSurfaceGetID(buffer: IoSurfaceRef) -> u32; -} - -#[link(name = "CoreFoundation", kind = "framework")] -unsafe extern "C" { - static kCFAllocatorDefault: CfAllocatorRef; - static kCFTypeDictionaryKeyCallBacks: *const c_void; - static kCFTypeDictionaryValueCallBacks: *const c_void; - fn CFDictionaryCreate( - allocator: CfAllocatorRef, - keys: *const *const c_void, - values: *const *const c_void, - num_values: isize, - key_callbacks: *const c_void, - value_callbacks: *const c_void, - ) -> CfDictionaryRef; - fn CFNumberCreate( - allocator: CfAllocatorRef, - the_type: i32, - value_ptr: *const c_void, - ) -> CfNumberRef; - fn CFStringCreateWithCString( - allocator: CfAllocatorRef, - c_str: *const i8, - encoding: u32, - ) -> CfStringRef; - fn CFRelease(cf: *const c_void); -} - -const KCFSTRING_ENCODING_UTF8: u32 = 0x0800_0100; - -fn cf_str(literal: &'static str) -> CfStringRef { - assert!(literal.ends_with('\0'), "literal must be nul-terminated"); - let bytes = literal.as_bytes(); - unsafe { - CFStringCreateWithCString( - kCFAllocatorDefault, - bytes.as_ptr() as *const i8, - KCFSTRING_ENCODING_UTF8, - ) - } -} - -fn cf_num_i32(v: i32) -> CfNumberRef { - let ptr: *const i32 = &v; - unsafe { - CFNumberCreate( - kCFAllocatorDefault, - KCF_NUMBER_SINT32_TYPE, - ptr as *const c_void, - ) - } -} - -pub struct OwnedIoSurface { - surface: NonNull, -} - -unsafe impl Send for OwnedIoSurface {} - -impl OwnedIoSurface { - pub fn create_nv12(width: u32, height: u32) -> Result { - assert!(width > 0, "create_nv12 width positive"); - assert!( - height > 0 && height.is_multiple_of(2), - "create_nv12 height positive and even" - ); - let width_key = cf_str("IOSurfaceWidth\0"); - let height_key = cf_str("IOSurfaceHeight\0"); - let pf_key = cf_str("IOSurfacePixelFormat\0"); - let bpe_key = cf_str("IOSurfaceBytesPerElement\0"); - let width_val = cf_num_i32(width as i32); - let height_val = cf_num_i32(height as i32); - let pf_val = cf_num_i32(IOSURFACE_PIXEL_FORMAT_420V as i32); - let bpe_val = cf_num_i32(1); - let keys: [*const c_void; 4] = [width_key, height_key, pf_key, bpe_key]; - let vals: [*const c_void; 4] = [width_val, height_val, pf_val, bpe_val]; - let dict = unsafe { - CFDictionaryCreate( - kCFAllocatorDefault, - keys.as_ptr(), - vals.as_ptr(), - 4, - kCFTypeDictionaryKeyCallBacks, - kCFTypeDictionaryValueCallBacks, - ) - }; - let raw = if dict.is_null() { - core::ptr::null_mut() - } else { - unsafe { IOSurfaceCreate(dict) } - }; - unsafe { - CFRelease(width_key); - CFRelease(height_key); - CFRelease(pf_key); - CFRelease(bpe_key); - CFRelease(width_val); - CFRelease(height_val); - CFRelease(pf_val); - CFRelease(bpe_val); - if !dict.is_null() { - CFRelease(dict); - } - } - let surface = NonNull::new(raw).ok_or(BackendError::PlatformUnsupported { - reason: "IOSurfaceCreate returned null", - })?; - assert!( - unsafe { IOSurfaceGetID(surface.as_ptr()) } != 0, - "IOSurfaceGetID non-zero" - ); - Ok(Self { surface }) - } - - pub fn handle(&self) -> u64 { - let id = unsafe { IOSurfaceGetID(self.surface.as_ptr()) }; - assert!(id != 0, "IOSurfaceID non-zero on handle()"); - assert!(self.surface.as_ptr() as usize != 0, "surface ptr non-null"); - id as u64 - } - - pub fn as_ptr(&self) -> *mut c_void { - let p = self.surface.as_ptr(); - assert!(!p.is_null(), "IOSurface raw pointer non-null"); - assert!(unsafe { IOSurfaceGetID(p) } != 0, "IOSurfaceID non-zero"); - p - } - - pub fn lock_for_writing(&mut self) -> Result<(), BackendError> { - let mut seed: u32 = 0; - let status = unsafe { IOSurfaceLock(self.surface.as_ptr(), 0, &mut seed) }; - if status != KIO_RETURN_SUCCESS { - return Err(BackendError::PlatformUnsupported { - reason: "IOSurfaceLock failed", - }); - } - assert_eq!(status, KIO_RETURN_SUCCESS, "lock status ok"); - assert!(seed < u32::MAX, "lock seed within range"); - Ok(()) - } - - pub fn unlock_after_writing(&mut self) -> Result<(), BackendError> { - let mut seed: u32 = 0; - let status = unsafe { IOSurfaceUnlock(self.surface.as_ptr(), 0, &mut seed) }; - if status != KIO_RETURN_SUCCESS { - return Err(BackendError::PlatformUnsupported { - reason: "IOSurfaceUnlock failed", - }); - } - assert_eq!(status, KIO_RETURN_SUCCESS, "unlock status ok"); - assert!(seed < u32::MAX, "unlock seed within range"); - Ok(()) - } -} - -impl Drop for OwnedIoSurface { - fn drop(&mut self) { - let ptr = self.surface.as_ptr(); - if !ptr.is_null() { - unsafe { CFRelease(ptr) }; - } - } -} diff --git a/fluxer_desktop/native/encoder-ring/src/nvenc.rs b/fluxer_desktop/native/encoder-ring/src/nvenc.rs deleted file mode 100644 index 1de76aec2..000000000 --- a/fluxer_desktop/native/encoder-ring/src/nvenc.rs +++ /dev/null @@ -1,1208 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::collections::HashMap; -use std::collections::VecDeque; -use std::ffi::c_void; -use std::ptr; -use std::sync::Arc; - -use libloading::{Library, Symbol}; -use windows::Win32::Foundation::{CloseHandle, HANDLE, WAIT_OBJECT_0}; -use windows::Win32::Graphics::Direct3D11::{ID3D11Device, ID3D11Multithread}; -use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject}; -use windows::core::Interface; - -use crate::encoder_handoff::{ - EncodedBitstream, EncoderCompletionCallback, EncoderDims, EncoderError, EncoderFrameRate, - EncoderSubmission, HandoffSlot, MAX_BITSTREAM_BYTES, NvencHandoff, PicParams, apply_dts_offset, - compute_dts_offset_us, -}; -use crate::ring::RingError; - -pub const COMPLETION_RING_CAPACITY: usize = 16; -pub const NVENC_SLOT_ID_PROBES: usize = 1024; -pub const NVENC_IN_FLIGHT_MAX: usize = 8; -const NVENC_PIPELINE_DEPTH: usize = 2; - -pub const NVENC_DLL_NAME: &str = "nvEncodeAPI64.dll"; - -const NVENCAPI_MAJOR_VERSION: u32 = 13; -const NVENCAPI_MINOR_VERSION: u32 = 0; -const NVENCAPI_VERSION: u32 = NVENCAPI_MAJOR_VERSION | (NVENCAPI_MINOR_VERSION << 24); - -const fn struct_version(ver: u32) -> u32 { - NVENCAPI_VERSION | (ver << 16) | (0x7 << 28) -} - -const NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER: u32 = struct_version(1); -const NV_ENCODE_API_FUNCTION_LIST_VER: u32 = struct_version(2); -const NV_ENC_INITIALIZE_PARAMS_VER: u32 = struct_version(7) | (1 << 31); -const NV_ENC_CONFIG_VER: u32 = struct_version(9) | (1 << 31); -const NV_ENC_PRESET_CONFIG_VER: u32 = struct_version(5) | (1 << 31); -const NV_ENC_PIC_PARAMS_VER: u32 = struct_version(7) | (1 << 31); -const NV_ENC_LOCK_BITSTREAM_VER: u32 = struct_version(2) | (1 << 31); -const NV_ENC_MAP_INPUT_RESOURCE_VER: u32 = struct_version(4); -const NV_ENC_REGISTER_RESOURCE_VER: u32 = struct_version(5); -const NV_ENC_EVENT_PARAMS_VER: u32 = struct_version(2); -const NV_ENC_CREATE_BITSTREAM_BUFFER_VER: u32 = struct_version(1); - -const NV_ENC_DEVICE_TYPE_DIRECTX: u32 = 0; -const NV_ENC_INPUT_RESOURCE_TYPE_DIRECTX: u32 = 0; -const NV_ENC_BUFFER_FORMAT_NV12: u32 = 0x00000001; -const NV_ENC_PIC_STRUCT_FRAME: u32 = 0x01; -const NV_ENC_PIC_TYPE_IDR: u32 = 0x05; -const NV_ENC_PIC_FLAG_FORCEIDR: u32 = 0x2; -const NV_ENC_PIC_FLAG_OUTPUT_SPSPPS: u32 = 0x4; -#[allow( - dead_code, - reason = "documented rate-control mode constant per NVENC SDK reference" -)] -const NV_ENC_PARAMS_RC_CBR: u32 = 0x2; -const NV_ENC_TUNING_INFO_LOW_LATENCY: u32 = 2; -const NV_ENC_SUCCESS: i32 = 0; - -const NV_ENC_CODEC_H264_GUID: Guid = Guid { - data1: 0x6bc82762, - data2: 0x4e63, - data3: 0x4ca4, - data4: [0xaa, 0x85, 0x1e, 0x50, 0xf3, 0x21, 0xf6, 0xbf], -}; -const NV_ENC_PRESET_P3_GUID: Guid = Guid { - data1: 0x47bcf4d8, - data2: 0xb1e6, - data3: 0x47ca, - data4: [0x9d, 0x9c, 0xdf, 0x9f, 0xb6, 0xfa, 0x29, 0xed], -}; - -#[repr(C)] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct Guid { - data1: u32, - data2: u16, - data3: u16, - data4: [u8; 8], -} - -#[repr(C)] -struct OpenEncodeSessionExParams { - version: u32, - device_type: u32, - device: *mut c_void, - reserved: *mut c_void, - api_version: u32, - reserved1: [u32; 253], - reserved2: [*mut c_void; 64], -} - -#[repr(C)] -struct EventParams { - version: u32, - reserved: u32, - completion_event: *mut c_void, - reserved1: [u32; 254], - reserved2: [*mut c_void; 64], -} - -#[repr(C)] -struct RegisterResource { - version: u32, - resource_type: u32, - width: u32, - height: u32, - pitch: u32, - subresource_index: u32, - resource_to_register: *mut c_void, - registered_resource: *mut c_void, - buffer_format: u32, - buffer_usage: u32, - pinput_fence_point: *mut c_void, - chroma_offset: [u32; 2], - reserved1: [u32; 247], - reserved2: [*mut c_void; 28], -} - -#[repr(C)] -struct MapInputResource { - version: u32, - subresource_index: u32, - input_resource: *mut c_void, - registered_resource: *mut c_void, - mapped_resource: *mut c_void, - mapped_buffer_fmt: u32, - reserved1: [u32; 251], - reserved2: [*mut c_void; 63], -} - -#[repr(C)] -struct CreateBitstreamBuffer { - version: u32, - size: u32, - memory_heap: u32, - reserved: u32, - bitstream_buffer: *mut c_void, - bitstream_buffer_ptr: *mut c_void, - reserved1: [u32; 58], - reserved2: [*mut c_void; 64], -} - -#[repr(C)] -struct LockBitstream { - version: u32, - flags: u32, - output_bitstream: *mut c_void, - slice_offsets: *mut u32, - frame_idx: u32, - h_w_encode_status: u32, - num_slices: u32, - bitstream_size_in_bytes: u32, - output_time_stamp: u64, - output_duration: u64, - bitstream_buffer_ptr: *mut c_void, - picture_type: u32, - picture_struct: u32, - frame_avg_qp: u32, - frame_satd: u32, - ltr_frame_idx: u32, - ltr_frame_bitmap: u32, - temporal_id: u32, - reserved: [u32; 12], - intra_mb_count: u32, - inter_mb_count: u32, - average_mvx: i32, - average_mvy: i32, - alpha_layer_size_in_bytes: u32, - reserved1: [u32; 218], - reserved2: [*mut c_void; 64], -} - -#[repr(C)] -struct PicParamsRaw { - version: u32, - input_width: u32, - input_height: u32, - input_pitch: u32, - encode_pic_flags: u32, - frame_idx: u32, - input_timestamp: u64, - input_duration: u64, - input_buffer: *mut c_void, - output_bitstream: *mut c_void, - completion_event: *mut c_void, - buffer_fmt: u32, - picture_struct: u32, - picture_type: u32, - codec_pic_params: [u8; 256], - rc_params: [u8; 1024], - qp_delta_map: *mut i8, - qp_delta_map_size: u32, - reserved3: u32, - me_hint_counts_per_block: [u32; 2], - me_external_hints: *mut c_void, - reserved1: [u32; 6], - reserved2: [*mut c_void; 2], - me_hint_ref_pic_dpb_idx: [i8; 2], - state_buffer_idx: u8, - reserved4: [u8; 5], - new_sps_pps_buffer: *mut c_void, - reserved5: [u32; 246], - reserved6: [*mut c_void; 60], -} - -const CONFIG_BYTES: usize = 1024; -const PRESET_CONFIG_BYTES: usize = 8 + CONFIG_BYTES; - -#[repr(C)] -struct InitializeParams { - version: u32, - encode_guid: Guid, - preset_guid: Guid, - encode_width: u32, - encode_height: u32, - dar_width: u32, - dar_height: u32, - frame_rate_num: u32, - frame_rate_den: u32, - enable_encode_async: u32, - enable_ptd: u32, - bitfields: u32, - priv_data_size: u32, - reserved: u32, - priv_data: *mut c_void, - encode_config: *mut c_void, - max_encode_width: u32, - max_encode_height: u32, - max_me_hint_counts_per_block: [u8; 32], - tuning_info: u32, - buffer_format: u32, - num_state_buffers: u32, - output_stats_level: u32, - reserved1: [u32; 284], - reserved2: [*mut c_void; 64], -} - -type StatusFn0 = unsafe extern "C" fn(*mut OpenEncodeSessionExParams, *mut *mut c_void) -> i32; -type DestroyEncoderFn = unsafe extern "C" fn(*mut c_void) -> i32; -type GetPresetConfigFn = unsafe extern "C" fn(*mut c_void, Guid, Guid, u32, *mut u8) -> i32; -type InitFn = unsafe extern "C" fn(*mut c_void, *mut u8) -> i32; -type RegisterFn = unsafe extern "C" fn(*mut c_void, *mut RegisterResource) -> i32; -type UnregisterFn = unsafe extern "C" fn(*mut c_void, *mut c_void) -> i32; -type MapFn = unsafe extern "C" fn(*mut c_void, *mut MapInputResource) -> i32; -type UnmapFn = unsafe extern "C" fn(*mut c_void, *mut c_void) -> i32; -type CreateBufFn = unsafe extern "C" fn(*mut c_void, *mut CreateBitstreamBuffer) -> i32; -type DestroyBufFn = unsafe extern "C" fn(*mut c_void, *mut c_void) -> i32; -type EncodePicFn = unsafe extern "C" fn(*mut c_void, *mut PicParamsRaw) -> i32; -type LockBitstreamFn = unsafe extern "C" fn(*mut c_void, *mut LockBitstream) -> i32; -type UnlockBitstreamFn = unsafe extern "C" fn(*mut c_void, *mut c_void) -> i32; -type RegisterAsyncEventFn = unsafe extern "C" fn(*mut c_void, *mut EventParams) -> i32; -type UnregisterAsyncEventFn = unsafe extern "C" fn(*mut c_void, *mut EventParams) -> i32; - -#[repr(C)] -struct ApiFunctionList { - version: u32, - reserved: u32, - fns: [*mut c_void; 64], - reserved2: [*mut c_void; 275], -} - -const FN_IDX_INITIALIZE_ENCODER: usize = 10; -const FN_IDX_CREATE_BITSTREAM_BUFFER: usize = 13; -const FN_IDX_DESTROY_BITSTREAM_BUFFER: usize = 14; -const FN_IDX_ENCODE_PICTURE: usize = 15; -const FN_IDX_LOCK_BITSTREAM: usize = 16; -const FN_IDX_UNLOCK_BITSTREAM: usize = 17; -const FN_IDX_REGISTER_ASYNC_EVENT: usize = 22; -const FN_IDX_UNREGISTER_ASYNC_EVENT: usize = 23; -const FN_IDX_MAP_INPUT_RESOURCE: usize = 24; -const FN_IDX_UNMAP_INPUT_RESOURCE: usize = 25; -const FN_IDX_DESTROY_ENCODER: usize = 26; -const FN_IDX_OPEN_ENCODE_SESSION_EX: usize = 28; -const FN_IDX_REGISTER_RESOURCE: usize = 29; -const FN_IDX_UNREGISTER_RESOURCE: usize = 30; -const FN_IDX_GET_PRESET_CONFIG_EX: usize = 38; - -struct ApiTable { - initialize: InitFn, - create_buf: CreateBufFn, - destroy_buf: DestroyBufFn, - encode_pic: EncodePicFn, - lock_bs: LockBitstreamFn, - unlock_bs: UnlockBitstreamFn, - register_event: RegisterAsyncEventFn, - unregister_event: UnregisterAsyncEventFn, - map_input: MapFn, - unmap_input: UnmapFn, - destroy_encoder: DestroyEncoderFn, - open_session_ex: StatusFn0, - register_resource: RegisterFn, - unregister_resource: UnregisterFn, - get_preset_config: GetPresetConfigFn, -} - -struct SlotState { - registered_resource: *mut c_void, - completion_event: HANDLE, - bitstream_buffer: *mut c_void, - pending_pts_us: u64, - pending_force_keyframe: bool, - in_flight: bool, -} - -pub struct NvencD3D11Handoff { - _library: Arc, - api: ApiTable, - encoder: *mut c_void, - slots: HashMap, - handle_to_slot: HashMap, - in_flight_order: VecDeque, - next_slot_index: u32, - dts_offset_us: i64, - first_frame_pts_us: Option, - completed_count: u64, - completed_ring: VecDeque, - dropped_count: u64, - frame_interval_us: u64, -} - -unsafe impl Send for NvencD3D11Handoff {} - -impl NvencD3D11Handoff { - pub fn new( - device: ID3D11Device, - dims: EncoderDims, - bitrate_bps: u32, - ) -> Result { - Self::new_with_frame_rate(device, dims, bitrate_bps, EncoderFrameRate::default()) - } - - pub fn new_with_frame_rate( - device: ID3D11Device, - dims: EncoderDims, - bitrate_bps: u32, - frame_rate: EncoderFrameRate, - ) -> Result { - assert!(dims.width > 0, "dims width positive"); - assert!(dims.height > 0, "dims height positive"); - assert!(frame_rate.numerator > 0, "frame rate numerator positive"); - assert!( - frame_rate.denominator > 0, - "frame rate denominator positive" - ); - if dims.width > 7680 || dims.height > 4320 { - return Err(EncoderError::DimensionsOutOfRange { - width: dims.width, - height: dims.height, - }); - } - enable_multithread_protected(&device)?; - let library = load_runtime()?; - let api = load_function_list(&library)?; - let raw_device_ptr = device.as_raw(); - let encoder = open_session(&api, raw_device_ptr)?; - let frame_interval_us = frame_rate.frame_interval_us(); - let mut handoff = Self { - _library: Arc::new(library), - api, - encoder, - slots: HashMap::new(), - handle_to_slot: HashMap::new(), - in_flight_order: VecDeque::with_capacity(NVENC_IN_FLIGHT_MAX), - next_slot_index: 0, - dts_offset_us: compute_dts_offset_us(0, 0, frame_interval_us), - first_frame_pts_us: None, - completed_count: 0, - completed_ring: VecDeque::with_capacity(COMPLETION_RING_CAPACITY), - dropped_count: 0, - frame_interval_us, - }; - initialize_encoder( - &handoff.api, - handoff.encoder, - dims, - bitrate_bps, - frame_rate.numerator, - frame_rate.denominator, - )?; - let _ = &mut handoff; - assert!(!handoff.encoder.is_null(), "encoder ptr non-null"); - assert!( - handoff.completed_ring.capacity() >= COMPLETION_RING_CAPACITY, - "ring pre-allocated" - ); - Ok(handoff) - } - - pub fn pending_completion(&self) -> usize { - let n = self.completed_ring.len(); - assert!(n <= COMPLETION_RING_CAPACITY, "completion ring bounded"); - assert!( - self.completed_count <= u64::MAX / 2, - "completed count plausible" - ); - n - } - - fn allocate_slot_index(&mut self) -> Option { - assert!(self.slots.len() < usize::MAX, "slot map count plausible"); - for _ in 0..NVENC_SLOT_ID_PROBES { - let candidate = self.next_slot_index; - self.next_slot_index = candidate.wrapping_add(1); - if !self.slots.contains_key(&candidate) { - return Some(candidate); - } - } - None - } - - pub fn completed_count(&self) -> u64 { - let n = self.completed_count; - assert!( - self.completed_ring.len() <= COMPLETION_RING_CAPACITY, - "ring bounded" - ); - n - } - - fn register_slot_impl( - &mut self, - shared_handle: u64, - _key: u64, - dims: EncoderDims, - ) -> Result { - assert!(shared_handle != 0, "shared_handle non-zero"); - assert!(dims.width > 0, "dims width positive"); - let texture_ptr = shared_handle as *mut c_void; - let mut reg = RegisterResource { - version: NV_ENC_REGISTER_RESOURCE_VER, - resource_type: NV_ENC_INPUT_RESOURCE_TYPE_DIRECTX, - width: dims.width, - height: dims.height, - pitch: 0, - subresource_index: 0, - resource_to_register: texture_ptr, - registered_resource: ptr::null_mut(), - buffer_format: NV_ENC_BUFFER_FORMAT_NV12, - buffer_usage: 0, - pinput_fence_point: ptr::null_mut(), - chroma_offset: [0; 2], - reserved1: [0; 247], - reserved2: [ptr::null_mut(); 28], - }; - let status = unsafe { (self.api.register_resource)(self.encoder, &mut reg) }; - if status != NV_ENC_SUCCESS { - return Err(EncoderError::RegisterFailed { - vendor: "nvenc", - status: status as i64, - }); - } - let event = create_completion_event()?; - register_async_event(&self.api, self.encoder, event)?; - let buffer = create_bitstream_buffer(&self.api, self.encoder)?; - let slot_index = self - .allocate_slot_index() - .ok_or(EncoderError::RegisterFailed { - vendor: "nvenc-slot-exhausted", - status: 0, - })?; - let slot = HandoffSlot::new(slot_index, shared_handle); - self.slots.insert( - slot_index, - SlotState { - registered_resource: reg.registered_resource, - completion_event: event, - bitstream_buffer: buffer, - pending_pts_us: 0, - pending_force_keyframe: false, - in_flight: false, - }, - ); - self.handle_to_slot.insert(shared_handle, slot_index); - let _ = slot; - assert!(self.slots.contains_key(&slot_index), "slot stored"); - assert!( - self.handle_to_slot.contains_key(&shared_handle), - "handle index stored" - ); - Ok(slot) - } - - fn drain_one_completed(&mut self, timeout_ms: u32) -> Option { - assert!( - self.in_flight_order.len() <= self.slots.len(), - "in-flight bounded by registered slots" - ); - let oldest = *self.in_flight_order.front()?; - let state = self.slots.get_mut(&oldest)?; - assert!(state.in_flight, "front of in-flight order is in flight"); - let wait = unsafe { WaitForSingleObject(state.completion_event, timeout_ms) }; - if wait != WAIT_OBJECT_0 { - return None; - } - let lock_result = lock_bitstream(&self.api, self.encoder, state.bitstream_buffer); - state.in_flight = false; - let popped = self.in_flight_order.pop_front(); - assert_eq!(popped, Some(oldest), "popped oldest in-flight slot"); - let (data, pts, is_keyframe) = match lock_result { - Ok(t) => t, - Err(_) => return None, - }; - if data.is_empty() { - return None; - } - let dts = apply_dts_offset(pts, self.dts_offset_us); - self.completed_count = self.completed_count.saturating_add(1); - let bs = - match std::panic::catch_unwind(|| EncodedBitstream::new(data, pts, dts, is_keyframe)) { - Ok(b) => b, - Err(_) => return None, - }; - Some(bs) - } - - fn drain_completions_into_ring(&mut self) { - while self.completed_ring.len() < COMPLETION_RING_CAPACITY { - let Some(bs) = self.drain_one_completed(0) else { - break; - }; - self.completed_ring.push_back(bs); - } - assert!( - self.completed_ring.len() <= COMPLETION_RING_CAPACITY, - "completion ring bounded" - ); - assert!( - self.in_flight_order.len() <= self.slots.len(), - "in-flight bounded by registered slots" - ); - } - - pub fn pre_register_slots( - &mut self, - handles: &[crate::d3d11::D3D11SharedHandle], - dims: EncoderDims, - ) -> Result<(), EncoderError> { - assert!( - !handles.is_empty(), - "pre-register needs at least one handle" - ); - assert!( - handles.len() <= crate::ring::RING_SIZE_MAX, - "pre-register bounded by ring max" - ); - for handle in handles { - if self.handle_to_slot.contains_key(&handle.raw_handle) { - continue; - } - let _ = self.register_slot_impl(handle.raw_handle, 0, dims)?; - } - assert!( - handles - .iter() - .all(|h| self.handle_to_slot.contains_key(&h.raw_handle)), - "all ring handles registered" - ); - Ok(()) - } -} - -fn enable_multithread_protected(device: &ID3D11Device) -> Result<(), EncoderError> { - let mt: ID3D11Multithread = device.cast().map_err(|_| EncoderError::SessionInitFailed { - vendor: "nvenc-mt", - status: -3, - })?; - let _ = unsafe { mt.SetMultithreadProtected(true) }; - Ok(()) -} - -fn load_runtime() -> Result { - let library = - unsafe { Library::new(NVENC_DLL_NAME) }.map_err(|_| EncoderError::SdkNotFound { - vendor: "nvenc", - dll: NVENC_DLL_NAME, - })?; - Ok(library) -} - -fn load_function_list(library: &Library) -> Result { - type CreateInstanceFn = unsafe extern "C" fn(*mut ApiFunctionList) -> i32; - let create_instance: Symbol<'_, CreateInstanceFn> = unsafe { - library.get(b"NvEncodeAPICreateInstance\0") - } - .map_err(|_| EncoderError::SymbolMissing { - vendor: "nvenc", - symbol: "NvEncodeAPICreateInstance", - })?; - let mut list = ApiFunctionList { - version: NV_ENCODE_API_FUNCTION_LIST_VER, - reserved: 0, - fns: [ptr::null_mut(); 64], - reserved2: [ptr::null_mut(); 275], - }; - let status = unsafe { create_instance(&mut list) }; - if status != NV_ENC_SUCCESS { - return Err(EncoderError::SessionInitFailed { - vendor: "nvenc", - status: status as i64, - }); - } - let api = build_api_table(&list)?; - Ok(api) -} - -fn build_api_table(list: &ApiFunctionList) -> Result { - let required_indices = [ - FN_IDX_INITIALIZE_ENCODER, - FN_IDX_CREATE_BITSTREAM_BUFFER, - FN_IDX_DESTROY_BITSTREAM_BUFFER, - FN_IDX_ENCODE_PICTURE, - FN_IDX_LOCK_BITSTREAM, - FN_IDX_UNLOCK_BITSTREAM, - FN_IDX_REGISTER_ASYNC_EVENT, - FN_IDX_UNREGISTER_ASYNC_EVENT, - FN_IDX_MAP_INPUT_RESOURCE, - FN_IDX_UNMAP_INPUT_RESOURCE, - FN_IDX_DESTROY_ENCODER, - FN_IDX_OPEN_ENCODE_SESSION_EX, - FN_IDX_REGISTER_RESOURCE, - FN_IDX_UNREGISTER_RESOURCE, - FN_IDX_GET_PRESET_CONFIG_EX, - ]; - for idx in required_indices.iter() { - if list.fns[*idx].is_null() { - return Err(EncoderError::SymbolMissing { - vendor: "nvenc", - symbol: "function_list entry", - }); - } - } - let api = unsafe { - ApiTable { - initialize: std::mem::transmute::<*mut c_void, InitFn>( - list.fns[FN_IDX_INITIALIZE_ENCODER], - ), - create_buf: std::mem::transmute::<*mut c_void, CreateBufFn>( - list.fns[FN_IDX_CREATE_BITSTREAM_BUFFER], - ), - destroy_buf: std::mem::transmute::<*mut c_void, DestroyBufFn>( - list.fns[FN_IDX_DESTROY_BITSTREAM_BUFFER], - ), - encode_pic: std::mem::transmute::<*mut c_void, EncodePicFn>( - list.fns[FN_IDX_ENCODE_PICTURE], - ), - lock_bs: std::mem::transmute::<*mut c_void, LockBitstreamFn>( - list.fns[FN_IDX_LOCK_BITSTREAM], - ), - unlock_bs: std::mem::transmute::<*mut c_void, UnlockBitstreamFn>( - list.fns[FN_IDX_UNLOCK_BITSTREAM], - ), - register_event: std::mem::transmute::<*mut c_void, RegisterAsyncEventFn>( - list.fns[FN_IDX_REGISTER_ASYNC_EVENT], - ), - unregister_event: std::mem::transmute::<*mut c_void, UnregisterAsyncEventFn>( - list.fns[FN_IDX_UNREGISTER_ASYNC_EVENT], - ), - map_input: std::mem::transmute::<*mut c_void, MapFn>( - list.fns[FN_IDX_MAP_INPUT_RESOURCE], - ), - unmap_input: std::mem::transmute::<*mut c_void, UnmapFn>( - list.fns[FN_IDX_UNMAP_INPUT_RESOURCE], - ), - destroy_encoder: std::mem::transmute::<*mut c_void, DestroyEncoderFn>( - list.fns[FN_IDX_DESTROY_ENCODER], - ), - open_session_ex: std::mem::transmute::<*mut c_void, StatusFn0>( - list.fns[FN_IDX_OPEN_ENCODE_SESSION_EX], - ), - register_resource: std::mem::transmute::<*mut c_void, RegisterFn>( - list.fns[FN_IDX_REGISTER_RESOURCE], - ), - unregister_resource: std::mem::transmute::<*mut c_void, UnregisterFn>( - list.fns[FN_IDX_UNREGISTER_RESOURCE], - ), - get_preset_config: std::mem::transmute::<*mut c_void, GetPresetConfigFn>( - list.fns[FN_IDX_GET_PRESET_CONFIG_EX], - ), - } - }; - Ok(api) -} - -fn open_session(api: &ApiTable, device: *mut c_void) -> Result<*mut c_void, EncoderError> { - let mut params = OpenEncodeSessionExParams { - version: NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER, - device_type: NV_ENC_DEVICE_TYPE_DIRECTX, - device, - reserved: ptr::null_mut(), - api_version: NVENCAPI_VERSION, - reserved1: [0; 253], - reserved2: [ptr::null_mut(); 64], - }; - let mut encoder: *mut c_void = ptr::null_mut(); - let status = unsafe { (api.open_session_ex)(&mut params, &mut encoder) }; - if status != NV_ENC_SUCCESS { - return Err(EncoderError::SessionInitFailed { - vendor: "nvenc-open-session", - status: status as i64, - }); - } - if encoder.is_null() { - return Err(EncoderError::SessionInitFailed { - vendor: "nvenc-open-session", - status: -1, - }); - } - Ok(encoder) -} - -fn initialize_encoder( - api: &ApiTable, - encoder: *mut c_void, - dims: EncoderDims, - _bitrate_bps: u32, - fps_num: u32, - fps_den: u32, -) -> Result<(), EncoderError> { - assert!(!encoder.is_null(), "encoder ptr non-null"); - assert!(dims.width > 0, "width positive"); - let mut preset = vec![0u8; PRESET_CONFIG_BYTES]; - write_u32(&mut preset, 0, NV_ENC_PRESET_CONFIG_VER); - write_u32(&mut preset, 8, NV_ENC_CONFIG_VER); - let status = unsafe { - (api.get_preset_config)( - encoder, - NV_ENC_CODEC_H264_GUID, - NV_ENC_PRESET_P3_GUID, - NV_ENC_TUNING_INFO_LOW_LATENCY, - preset.as_mut_ptr(), - ) - }; - if status != NV_ENC_SUCCESS { - return Err(EncoderError::SessionInitFailed { - vendor: "nvenc-preset", - status: status as i64, - }); - } - let mut init_params = InitializeParams { - version: NV_ENC_INITIALIZE_PARAMS_VER, - encode_guid: NV_ENC_CODEC_H264_GUID, - preset_guid: NV_ENC_PRESET_P3_GUID, - encode_width: dims.width, - encode_height: dims.height, - dar_width: dims.width, - dar_height: dims.height, - frame_rate_num: fps_num, - frame_rate_den: fps_den, - enable_encode_async: 1, - enable_ptd: 1, - bitfields: 0, - priv_data_size: 0, - reserved: 0, - priv_data: ptr::null_mut(), - encode_config: ptr::null_mut(), - max_encode_width: dims.width, - max_encode_height: dims.height, - max_me_hint_counts_per_block: [0; 32], - tuning_info: NV_ENC_TUNING_INFO_LOW_LATENCY, - buffer_format: NV_ENC_BUFFER_FORMAT_NV12, - num_state_buffers: 0, - output_stats_level: 0, - reserved1: [0; 284], - reserved2: [ptr::null_mut(); 64], - }; - let status = unsafe { (api.initialize)(encoder, &mut init_params as *mut _ as *mut u8) }; - if status != NV_ENC_SUCCESS { - return Err(EncoderError::SessionInitFailed { - vendor: "nvenc-init", - status: status as i64, - }); - } - Ok(()) -} - -fn write_u32(buf: &mut [u8], offset: usize, value: u32) { - assert!(offset + 4 <= buf.len(), "u32 write in bounds"); - buf[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); -} - -fn create_completion_event() -> Result { - let event = unsafe { CreateEventW(None, false, false, windows::core::PCWSTR::null()) } - .map_err(|_| EncoderError::SessionInitFailed { - vendor: "nvenc", - status: -2, - })?; - assert!(!event.is_invalid(), "event handle valid"); - Ok(event) -} - -fn register_async_event( - api: &ApiTable, - encoder: *mut c_void, - event: HANDLE, -) -> Result<(), EncoderError> { - let mut params = EventParams { - version: NV_ENC_EVENT_PARAMS_VER, - reserved: 0, - completion_event: event.0, - reserved1: [0; 254], - reserved2: [ptr::null_mut(); 64], - }; - let status = unsafe { (api.register_event)(encoder, &mut params) }; - if status != NV_ENC_SUCCESS { - return Err(EncoderError::SessionInitFailed { - vendor: "nvenc", - status: status as i64, - }); - } - Ok(()) -} - -fn create_bitstream_buffer( - api: &ApiTable, - encoder: *mut c_void, -) -> Result<*mut c_void, EncoderError> { - let mut params = CreateBitstreamBuffer { - version: NV_ENC_CREATE_BITSTREAM_BUFFER_VER, - size: 0, - memory_heap: 0, - reserved: 0, - bitstream_buffer: ptr::null_mut(), - bitstream_buffer_ptr: ptr::null_mut(), - reserved1: [0; 58], - reserved2: [ptr::null_mut(); 64], - }; - let status = unsafe { (api.create_buf)(encoder, &mut params) }; - if status != NV_ENC_SUCCESS { - return Err(EncoderError::SessionInitFailed { - vendor: "nvenc", - status: status as i64, - }); - } - Ok(params.bitstream_buffer) -} - -fn map_input( - api: &ApiTable, - encoder: *mut c_void, - registered: *mut c_void, -) -> Result<*mut c_void, EncoderError> { - let mut params = MapInputResource { - version: NV_ENC_MAP_INPUT_RESOURCE_VER, - subresource_index: 0, - input_resource: ptr::null_mut(), - registered_resource: registered, - mapped_resource: ptr::null_mut(), - mapped_buffer_fmt: 0, - reserved1: [0; 251], - reserved2: [ptr::null_mut(); 63], - }; - let status = unsafe { (api.map_input)(encoder, &mut params) }; - if status != NV_ENC_SUCCESS { - return Err(EncoderError::EncodeFailed { - vendor: "nvenc", - status: status as i64, - }); - } - Ok(params.mapped_resource) -} - -struct EncodePictureArgs { - input: *mut c_void, - output: *mut c_void, - event: HANDLE, - dims: EncoderDims, - pts_us: u64, - force_keyframe: bool, -} - -fn encode_picture( - api: &ApiTable, - encoder: *mut c_void, - args: EncodePictureArgs, -) -> Result<(), EncoderError> { - assert!(args.dims.width > 0, "dims width positive"); - assert!(!args.input.is_null(), "input ptr non-null"); - let mut flags = 0_u32; - if args.force_keyframe { - flags |= NV_ENC_PIC_FLAG_FORCEIDR | NV_ENC_PIC_FLAG_OUTPUT_SPSPPS; - } - let mut params = PicParamsRaw { - version: NV_ENC_PIC_PARAMS_VER, - input_width: args.dims.width, - input_height: args.dims.height, - input_pitch: args.dims.width, - encode_pic_flags: flags, - frame_idx: 0, - input_timestamp: args.pts_us, - input_duration: 0, - input_buffer: args.input, - output_bitstream: args.output, - completion_event: args.event.0, - buffer_fmt: NV_ENC_BUFFER_FORMAT_NV12, - picture_struct: NV_ENC_PIC_STRUCT_FRAME, - picture_type: NV_ENC_PIC_TYPE_IDR, - codec_pic_params: [0; 256], - rc_params: [0; 1024], - qp_delta_map: ptr::null_mut(), - qp_delta_map_size: 0, - reserved3: 0, - me_hint_counts_per_block: [0; 2], - me_external_hints: ptr::null_mut(), - reserved1: [0; 6], - reserved2: [ptr::null_mut(); 2], - me_hint_ref_pic_dpb_idx: [-1; 2], - state_buffer_idx: 0, - reserved4: [0; 5], - new_sps_pps_buffer: ptr::null_mut(), - reserved5: [0; 246], - reserved6: [ptr::null_mut(); 60], - }; - let status = unsafe { (api.encode_pic)(encoder, &mut params) }; - if status != NV_ENC_SUCCESS && status != 1 { - return Err(EncoderError::EncodeFailed { - vendor: "nvenc", - status: status as i64, - }); - } - Ok(()) -} - -fn lock_bitstream( - api: &ApiTable, - encoder: *mut c_void, - output: *mut c_void, -) -> Result<(Vec, u64, bool), EncoderError> { - let mut params = LockBitstream { - version: NV_ENC_LOCK_BITSTREAM_VER, - flags: 1, - output_bitstream: output, - slice_offsets: ptr::null_mut(), - frame_idx: 0, - h_w_encode_status: 0, - num_slices: 0, - bitstream_size_in_bytes: 0, - output_time_stamp: 0, - output_duration: 0, - bitstream_buffer_ptr: ptr::null_mut(), - picture_type: 0, - picture_struct: 0, - frame_avg_qp: 0, - frame_satd: 0, - ltr_frame_idx: 0, - ltr_frame_bitmap: 0, - temporal_id: 0, - reserved: [0; 12], - intra_mb_count: 0, - inter_mb_count: 0, - average_mvx: 0, - average_mvy: 0, - alpha_layer_size_in_bytes: 0, - reserved1: [0; 218], - reserved2: [ptr::null_mut(); 64], - }; - let status = unsafe { (api.lock_bs)(encoder, &mut params) }; - if status != NV_ENC_SUCCESS { - return Err(EncoderError::BitstreamReadFailed { - vendor: "nvenc", - status: status as i64, - }); - } - let len = params.bitstream_size_in_bytes as usize; - assert!(len <= 16 * 1024 * 1024, "bitstream within sanity cap"); - let mut data: Vec = Vec::with_capacity(len); - if len > 0 && !params.bitstream_buffer_ptr.is_null() { - let src = - unsafe { std::slice::from_raw_parts(params.bitstream_buffer_ptr as *const u8, len) }; - data.extend_from_slice(src); - } - let pts = params.output_time_stamp; - let is_idr = params.picture_type == NV_ENC_PIC_TYPE_IDR || (params.picture_type & 0x0F) == 0; - let _ = unsafe { (api.unlock_bs)(encoder, output) }; - Ok((data, pts, is_idr)) -} - -impl Drop for NvencD3D11Handoff { - fn drop(&mut self) { - let keys: Vec = self.slots.keys().copied().collect(); - for key in keys { - if let Some(state) = self.slots.remove(&key) { - self.cleanup_slot(state); - } - } - self.handle_to_slot.clear(); - self.in_flight_order.clear(); - self.completed_ring.clear(); - if !self.encoder.is_null() { - let _ = unsafe { (self.api.destroy_encoder)(self.encoder) }; - self.encoder = ptr::null_mut(); - } - } -} - -impl NvencD3D11Handoff { - fn cleanup_slot(&self, state: SlotState) { - if !state.bitstream_buffer.is_null() { - let _ = unsafe { (self.api.destroy_buf)(self.encoder, state.bitstream_buffer) }; - } - if !state.registered_resource.is_null() { - let _ = - unsafe { (self.api.unregister_resource)(self.encoder, state.registered_resource) }; - } - if !state.completion_event.is_invalid() { - let mut params = EventParams { - version: NV_ENC_EVENT_PARAMS_VER, - reserved: 0, - completion_event: state.completion_event.0, - reserved1: [0; 254], - reserved2: [ptr::null_mut(); 64], - }; - let _ = unsafe { (self.api.unregister_event)(self.encoder, &mut params) }; - let _ = unsafe { CloseHandle(state.completion_event) }; - } - } -} - -impl NvencHandoff for NvencD3D11Handoff { - fn register_slot( - &mut self, - shared_handle: u64, - key: u64, - dims: EncoderDims, - ) -> Result { - self.register_slot_impl(shared_handle, key, dims) - } - - fn encode_shared_async( - &mut self, - slot: HandoffSlot, - _key: u64, - dims: EncoderDims, - pic_params: PicParams, - ) -> Result<(), EncoderError> { - assert!(dims.width > 0, "dims width positive"); - assert!(slot.shared_handle != 0, "slot handle non-zero"); - let state = self - .slots - .get_mut(&slot.slot_index) - .ok_or(EncoderError::SlotUnknown { - slot_index: slot.slot_index, - })?; - if state.in_flight { - return Err(EncoderError::EncodeFailed { - vendor: "nvenc", - status: -10, - }); - } - let mapped = map_input(&self.api, self.encoder, state.registered_resource)?; - if self.first_frame_pts_us.is_none() { - self.first_frame_pts_us = Some(pic_params.pts_us); - } - let result = encode_picture( - &self.api, - self.encoder, - EncodePictureArgs { - input: mapped, - output: state.bitstream_buffer, - event: state.completion_event, - dims, - pts_us: pic_params.pts_us, - force_keyframe: pic_params.force_keyframe || self.completed_count == 0, - }, - ); - let _ = unsafe { (self.api.unmap_input)(self.encoder, mapped) }; - result?; - state.pending_pts_us = pic_params.pts_us; - state.pending_force_keyframe = pic_params.force_keyframe; - state.in_flight = true; - self.in_flight_order.push_back(slot.slot_index); - assert!( - self.in_flight_order.len() <= self.slots.len(), - "in-flight bounded by registered slots" - ); - assert_eq!( - self.in_flight_order.back(), - Some(&slot.slot_index), - "newest submission at back of in-flight order" - ); - Ok(()) - } - - fn poll_completed(&mut self, slot: HandoffSlot) -> Option { - let _ = slot; - if let Some(bs) = self.completed_ring.pop_front() { - assert!(!bs.data.is_empty(), "ring payload non-empty"); - assert!(bs.data.len() <= MAX_BITSTREAM_BYTES, "ring payload bounded"); - return Some(bs); - } - let drained = self.drain_one_completed(0); - if let Some(bs) = drained { - assert!(!bs.data.is_empty(), "drained payload non-empty"); - return Some(bs); - } - None - } - - fn unregister_slot(&mut self, slot: HandoffSlot) { - if let Some(state) = self.slots.remove(&slot.slot_index) { - self.handle_to_slot.remove(&slot.shared_handle); - self.in_flight_order.retain(|&idx| idx != slot.slot_index); - self.cleanup_slot(state); - } - } - - fn encode_shared( - &mut self, - submission: EncoderSubmission, - callback: &mut dyn EncoderCompletionCallback, - ) -> Result<(), RingError> { - assert!(submission.shared_handle != 0, "submission handle non-zero"); - assert!(submission.dims.width > 0, "submission width positive"); - self.drain_completions_into_ring(); - if self.completed_ring.len() >= COMPLETION_RING_CAPACITY { - self.dropped_count = self.dropped_count.saturating_add(1); - return Err(RingError::FullDropped { - dropped_so_far: self.dropped_count, - }); - } - let slot = self.ensure_slot_for_handle(submission.shared_handle, submission.dims)?; - let pic = PicParams::new( - submission.sequence.saturating_mul(self.frame_interval_us), - false, - ); - let async_result = NvencHandoff::encode_shared_async( - self, - slot, - submission.keyed_mutex_key, - submission.dims, - pic, - ); - if let Err(e) = async_result { - return Err(RingError::BackendFailed { - source: crate::backend::BackendError::PlatformUnsupported { - reason: match e { - EncoderError::SlotUnknown { .. } => "nvenc slot unknown", - EncoderError::EncodeFailed { .. } => "nvenc encode failed", - _ => "nvenc session error", - }, - }, - }); - } - self.drain_completions_into_ring(); - if self.in_flight_order.len() >= NVENC_PIPELINE_DEPTH - && self.completed_ring.len() < COMPLETION_RING_CAPACITY - { - let timeout_ms = (self.frame_interval_us.saturating_add(999) / 1000) as u32; - assert!(timeout_ms <= 1000, "blocking drain capped at one second"); - if let Some(bs) = self.drain_one_completed(timeout_ms) { - self.completed_ring.push_back(bs); - } - } - let encoded_bytes_estimate = self.pending_completion() as u32; - callback.on_complete(submission.sequence, encoded_bytes_estimate); - Ok(()) - } -} - -impl NvencD3D11Handoff { - fn ensure_slot_for_handle( - &mut self, - shared_handle: u64, - dims: EncoderDims, - ) -> Result { - if let Some(&idx) = self.handle_to_slot.get(&shared_handle) { - return Ok(HandoffSlot::new(idx, shared_handle)); - } - self.register_slot_impl(shared_handle, 0, dims) - .map_err(|e| RingError::BackendFailed { - source: crate::backend::BackendError::PlatformUnsupported { - reason: match e { - EncoderError::RegisterFailed { .. } => "nvenc register failed", - EncoderError::SessionInitFailed { .. } => "nvenc session init", - EncoderError::DimensionsOutOfRange { .. } => "nvenc dims out of range", - _ => "nvenc register slot", - }, - }, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn struct_version_layout_is_stable() { - assert_eq!(NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER, struct_version(1)); - assert_eq!(NV_ENCODE_API_FUNCTION_LIST_VER, struct_version(2)); - } - - #[test] - fn guid_h264_layout_matches_nvenc_spec() { - assert_eq!(NV_ENC_CODEC_H264_GUID.data1, 0x6bc82762); - assert_eq!(NV_ENC_CODEC_H264_GUID.data4[7], 0xbf); - } - - #[test] - fn nvencapi_version_packs_major_and_minor() { - let expected = 13_u32; - assert_eq!(NVENCAPI_VERSION, expected); - } -} diff --git a/fluxer_desktop/native/encoder-ring/src/qsv.rs b/fluxer_desktop/native/encoder-ring/src/qsv.rs deleted file mode 100644 index 011bba2f1..000000000 --- a/fluxer_desktop/native/encoder-ring/src/qsv.rs +++ /dev/null @@ -1,930 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::collections::HashMap; -use std::ffi::c_void; -use std::ptr; -use std::sync::Arc; - -use libloading::{Library, Symbol}; -use windows::Win32::Graphics::Direct3D11::{ID3D11Device, ID3D11Multithread}; -use windows::core::Interface; - -use crate::encoder_handoff::{ - EncodedBitstream, EncoderCompletionCallback, EncoderDims, EncoderError, EncoderFrameRate, - EncoderSubmission, HandoffSlot, PicParams, QsvHandoff, apply_dts_offset, compute_dts_offset_us, -}; -use crate::ring::RingError; - -pub const QSV_DLL_NAME_VPL: &str = "libvpl.dll"; -pub const QSV_DLL_NAME_MFX: &str = "libmfxhw64.dll"; - -const MFX_IMPL_HARDWARE: i32 = 0x0002; -const MFX_IMPL_VIA_D3D11: i32 = 0x0300; -const MFX_IMPL_TYPE_HARDWARE: u32 = 2; -const MFX_ACCEL_MODE_VIA_D3D11: u32 = 0x0300; -const MFX_HANDLE_D3D11_DEVICE: u32 = 3; -const MFX_FOURCC_NV12: u32 = u32::from_le_bytes(*b"NV12"); -const MFX_CODEC_AVC: u32 = u32::from_le_bytes(*b"AVC "); -const MFX_RATECONTROL_CBR: u16 = 1; -const MFX_PICSTRUCT_PROGRESSIVE: u16 = 0x01; -const MFX_CHROMAFORMAT_YUV420: u16 = 1; -const MFX_IOPATTERN_IN_VIDEO_MEMORY: u16 = 0x01; - -const MFX_VARIANT_TYPE_U32: u32 = 5; -const MFX_VARIANT_VERSION_MINOR: u8 = 1; -const MFX_VARIANT_VERSION_MAJOR: u8 = 1; - -const MFX_ERR_NONE: i32 = 0; -const MFX_WRN_IN_EXECUTION: i32 = 1; -const MFX_ERR_MORE_DATA: i32 = -10; - -const FILTER_PROPERTY_IMPL: &[u8] = b"mfxImplDescription.Impl\0"; -const FILTER_PROPERTY_ACCEL: &[u8] = b"mfxImplDescription.AccelerationMode\0"; - -#[repr(C)] -#[derive(Clone, Copy, Default)] -struct MfxVersion { - minor: u16, - major: u16, -} - -#[repr(C)] -#[derive(Default)] -struct MfxFrameInfo { - reserved: [u32; 4], - channel_id: u16, - bit_depth_luma: u16, - bit_depth_chroma: u16, - shift: u16, - frame_id_temporal: u16, - frame_id_priority: u16, - frame_id_view_or_dependency: u16, - frame_id_quality: u16, - four_cc: u32, - width: u16, - height: u16, - crop_x: u16, - crop_y: u16, - crop_w: u16, - crop_h: u16, - frame_rate_extn: u32, - frame_rate_extd: u32, - reserved3: u16, - aspect_ratio_w: u16, - aspect_ratio_h: u16, - pic_struct: u16, - chroma_format: u16, - reserved2: u16, -} - -#[repr(C)] -#[derive(Default)] -struct MfxInfoMfx { - reserved: [u32; 7], - low_power: u16, - brc_param_multiplier: u16, - frame_info: MfxFrameInfo, - codec_id: u32, - codec_profile: u16, - codec_level: u16, - num_thread: u16, - target_usage: u16, - gop_pic_size: u16, - gop_ref_dist: u16, - gop_opt_flag: u16, - idr_interval: u16, - rate_control_method: u16, - init_qp: u16, - buffer_size_in_kb: u16, - target_kbps: u16, - max_kbps: u16, - num_slice: u16, - num_ref_frame: u16, - encoded_order: u16, - union_pad: [u16; 15], -} - -#[repr(C)] -struct MfxVideoParam { - alloc_id: u32, - reserved: [u32; 2], - reserved3: u16, - async_depth: u16, - mfx: MfxInfoMfx, - protected: u16, - io_pattern: u16, - ext_param: *mut c_void, - num_ext_param: u16, - reserved2: u16, -} - -#[repr(C)] -struct MfxBitstream { - encrypted_data: *mut c_void, - num_extparam: u16, - ext_param: *mut c_void, - reserved: [u32; 6], - decode_time_stamp: u64, - time_stamp: u64, - data: *mut u8, - data_offset: u32, - data_length: u32, - max_length: u32, - pic_struct: u16, - frame_type: u16, - data_flag: u16, - reserved2: u16, -} - -#[repr(C)] -struct MfxFrameData { - ext_param: *mut c_void, - num_extparam: u16, - reserved: [u32; 8], - mem_type: u16, - pitch_high: u16, - time_stamp: u64, - frame_order: u32, - locked: u16, - pitch_low: u16, - plane_ptrs: [*mut u8; 7], - mem_id: *mut c_void, - corrupted: u16, - data_flag: u16, -} - -#[repr(C)] -struct MfxFrameSurface1 { - reserved: [u32; 4], - interface_ptr: *mut c_void, - info: MfxFrameInfo, - data: MfxFrameData, -} - -#[repr(C)] -#[derive(Clone, Copy)] -union MfxVariantData { - u32_: u32, - u64_: u64, - ptr: *mut c_void, - pad: [u8; 16], -} - -#[repr(C)] -#[derive(Clone, Copy)] -struct MfxVariant { - version: MfxStructVersion, - type_: u32, - data: MfxVariantData, -} - -#[repr(C)] -#[derive(Clone, Copy, Default)] -struct MfxStructVersion { - minor: u8, - major: u8, -} - -type MfxInit = unsafe extern "C" fn(i32, *mut MfxVersion, *mut *mut c_void) -> i32; -type MfxClose = unsafe extern "C" fn(*mut c_void) -> i32; -type MfxSetHandle = unsafe extern "C" fn(*mut c_void, u32, *mut c_void) -> i32; -type MfxEncodeInit = unsafe extern "C" fn(*mut c_void, *mut MfxVideoParam) -> i32; -type MfxEncodeClose = unsafe extern "C" fn(*mut c_void) -> i32; -type MfxEncodeQuery = - unsafe extern "C" fn(*mut c_void, *mut MfxVideoParam, *mut MfxVideoParam) -> i32; -type MfxEncodeFrameAsync = unsafe extern "C" fn( - *mut c_void, - *mut c_void, - *mut MfxFrameSurface1, - *mut MfxBitstream, - *mut *mut c_void, -) -> i32; -type MfxSyncOperation = unsafe extern "C" fn(*mut c_void, *mut c_void, u32) -> i32; - -type MfxLoad = unsafe extern "C" fn() -> *mut c_void; -type MfxUnload = unsafe extern "C" fn(*mut c_void); -type MfxCreateConfig = unsafe extern "C" fn(*mut c_void) -> *mut c_void; -type MfxSetConfigFilterProperty = unsafe extern "C" fn(*mut c_void, *const u8, MfxVariant) -> i32; -type MfxCreateSession = unsafe extern "C" fn(*mut c_void, u32, *mut *mut c_void) -> i32; - -struct ApiTable { - init: MfxInit, - close: MfxClose, - set_handle: MfxSetHandle, - encode_init: MfxEncodeInit, - encode_close: MfxEncodeClose, - encode_query: MfxEncodeQuery, - encode_frame_async: MfxEncodeFrameAsync, - sync_operation: MfxSyncOperation, -} - -struct DispatcherTable { - load: MfxLoad, - unload: MfxUnload, - create_config: MfxCreateConfig, - set_config_property: MfxSetConfigFilterProperty, - create_session: MfxCreateSession, -} - -struct SlotState { - pending_pts_us: u64, - pending_force_keyframe: bool, - sync_point: *mut c_void, - bitstream: MfxBitstream, - bitstream_buf: Vec, - surface: MfxFrameSurface1, - in_flight: bool, -} - -pub struct QsvD3D11Handoff { - _library: Arc, - api: ApiTable, - dispatcher: Option, - loader: *mut c_void, - session: *mut c_void, - slots: HashMap, - next_slot_index: u32, - dts_offset_us: i64, - completed_count: u64, - target_kbps: u16, - frame_rate: EncoderFrameRate, -} - -unsafe impl Send for QsvD3D11Handoff {} - -impl QsvD3D11Handoff { - pub fn new( - device: ID3D11Device, - dims: EncoderDims, - bitrate_bps: u32, - ) -> Result { - Self::new_with_frame_rate(device, dims, bitrate_bps, EncoderFrameRate::default()) - } - - pub fn new_with_frame_rate( - device: ID3D11Device, - dims: EncoderDims, - bitrate_bps: u32, - frame_rate: EncoderFrameRate, - ) -> Result { - assert!(dims.width > 0, "dims width positive"); - assert!(dims.height > 0, "dims height positive"); - assert!(frame_rate.numerator > 0, "frame rate numerator positive"); - assert!( - frame_rate.denominator > 0, - "frame rate denominator positive" - ); - if dims.width > 7680 || dims.height > 4320 { - return Err(EncoderError::DimensionsOutOfRange { - width: dims.width, - height: dims.height, - }); - } - let library = load_runtime()?; - let api = load_api(&library)?; - let dispatcher_opt = load_dispatcher(&library); - let (loader, session) = open_session(&api, dispatcher_opt.as_ref())?; - set_d3d11_handle(&api, session, &device)?; - let target_kbps = (bitrate_bps / 1000).clamp(500, 60_000) as u16; - encode_init(&api, session, dims, target_kbps, frame_rate)?; - let dts_offset_us = compute_dts_offset_us(0, 0, frame_rate.frame_interval_us()); - let handoff = Self { - _library: Arc::new(library), - api, - dispatcher: dispatcher_opt, - loader, - session, - slots: HashMap::new(), - next_slot_index: 0, - dts_offset_us, - completed_count: 0, - target_kbps, - frame_rate, - }; - assert!(!handoff.session.is_null(), "session non-null"); - Ok(handoff) - } -} - -fn load_runtime() -> Result { - let vpl_result = unsafe { Library::new(QSV_DLL_NAME_VPL) }; - match vpl_result { - Ok(lib) => Ok(lib), - Err(_) => { - unsafe { Library::new(QSV_DLL_NAME_MFX) }.map_err(|_| EncoderError::SdkNotFound { - vendor: "qsv", - dll: QSV_DLL_NAME_MFX, - }) - } - } -} - -fn load_api(library: &Library) -> Result { - let init: Symbol<'_, MfxInit> = - unsafe { library.get(b"MFXInit\0") }.map_err(|_| EncoderError::SymbolMissing { - vendor: "qsv", - symbol: "MFXInit", - })?; - let close: Symbol<'_, MfxClose> = - unsafe { library.get(b"MFXClose\0") }.map_err(|_| EncoderError::SymbolMissing { - vendor: "qsv", - symbol: "MFXClose", - })?; - let set_handle: Symbol<'_, MfxSetHandle> = unsafe { library.get(b"MFXVideoCORE_SetHandle\0") } - .map_err(|_| EncoderError::SymbolMissing { - vendor: "qsv", - symbol: "MFXVideoCORE_SetHandle", - })?; - let encode_init: Symbol<'_, MfxEncodeInit> = unsafe { library.get(b"MFXVideoENCODE_Init\0") } - .map_err(|_| EncoderError::SymbolMissing { - vendor: "qsv", - symbol: "MFXVideoENCODE_Init", - })?; - let encode_close: Symbol<'_, MfxEncodeClose> = unsafe { - library.get(b"MFXVideoENCODE_Close\0") - } - .map_err(|_| EncoderError::SymbolMissing { - vendor: "qsv", - symbol: "MFXVideoENCODE_Close", - })?; - let encode_query: Symbol<'_, MfxEncodeQuery> = unsafe { - library.get(b"MFXVideoENCODE_Query\0") - } - .map_err(|_| EncoderError::SymbolMissing { - vendor: "qsv", - symbol: "MFXVideoENCODE_Query", - })?; - let encode_frame_async: Symbol<'_, MfxEncodeFrameAsync> = unsafe { - library.get(b"MFXVideoENCODE_EncodeFrameAsync\0") - } - .map_err(|_| EncoderError::SymbolMissing { - vendor: "qsv", - symbol: "MFXVideoENCODE_EncodeFrameAsync", - })?; - let sync_operation: Symbol<'_, MfxSyncOperation> = unsafe { - library.get(b"MFXVideoCORE_SyncOperation\0") - } - .map_err(|_| EncoderError::SymbolMissing { - vendor: "qsv", - symbol: "MFXVideoCORE_SyncOperation", - })?; - Ok(ApiTable { - init: *init, - close: *close, - set_handle: *set_handle, - encode_init: *encode_init, - encode_close: *encode_close, - encode_query: *encode_query, - encode_frame_async: *encode_frame_async, - sync_operation: *sync_operation, - }) -} - -fn load_dispatcher(library: &Library) -> Option { - let load: Symbol<'_, MfxLoad> = unsafe { library.get(b"MFXLoad\0") }.ok()?; - let unload: Symbol<'_, MfxUnload> = unsafe { library.get(b"MFXUnload\0") }.ok()?; - let create_config: Symbol<'_, MfxCreateConfig> = - unsafe { library.get(b"MFXCreateConfig\0") }.ok()?; - let set_config_property: Symbol<'_, MfxSetConfigFilterProperty> = - unsafe { library.get(b"MFXSetConfigFilterProperty\0") }.ok()?; - let create_session: Symbol<'_, MfxCreateSession> = - unsafe { library.get(b"MFXCreateSession\0") }.ok()?; - Some(DispatcherTable { - load: *load, - unload: *unload, - create_config: *create_config, - set_config_property: *set_config_property, - create_session: *create_session, - }) -} - -fn open_session( - api: &ApiTable, - dispatcher: Option<&DispatcherTable>, -) -> Result<(*mut c_void, *mut c_void), EncoderError> { - if let Some(d) = dispatcher { - match modern_session(d) { - Ok((loader, session)) => return Ok((loader, session)), - Err(e) => { - let session = init_session_legacy(api)?; - let _ = e; - return Ok((ptr::null_mut(), session)); - } - } - } - let session = init_session_legacy(api)?; - Ok((ptr::null_mut(), session)) -} - -fn modern_session(d: &DispatcherTable) -> Result<(*mut c_void, *mut c_void), EncoderError> { - let loader = unsafe { (d.load)() }; - if loader.is_null() { - return Err(EncoderError::SessionInitFailed { - vendor: "qsv-mfxload", - status: -1, - }); - } - assert!(!loader.is_null(), "MFXLoad returned non-null"); - if let Err(e) = set_filter_u32(d, loader, FILTER_PROPERTY_IMPL, MFX_IMPL_TYPE_HARDWARE) { - unsafe { (d.unload)(loader) }; - return Err(e); - } - if let Err(e) = set_filter_u32(d, loader, FILTER_PROPERTY_ACCEL, MFX_ACCEL_MODE_VIA_D3D11) { - unsafe { (d.unload)(loader) }; - return Err(e); - } - let mut session: *mut c_void = ptr::null_mut(); - let status = unsafe { (d.create_session)(loader, 0, &mut session) }; - if status != MFX_ERR_NONE || session.is_null() { - unsafe { (d.unload)(loader) }; - return Err(EncoderError::SessionInitFailed { - vendor: "qsv-create-session", - status: status as i64, - }); - } - assert!(!session.is_null(), "modern session non-null"); - Ok((loader, session)) -} - -fn set_filter_u32( - d: &DispatcherTable, - loader: *mut c_void, - property: &'static [u8], - value_u32: u32, -) -> Result<(), EncoderError> { - assert!(!loader.is_null(), "loader non-null"); - assert!(!property.is_empty(), "property non-empty"); - let cfg = unsafe { (d.create_config)(loader) }; - if cfg.is_null() { - return Err(EncoderError::SessionInitFailed { - vendor: "qsv-create-config", - status: -1, - }); - } - let variant = MfxVariant { - version: MfxStructVersion { - minor: MFX_VARIANT_VERSION_MINOR, - major: MFX_VARIANT_VERSION_MAJOR, - }, - type_: MFX_VARIANT_TYPE_U32, - data: MfxVariantData { u32_: value_u32 }, - }; - let status = unsafe { (d.set_config_property)(cfg, property.as_ptr(), variant) }; - if status != MFX_ERR_NONE { - let vendor = if property == FILTER_PROPERTY_ACCEL { - "qsv-accel-mode-rejected" - } else { - "qsv-set-config-property" - }; - return Err(EncoderError::SessionInitFailed { - vendor, - status: status as i64, - }); - } - Ok(()) -} - -fn init_session_legacy(api: &ApiTable) -> Result<*mut c_void, EncoderError> { - let session_via_d3d11 = try_mfxinit(api, MFX_IMPL_HARDWARE | MFX_IMPL_VIA_D3D11); - if let Ok(s) = session_via_d3d11 { - return Ok(s); - } - try_mfxinit(api, MFX_IMPL_HARDWARE) -} - -fn try_mfxinit(api: &ApiTable, impl_flags: i32) -> Result<*mut c_void, EncoderError> { - let mut version = MfxVersion { major: 1, minor: 0 }; - let mut session: *mut c_void = ptr::null_mut(); - let status = unsafe { (api.init)(impl_flags, &mut version, &mut session) }; - if status != MFX_ERR_NONE { - return Err(EncoderError::SessionInitFailed { - vendor: "qsv-mfxinit", - status: status as i64, - }); - } - if session.is_null() { - return Err(EncoderError::SessionInitFailed { - vendor: "qsv-mfxinit", - status: -1, - }); - } - assert!(!session.is_null(), "session non-null after MFXInit"); - Ok(session) -} - -fn set_d3d11_handle( - api: &ApiTable, - session: *mut c_void, - device: &ID3D11Device, -) -> Result<(), EncoderError> { - assert!(!session.is_null(), "session non-null"); - let raw = device.as_raw(); - assert!(!raw.is_null(), "device raw non-null"); - if let Ok(mt) = device.cast::() { - let _ = unsafe { mt.SetMultithreadProtected(true) }; - } - let status = unsafe { (api.set_handle)(session, MFX_HANDLE_D3D11_DEVICE, raw) }; - if status != MFX_ERR_NONE && status != MFX_WRN_IN_EXECUTION { - return Err(EncoderError::SessionInitFailed { - vendor: "qsv-set-handle", - status: status as i64, - }); - } - Ok(()) -} - -fn build_video_params( - dims: EncoderDims, - target_kbps: u16, - frame_rate: EncoderFrameRate, -) -> MfxVideoParam { - let info = MfxFrameInfo { - four_cc: MFX_FOURCC_NV12, - width: align16(dims.width as u16), - height: align16(dims.height as u16), - crop_w: dims.width as u16, - crop_h: dims.height as u16, - frame_rate_extn: frame_rate.numerator, - frame_rate_extd: frame_rate.denominator, - aspect_ratio_w: 1, - aspect_ratio_h: 1, - pic_struct: MFX_PICSTRUCT_PROGRESSIVE, - chroma_format: MFX_CHROMAFORMAT_YUV420, - ..Default::default() - }; - let mfx = MfxInfoMfx { - frame_info: info, - codec_id: MFX_CODEC_AVC, - target_usage: 4, - gop_pic_size: frame_rate.gop_pic_size(), - gop_ref_dist: 1, - rate_control_method: MFX_RATECONTROL_CBR, - target_kbps, - max_kbps: target_kbps, - num_slice: 1, - num_ref_frame: 1, - ..Default::default() - }; - MfxVideoParam { - alloc_id: 0, - reserved: [0; 2], - reserved3: 0, - async_depth: 1, - mfx, - protected: 0, - io_pattern: MFX_IOPATTERN_IN_VIDEO_MEMORY, - ext_param: ptr::null_mut(), - num_ext_param: 0, - reserved2: 0, - } -} - -fn align16(v: u16) -> u16 { - (v + 15) & !15 -} - -fn encode_init( - api: &ApiTable, - session: *mut c_void, - dims: EncoderDims, - target_kbps: u16, - frame_rate: EncoderFrameRate, -) -> Result<(), EncoderError> { - let mut params = build_video_params(dims, target_kbps, frame_rate); - let mut query_out = build_video_params(dims, target_kbps, frame_rate); - let q_status = unsafe { (api.encode_query)(session, &mut params, &mut query_out) }; - let q_ok = q_status == MFX_ERR_NONE - || q_status == MFX_WRN_IN_EXECUTION - || q_status == -3 - || q_status > 0; - if !q_ok { - return Err(EncoderError::SessionInitFailed { - vendor: "qsv-encode-query", - status: q_status as i64, - }); - } - let status = unsafe { (api.encode_init)(session, &mut query_out) }; - if status != MFX_ERR_NONE && status != MFX_WRN_IN_EXECUTION { - return Err(EncoderError::SessionInitFailed { - vendor: "qsv-encode-init", - status: status as i64, - }); - } - Ok(()) -} - -fn build_slot_state( - shared_handle: u64, - dims: EncoderDims, - target_kbps: u16, - frame_rate: EncoderFrameRate, -) -> SlotState { - let max_bs = (dims.width as usize * dims.height as usize * 3 / 2).max(512 * 1024); - let mut bitstream_buf = vec![0u8; max_bs]; - let bs_ptr = bitstream_buf.as_mut_ptr(); - let bitstream = MfxBitstream { - encrypted_data: ptr::null_mut(), - num_extparam: 0, - ext_param: ptr::null_mut(), - reserved: [0; 6], - decode_time_stamp: 0, - time_stamp: 0, - data: bs_ptr, - data_offset: 0, - data_length: 0, - max_length: max_bs as u32, - pic_struct: 0, - frame_type: 0, - data_flag: 0, - reserved2: 0, - }; - let info = MfxFrameInfo { - four_cc: MFX_FOURCC_NV12, - width: align16(dims.width as u16), - height: align16(dims.height as u16), - crop_w: dims.width as u16, - crop_h: dims.height as u16, - frame_rate_extn: frame_rate.numerator, - frame_rate_extd: frame_rate.denominator, - aspect_ratio_w: 1, - aspect_ratio_h: 1, - pic_struct: MFX_PICSTRUCT_PROGRESSIVE, - chroma_format: MFX_CHROMAFORMAT_YUV420, - ..Default::default() - }; - let _ = target_kbps; - let surface = MfxFrameSurface1 { - reserved: [0; 4], - interface_ptr: ptr::null_mut(), - info, - data: MfxFrameData { - ext_param: ptr::null_mut(), - num_extparam: 0, - reserved: [0; 8], - mem_type: 0, - pitch_high: 0, - time_stamp: 0, - frame_order: 0, - locked: 0, - pitch_low: 0, - plane_ptrs: [ptr::null_mut(); 7], - mem_id: shared_handle as *mut c_void, - corrupted: 0, - data_flag: 0, - }, - }; - SlotState { - pending_pts_us: 0, - pending_force_keyframe: false, - sync_point: ptr::null_mut(), - bitstream, - bitstream_buf, - surface, - in_flight: false, - } -} - -impl Drop for QsvD3D11Handoff { - fn drop(&mut self) { - self.slots.clear(); - if !self.session.is_null() { - let _ = unsafe { (self.api.encode_close)(self.session) }; - let _ = unsafe { (self.api.close)(self.session) }; - self.session = ptr::null_mut(); - } - if let Some(d) = self.dispatcher.take() - && !self.loader.is_null() - { - unsafe { (d.unload)(self.loader) }; - self.loader = ptr::null_mut(); - } - } -} - -impl QsvHandoff for QsvD3D11Handoff { - fn register_slot( - &mut self, - shared_handle: u64, - _key: u64, - dims: EncoderDims, - ) -> Result { - assert!(shared_handle != 0, "shared_handle non-zero"); - assert!(dims.width > 0, "width positive"); - let slot_index = self.next_slot_index; - self.next_slot_index = self.next_slot_index.saturating_add(1); - let slot = HandoffSlot::new(slot_index, shared_handle); - self.slots.insert( - slot_index, - build_slot_state(shared_handle, dims, self.target_kbps, self.frame_rate), - ); - assert!(self.slots.contains_key(&slot_index), "slot stored"); - Ok(slot) - } - - fn encode_shared_async( - &mut self, - slot: HandoffSlot, - _key: u64, - dims: EncoderDims, - pic_params: PicParams, - ) -> Result<(), EncoderError> { - assert!(slot.shared_handle != 0, "slot handle non-zero"); - assert!(dims.width > 0, "width positive"); - let state = self - .slots - .get_mut(&slot.slot_index) - .ok_or(EncoderError::SlotUnknown { - slot_index: slot.slot_index, - })?; - state.surface.data.time_stamp = pic_params.pts_us; - state.bitstream.data_length = 0; - state.bitstream.data_offset = 0; - let mut sync: *mut c_void = ptr::null_mut(); - let status = unsafe { - (self.api.encode_frame_async)( - self.session, - ptr::null_mut(), - &mut state.surface, - &mut state.bitstream, - &mut sync, - ) - }; - state.pending_pts_us = pic_params.pts_us; - state.pending_force_keyframe = pic_params.force_keyframe; - if status == MFX_ERR_MORE_DATA { - state.in_flight = false; - return Ok(()); - } - if status != MFX_ERR_NONE { - return Err(EncoderError::EncodeFailed { - vendor: "qsv", - status: status as i64, - }); - } - state.sync_point = sync; - state.in_flight = !sync.is_null(); - Ok(()) - } - - fn poll_completed(&mut self, slot: HandoffSlot) -> Option { - let session_ptr = self.session; - let dts_offset_us = self.dts_offset_us; - let completed_count = self.completed_count; - let state = self.slots.get_mut(&slot.slot_index)?; - if !state.in_flight || state.sync_point.is_null() { - return None; - } - let status = unsafe { (self.api.sync_operation)(session_ptr, state.sync_point, 0) }; - if status == MFX_WRN_IN_EXECUTION || status != MFX_ERR_NONE { - return None; - } - let len = state.bitstream.data_length as usize; - if len == 0 { - state.in_flight = false; - return None; - } - let mut data: Vec = Vec::with_capacity(len); - let offset = state.bitstream.data_offset as usize; - data.extend_from_slice(&state.bitstream_buf[offset..offset + len]); - let pts = state.bitstream.time_stamp; - let is_keyframe = (state.bitstream.frame_type & 0x1) != 0 || completed_count == 0; - let dts = apply_dts_offset(pts, dts_offset_us); - state.in_flight = false; - state.sync_point = ptr::null_mut(); - self.completed_count = self.completed_count.saturating_add(1); - Some(EncodedBitstream::new(data, pts, dts, is_keyframe)) - } - - fn unregister_slot(&mut self, slot: HandoffSlot) { - self.slots.remove(&slot.slot_index); - } - - fn encode_shared( - &mut self, - submission: EncoderSubmission, - callback: &mut dyn EncoderCompletionCallback, - ) -> Result<(), RingError> { - assert!(submission.shared_handle != 0, "submission handle non-zero"); - assert!(submission.dims.width > 0, "submission width positive"); - let slot = self - .register_slot( - submission.shared_handle, - submission.keyed_mutex_key, - submission.dims, - ) - .map_err(|_| RingError::NotImplemented { - what: "qsv::register_slot in encode_shared", - })?; - let pts_us = submission.capture_pts_us.unwrap_or_else(|| { - submission - .sequence - .saturating_mul(self.frame_rate.frame_interval_us()) - }); - let pic = PicParams::new(pts_us, false); - QsvHandoff::encode_shared_async( - self, - slot, - submission.keyed_mutex_key, - submission.dims, - pic, - ) - .map_err(|_| RingError::NotImplemented { - what: "qsv::encode_shared_async", - })?; - if let Some(bs) = QsvHandoff::poll_completed(self, slot) { - callback.on_complete(submission.sequence, bs.data.len() as u32); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fourcc_nv12_packs_correctly() { - assert_eq!(MFX_FOURCC_NV12, 0x3231564E); - } - - #[test] - fn align16_rounds_up() { - assert_eq!(align16(1080), 1088); - assert_eq!(align16(1920), 1920); - assert_eq!(align16(0), 0); - } - - #[test] - fn target_kbps_clamps_against_overflow() { - let bitrate: u32 = u32::MAX; - let kbps = (bitrate / 1000).clamp(500, 60_000) as u16; - assert_eq!(kbps, 60_000); - } - - #[test] - fn accel_mode_constant_matches_onevpl_spec() { - assert_eq!(MFX_ACCEL_MODE_VIA_D3D11, 0x0300); - assert_ne!(MFX_ACCEL_MODE_VIA_D3D11, 0x0200); - } - - #[test] - fn impl_type_hardware_constant_matches_onevpl_spec() { - assert_eq!(MFX_IMPL_TYPE_HARDWARE, 2); - } - - #[test] - fn variant_type_u32_constant_matches_onevpl_spec() { - assert_eq!(MFX_VARIANT_TYPE_U32, 5); - assert_ne!(MFX_VARIANT_TYPE_U32, 8); - } - - #[test] - fn d3d11_device_handle_type_matches_onevpl_spec() { - assert_eq!(MFX_HANDLE_D3D11_DEVICE, 3); - assert_ne!(MFX_HANDLE_D3D11_DEVICE, 2); - } - - #[test] - fn variant_version_matches_onevpl_spec() { - assert_eq!(MFX_VARIANT_VERSION_MAJOR, 1); - assert_eq!(MFX_VARIANT_VERSION_MINOR, 1); - } - - #[test] - fn filter_property_name_is_null_terminated_accel() { - assert!(FILTER_PROPERTY_ACCEL.ends_with(b"\0")); - let view = &FILTER_PROPERTY_ACCEL[..FILTER_PROPERTY_ACCEL.len() - 1]; - assert_eq!(view, b"mfxImplDescription.AccelerationMode"); - } - - #[test] - fn filter_property_name_is_null_terminated_impl() { - assert!(FILTER_PROPERTY_IMPL.ends_with(b"\0")); - let view = &FILTER_PROPERTY_IMPL[..FILTER_PROPERTY_IMPL.len() - 1]; - assert_eq!(view, b"mfxImplDescription.Impl"); - } - - #[test] - fn variant_payload_holds_u32_value() { - let v = MfxVariant { - version: MfxStructVersion { - minor: MFX_VARIANT_VERSION_MINOR, - major: MFX_VARIANT_VERSION_MAJOR, - }, - type_: MFX_VARIANT_TYPE_U32, - data: MfxVariantData { - u32_: MFX_ACCEL_MODE_VIA_D3D11, - }, - }; - let read = unsafe { v.data.u32_ }; - assert_eq!(read, MFX_ACCEL_MODE_VIA_D3D11); - assert_eq!(v.type_, 5); - } - - #[derive(Default)] - struct FilterRecord { - property: Vec, - value_u32: u32, - variant_type: u32, - } -} diff --git a/fluxer_desktop/native/encoder-ring/src/ring.rs b/fluxer_desktop/native/encoder-ring/src/ring.rs deleted file mode 100644 index 7d1f9fecc..000000000 --- a/fluxer_desktop/native/encoder-ring/src/ring.rs +++ /dev/null @@ -1,966 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use crate::backend::{BackendError, KeyedMutexBackend, TextureFormat}; - -pub const RING_SIZE: usize = 8; - -pub const DUPLICATE_COUNT_MAX: u32 = 30; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SlotState { - Free, - Filling, - Submitted, - Dispatched, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RingError { - FullDropped { - dropped_so_far: u64, - }, - BackendFailed { - source: BackendError, - }, - NotInitialised, - AlreadyInitialised, - UnknownSlot, - UnexpectedSlotState { - slot_index: u32, - observed: SlotState, - }, - PlatformUnsupported { - reason: &'static str, - }, - NotImplemented { - what: &'static str, - }, - SlotsExhausted, -} - -impl std::fmt::Display for RingError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::FullDropped { dropped_so_far } => { - write!(f, "ring full; total dropped={dropped_so_far}") - } - Self::BackendFailed { source } => write!(f, "backend failed: {source}"), - Self::NotInitialised => write!(f, "ring not initialised"), - Self::AlreadyInitialised => write!(f, "ring already initialised"), - Self::UnknownSlot => write!(f, "slot handle does not belong to this ring"), - Self::UnexpectedSlotState { - slot_index, - observed, - } => { - write!(f, "slot {slot_index} in unexpected state {observed:?}") - } - Self::PlatformUnsupported { reason } => write!(f, "platform unsupported: {reason}"), - Self::NotImplemented { what } => write!(f, "not implemented: {what}"), - Self::SlotsExhausted => write!(f, "slot id space exhausted (all candidates in use)"), - } - } -} - -impl std::error::Error for RingError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct RingMetrics { - pub submitted_count: u64, - pub completed_count: u64, - pub dropped_count: u64, - pub dispatched_count: u64, - pub lagged_count: u64, -} - -pub struct EncoderReady { - pub handle: H, - pub sequence: u64, - pub slot_index: u32, - pub duplicate_count: u32, -} - -pub struct FillReservation { - pub handle: H, - slot_index: u32, - key: u64, -} - -impl FillReservation { - pub fn slot_index(&self) -> u32 { - let index = self.slot_index; - assert!((index as usize) < RING_SIZE_MAX, "slot_index within max"); - assert!(self.key < u64::MAX, "reservation key plausible"); - index - } -} - -struct SlotMeta { - state: SlotState, - sequence: u64, - key: u64, - duplicate_count: u32, -} - -impl SlotMeta { - const fn fresh() -> Self { - Self { - state: SlotState::Free, - sequence: 0, - key: 0, - duplicate_count: 0, - } - } -} - -pub struct EncoderInputRing { - backend: B, - slots: Vec, - meta: Vec, - metrics: RingMetrics, - pending_lagged: u32, - initialised: bool, - width: u32, - height: u32, - format: TextureFormat, -} - -impl EncoderInputRing { - pub fn new(backend: B) -> Self { - let ring = Self { - backend, - slots: Vec::with_capacity(B::NUM_SLOTS), - meta: Vec::with_capacity(B::NUM_SLOTS), - metrics: RingMetrics::default(), - pending_lagged: 0, - initialised: false, - width: 0, - height: 0, - format: TextureFormat::Nv12, - }; - assert!(!ring.initialised, "fresh ring is uninitialised"); - assert_eq!(ring.slots.len(), 0, "fresh ring has no slots"); - ring - } - - pub fn initialise( - &mut self, - width: u32, - height: u32, - format: TextureFormat, - ) -> Result<(), RingError> { - if self.initialised { - return Err(RingError::AlreadyInitialised); - } - let handles = self - .backend - .create_slots(width, height, format) - .map_err(|source| RingError::BackendFailed { source })?; - assert_eq!( - handles.len(), - B::NUM_SLOTS, - "backend returns NUM_SLOTS handles" - ); - assert!(handles.len() <= RING_SIZE_MAX, "NUM_SLOTS within max"); - self.slots = handles; - self.meta = (0..B::NUM_SLOTS).map(|_| SlotMeta::fresh()).collect(); - self.width = width; - self.height = height; - self.format = format; - self.initialised = true; - assert!(self.initialised, "initialised flipped"); - assert_eq!(self.slots.len(), self.meta.len(), "slots and meta align"); - Ok(()) - } - - pub fn submit(&mut self, fill: F) -> Result<(), RingError> - where - F: FnOnce(&mut B::SlotHandle), - { - let mut reservation = self.reserve()?; - fill(&mut reservation.handle); - let sequence = self.commit(reservation)?; - assert!(sequence > 0, "committed sequence positive"); - assert!( - self.metrics.submitted_count >= sequence, - "monotonic submitted" - ); - Ok(()) - } - - pub fn submit_skip_oldest(&mut self, fill: F) -> Result<(), RingError> - where - F: FnOnce(&mut B::SlotHandle), - { - let mut reservation = self.reserve_skip_oldest()?; - fill(&mut reservation.handle); - let sequence = self.commit(reservation)?; - assert!(sequence > 0, "skip-oldest: committed sequence positive"); - assert!( - self.metrics.submitted_count >= sequence, - "skip-oldest: monotonic submitted" - ); - Ok(()) - } - - pub fn reserve(&mut self) -> Result, RingError> { - if !self.initialised { - return Err(RingError::NotInitialised); - } - self.acquire_free_slot() - } - - pub fn reserve_skip_oldest(&mut self) -> Result, RingError> { - if !self.initialised { - return Err(RingError::NotInitialised); - } - if self.find_free_slot().is_none() { - let _ = self.evict_oldest_submitted(); - } - self.acquire_free_slot() - } - - fn acquire_free_slot(&mut self) -> Result, RingError> { - assert!(self.initialised, "acquire requires initialised ring"); - assert_eq!(self.slots.len(), self.meta.len(), "slots and meta align"); - for index in 0..self.meta.len() { - if self.meta[index].state != SlotState::Free { - continue; - } - let key = self.meta[index].key; - match self.backend.acquire_write(&self.slots[index], key) { - Ok(()) => { - self.meta[index].state = SlotState::Filling; - return Ok(FillReservation { - handle: self.slots[index].clone(), - slot_index: index as u32, - key, - }); - } - Err(BackendError::WouldBlock { .. }) => continue, - Err(source) => return Err(RingError::BackendFailed { source }), - } - } - self.fold_lagged(1); - self.metrics.lagged_count = self.metrics.lagged_count.saturating_add(1); - self.metrics.dropped_count = self.metrics.dropped_count.saturating_add(1); - Err(RingError::FullDropped { - dropped_so_far: self.metrics.dropped_count, - }) - } - - pub fn commit( - &mut self, - reservation: FillReservation, - ) -> Result { - if !self.initialised { - return Err(RingError::NotInitialised); - } - let index = reservation.slot_index as usize; - if index >= self.meta.len() { - return Err(RingError::UnknownSlot); - } - let observed = self.meta[index].state; - if observed != SlotState::Filling { - return Err(RingError::UnexpectedSlotState { - slot_index: reservation.slot_index, - observed, - }); - } - assert_eq!( - self.meta[index].key, reservation.key, - "reservation key matches slot" - ); - let next_key = reservation.key.wrapping_add(1); - self.backend - .release_write(&self.slots[index], next_key) - .map_err(|source| RingError::BackendFailed { source })?; - let sequence = self.metrics.submitted_count.saturating_add(1); - self.meta[index].state = SlotState::Submitted; - self.meta[index].sequence = sequence; - self.meta[index].key = next_key; - self.meta[index].duplicate_count = self.pending_lagged; - self.pending_lagged = 0; - self.metrics.submitted_count = sequence; - self.metrics.completed_count = self.metrics.completed_count.saturating_add(1); - assert_eq!( - self.meta[index].state, - SlotState::Submitted, - "post-commit submitted" - ); - assert!( - self.metrics.submitted_count >= sequence, - "commit: monotonic submitted" - ); - Ok(sequence) - } - - pub fn cancel(&mut self, reservation: FillReservation) -> Result<(), RingError> { - if !self.initialised { - return Err(RingError::NotInitialised); - } - let index = reservation.slot_index as usize; - if index >= self.meta.len() { - return Err(RingError::UnknownSlot); - } - let observed = self.meta[index].state; - if observed != SlotState::Filling { - return Err(RingError::UnexpectedSlotState { - slot_index: reservation.slot_index, - observed, - }); - } - assert_eq!( - self.meta[index].key, reservation.key, - "cancel: reservation key matches slot" - ); - let next_key = reservation.key.wrapping_add(1); - self.backend - .release_write(&self.slots[index], next_key) - .map_err(|source| RingError::BackendFailed { source })?; - self.backend.mark_consumed(&self.slots[index]); - self.meta[index].state = SlotState::Free; - self.meta[index].key = next_key; - assert_eq!(self.meta[index].state, SlotState::Free, "post-cancel free"); - assert!( - !self.backend.poll_complete(&self.slots[index]), - "cancelled slot not complete" - ); - Ok(()) - } - - fn fold_lagged(&mut self, amount: u32) { - assert!(amount > 0, "fold amount positive"); - assert!( - amount <= DUPLICATE_COUNT_MAX.saturating_add(1), - "fold amount bounded" - ); - let mut newest: Option = None; - let mut newest_sequence: u64 = 0; - for (i, meta) in self.meta.iter().enumerate() { - if meta.state != SlotState::Submitted { - continue; - } - if meta.sequence >= newest_sequence { - newest_sequence = meta.sequence; - newest = Some(i); - } - } - match newest { - Some(index) => { - let total = self.meta[index].duplicate_count.saturating_add(amount); - self.meta[index].duplicate_count = total.min(DUPLICATE_COUNT_MAX); - } - None => { - let total = self.pending_lagged.saturating_add(amount); - self.pending_lagged = total.min(DUPLICATE_COUNT_MAX); - } - } - assert!( - self.pending_lagged <= DUPLICATE_COUNT_MAX, - "pending lag bounded" - ); - } - - fn evict_oldest_submitted(&mut self) -> bool { - let mut chosen: Option = None; - let mut chosen_sequence: u64 = u64::MAX; - for (i, meta) in self.meta.iter().enumerate() { - if meta.state != SlotState::Submitted { - continue; - } - if meta.sequence < chosen_sequence { - chosen_sequence = meta.sequence; - chosen = Some(i); - } - } - let Some(index) = chosen else { - return false; - }; - assert_eq!( - self.meta[index].state, - SlotState::Submitted, - "evict candidate is submitted" - ); - assert!( - chosen_sequence != u64::MAX, - "evict candidate had real sequence" - ); - let folded = self.meta[index].duplicate_count.saturating_add(1); - self.backend.mark_consumed(&self.slots[index]); - self.meta[index].state = SlotState::Free; - self.meta[index].duplicate_count = 0; - self.fold_lagged(folded); - self.metrics.lagged_count = self.metrics.lagged_count.saturating_add(1); - self.metrics.dropped_count = self.metrics.dropped_count.saturating_add(1); - true - } - - pub fn poll_next_ready(&mut self) -> Option> { - if !self.initialised { - return None; - } - let mut chosen: Option = None; - let mut chosen_sequence: u64 = u64::MAX; - for i in 0..self.meta.len() { - if self.meta[i].state != SlotState::Submitted { - continue; - } - if !self.backend.poll_complete(&self.slots[i]) { - continue; - } - if self.meta[i].sequence < chosen_sequence { - chosen_sequence = self.meta[i].sequence; - chosen = Some(i); - } - } - let index = chosen?; - assert_eq!( - self.meta[index].state, - SlotState::Submitted, - "ready slot was submitted" - ); - assert!( - self.backend.poll_complete(&self.slots[index]), - "ready slot is complete" - ); - self.meta[index].state = SlotState::Dispatched; - self.metrics.dispatched_count = self.metrics.dispatched_count.saturating_add(1); - Some(EncoderReady { - handle: self.slots[index].clone(), - sequence: self.meta[index].sequence, - slot_index: index as u32, - duplicate_count: self.meta[index].duplicate_count, - }) - } - - pub fn release_completed( - &mut self, - ready: EncoderReady, - ) -> Result<(), RingError> { - if !self.initialised { - return Err(RingError::NotInitialised); - } - let index = ready.slot_index as usize; - if index >= self.meta.len() { - return Err(RingError::UnknownSlot); - } - let observed = self.meta[index].state; - if observed != SlotState::Dispatched { - return Err(RingError::UnexpectedSlotState { - slot_index: ready.slot_index, - observed, - }); - } - self.backend.mark_consumed(&self.slots[index]); - self.meta[index].state = SlotState::Free; - assert_eq!(self.meta[index].state, SlotState::Free, "post-release free"); - assert!( - !self.backend.poll_complete(&self.slots[index]), - "no longer reports complete" - ); - Ok(()) - } - - pub fn metrics(&self) -> RingMetrics { - assert!( - self.metrics.completed_count <= self.metrics.submitted_count, - "complete<=submit" - ); - assert!( - self.metrics.dispatched_count <= self.metrics.completed_count, - "dispatch<=complete" - ); - self.metrics - } - - pub fn submitted_count(&self) -> u64 { - self.metrics.submitted_count - } - - pub fn completed_count(&self) -> u64 { - self.metrics.completed_count - } - - pub fn dropped_count(&self) -> u64 { - self.metrics.dropped_count - } - - pub fn dispatched_count(&self) -> u64 { - self.metrics.dispatched_count - } - - pub fn capacity(&self) -> usize { - let cap = B::NUM_SLOTS; - assert!(cap > 0, "NUM_SLOTS must be positive"); - assert!(cap <= RING_SIZE_MAX, "NUM_SLOTS within max"); - cap - } - - pub fn free_count(&self) -> usize { - let mut count: usize = 0; - for meta in self.meta.iter() { - if meta.state == SlotState::Free { - count = count.saturating_add(1); - } - } - assert!(count <= self.meta.len(), "free count within capacity"); - count - } - - pub fn backend_mut(&mut self) -> &mut B { - assert!(self.initialised, "backend access requires init"); - &mut self.backend - } - - fn find_free_slot(&self) -> Option { - for (i, meta) in self.meta.iter().enumerate() { - if meta.state == SlotState::Free { - return Some(i); - } - } - None - } -} - -pub const RING_SIZE_MAX: usize = 16; - -const _: () = assert!(RING_SIZE <= RING_SIZE_MAX, "RING_SIZE within max"); - -#[cfg(test)] -mod tests { - use super::*; - use crate::backend::{CpuMemcpyBackend, CpuSlotHandle}; - - fn make_ring() -> EncoderInputRing { - let mut ring = EncoderInputRing::new(CpuMemcpyBackend::new()); - ring.initialise(64, 64, TextureFormat::Nv12).expect("init"); - ring - } - - fn fill_noop(_: &mut CpuSlotHandle) {} - - #[test] - fn submit_then_poll_returns_some() { - let mut ring = make_ring(); - ring.submit(fill_noop).expect("submit"); - let ready = ring.poll_next_ready().expect("poll yields ready"); - assert_eq!(ready.sequence, 1); - ring.release_completed(ready).expect("release"); - } - - #[test] - fn eight_submits_dispatch_in_fifo_order() { - let mut ring = make_ring(); - for _ in 0..8 { - ring.submit(fill_noop).expect("submit"); - } - assert_eq!(ring.submitted_count(), 8); - let mut observed_seq: Vec = Vec::new(); - for _ in 0..8 { - let ready = ring.poll_next_ready().expect("ready"); - observed_seq.push(ready.sequence); - ring.release_completed(ready).expect("release"); - } - assert_eq!(observed_seq, vec![1, 2, 3, 4, 5, 6, 7, 8]); - } - - #[test] - fn ninth_submit_when_full_returns_full_dropped() { - let mut ring = make_ring(); - for _ in 0..8 { - ring.submit(fill_noop).expect("submit"); - } - let err = ring.submit(fill_noop).err(); - assert!(matches!( - err, - Some(RingError::FullDropped { dropped_so_far: 1 }) - )); - assert_eq!(ring.dropped_count(), 1); - let err2 = ring.submit(fill_noop).err(); - assert!(matches!( - err2, - Some(RingError::FullDropped { dropped_so_far: 2 }) - )); - assert_eq!(ring.dropped_count(), 2); - } - - #[test] - fn release_completed_returns_slot_to_pool() { - let mut ring = make_ring(); - ring.submit(fill_noop).expect("submit"); - let ready = ring.poll_next_ready().expect("ready"); - assert_eq!(ring.free_count(), 7); - ring.release_completed(ready).expect("release"); - assert_eq!(ring.free_count(), 8); - ring.submit(fill_noop).expect("re-submit after release"); - } - - #[test] - fn pair_asserts_pass_under_random_submit_poll_release() { - let mut ring = make_ring(); - let mut state: u64 = 0xcafef00d; - let mut in_flight: Vec> = Vec::new(); - for _ in 0..1000 { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - let action = state % 3; - match action { - 0 => { - let _ = ring.submit(fill_noop); - } - 1 => { - if let Some(r) = ring.poll_next_ready() { - in_flight.push(r); - } - } - _ => { - if let Some(r) = in_flight.pop() { - ring.release_completed(r).expect("release"); - } - } - } - let metrics = ring.metrics(); - assert!(metrics.completed_count <= metrics.submitted_count); - assert!(metrics.dispatched_count <= metrics.completed_count); - } - while let Some(r) = in_flight.pop() { - ring.release_completed(r).expect("drain release"); - } - while let Some(r) = ring.poll_next_ready() { - ring.release_completed(r).expect("drain release post poll"); - } - assert_eq!(ring.free_count(), ring.capacity()); - } - - #[test] - #[allow(clippy::panic)] - fn submit_with_panicking_closure_leaves_state_consistent() { - use std::panic::{AssertUnwindSafe, catch_unwind}; - let mut ring = make_ring(); - ring.submit(fill_noop).expect("first submit ok"); - let result = catch_unwind(AssertUnwindSafe(|| { - let _ = ring.submit(|_h: &mut CpuSlotHandle| panic!("user fill panicked")); - })); - assert!(result.is_err()); - let metrics = ring.metrics(); - assert!(metrics.submitted_count >= 1); - assert!(metrics.completed_count <= metrics.submitted_count); - assert!(metrics.dispatched_count <= metrics.completed_count); - let ready = ring.poll_next_ready().expect("first frame still pollable"); - assert_eq!(ready.sequence, 1); - ring.release_completed(ready).expect("release first"); - } - - #[test] - fn determinism_same_sequence_yields_same_release_order() { - fn run() -> Vec { - let mut ring = make_ring(); - let mut released: Vec = Vec::new(); - for _ in 0..8 { - ring.submit(fill_noop).expect("submit"); - } - for _ in 0..8 { - let r = ring.poll_next_ready().expect("ready"); - released.push(r.sequence); - ring.release_completed(r).expect("release"); - } - released - } - let a = run(); - let b = run(); - assert_eq!(a, b); - assert_eq!(a, vec![1, 2, 3, 4, 5, 6, 7, 8]); - } - - #[test] - fn capacity_is_eight() { - let ring = make_ring(); - assert_eq!(ring.capacity(), 8); - assert_eq!(ring.free_count(), 8); - } - - #[test] - fn double_release_of_same_ready_rejected() { - let mut ring = make_ring(); - ring.submit(fill_noop).expect("submit"); - let ready = ring.poll_next_ready().expect("ready"); - let cloned = EncoderReady { - handle: ready.handle.clone(), - sequence: ready.sequence, - slot_index: ready.slot_index, - duplicate_count: ready.duplicate_count, - }; - ring.release_completed(ready).expect("first release"); - let err = ring.release_completed(cloned).err(); - assert!(matches!(err, Some(RingError::UnexpectedSlotState { .. }))); - } - - #[test] - fn submit_before_init_returns_not_initialised() { - let mut ring: EncoderInputRing = - EncoderInputRing::new(CpuMemcpyBackend::new()); - let err = ring.submit(fill_noop).err(); - assert!(matches!(err, Some(RingError::NotInitialised))); - } - - #[test] - fn re_initialise_rejected() { - let mut ring = make_ring(); - let err = ring.initialise(64, 64, TextureFormat::Nv12).err(); - assert!(matches!(err, Some(RingError::AlreadyInitialised))); - } - - #[test] - fn poll_when_empty_returns_none() { - let mut ring = make_ring(); - assert!(ring.poll_next_ready().is_none()); - } - - #[test] - fn skip_oldest_rejects_when_every_slot_is_dispatched() { - let mut ring = make_ring(); - for _ in 0..8 { - ring.submit(fill_noop).expect("submit"); - } - let mut in_flight: Vec> = Vec::new(); - for _ in 0..8 { - in_flight.push(ring.poll_next_ready().expect("ready")); - } - assert_eq!(ring.free_count(), 0); - let err = ring.submit_skip_oldest(fill_noop).err(); - assert!(matches!( - err, - Some(RingError::FullDropped { dropped_so_far: 1 }) - )); - assert_eq!(ring.dropped_count(), 1); - assert_eq!(ring.dispatched_count(), 8); - for ready in in_flight.drain(..) { - ring.release_completed(ready).expect("release"); - } - assert_eq!(ring.free_count(), 8); - ring.submit_skip_oldest(fill_noop) - .expect("submit succeeds once dispatched slots are released"); - } - - #[test] - fn skip_oldest_evicts_oldest_submitted_never_dispatched() { - let mut ring = make_ring(); - for _ in 0..8 { - ring.submit(fill_noop).expect("submit"); - } - let mut dispatched: Vec> = Vec::new(); - for _ in 0..3 { - dispatched.push(ring.poll_next_ready().expect("ready")); - } - assert_eq!(dispatched[0].sequence, 1); - assert_eq!(dispatched[2].sequence, 3); - ring.submit_skip_oldest(fill_noop) - .expect("skip-oldest evicts a submitted slot"); - assert_eq!(ring.dropped_count(), 1); - assert_eq!(ring.submitted_count(), 9); - let mut remaining: Vec = Vec::new(); - while let Some(ready) = ring.poll_next_ready() { - remaining.push(ready.sequence); - ring.release_completed(ready).expect("release"); - } - assert_eq!(remaining, vec![5, 6, 7, 8, 9], "sequence 4 was evicted"); - for ready in dispatched.drain(..) { - ring.release_completed(ready).expect("release dispatched"); - } - assert_eq!(ring.free_count(), 8); - } - - #[test] - fn reserve_then_commit_matches_submit_semantics() { - let mut ring = make_ring(); - let reservation = ring.reserve().expect("reserve"); - assert_eq!(reservation.slot_index(), 0); - assert_eq!(ring.free_count(), 7); - assert_eq!(ring.submitted_count(), 0, "sequence assigned at commit"); - let sequence = ring.commit(reservation).expect("commit"); - assert_eq!(sequence, 1); - let ready = ring.poll_next_ready().expect("ready"); - assert_eq!(ready.sequence, 1); - assert_eq!(ready.duplicate_count, 0); - ring.release_completed(ready).expect("release"); - } - - #[test] - fn cancel_returns_slot_to_free_without_sequence() { - let mut ring = make_ring(); - let reservation = ring.reserve().expect("reserve"); - ring.cancel(reservation).expect("cancel"); - assert_eq!(ring.free_count(), 8); - assert_eq!(ring.submitted_count(), 0); - assert!(ring.poll_next_ready().is_none()); - ring.submit(fill_noop).expect("submit after cancel"); - let ready = ring.poll_next_ready().expect("ready"); - assert_eq!(ready.sequence, 1); - ring.release_completed(ready).expect("release"); - } - - #[test] - fn commit_of_freed_reservation_rejected() { - let mut ring = make_ring(); - let first = ring.reserve().expect("reserve"); - let index = first.slot_index(); - ring.cancel(first).expect("cancel"); - let second = ring.reserve().expect("re-reserve"); - assert_eq!(second.slot_index(), index, "same slot reused"); - ring.commit(second).expect("commit reused slot"); - let ready = ring.poll_next_ready().expect("ready"); - let stale = EncoderReady { - handle: ready.handle.clone(), - sequence: ready.sequence, - slot_index: ready.slot_index, - duplicate_count: ready.duplicate_count, - }; - ring.release_completed(ready).expect("release"); - let err = ring.release_completed(stale).err(); - assert!(matches!(err, Some(RingError::UnexpectedSlotState { .. }))); - } - - #[test] - fn skip_oldest_never_evicts_filling_slot() { - let mut ring = make_ring(); - for _ in 0..7 { - ring.submit(fill_noop).expect("submit"); - } - let reservation = ring.reserve().expect("reserve eighth slot"); - let reserved_index = reservation.slot_index(); - ring.submit_skip_oldest(fill_noop) - .expect("skip-oldest evicts a submitted slot"); - assert_eq!(ring.dropped_count(), 1); - let sequence = ring.commit(reservation).expect("commit survives eviction"); - assert_eq!(sequence, 9); - let mut seen_indices: Vec = Vec::new(); - while let Some(ready) = ring.poll_next_ready() { - seen_indices.push(ready.slot_index); - ring.release_completed(ready).expect("release"); - } - assert!(seen_indices.contains(&reserved_index)); - } - - #[test] - fn full_ring_submit_folds_lag_into_newest_submitted() { - let mut ring = make_ring(); - for _ in 0..8 { - ring.submit(fill_noop).expect("submit"); - } - let err = ring.submit(fill_noop).err(); - assert!(matches!( - err, - Some(RingError::FullDropped { dropped_so_far: 1 }) - )); - assert_eq!(ring.metrics().lagged_count, 1); - let mut by_sequence: Vec<(u64, u32)> = Vec::new(); - while let Some(ready) = ring.poll_next_ready() { - by_sequence.push((ready.sequence, ready.duplicate_count)); - ring.release_completed(ready).expect("release"); - } - assert_eq!(by_sequence.len(), 8); - for (sequence, duplicate_count) in by_sequence.iter().take(7) { - assert_eq!(*duplicate_count, 0, "sequence {sequence} not duplicated"); - } - assert_eq!(by_sequence[7], (8, 1), "newest holds the lagged frame"); - } - - #[test] - fn eviction_conserves_duplicate_timing_slots() { - let mut ring = make_ring(); - for _ in 0..8 { - ring.submit(fill_noop).expect("submit"); - } - let mut dispatched: Vec> = Vec::new(); - for _ in 0..7 { - dispatched.push(ring.poll_next_ready().expect("ready")); - } - let _ = ring.submit(fill_noop).err(); - ring.submit_skip_oldest(fill_noop) - .expect("evicts the only submitted slot"); - let ready = ring.poll_next_ready().expect("new frame ready"); - assert_eq!(ready.sequence, 9); - assert_eq!( - ready.duplicate_count, 2, - "evicted frame plus its duplicate folded into successor" - ); - assert_eq!(ring.metrics().lagged_count, 2); - ring.release_completed(ready).expect("release"); - for ready in dispatched.drain(..) { - ring.release_completed(ready).expect("release dispatched"); - } - } - - #[test] - fn all_dispatched_lag_attaches_to_next_submission() { - let mut ring = make_ring(); - for _ in 0..8 { - ring.submit(fill_noop).expect("submit"); - } - let mut in_flight: Vec> = Vec::new(); - for _ in 0..8 { - in_flight.push(ring.poll_next_ready().expect("ready")); - } - for _ in 0..2 { - let err = ring.submit_skip_oldest(fill_noop).err(); - assert!(matches!(err, Some(RingError::FullDropped { .. }))); - } - assert_eq!(ring.metrics().lagged_count, 2); - let first = in_flight.remove(0); - ring.release_completed(first).expect("release one"); - ring.submit_skip_oldest(fill_noop) - .expect("submit after free"); - let ready = ring.poll_next_ready().expect("ready"); - assert_eq!(ready.sequence, 9); - assert_eq!(ready.duplicate_count, 2, "pending lag attached"); - ring.release_completed(ready).expect("release"); - for ready in in_flight.drain(..) { - ring.release_completed(ready).expect("release in flight"); - } - } - - #[test] - fn duplicate_count_saturates_at_named_cap() { - let mut ring = make_ring(); - for _ in 0..8 { - ring.submit(fill_noop).expect("submit"); - } - for _ in 0..(DUPLICATE_COUNT_MAX + 5) { - let _ = ring.submit(fill_noop).err(); - } - let mut last: Option<(u64, u32)> = None; - while let Some(ready) = ring.poll_next_ready() { - last = Some((ready.sequence, ready.duplicate_count)); - ring.release_completed(ready).expect("release"); - } - assert_eq!( - last, - Some((8, DUPLICATE_COUNT_MAX)), - "duplicates saturate at cap" - ); - } - - #[test] - fn skip_oldest_repeated_rejection_counts_every_drop() { - let mut ring = make_ring(); - for _ in 0..8 { - ring.submit(fill_noop).expect("submit"); - } - let mut in_flight: Vec> = Vec::new(); - for _ in 0..8 { - in_flight.push(ring.poll_next_ready().expect("ready")); - } - for expected_drops in 1..=3_u64 { - let err = ring.submit_skip_oldest(fill_noop).err(); - assert!(matches!( - err, - Some(RingError::FullDropped { dropped_so_far }) if dropped_so_far == expected_drops - )); - } - assert_eq!(ring.dropped_count(), 3); - while let Some(ready) = in_flight.pop() { - ring.release_completed(ready).expect("release"); - } - } -} diff --git a/fluxer_desktop/native/encoder-ring/src/vt_compression_macos.rs b/fluxer_desktop/native/encoder-ring/src/vt_compression_macos.rs deleted file mode 100644 index c6a36b606..000000000 --- a/fluxer_desktop/native/encoder-ring/src/vt_compression_macos.rs +++ /dev/null @@ -1,893 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use core::ffi::c_void; -use core::ptr::{self, NonNull}; -use std::collections::HashMap; -use std::collections::VecDeque; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; - -use crate::encoder_handoff::{ - EncodedBitstream, EncoderDims, EncoderError, EncoderFrameRate, EncoderSubmission, HandoffSlot, - MAX_BITSTREAM_BYTES, PicParams, VideoToolboxHandoff, -}; -use crate::ring::RingError; - -type CfDictionaryRef = *const c_void; -type CfStringRef = *const c_void; -type CfNumberRef = *const c_void; -type CfBooleanRef = *const c_void; -type CfAllocatorRef = *const c_void; -type CmTime = OsCmTime; -pub(crate) type CvPixelBufferRef = *mut c_void; -type CmSampleBufferRef = *mut c_void; -type CmBlockBufferRef = *mut c_void; -type VtCompressionSessionRef = *mut c_void; -type VtPixelTransferSessionRef = *mut c_void; -type OsStatus = i32; -pub(crate) type IoSurfaceRef = *mut c_void; - -const KCM_VIDEO_CODEC_TYPE_H264: u32 = u32::from_be_bytes(*b"avc1"); -const NOERR: OsStatus = 0; -const KCM_TIME_FLAGS_VALID: u32 = 1; -const KCF_NUMBER_SINT32_TYPE: i32 = 3; -const COMPLETION_RING_CAPACITY: usize = 16; -const KVT_INVALID_SESSION_ERR: OsStatus = -12903; -const VT_SESSION_REBUILD_MAX: u64 = 3; -const VT_ENCODE_ATTEMPTS_PER_FRAME_MAX: usize = 2; - -#[repr(C)] -#[derive(Copy, Clone)] -struct OsCmTime { - value: i64, - timescale: i32, - flags: u32, - epoch: i64, -} - -#[link(name = "CoreFoundation", kind = "framework")] -unsafe extern "C" { - static kCFAllocatorDefault: CfAllocatorRef; - static kCFBooleanTrue: CfBooleanRef; - fn CFNumberCreate( - allocator: CfAllocatorRef, - the_type: i32, - value_ptr: *const c_void, - ) -> CfNumberRef; - fn CFRelease(cf: *const c_void); - fn CFRetain(cf: *const c_void) -> *const c_void; -} - -#[link(name = "CoreVideo", kind = "framework")] -unsafe extern "C" { - fn CVPixelBufferCreateWithIOSurface( - allocator: CfAllocatorRef, - surface: IoSurfaceRef, - pixel_buffer_attributes: CfDictionaryRef, - pixel_buffer_out: *mut CvPixelBufferRef, - ) -> i32; - fn CVPixelBufferRelease(buffer: CvPixelBufferRef); -} - -#[link(name = "CoreMedia", kind = "framework")] -unsafe extern "C" { - fn CMSampleBufferGetDataBuffer(sample_buffer: CmSampleBufferRef) -> CmBlockBufferRef; - fn CMBlockBufferGetDataLength(block_buffer: CmBlockBufferRef) -> usize; - fn CMBlockBufferCopyDataBytes( - block_buffer: CmBlockBufferRef, - offset_to_data: usize, - data_length: usize, - destination: *mut c_void, - ) -> OsStatus; -} - -#[link(name = "VideoToolbox", kind = "framework")] -unsafe extern "C" { - static kVTCompressionPropertyKey_RealTime: CfStringRef; - static kVTCompressionPropertyKey_ProfileLevel: CfStringRef; - static kVTProfileLevel_H264_Baseline_AutoLevel: CfStringRef; - static kVTCompressionPropertyKey_AverageBitRate: CfStringRef; - static kVTCompressionPropertyKey_ExpectedFrameRate: CfStringRef; - static kVTCompressionPropertyKey_AllowFrameReordering: CfStringRef; - fn VTCompressionSessionCreate( - allocator: CfAllocatorRef, - width: i32, - height: i32, - codec_type: u32, - encoder_specification: CfDictionaryRef, - source_image_buffer_attributes: CfDictionaryRef, - compressed_data_allocator: CfAllocatorRef, - output_callback: Option, - output_callback_refcon: *mut c_void, - session_out: *mut VtCompressionSessionRef, - ) -> OsStatus; - fn VTCompressionSessionEncodeFrame( - session: VtCompressionSessionRef, - image_buffer: CvPixelBufferRef, - presentation_time_stamp: CmTime, - duration: CmTime, - frame_properties: CfDictionaryRef, - source_frame_refcon: *mut c_void, - info_flags_out: *mut u32, - ) -> OsStatus; - fn VTCompressionSessionCompleteFrames( - session: VtCompressionSessionRef, - complete_until: CmTime, - ) -> OsStatus; - fn VTCompressionSessionInvalidate(session: VtCompressionSessionRef); - fn VTSessionSetProperty( - session: *mut c_void, - property_key: CfStringRef, - property_value: *const c_void, - ) -> OsStatus; - fn VTPixelTransferSessionCreate( - allocator: CfAllocatorRef, - session_out: *mut VtPixelTransferSessionRef, - ) -> OsStatus; - fn VTPixelTransferSessionTransferImage( - session: VtPixelTransferSessionRef, - source: CvPixelBufferRef, - destination: CvPixelBufferRef, - ) -> OsStatus; - fn VTPixelTransferSessionInvalidate(session: VtPixelTransferSessionRef); -} - -type VtCompressionOutputCallback = unsafe extern "C" fn( - output_callback_refcon: *mut c_void, - source_frame_refcon: *mut c_void, - status: OsStatus, - info_flags: u32, - sample_buffer: CmSampleBufferRef, -); - -fn is_invalid_session_error(err: &EncoderError) -> bool { - match err { - EncoderError::EncodeFailed { vendor, status } => { - assert!(!vendor.is_empty(), "encode error vendor non-empty"); - *vendor == "vt-compression-encode" && *status == KVT_INVALID_SESSION_ERR as i64 - } - _ => false, - } -} - -fn cf_num_i32(v: i32) -> CfNumberRef { - let ptr: *const i32 = &v; - unsafe { - CFNumberCreate( - kCFAllocatorDefault, - KCF_NUMBER_SINT32_TYPE, - ptr as *const c_void, - ) - } -} - -pub struct VtPixelTransfer { - session: NonNull, -} - -unsafe impl Send for VtPixelTransfer {} - -impl VtPixelTransfer { - pub fn new() -> Result { - let mut raw: VtPixelTransferSessionRef = ptr::null_mut(); - let status = unsafe { VTPixelTransferSessionCreate(kCFAllocatorDefault, &mut raw) }; - if status != NOERR || raw.is_null() { - return Err(EncoderError::SessionInitFailed { - vendor: "vt-pixel-transfer", - status: status as i64, - }); - } - let session = NonNull::new(raw).ok_or(EncoderError::SessionInitFailed { - vendor: "vt-pixel-transfer", - status: -1, - })?; - assert!( - session.as_ptr() as usize != 0, - "transfer session non-null after create" - ); - Ok(Self { session }) - } - - #[allow(clippy::missing_safety_doc)] - pub unsafe fn transfer( - &self, - source: CvPixelBufferRef, - destination: CvPixelBufferRef, - ) -> Result<(), EncoderError> { - assert!(!source.is_null(), "source pixel buffer non-null"); - assert!(!destination.is_null(), "destination pixel buffer non-null"); - let status = unsafe { - VTPixelTransferSessionTransferImage(self.session.as_ptr(), source, destination) - }; - if status != NOERR { - return Err(EncoderError::EncodeFailed { - vendor: "vt-pixel-transfer", - status: status as i64, - }); - } - Ok(()) - } - - #[allow(clippy::missing_safety_doc)] - pub unsafe fn wrap_iosurface( - surface: IoSurfaceRef, - width: u32, - height: u32, - ) -> Result { - assert!(!surface.is_null(), "wrap source IOSurface non-null"); - assert!(width > 0 && height > 0, "wrap dims positive"); - let mut pb: CvPixelBufferRef = ptr::null_mut(); - let status = unsafe { - CVPixelBufferCreateWithIOSurface(kCFAllocatorDefault, surface, ptr::null(), &mut pb) - }; - if status != 0 || pb.is_null() { - return Err(EncoderError::EncodeFailed { - vendor: "cv-create-iosurface", - status: status as i64, - }); - } - assert!(!pb.is_null(), "wrap pb non-null after create"); - Ok(pb) - } -} - -impl Drop for VtPixelTransfer { - fn drop(&mut self) { - let p = self.session.as_ptr(); - if !p.is_null() { - unsafe { VTPixelTransferSessionInvalidate(p) }; - unsafe { CFRelease(p) }; - } - } -} - -struct CompletionRing { - buffer: VecDeque, -} - -impl CompletionRing { - fn new() -> Self { - Self { - buffer: VecDeque::with_capacity(COMPLETION_RING_CAPACITY), - } - } - - fn push(&mut self, item: EncodedBitstream) { - if self.buffer.len() >= COMPLETION_RING_CAPACITY { - self.buffer.pop_front(); - } - self.buffer.push_back(item); - assert!( - self.buffer.len() <= COMPLETION_RING_CAPACITY, - "completion ring bounded" - ); - } - - fn pop(&mut self) -> Option { - let item = self.buffer.pop_front(); - assert!( - self.buffer.len() <= COMPLETION_RING_CAPACITY, - "completion ring bounded after pop" - ); - item - } - - fn len(&self) -> usize { - let n = self.buffer.len(); - assert!(n <= COMPLETION_RING_CAPACITY, "completion ring len bounded"); - n - } -} - -struct SharedState { - completed: CompletionRing, - accepted: u64, - last_pts_us: u64, -} - -struct VtCallbackContext { - state: Arc>, - failed_completions: AtomicU64, -} - -unsafe extern "C" fn vt_compression_output( - output_callback_refcon: *mut c_void, - source_frame_refcon: *mut c_void, - status: OsStatus, - _info_flags: u32, - sample_buffer: CmSampleBufferRef, -) { - if output_callback_refcon.is_null() { - return; - } - let ctx = unsafe { &*(output_callback_refcon as *const VtCallbackContext) }; - if status != NOERR { - ctx.failed_completions.fetch_add(1, Ordering::Relaxed); - return; - } - if sample_buffer.is_null() { - return; - } - let block = unsafe { CMSampleBufferGetDataBuffer(sample_buffer) }; - if block.is_null() { - return; - } - let len = unsafe { CMBlockBufferGetDataLength(block) }; - if len == 0 || len > MAX_BITSTREAM_BYTES { - return; - } - let mut bytes: Vec = vec![0u8; len]; - let copy_status = - unsafe { CMBlockBufferCopyDataBytes(block, 0, len, bytes.as_mut_ptr() as *mut c_void) }; - if copy_status != NOERR { - return; - } - let pts_us = source_frame_refcon as usize as u64; - let payload = - match std::panic::catch_unwind(|| EncodedBitstream::new(bytes, pts_us, pts_us, true)) { - Ok(p) => p, - Err(_) => return, - }; - if let Ok(mut guard) = ctx.state.lock() { - guard.completed.push(payload); - } -} - -struct SlotSurface { - surface: IoSurfaceRef, - pixel_buffer: CvPixelBufferRef, -} - -pub struct VtCompressionHandoff { - session: NonNull, - callback_context: Box, - shared: Arc>, - surfaces: HashMap, - dims: EncoderDims, - next_slot: u32, - frame_interval_us: u64, - frame_rate: EncoderFrameRate, - session_rebuilds: u64, -} - -unsafe impl Send for VtCompressionHandoff {} - -impl VtCompressionHandoff { - pub fn try_new(dims: EncoderDims) -> Result { - Self::try_new_with_frame_rate(dims, EncoderFrameRate::from_fps(30)) - } - - pub fn try_new_with_frame_rate( - dims: EncoderDims, - frame_rate: EncoderFrameRate, - ) -> Result { - assert!( - dims.width > 0 && dims.height > 0, - "compression dims positive" - ); - assert!(frame_rate.numerator > 0, "frame rate numerator positive"); - assert!( - frame_rate.denominator > 0, - "frame rate denominator positive" - ); - if dims.width > 7680 || dims.height > 4320 { - return Err(EncoderError::DimensionsOutOfRange { - width: dims.width, - height: dims.height, - }); - } - let shared = Arc::new(Mutex::new(SharedState { - completed: CompletionRing::new(), - accepted: 0, - last_pts_us: 0, - })); - let mut callback_context = Box::new(VtCallbackContext { - state: shared.clone(), - failed_completions: AtomicU64::new(0), - }); - let cb_ctx_ptr = callback_context.as_mut() as *mut VtCallbackContext as *mut c_void; - let session = Self::create_session(dims, cb_ctx_ptr, frame_rate)?; - let handoff = Self { - session, - callback_context, - shared, - surfaces: HashMap::new(), - dims, - next_slot: 0, - frame_interval_us: frame_rate.frame_interval_us(), - frame_rate, - session_rebuilds: 0, - }; - assert!( - handoff.session.as_ptr() as usize != 0, - "session non-null after init" - ); - assert_eq!(handoff.next_slot, 0, "next slot fresh"); - Ok(handoff) - } - - fn create_session( - dims: EncoderDims, - cb_ctx_ptr: *mut c_void, - frame_rate: EncoderFrameRate, - ) -> Result, EncoderError> { - assert!(dims.width > 0, "session dims width positive"); - assert!(!cb_ctx_ptr.is_null(), "session callback context non-null"); - assert!(frame_rate.numerator > 0, "frame rate numerator positive"); - let mut raw: VtCompressionSessionRef = ptr::null_mut(); - let status = unsafe { - VTCompressionSessionCreate( - kCFAllocatorDefault, - dims.width as i32, - dims.height as i32, - KCM_VIDEO_CODEC_TYPE_H264, - ptr::null(), - ptr::null(), - kCFAllocatorDefault, - Some(vt_compression_output), - cb_ctx_ptr, - &mut raw, - ) - }; - if status != NOERR || raw.is_null() { - return Err(EncoderError::SessionInitFailed { - vendor: "vt-compression", - status: status as i64, - }); - } - let session = NonNull::new(raw).ok_or(EncoderError::SessionInitFailed { - vendor: "vt-compression", - status: -1, - })?; - Self::configure(session.as_ptr(), frame_rate)?; - Ok(session) - } - - fn try_rebuild_session(&mut self) -> bool { - assert!( - self.session_rebuilds <= VT_SESSION_REBUILD_MAX, - "rebuild count within cap" - ); - if self.session_rebuilds >= VT_SESSION_REBUILD_MAX { - return false; - } - let cb_ctx_ptr = self.callback_context.as_mut() as *mut VtCallbackContext as *mut c_void; - let Ok(new_session) = Self::create_session(self.dims, cb_ctx_ptr, self.frame_rate) else { - return false; - }; - let old = self.session.as_ptr(); - assert!(!old.is_null(), "old session non-null before replace"); - unsafe { - VTCompressionSessionInvalidate(old); - CFRelease(old); - } - self.session = new_session; - self.session_rebuilds = self.session_rebuilds.saturating_add(1); - assert!( - self.session_rebuilds <= VT_SESSION_REBUILD_MAX, - "rebuild count stays within cap" - ); - true - } - - fn configure(session: *mut c_void, frame_rate: EncoderFrameRate) -> Result<(), EncoderError> { - assert!(!session.is_null(), "configure session non-null"); - assert!(frame_rate.numerator > 0, "frame rate numerator positive"); - let real_time_true = unsafe { kCFBooleanTrue }; - let st1 = unsafe { - VTSessionSetProperty(session, kVTCompressionPropertyKey_RealTime, real_time_true) - }; - if st1 != NOERR { - return Err(EncoderError::SessionInitFailed { - vendor: "vt-compression-realtime", - status: st1 as i64, - }); - } - let st2 = unsafe { - VTSessionSetProperty( - session, - kVTCompressionPropertyKey_ProfileLevel, - kVTProfileLevel_H264_Baseline_AutoLevel, - ) - }; - if st2 != NOERR { - return Err(EncoderError::SessionInitFailed { - vendor: "vt-compression-profile", - status: st2 as i64, - }); - } - let bitrate = cf_num_i32(2_000_000); - let st3 = unsafe { - VTSessionSetProperty(session, kVTCompressionPropertyKey_AverageBitRate, bitrate) - }; - unsafe { CFRelease(bitrate) }; - if st3 != NOERR { - return Err(EncoderError::SessionInitFailed { - vendor: "vt-compression-bitrate", - status: st3 as i64, - }); - } - let fps_value = frame_rate.gop_pic_size() as i32; - let fps = cf_num_i32(fps_value); - let st4 = unsafe { - VTSessionSetProperty(session, kVTCompressionPropertyKey_ExpectedFrameRate, fps) - }; - unsafe { CFRelease(fps) }; - if st4 != NOERR { - return Err(EncoderError::SessionInitFailed { - vendor: "vt-compression-fps", - status: st4 as i64, - }); - } - let no_reorder = unsafe { CFRetain(kCFBooleanTrue) }; - let st5 = unsafe { - VTSessionSetProperty( - session, - kVTCompressionPropertyKey_AllowFrameReordering, - no_reorder, - ) - }; - unsafe { CFRelease(no_reorder) }; - if st5 != NOERR { - return Err(EncoderError::SessionInitFailed { - vendor: "vt-compression-reorder", - status: st5 as i64, - }); - } - Ok(()) - } - - pub fn pending_completion(&self) -> usize { - let guard = match self.shared.lock() { - Ok(g) => g, - Err(_) => return 0, - }; - let n = guard.completed.len(); - assert!(n <= COMPLETION_RING_CAPACITY, "pending count bounded"); - n - } - - pub fn accepted_count(&self) -> u64 { - let guard = match self.shared.lock() { - Ok(g) => g, - Err(_) => return 0, - }; - let n = guard.accepted; - assert!( - self.surfaces.len() as u64 <= u32::MAX as u64, - "surfaces table bounded" - ); - n - } - - pub fn failed_completion_count(&self) -> u64 { - let n = self - .callback_context - .failed_completions - .load(Ordering::Relaxed); - assert!( - self.session_rebuilds <= VT_SESSION_REBUILD_MAX, - "rebuild count within cap" - ); - n - } - - pub fn session_rebuild_count(&self) -> u64 { - let n = self.session_rebuilds; - assert!(n <= VT_SESSION_REBUILD_MAX, "rebuild count within cap"); - n - } - - pub fn wait_for_completion(&self) -> Result<(), EncoderError> { - let until = CmTime { - value: 0, - timescale: 0, - flags: 0, - epoch: 0, - }; - let status = unsafe { VTCompressionSessionCompleteFrames(self.session.as_ptr(), until) }; - if status != NOERR { - return Err(EncoderError::EncodeFailed { - vendor: "vt-compression-complete", - status: status as i64, - }); - } - Ok(()) - } - - fn encode_frame_once( - &self, - pixel_buffer: CvPixelBufferRef, - pic_params: PicParams, - ) -> Result<(), EncoderError> { - assert!(!pixel_buffer.is_null(), "encode pixel buffer non-null"); - assert!(self.frame_interval_us > 0, "frame interval positive"); - let pts = CmTime { - value: pic_params.pts_us as i64, - timescale: 1_000_000, - flags: KCM_TIME_FLAGS_VALID, - epoch: 0, - }; - let dur = CmTime { - value: self.frame_interval_us as i64, - timescale: 1_000_000, - flags: KCM_TIME_FLAGS_VALID, - epoch: 0, - }; - let mut flags_out: u32 = 0; - let refcon = pic_params.pts_us as usize as *mut c_void; - let enc_status = unsafe { - VTCompressionSessionEncodeFrame( - self.session.as_ptr(), - pixel_buffer, - pts, - dur, - ptr::null(), - refcon, - &mut flags_out, - ) - }; - if enc_status != NOERR { - return Err(EncoderError::EncodeFailed { - vendor: "vt-compression-encode", - status: enc_status as i64, - }); - } - Ok(()) - } - - fn encode_one(&mut self, slot: HandoffSlot, pic_params: PicParams) -> Result<(), EncoderError> { - assert!(slot.shared_handle != 0, "encode slot handle non-zero"); - let pixel_buffer = match self.surfaces.get(&slot.slot_index) { - Some(entry) => { - assert!(!entry.surface.is_null(), "cached slot surface non-null"); - assert!( - !entry.pixel_buffer.is_null(), - "cached pixel buffer non-null" - ); - entry.pixel_buffer - } - None => { - return Err(EncoderError::SlotUnknown { - slot_index: slot.slot_index, - }); - } - }; - let mut attempts: usize = 0; - while attempts < VT_ENCODE_ATTEMPTS_PER_FRAME_MAX { - attempts += 1; - assert!( - attempts <= VT_ENCODE_ATTEMPTS_PER_FRAME_MAX, - "encode attempts bounded" - ); - match self.encode_frame_once(pixel_buffer, pic_params) { - Ok(()) => { - if let Ok(mut guard) = self.shared.lock() { - guard.accepted = guard.accepted.saturating_add(1); - guard.last_pts_us = pic_params.pts_us; - } - return Ok(()); - } - Err(err) => { - if !is_invalid_session_error(&err) { - return Err(err); - } - if !self.try_rebuild_session() { - break; - } - } - } - } - Err(EncoderError::EncodeFailed { - vendor: "vt-compression-invalid-session", - status: KVT_INVALID_SESSION_ERR as i64, - }) - } -} - -impl Drop for VtCompressionHandoff { - fn drop(&mut self) { - for (_, entry) in self.surfaces.drain() { - if !entry.pixel_buffer.is_null() { - unsafe { CVPixelBufferRelease(entry.pixel_buffer) }; - } - } - let p = self.session.as_ptr(); - if !p.is_null() { - unsafe { VTCompressionSessionInvalidate(p) }; - unsafe { CFRelease(p) }; - } - let _ = &self.callback_context; - } -} - -impl VideoToolboxHandoff for VtCompressionHandoff { - fn register_slot( - &mut self, - iosurface_handle: u64, - _key: u64, - dims: EncoderDims, - ) -> Result { - if iosurface_handle == 0 { - return Err(EncoderError::SlotUnknown { - slot_index: u32::MAX, - }); - } - if dims.width == 0 || dims.height == 0 { - return Err(EncoderError::DimensionsOutOfRange { - width: dims.width, - height: dims.height, - }); - } - let idx = self.next_slot; - let surface = iosurface_handle as IoSurfaceRef; - if surface.is_null() { - return Err(EncoderError::SlotUnknown { slot_index: idx }); - } - let mut pixel_buffer: CvPixelBufferRef = ptr::null_mut(); - let status = unsafe { - CVPixelBufferCreateWithIOSurface( - kCFAllocatorDefault, - surface, - ptr::null(), - &mut pixel_buffer, - ) - }; - if status != 0 || pixel_buffer.is_null() { - return Err(EncoderError::RegisterFailed { - vendor: "vt-cv-create", - status: status as i64, - }); - } - self.surfaces.insert( - idx, - SlotSurface { - surface, - pixel_buffer, - }, - ); - self.next_slot = self.next_slot.saturating_add(1); - let slot = HandoffSlot::new(idx, iosurface_handle); - assert_eq!( - slot.shared_handle, iosurface_handle, - "slot handle preserved" - ); - assert!(self.next_slot > idx, "slot counter advanced"); - Ok(slot) - } - - fn encode_shared_async( - &mut self, - slot: HandoffSlot, - _key: u64, - dims: EncoderDims, - pic_params: PicParams, - ) -> Result<(), EncoderError> { - if dims.width == 0 || dims.height == 0 { - return Err(EncoderError::DimensionsOutOfRange { - width: dims.width, - height: dims.height, - }); - } - self.encode_one(slot, pic_params) - } - - fn poll_completed(&mut self, _slot: HandoffSlot) -> Option { - let mut guard = self.shared.lock().ok()?; - let item = guard.completed.pop(); - if let Some(ref payload) = item { - assert!(!payload.data.is_empty(), "polled payload non-empty"); - assert!( - payload.data.len() <= MAX_BITSTREAM_BYTES, - "polled payload bounded" - ); - } - item - } - - fn unregister_slot(&mut self, slot: HandoffSlot) { - assert!(slot.shared_handle != 0, "unregister handle non-zero"); - if let Some(entry) = self.surfaces.remove(&slot.slot_index) { - assert!( - !entry.pixel_buffer.is_null(), - "cached pixel buffer non-null on unregister" - ); - unsafe { CVPixelBufferRelease(entry.pixel_buffer) }; - } - } - - fn encode_shared( - &mut self, - submission: EncoderSubmission, - callback: &mut dyn crate::encoder_handoff::EncoderCompletionCallback, - ) -> Result<(), RingError> { - if submission.shared_handle == 0 { - return Err(RingError::UnknownSlot); - } - if submission.dims.width == 0 || submission.dims.height == 0 { - return Err(RingError::BackendFailed { - source: crate::backend::BackendError::DimensionsOutOfRange { - width: submission.dims.width, - height: submission.dims.height, - }, - }); - } - let slot = HandoffSlot::new( - (submission.sequence & 0x3f) as u32, - submission.shared_handle, - ); - let pts_us = match submission.capture_pts_us { - Some(capture_pts_us) => capture_pts_us, - None => submission.sequence.saturating_mul(self.frame_interval_us), - }; - let params = PicParams::new(pts_us, false); - let encoded_bytes_estimate = self.pending_completion() as u32; - if let Err(e) = self.encode_one(slot, params) { - return Err(RingError::BackendFailed { - source: crate::backend::BackendError::PlatformUnsupported { - reason: match e { - EncoderError::SlotUnknown { .. } => "vt slot unknown", - EncoderError::EncodeFailed { .. } => "vt encode failed", - _ => "vt session error", - }, - }, - }); - } - callback.on_complete(submission.sequence, encoded_bytes_estimate); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn output_callback_counts_failed_completions() { - let ctx = VtCallbackContext { - state: Arc::new(Mutex::new(SharedState { - completed: CompletionRing::new(), - accepted: 0, - last_pts_us: 0, - })), - failed_completions: AtomicU64::new(0), - }; - let ctx_ptr = &ctx as *const VtCallbackContext as *mut c_void; - unsafe { - vt_compression_output( - ctx_ptr, - ptr::null_mut(), - KVT_INVALID_SESSION_ERR, - 0, - ptr::null_mut(), - ); - vt_compression_output(ctx_ptr, ptr::null_mut(), -1, 0, ptr::null_mut()); - } - assert_eq!(ctx.failed_completions.load(Ordering::Relaxed), 2); - let guard = ctx.state.lock().expect("state lock"); - assert_eq!( - guard.completed.len(), - 0, - "failed completions enqueue nothing" - ); - } - - #[test] - fn invalid_session_error_detection_is_exact() { - let invalid = EncoderError::EncodeFailed { - vendor: "vt-compression-encode", - status: KVT_INVALID_SESSION_ERR as i64, - }; - assert!(is_invalid_session_error(&invalid)); - let other_status = EncoderError::EncodeFailed { - vendor: "vt-compression-encode", - status: -1, - }; - assert!(!is_invalid_session_error(&other_status)); - let other_vendor = EncoderError::EncodeFailed { - vendor: "vt-cv-create", - status: KVT_INVALID_SESSION_ERR as i64, - }; - assert!(!is_invalid_session_error(&other_vendor)); - } -} diff --git a/fluxer_desktop/native/encoder-ring/tests/ring_skip_dont_block.rs b/fluxer_desktop/native/encoder-ring/tests/ring_skip_dont_block.rs deleted file mode 100644 index 99dae4d41..000000000 --- a/fluxer_desktop/native/encoder-ring/tests/ring_skip_dont_block.rs +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use fluxer_encoder_ring::{ - CpuMemcpyBackend, CpuSlotHandle, EncoderInputRing, RING_SIZE, TextureFormat, -}; - -fn fill_noop(_: &mut CpuSlotHandle) {} - -#[test] -fn submit_skip_oldest_drops_first_half_when_pushed_twice_capacity() { - let mut ring = EncoderInputRing::new(CpuMemcpyBackend::new()); - ring.initialise(64, 64, TextureFormat::Nv12) - .expect("init ring"); - let capacity = ring.capacity(); - assert_eq!(capacity, RING_SIZE); - assert_eq!(capacity, 8); - let total_pushes: u64 = 2 * capacity as u64; - for _ in 0..total_pushes { - ring.submit_skip_oldest(fill_noop) - .expect("skip-oldest accepts every push"); - } - assert_eq!(ring.submitted_count(), total_pushes); - assert_eq!(ring.dropped_count(), capacity as u64); - assert_eq!(ring.free_count(), 0); - let mut observed: Vec = Vec::with_capacity(capacity); - for _ in 0..capacity { - let ready = ring.poll_next_ready().expect("ready"); - observed.push(ready.sequence); - ring.release_completed(ready).expect("release"); - } - let expected: Vec = (capacity as u64 + 1..=total_pushes).collect(); - assert_eq!( - observed, expected, - "skip-don't-block kept newest {capacity} frames" - ); - assert_eq!(ring.free_count(), capacity); - assert!(ring.poll_next_ready().is_none()); -} - -#[test] -fn submit_skip_oldest_succeeds_when_room_available() { - let mut ring = EncoderInputRing::new(CpuMemcpyBackend::new()); - ring.initialise(64, 64, TextureFormat::Nv12) - .expect("init ring"); - for _ in 0..3 { - ring.submit_skip_oldest(fill_noop).expect("submit"); - } - assert_eq!(ring.dropped_count(), 0); - assert_eq!(ring.submitted_count(), 3); - assert_eq!(ring.free_count(), ring.capacity() - 3); -} - -#[test] -fn submit_skip_oldest_keeps_metrics_consistent() { - let mut ring = EncoderInputRing::new(CpuMemcpyBackend::new()); - ring.initialise(64, 64, TextureFormat::Nv12) - .expect("init ring"); - for _ in 0..(3 * ring.capacity() as u64) { - ring.submit_skip_oldest(fill_noop).expect("submit"); - } - let metrics = ring.metrics(); - assert_eq!(metrics.submitted_count, 3 * ring.capacity() as u64); - assert_eq!(metrics.dropped_count, 2 * ring.capacity() as u64); - assert!(metrics.completed_count <= metrics.submitted_count); -} diff --git a/fluxer_desktop/native/gateway-socket/index.js b/fluxer_desktop/native/gateway-socket/index.js index 5ae8de150..070d5ffb6 100644 --- a/fluxer_desktop/native/gateway-socket/index.js +++ b/fluxer_desktop/native/gateway-socket/index.js @@ -3,7 +3,20 @@ const {existsSync} = require('node:fs'); const {join, sep} = require('node:path'); const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs'); -const {nativeFileName} = require('./pure.cjs'); +function nativeFileName(platform = process.platform, arch = process.arch) { + switch (platform) { + case 'darwin': + if (arch === 'x64' || arch === 'arm64') return `gateway-socket.darwin-${arch}.node`; + break; + case 'win32': + if (arch === 'x64' || arch === 'arm64') return `gateway-socket.win32-${arch}-msvc.node`; + break; + case 'linux': + if (arch === 'x64' || arch === 'arm64') return `gateway-socket.linux-${arch}-gnu.node`; + break; + } + throw new Error(`Unsupported gateway-socket target: ${platform}/${arch}`); +} const MODULE_NAME = '@fluxer/gateway-socket'; const SKIP_NATIVE_PROBE_ENV = 'FLUXER_GATEWAY_SOCKET_SKIP_NATIVE_PROBE'; @@ -46,7 +59,4 @@ module.exports = { connect: binding ? binding.connect : null, warmup: binding && typeof binding.warmup === 'function' ? binding.warmup : null, loadError, - _private: { - nativeFileName, - }, }; diff --git a/fluxer_desktop/native/gateway-socket/package.json b/fluxer_desktop/native/gateway-socket/package.json index 4d7c902ba..eeac6314e 100644 --- a/fluxer_desktop/native/gateway-socket/package.json +++ b/fluxer_desktop/native/gateway-socket/package.json @@ -14,7 +14,6 @@ "index.js", "index.d.ts", "loader-diagnostics.cjs", - "pure.cjs", "gateway-socket.darwin-x64.node", "gateway-socket.darwin-arm64.node", "gateway-socket.win32-x64-msvc.node", diff --git a/fluxer_desktop/native/gateway-socket/pure.cjs b/fluxer_desktop/native/gateway-socket/pure.cjs deleted file mode 100644 index 32a5d45e8..000000000 --- a/fluxer_desktop/native/gateway-socket/pure.cjs +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -function nativeFileName(platform = process.platform, arch = process.arch) { - switch (platform) { - case 'darwin': - if (arch === 'x64' || arch === 'arm64') return `gateway-socket.darwin-${arch}.node`; - break; - case 'win32': - if (arch === 'x64' || arch === 'arm64') return `gateway-socket.win32-${arch}-msvc.node`; - break; - case 'linux': - if (arch === 'x64' || arch === 'arm64') return `gateway-socket.linux-${arch}-gnu.node`; - break; - } - throw new Error(`Unsupported gateway-socket target: ${platform}/${arch}`); -} - -module.exports = { - nativeFileName, -}; diff --git a/fluxer_desktop/native/gateway-socket/src/lib.rs b/fluxer_desktop/native/gateway-socket/src/lib.rs index b2dc6d763..5fddb4698 100644 --- a/fluxer_desktop/native/gateway-socket/src/lib.rs +++ b/fluxer_desktop/native/gateway-socket/src/lib.rs @@ -1292,51 +1292,6 @@ mod tests { assert_eq!(literal.port, 9000); } - #[test] - fn every_secure_connection_shares_one_tls_client_config() { - match (tls_client_config(), tls_client_config()) { - (Ok(first), Ok(second)) => assert!(Arc::ptr_eq(&first, &second)), - (Err(first), Err(second)) => assert_eq!(first, second), - _ => panic!("the cached TLS client config changed between calls"), - } - } - - #[tokio::test] - async fn a_cleartext_target_needs_no_tls_connector() { - let target = parse_websocket_connection_target("ws://127.0.0.1:9/", None).unwrap(); - assert!(tls_connector_for(&target).await.unwrap().is_none()); - } - - #[test] - fn transport_modes_parse_and_bound_their_frames() { - assert_eq!( - TransportMode::parse("gateway").unwrap().max_frame_bytes(), - GATEWAY_FRAME_MAX_BYTES - ); - assert_eq!( - TransportMode::parse("voice").unwrap().max_frame_bytes(), - VOICE_FRAME_MAX_BYTES - ); - assert!(TransportMode::parse("Gateway").is_err()); - assert!(TransportMode::parse("").is_err()); - assert_eq!( - TransportMode::Gateway.read_capacity_bytes(), - GATEWAY_FRAME_MAX_BYTES + 1 - ); - } - - #[test] - fn close_codes_and_reasons_are_validated() { - assert_eq!(validate_close_code(1000).unwrap(), 1000); - assert_eq!(validate_close_code(4004).unwrap(), 4004); - assert!(validate_close_code(1005).is_err()); - assert!(validate_close_code(1006).is_err()); - assert!(validate_close_code(70_000).is_err()); - assert!(validate_close_reason(&"a".repeat(CLOSE_REASON_MAX_BYTES)).is_ok()); - assert!(validate_close_reason(&"a".repeat(CLOSE_REASON_MAX_BYTES + 1)).is_err()); - assert!(validate_close_reason(&"\u{1f600}".repeat(31)).is_err()); - } - #[test] fn the_queue_budget_rejects_past_its_entry_and_byte_caps() { static GLOBAL_BYTES: AtomicUsize = AtomicUsize::new(0); diff --git a/fluxer_desktop/native/gpu-rebuild/Cargo.lock b/fluxer_desktop/native/gpu-rebuild/Cargo.lock deleted file mode 100644 index 65e4f05e9..000000000 --- a/fluxer_desktop/native/gpu-rebuild/Cargo.lock +++ /dev/null @@ -1,539 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "bit-set" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" - -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" - -[[package]] -name = "bytemuck" -version = "1.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "codespan-reporting" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" -dependencies = [ - "unicode-width", -] - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "fluxer_gpu_rebuild" -version = "0.0.0" -dependencies = [ - "parking_lot", - "wgpu", -] - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "num-traits", - "zerocopy", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "foldhash", -] - -[[package]] -name = "indexmap" -version = "2.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[package]] -name = "naga" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" -dependencies = [ - "arrayvec", - "bit-set", - "bitflags", - "cfg-if", - "cfg_aliases", - "codespan-reporting", - "half", - "hashbrown", - "indexmap", - "libm", - "log", - "naga-types", - "num-traits", - "once_cell", - "rustc-hash", - "thiserror", - "unicode-ident", -] - -[[package]] -name = "naga-types" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" -dependencies = [ - "hashbrown", - "indexmap", - "rustc-hash", - "thiserror", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "portable-atomic" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" - -[[package]] -name = "portable-atomic-util" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "profiling" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "renderdoc-sys" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "smallvec" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "unicode-ident" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "wgpu" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" -dependencies = [ - "arrayvec", - "bitflags", - "bytemuck", - "cfg-if", - "cfg_aliases", - "document-features", - "hashbrown", - "log", - "portable-atomic", - "profiling", - "raw-window-handle", - "smallvec", - "static_assertions", - "wgpu-core", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-core" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" -dependencies = [ - "arrayvec", - "bit-set", - "bit-vec", - "bitflags", - "bytemuck", - "cfg_aliases", - "document-features", - "hashbrown", - "indexmap", - "log", - "naga", - "naga-types", - "once_cell", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "rustc-hash", - "smallvec", - "thiserror", - "wgpu-core-deps-windows-linux-android", - "wgpu-hal", - "wgpu-naga-bridge", - "wgpu-types", -] - -[[package]] -name = "wgpu-core-deps-windows-linux-android" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-hal" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "hashbrown", - "libloading", - "log", - "naga", - "naga-types", - "portable-atomic", - "portable-atomic-util", - "raw-window-handle", - "renderdoc-sys", - "static_assertions", - "thiserror", - "wgpu-naga-bridge", - "wgpu-types", -] - -[[package]] -name = "wgpu-naga-bridge" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" -dependencies = [ - "naga", - "wgpu-types", -] - -[[package]] -name = "wgpu-types" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" -dependencies = [ - "bitflags", - "bytemuck", - "log", - "naga-types", - "raw-window-handle", - "static_assertions", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "zerocopy" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] diff --git a/fluxer_desktop/native/gpu-rebuild/Cargo.toml b/fluxer_desktop/native/gpu-rebuild/Cargo.toml deleted file mode 100644 index fa9d2eddb..000000000 --- a/fluxer_desktop/native/gpu-rebuild/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "fluxer_gpu_rebuild" -version = "0.0.0" -edition = "2024" -license = "AGPL-3.0-or-later" -publish = false - -[workspace] -resolver = "2" - -[lib] -crate-type = ["rlib"] - -[features] -default = [] -wgpu = ["dep:wgpu"] - -[dependencies] -parking_lot = "0.12" -wgpu = { version = "30", optional = true, default-features = false } - -[dev-dependencies] -parking_lot = "0.12" diff --git a/fluxer_desktop/native/gpu-rebuild/src/lib.rs b/fluxer_desktop/native/gpu-rebuild/src/lib.rs deleted file mode 100644 index 482b64830..000000000 --- a/fluxer_desktop/native/gpu-rebuild/src/lib.rs +++ /dev/null @@ -1,677 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -#![deny(clippy::too_many_lines)] -#![deny(clippy::unwrap_used)] -#![deny(clippy::panic)] -#![deny(warnings)] - -use parking_lot::Mutex; -use std::sync::Arc; - -pub const MAX_REGISTERED_OWNERS: usize = 1024; - -#[cfg(feature = "wgpu")] -pub type GpuDevice = wgpu::Device; -#[cfg(feature = "wgpu")] -pub type GpuQueue = wgpu::Queue; - -#[cfg(not(feature = "wgpu"))] -#[derive(Debug)] -pub struct GpuDevice { - pub id: u64, -} - -#[cfg(not(feature = "wgpu"))] -#[derive(Debug)] -pub struct GpuQueue { - pub id: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum GpuRebuildError { - DeviceRejected { reason: &'static str }, - ResourceCreateFailed { reason: &'static str }, - OwnerInvariantBroken { reason: &'static str }, - Other { code: u32 }, -} - -impl std::fmt::Display for GpuRebuildError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::DeviceRejected { reason } => write!(f, "device rejected: {reason}"), - Self::ResourceCreateFailed { reason } => write!(f, "resource create failed: {reason}"), - Self::OwnerInvariantBroken { reason } => write!(f, "owner invariant broken: {reason}"), - Self::Other { code } => write!(f, "other rebuild error code={code}"), - } - } -} - -impl std::error::Error for GpuRebuildError {} - -pub type OwnerId = u64; - -pub trait GpuLossCallback: Send { - fn release(&mut self); - fn rebuild(&mut self, device: &GpuDevice, queue: &GpuQueue) -> Result<(), GpuRebuildError>; - fn is_ready(&self) -> bool; - fn debug_label(&self) -> &'static str { - "" - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RebuildOutcome { - Rebuilt { - owner_id: OwnerId, - label: &'static str, - }, - Failed { - owner_id: OwnerId, - label: &'static str, - error: GpuRebuildError, - }, - Vacant { - owner_id: OwnerId, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RebuildReport { - pub released_count: u32, - pub rebuilt_count: u32, - pub failed_count: u32, - pub vacant_count: u32, - pub outcomes: Vec, -} - -impl RebuildReport { - pub fn is_total_success(&self) -> bool { - self.failed_count == 0 && self.rebuilt_count > 0 - } - - pub fn is_empty_pass(&self) -> bool { - self.released_count == 0 - && self.rebuilt_count == 0 - && self.failed_count == 0 - && self.vacant_count == 0 - } -} - -struct Slot { - owner_id: OwnerId, - callback: Option>, -} - -struct RegistryInner { - slots: Vec, - next_owner_id: OwnerId, - registration_order_monotonic_floor: OwnerId, -} - -impl RegistryInner { - fn new() -> Self { - Self { - slots: Vec::with_capacity(MAX_REGISTERED_OWNERS), - next_owner_id: 1, - registration_order_monotonic_floor: 0, - } - } - - fn deregister(&mut self, owner_id: OwnerId) { - assert!(owner_id > 0, "owner_id must be positive"); - assert!( - owner_id < self.next_owner_id, - "owner_id must come from a real registration" - ); - let before = self.slots.len(); - self.slots.retain(|slot| slot.owner_id != owner_id); - let after = self.slots.len(); - assert!(after <= before, "deregister must not grow slots"); - assert!( - after >= before.saturating_sub(1), - "deregister removes at most one slot" - ); - } -} - -pub struct GpuLossRegistry { - inner: Arc>, -} - -impl GpuLossRegistry { - pub fn new() -> Self { - let inner = Arc::new(Mutex::new(RegistryInner::new())); - let registry = Self { inner }; - assert_eq!(registry.len(), 0, "fresh registry must be empty"); - assert!(registry.is_empty(), "fresh registry must report empty"); - registry - } - - pub fn len(&self) -> usize { - let guard = self.inner.lock(); - guard.slots.len() - } - - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - pub fn register(&self, owner: Box) -> RegistrationGuard { - let mut guard = self.inner.lock(); - assert!( - guard.slots.len() < MAX_REGISTERED_OWNERS, - "registry exceeds MAX_REGISTERED_OWNERS", - ); - let id = guard.next_owner_id; - assert!( - id > guard.registration_order_monotonic_floor, - "ids must be monotonic" - ); - guard.registration_order_monotonic_floor = id; - guard.next_owner_id = id.checked_add(1).unwrap_or(OwnerId::MAX); - guard.slots.push(Slot { - owner_id: id, - callback: Some(owner), - }); - drop(guard); - RegistrationGuard { - owner_id: id, - registry: Arc::clone(&self.inner), - } - } - - pub fn handle_device_lost( - &self, - new_device: &GpuDevice, - new_queue: &GpuQueue, - ) -> RebuildReport { - let mut guard = self.inner.lock(); - assert!( - guard.slots.len() <= MAX_REGISTERED_OWNERS, - "slots within cap before walk", - ); - let released_count = release_in_reverse_order(&mut guard.slots); - let outcomes = rebuild_in_forward_order(&mut guard.slots, new_device, new_queue); - let report = summarize_outcomes(outcomes, released_count); - assert_pair_total_accounting(&guard.slots, &report); - assert_pair_post_rebuild_ready(&guard.slots, &report); - report - } -} - -impl Default for GpuLossRegistry { - fn default() -> Self { - Self::new() - } -} - -pub struct RegistrationGuard { - owner_id: OwnerId, - registry: Arc>, -} - -impl RegistrationGuard { - pub fn owner_id(&self) -> OwnerId { - assert!(self.owner_id > 0, "guard must hold a valid id"); - assert!( - Arc::strong_count(&self.registry) >= 1, - "registry must outlive guard" - ); - self.owner_id - } -} - -impl Drop for RegistrationGuard { - fn drop(&mut self) { - let owner_id = self.owner_id; - if owner_id == 0 { - return; - } - let mut guard = self.registry.lock(); - guard.deregister(owner_id); - } -} - -fn release_in_reverse_order(slots: &mut [Slot]) -> u32 { - let total = slots.len(); - assert!( - total <= MAX_REGISTERED_OWNERS, - "slots within cap on release" - ); - let mut released: u32 = 0; - for slot in slots.iter_mut().rev() { - if let Some(callback) = slot.callback.as_mut() { - callback.release(); - released = released.saturating_add(1); - } - } - assert!(released as usize <= total, "released cannot exceed total"); - released -} - -fn rebuild_in_forward_order( - slots: &mut [Slot], - device: &GpuDevice, - queue: &GpuQueue, -) -> Vec { - let total = slots.len(); - assert!( - total <= MAX_REGISTERED_OWNERS, - "slots within cap on rebuild" - ); - let mut outcomes: Vec = Vec::with_capacity(total); - for slot in slots.iter_mut() { - let owner_id = slot.owner_id; - assert!(owner_id > 0, "rebuild slot must have non-zero id"); - let outcome = rebuild_single_slot(slot, device, queue); - outcomes.push(outcome); - } - assert_eq!(outcomes.len(), total, "one outcome per slot"); - outcomes -} - -fn rebuild_single_slot(slot: &mut Slot, device: &GpuDevice, queue: &GpuQueue) -> RebuildOutcome { - let owner_id = slot.owner_id; - let callback = match slot.callback.as_mut() { - Some(cb) => cb, - None => return RebuildOutcome::Vacant { owner_id }, - }; - let label = callback.debug_label(); - match callback.rebuild(device, queue) { - Ok(()) => RebuildOutcome::Rebuilt { owner_id, label }, - Err(error) => RebuildOutcome::Failed { - owner_id, - label, - error, - }, - } -} - -fn summarize_outcomes(outcomes: Vec, released_count: u32) -> RebuildReport { - let mut rebuilt_count: u32 = 0; - let mut failed_count: u32 = 0; - let mut vacant_count: u32 = 0; - for outcome in outcomes.iter() { - match outcome { - RebuildOutcome::Rebuilt { .. } => rebuilt_count = rebuilt_count.saturating_add(1), - RebuildOutcome::Failed { .. } => failed_count = failed_count.saturating_add(1), - RebuildOutcome::Vacant { .. } => vacant_count = vacant_count.saturating_add(1), - } - } - let report = RebuildReport { - released_count, - rebuilt_count, - failed_count, - vacant_count, - outcomes, - }; - assert_eq!( - report.outcomes.len() as u32, - report.rebuilt_count + report.failed_count + report.vacant_count, - "outcome totals must match", - ); - report -} - -fn assert_pair_total_accounting(slots: &[Slot], report: &RebuildReport) { - let total = slots.len() as u32; - assert_eq!( - total, - report.rebuilt_count + report.failed_count + report.vacant_count, - "report covers every slot", - ); - let mut last_id: OwnerId = 0; - for slot in slots.iter() { - assert!( - slot.owner_id > last_id, - "registration ids must be monotonic" - ); - last_id = slot.owner_id; - } -} - -fn assert_pair_post_rebuild_ready(slots: &[Slot], report: &RebuildReport) { - assert_eq!( - slots.len(), - report.outcomes.len(), - "slot count must match report" - ); - for (slot, outcome) in slots.iter().zip(report.outcomes.iter()) { - match outcome { - RebuildOutcome::Rebuilt { owner_id, .. } => { - assert_eq!(*owner_id, slot.owner_id, "owner id alignment"); - if let Some(cb) = slot.callback.as_ref() { - assert!(cb.is_ready(), "rebuilt owner must report ready"); - } - } - RebuildOutcome::Failed { owner_id, .. } => { - assert_eq!(*owner_id, slot.owner_id, "failed owner id alignment"); - } - RebuildOutcome::Vacant { owner_id } => { - assert_eq!(*owner_id, slot.owner_id, "vacant owner id alignment"); - assert!(slot.callback.is_none(), "vacant slot must have no callback"); - } - } - } -} - -#[cfg(all(test, not(feature = "wgpu")))] -mod tests { - use super::*; - use std::sync::atomic::{AtomicU32, Ordering}; - use std::thread; - - fn make_device_queue() -> (GpuDevice, GpuQueue) { - (GpuDevice { id: 42 }, GpuQueue { id: 42 }) - } - - #[derive(Default)] - struct Counters { - release_calls: AtomicU32, - rebuild_calls: AtomicU32, - release_seq: AtomicU32, - rebuild_seq: AtomicU32, - } - - struct MockOwner { - counters: Arc, - ready: bool, - fail_rebuild: bool, - release_order: Arc>>, - rebuild_order: Arc>>, - slot_marker: u32, - label: &'static str, - } - - impl GpuLossCallback for MockOwner { - fn release(&mut self) { - self.ready = false; - let n = self.counters.release_calls.fetch_add(1, Ordering::SeqCst); - self.counters.release_seq.store(n + 1, Ordering::SeqCst); - let mut order = self.release_order.lock(); - order.push(self.slot_marker); - } - - fn rebuild( - &mut self, - _device: &GpuDevice, - _queue: &GpuQueue, - ) -> Result<(), GpuRebuildError> { - let n = self.counters.rebuild_calls.fetch_add(1, Ordering::SeqCst); - self.counters.rebuild_seq.store(n + 1, Ordering::SeqCst); - let mut order = self.rebuild_order.lock(); - order.push(self.slot_marker); - if self.fail_rebuild { - return Err(GpuRebuildError::ResourceCreateFailed { - reason: "mock fail", - }); - } - self.ready = true; - Ok(()) - } - - fn is_ready(&self) -> bool { - self.ready - } - - fn debug_label(&self) -> &'static str { - self.label - } - } - - fn fresh_counters() -> Arc { - Arc::new(Counters::default()) - } - - fn make_owner( - marker: u32, - counters: Arc, - release_order: Arc>>, - rebuild_order: Arc>>, - ) -> Box { - Box::new(MockOwner { - counters, - ready: true, - fail_rebuild: false, - release_order, - rebuild_order, - slot_marker: marker, - label: "mock", - }) - } - - #[test] - fn five_owners_release_lifo_rebuild_fifo() { - let registry = GpuLossRegistry::new(); - let counters = fresh_counters(); - let release_order = Arc::new(Mutex::new(Vec::new())); - let rebuild_order = Arc::new(Mutex::new(Vec::new())); - let mut guards = Vec::new(); - for i in 0..5u32 { - let owner = make_owner( - i, - Arc::clone(&counters), - Arc::clone(&release_order), - Arc::clone(&rebuild_order), - ); - guards.push(registry.register(owner)); - } - assert_eq!(registry.len(), 5); - let (device, queue) = make_device_queue(); - let report = registry.handle_device_lost(&device, &queue); - assert_eq!(report.released_count, 5); - assert_eq!(report.rebuilt_count, 5); - assert_eq!(report.failed_count, 0); - assert_eq!(report.vacant_count, 0); - let rel = release_order.lock().clone(); - assert_eq!(rel, vec![4, 3, 2, 1, 0]); - let reb = rebuild_order.lock().clone(); - assert_eq!(reb, vec![0, 1, 2, 3, 4]); - drop(guards); - } - - #[test] - fn owner_rebuild_failure_does_not_abort_other_owners() { - let registry = GpuLossRegistry::new(); - let counters = fresh_counters(); - let release_order = Arc::new(Mutex::new(Vec::new())); - let rebuild_order = Arc::new(Mutex::new(Vec::new())); - let mut guards = Vec::new(); - for i in 0..4u32 { - let mut owner = make_owner( - i, - Arc::clone(&counters), - Arc::clone(&release_order), - Arc::clone(&rebuild_order), - ); - if i == 1 { - owner.fail_rebuild = true; - } - guards.push(registry.register(owner)); - } - let (device, queue) = make_device_queue(); - let report = registry.handle_device_lost(&device, &queue); - assert_eq!(report.released_count, 4); - assert_eq!(report.rebuilt_count, 3); - assert_eq!(report.failed_count, 1); - let failed = report - .outcomes - .iter() - .filter(|o| matches!(o, RebuildOutcome::Failed { .. })) - .count(); - assert_eq!(failed, 1); - drop(guards); - } - - #[test] - fn registration_guard_drop_deregisters() { - let registry = GpuLossRegistry::new(); - let counters = fresh_counters(); - let release_order = Arc::new(Mutex::new(Vec::new())); - let rebuild_order = Arc::new(Mutex::new(Vec::new())); - let owner = make_owner( - 7, - Arc::clone(&counters), - Arc::clone(&release_order), - Arc::clone(&rebuild_order), - ); - let guard = registry.register(owner); - assert_eq!(registry.len(), 1); - drop(guard); - assert_eq!(registry.len(), 0); - assert!(registry.is_empty()); - } - - #[test] - fn empty_registry_device_loss_is_noop() { - let registry = GpuLossRegistry::new(); - let (device, queue) = make_device_queue(); - let report = registry.handle_device_lost(&device, &queue); - assert!(report.is_empty_pass()); - assert!(!report.is_total_success()); - assert_eq!(report.outcomes.len(), 0); - } - - #[test] - fn concurrent_registration_is_safe() { - let registry = Arc::new(GpuLossRegistry::new()); - let counters = fresh_counters(); - let release_order = Arc::new(Mutex::new(Vec::new())); - let rebuild_order = Arc::new(Mutex::new(Vec::new())); - let mut threads = Vec::new(); - let guards_collector: Arc>> = Arc::new(Mutex::new(Vec::new())); - for i in 0..16u32 { - let registry = Arc::clone(®istry); - let counters = Arc::clone(&counters); - let release_order = Arc::clone(&release_order); - let rebuild_order = Arc::clone(&rebuild_order); - let guards_collector = Arc::clone(&guards_collector); - threads.push(thread::spawn(move || { - let owner = make_owner(i, counters, release_order, rebuild_order); - let guard = registry.register(owner); - let mut store = guards_collector.lock(); - store.push(guard); - })); - } - for t in threads { - assert!(t.join().is_ok()); - } - assert_eq!(registry.len(), 16); - } - - #[test] - fn pair_asserted_invariants_under_stress() { - let registry = GpuLossRegistry::new(); - let counters = fresh_counters(); - let release_order = Arc::new(Mutex::new(Vec::new())); - let rebuild_order = Arc::new(Mutex::new(Vec::new())); - let mut guards = Vec::new(); - for i in 0..64u32 { - let mut owner = make_owner( - i, - Arc::clone(&counters), - Arc::clone(&release_order), - Arc::clone(&rebuild_order), - ); - if i % 7 == 0 { - owner.fail_rebuild = true; - } - guards.push(registry.register(owner)); - } - let (device, queue) = make_device_queue(); - for _ in 0..5 { - release_order.lock().clear(); - rebuild_order.lock().clear(); - let _report = registry.handle_device_lost(&device, &queue); - } - let rel = release_order.lock().clone(); - let reb = rebuild_order.lock().clone(); - assert_eq!(rel.len(), 64); - assert_eq!(reb.len(), 64); - for i in 0..64u32 { - assert_eq!(rel[i as usize], 63 - i); - assert_eq!(reb[i as usize], i); - } - drop(guards); - assert!(registry.is_empty()); - } - - #[test] - fn mock_callback_counters_are_deterministic() { - let registry = GpuLossRegistry::new(); - let counters = fresh_counters(); - let release_order = Arc::new(Mutex::new(Vec::new())); - let rebuild_order = Arc::new(Mutex::new(Vec::new())); - let guard = registry.register(make_owner( - 1, - Arc::clone(&counters), - Arc::clone(&release_order), - Arc::clone(&rebuild_order), - )); - let (device, queue) = make_device_queue(); - for _ in 0..3 { - registry.handle_device_lost(&device, &queue); - } - assert_eq!(counters.release_calls.load(Ordering::SeqCst), 3); - assert_eq!(counters.rebuild_calls.load(Ordering::SeqCst), 3); - drop(guard); - } - - #[test] - fn deregister_middle_owner_preserves_order() { - let registry = GpuLossRegistry::new(); - let counters = fresh_counters(); - let release_order = Arc::new(Mutex::new(Vec::new())); - let rebuild_order = Arc::new(Mutex::new(Vec::new())); - let g0 = registry.register(make_owner( - 0, - Arc::clone(&counters), - Arc::clone(&release_order), - Arc::clone(&rebuild_order), - )); - let g1 = registry.register(make_owner( - 1, - Arc::clone(&counters), - Arc::clone(&release_order), - Arc::clone(&rebuild_order), - )); - let g2 = registry.register(make_owner( - 2, - Arc::clone(&counters), - Arc::clone(&release_order), - Arc::clone(&rebuild_order), - )); - assert_eq!(registry.len(), 3); - drop(g1); - assert_eq!(registry.len(), 2); - let (device, queue) = make_device_queue(); - let report = registry.handle_device_lost(&device, &queue); - assert_eq!(report.rebuilt_count, 2); - let rel = release_order.lock().clone(); - assert_eq!(rel, vec![2, 0]); - let reb = rebuild_order.lock().clone(); - assert_eq!(reb, vec![0, 2]); - drop(g0); - drop(g2); - assert!(registry.is_empty()); - } - - #[test] - fn rebuild_report_total_success_flag() { - let registry = GpuLossRegistry::new(); - let counters = fresh_counters(); - let release_order = Arc::new(Mutex::new(Vec::new())); - let rebuild_order = Arc::new(Mutex::new(Vec::new())); - let guard = registry.register(make_owner( - 0, - Arc::clone(&counters), - Arc::clone(&release_order), - Arc::clone(&rebuild_order), - )); - let (device, queue) = make_device_queue(); - let report = registry.handle_device_lost(&device, &queue); - assert!(report.is_total_success()); - assert!(!report.is_empty_pass()); - drop(guard); - } -} diff --git a/fluxer_desktop/native/hardware-encoder/build.rs b/fluxer_desktop/native/hardware-encoder/build.rs index cb9f38d4d..d0a03dbb8 100644 --- a/fluxer_desktop/native/hardware-encoder/build.rs +++ b/fluxer_desktop/native/hardware-encoder/build.rs @@ -13,18 +13,6 @@ fn main() { let has_windows_nvenc = target_os == "windows" && supports_nvenc_arch(); let has_windows_nvenc_encoder = has_windows_nvenc; - println!( - "cargo:rustc-env=FLUXER_LINUX_NVENC_COMPILED={}", - if has_linux_nvenc { "1" } else { "0" } - ); - println!( - "cargo:rustc-env=FLUXER_WINDOWS_NVENC_COMPILED={}", - if has_windows_nvenc { "1" } else { "0" } - ); - println!( - "cargo:rustc-env=FLUXER_WINDOWS_NVENC_ENCODER_COMPILED={}", - if has_windows_nvenc_encoder { "1" } else { "0" } - ); if has_linux_nvenc { println!("cargo:rustc-cfg=fluxer_linux_nvenc"); } @@ -34,10 +22,6 @@ fn main() { if has_windows_nvenc_encoder { println!("cargo:rustc-cfg=fluxer_windows_nvenc_encoder"); } - println!( - "cargo:rustc-env=FLUXER_MACOS_VIDEOTOOLBOX_COMPILED={}", - if is_macos { "1" } else { "0" } - ); if is_macos { println!("cargo:rustc-cfg=fluxer_macos_videotoolbox"); println!("cargo:rustc-link-lib=framework=CoreFoundation"); diff --git a/fluxer_desktop/native/hardware-encoder/index.d.ts b/fluxer_desktop/native/hardware-encoder/index.d.ts index c450ff427..3b1bd507a 100644 --- a/fluxer_desktop/native/hardware-encoder/index.d.ts +++ b/fluxer_desktop/native/hardware-encoder/index.d.ts @@ -12,5 +12,4 @@ export interface HardwareEncoderCapability { detail?: string; } -export declare function isSupported(): boolean; export declare function getHardwareEncoderCapabilities(): HardwareEncoderCapability; diff --git a/fluxer_desktop/native/hardware-encoder/index.js b/fluxer_desktop/native/hardware-encoder/index.js index 2dc4f2feb..08e11b4b5 100644 --- a/fluxer_desktop/native/hardware-encoder/index.js +++ b/fluxer_desktop/native/hardware-encoder/index.js @@ -39,10 +39,6 @@ try { loadError = error instanceof Error ? error : new Error(String(error)); } -function isSupported() { - return Boolean(binding); -} - function unavailableHardwareEncoderCapability(reason, detail) { return { available: false, @@ -78,6 +74,5 @@ function getHardwareEncoderCapabilities() { } module.exports = { - isSupported, getHardwareEncoderCapabilities, }; diff --git a/fluxer_desktop/native/hardware-encoder/src/lib.rs b/fluxer_desktop/native/hardware-encoder/src/lib.rs index 6b9b5c425..ea9461965 100644 --- a/fluxer_desktop/native/hardware-encoder/src/lib.rs +++ b/fluxer_desktop/native/hardware-encoder/src/lib.rs @@ -349,36 +349,6 @@ pub fn hardware_encoder_capability() -> HardwareEncoderCapability { ) } -pub fn require_publish_codec_runtime_support(canonical_codec: &str) -> Result<(), String> { - let capability = hardware_encoder_capability(); - require_publish_codec_runtime_support_for_capability(canonical_codec, &capability) -} - -fn require_publish_codec_runtime_support_for_capability( - canonical_codec: &str, - capability: &HardwareEncoderCapability, -) -> Result<(), String> { - if !canonical_codec.eq_ignore_ascii_case("h265") { - return Ok(()); - } - if capability.available - && capability - .codecs - .iter() - .any(|codec| codec.eq_ignore_ascii_case(canonical_codec)) - { - return Ok(()); - } - let detail = capability - .detail - .as_deref() - .or(capability.reason.as_deref()) - .unwrap_or("hardware encoder is unavailable"); - Err(format!( - "H.265 publishing requires hardware encoder support; {detail}" - )) -} - fn current_platform() -> CapabilityPlatform { if cfg!(target_os = "linux") { CapabilityPlatform::Linux @@ -930,10 +900,8 @@ fn probe_macos_videotoolbox_runtime() -> Option { } unsafe fn hardware_required_dictionary() -> CFDictionaryRef { - let key = unsafe { - kVTVideoEncoderSpecification_RequireHardwareAcceleratedVideoEncoder as *const c_void - }; - let value = unsafe { kCFBooleanTrue as *const c_void }; + let key = unsafe { kVTVideoEncoderSpecification_RequireHardwareAcceleratedVideoEncoder }; + let value = unsafe { kCFBooleanTrue }; let keys = [key]; let values = [value]; unsafe { @@ -1048,26 +1016,6 @@ mod tests { assert!(capability.zero_copy); } - #[test] - fn publish_codec_runtime_gate_only_requires_hardware_for_h265() { - let unavailable = describe_capability(CapabilityPlatform::Linux, false, None); - - assert!(require_publish_codec_runtime_support_for_capability("h264", &unavailable).is_ok()); - assert!(require_publish_codec_runtime_support_for_capability("vp8", &unavailable).is_ok()); - let error = - require_publish_codec_runtime_support_for_capability("h265", &unavailable).unwrap_err(); - assert!(error.contains("H.265 publishing requires hardware encoder support")); - assert!(error.contains("vendored CUDA Driver API header")); - } - - #[test] - fn publish_codec_runtime_gate_accepts_h265_when_reported_available() { - let capability = describe_videotoolbox_capability(Some(videotoolbox_ready())); - - assert!(require_publish_codec_runtime_support_for_capability("h265", &capability).is_ok()); - assert!(require_publish_codec_runtime_support_for_capability("H265", &capability).is_ok()); - } - #[test] fn reports_no_supported_codecs_when_guid_query_finds_no_fluxer_codecs() { let capability = describe_capability( diff --git a/fluxer_desktop/native/linux-audio-capture/Cargo.lock b/fluxer_desktop/native/linux-audio-capture/Cargo.lock index 3fd14539b..cc9f32b99 100644 --- a/fluxer_desktop/native/linux-audio-capture/Cargo.lock +++ b/fluxer_desktop/native/linux-audio-capture/Cargo.lock @@ -147,24 +147,11 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" -[[package]] -name = "fluxer_audio_apm" -version = "0.0.0" - -[[package]] -name = "fluxer_gpu_rebuild" -version = "0.0.0" -dependencies = [ - "parking_lot", -] - [[package]] name = "fluxer_linux_audio_capture" version = "0.0.0" dependencies = [ - "fluxer_audio_apm", "fluxer_rt_thread", - "fluxer_screen_frame_bus", "napi", "napi-build", "napi-derive", @@ -179,14 +166,6 @@ dependencies = [ "windows", ] -[[package]] -name = "fluxer_screen_frame_bus" -version = "0.0.0" -dependencies = [ - "fluxer_gpu_rebuild", - "parking_lot", -] - [[package]] name = "futures" version = "0.3.34" @@ -371,15 +350,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "memchr" version = "2.8.3" @@ -475,29 +445,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -553,15 +500,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - [[package]] name = "regex" version = "1.13.1" @@ -610,12 +548,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "semver" version = "1.0.28" diff --git a/fluxer_desktop/native/linux-audio-capture/Cargo.toml b/fluxer_desktop/native/linux-audio-capture/Cargo.toml index b8d410bee..8e8bad652 100644 --- a/fluxer_desktop/native/linux-audio-capture/Cargo.toml +++ b/fluxer_desktop/native/linux-audio-capture/Cargo.toml @@ -15,8 +15,6 @@ crate-type = ["cdylib", "rlib"] napi = {version = "3.12.6", default-features = false, features = ["dyn-symbols", "napi8"]} napi-derive = "3.6.7" fluxer_rt_thread = {path = "../rt-thread"} -fluxer_audio_apm = {path = "../audio-apm"} -fluxer_screen_frame_bus = {path = "../screen-frame-bus"} [target.'cfg(target_os = "linux")'.dependencies] pipewire = "0.10.1" diff --git a/fluxer_desktop/native/linux-audio-capture/index.js b/fluxer_desktop/native/linux-audio-capture/index.js index be4e12ab3..a0b362b2d 100644 --- a/fluxer_desktop/native/linux-audio-capture/index.js +++ b/fluxer_desktop/native/linux-audio-capture/index.js @@ -365,25 +365,9 @@ class ProcessLoopback extends EventEmitter { } } - setScreenAudioSink(handle) { - if (!this.capture || typeof this.capture.setScreenAudioSink !== 'function') return false; - try { - return this.capture.setScreenAudioSink(handle) !== false; - } catch { - return false; - } - } - - clearScreenAudioSink() { - if (this.capture && typeof this.capture.clearScreenAudioSink === 'function') { - this.capture.clearScreenAudioSink(); - } - } - async stop() { if (this.closed) return; this.closed = true; - this.clearScreenAudioSink(); if (this.timer) clearInterval(this.timer); this.timer = null; if (this.refreshTimer) clearInterval(this.refreshTimer); diff --git a/fluxer_desktop/native/linux-audio-capture/src/audio_contract.rs b/fluxer_desktop/native/linux-audio-capture/src/audio_contract.rs index 224c7e37e..0227fa260 100644 --- a/fluxer_desktop/native/linux-audio-capture/src/audio_contract.rs +++ b/fluxer_desktop/native/linux-audio-capture/src/audio_contract.rs @@ -25,18 +25,6 @@ pub fn whole_frame_sample_count(sample_count: usize, channels: u32) -> usize { sample_count - (sample_count % channel_count) } -pub fn direct_whole_frame_sample_count(sample_count: usize) -> usize { - whole_frame_sample_count(sample_count, DIRECT_CAPTURE_CHANNELS) -} - -pub fn bounded_direct_read_sample_count(available: usize) -> usize { - bounded_direct_read_sample_count_for_format( - available, - DIRECT_CAPTURE_SAMPLE_RATE, - DIRECT_CAPTURE_CHANNELS, - ) -} - pub fn sample_count_for_duration_us(sample_rate: u32, channels: u32, duration_us: u32) -> usize { if sample_rate == 0 || channels == 0 || duration_us == 0 { return 0; @@ -62,16 +50,6 @@ pub fn bounded_direct_read_sample_count_for_format( whole_frame_sample_count(available.min(max), channels) } -pub fn bounded_direct_append_slice(input: &[f32]) -> &[f32] { - let whole = direct_whole_frame_sample_count(input.len()); - let framed = &input[..whole]; - if framed.len() > DIRECT_CAPTURE_MAX_SAMPLES { - &framed[framed.len() - DIRECT_CAPTURE_MAX_SAMPLES..] - } else { - framed - } -} - pub fn direct_capture_latency_fraction() -> String { format!("{DIRECT_CAPTURE_LATENCY_FRAMES}/{DIRECT_CAPTURE_SAMPLE_RATE}") } @@ -89,16 +67,6 @@ mod tests { assert_eq!(0, whole_frame_sample_count(7, 0)); } - #[test] - fn direct_read_count_is_bounded_and_stereo_aligned() { - assert_eq!(0, bounded_direct_read_sample_count(1)); - assert_eq!(2, bounded_direct_read_sample_count(3)); - assert_eq!( - DIRECT_CAPTURE_MAX_READ_SAMPLES, - bounded_direct_read_sample_count(DIRECT_CAPTURE_MAX_READ_SAMPLES + 1), - ); - } - #[test] fn direct_read_count_uses_stable_twenty_ms_chunks() { assert_eq!(1_920, DIRECT_CAPTURE_MAX_READ_SAMPLES); @@ -111,37 +79,4 @@ mod tests { bounded_direct_read_sample_count_for_format(9_600, 44_100, 2) ); } - - #[test] - fn sample_duration_conversion_uses_whole_audio_frames() { - assert_eq!(1_920, sample_count_for_duration_us(48_000, 2, 20_000)); - assert_eq!(20_000, duration_us_for_sample_count(1_920, 48_000, 2)); - assert_eq!(0, sample_count_for_duration_us(0, 2, 20_000)); - assert_eq!(0, duration_us_for_sample_count(1_920, 0, 2)); - } - - #[test] - fn direct_append_slice_keeps_only_complete_stereo_samples_within_queue_cap() { - let samples: [f32; 5] = [1.0, 2.0, 3.0, 4.0, 5.0]; - let trimmed = bounded_direct_append_slice(&samples); - assert_eq!(4, trimmed.len()); - assert_eq!(&samples[..4], trimmed); - } - - #[test] - fn direct_capture_latency_matches_stable_screen_share_buffer() { - assert_eq!("4096/48000", direct_capture_latency_fraction()); - } - - #[test] - fn routing_parser_caps_are_intentionally_finite() { - const { - assert!(MAX_ROUTING_RULE_PATTERNS > 0); - assert!(MAX_ROUTING_RULE_KEYS_PER_PATTERN > 0); - assert!(MAX_ROUTING_RULE_KEY_LENGTH > 0); - assert!(MAX_ROUTING_RULE_VALUE_LENGTH >= MAX_ROUTING_RULE_KEY_LENGTH); - assert!(MAX_INVENTORY_FIELDS <= MAX_ROUTING_RULE_KEYS_PER_PATTERN); - assert!(MAX_INVENTORY_FIELD_LENGTH <= MAX_ROUTING_RULE_KEY_LENGTH); - } - } } diff --git a/fluxer_desktop/native/linux-audio-capture/src/backend.rs b/fluxer_desktop/native/linux-audio-capture/src/backend.rs deleted file mode 100644 index 76111434d..000000000 --- a/fluxer_desktop/native/linux-audio-capture/src/backend.rs +++ /dev/null @@ -1,37 +0,0 @@ -#![allow(dead_code)] - -// SPDX-License-Identifier: AGPL-3.0-or-later - -use crate::routing::{PropMap, RoutingRule, SelfIdentity}; - -pub trait CaptureBridge: Send + Sync { - fn inventory(&self) -> Vec; - fn apply(&self, rule: RoutingRule) -> bool; - fn release(&self); - fn populate_self_identity(&self, identity: SelfIdentity); - fn backend_name(&self) -> &'static str; -} - -pub trait DirectCapture: Send + Sync { - fn start(&self, rule: RoutingRule) -> bool; - - fn set_rule(&self, rule: RoutingRule) -> bool; - fn read(&self) -> Option; - fn stop(&self); - fn populate_self_identity(&self, identity: SelfIdentity); - - fn set_screen_audio_sink( - &self, - _sink: std::sync::Arc, - ) { - } - - fn clear_screen_audio_sink(&self) {} -} - -pub struct CapturedFrame { - pub samples: Vec, - pub sample_rate: u32, - pub channels: u32, - pub timestamp_us: i64, -} diff --git a/fluxer_desktop/native/linux-audio-capture/src/direct_buffer.rs b/fluxer_desktop/native/linux-audio-capture/src/direct_buffer.rs index 8e134684c..696fb4945 100644 --- a/fluxer_desktop/native/linux-audio-capture/src/direct_buffer.rs +++ b/fluxer_desktop/native/linux-audio-capture/src/direct_buffer.rs @@ -7,7 +7,12 @@ use std::collections::VecDeque; use crate::audio_contract::{ self, DIRECT_CAPTURE_CHANNELS, DIRECT_CAPTURE_MAX_SAMPLES, DIRECT_CAPTURE_SAMPLE_RATE, }; -use crate::backend::CapturedFrame; +pub struct CapturedFrame { + pub samples: Vec, + pub sample_rate: u32, + pub channels: u32, + pub timestamp_us: i64, +} pub struct DirectReadMeta { pub sample_rate: u32, diff --git a/fluxer_desktop/native/linux-audio-capture/src/lib.rs b/fluxer_desktop/native/linux-audio-capture/src/lib.rs index dc49e5b59..dfa4d06bb 100644 --- a/fluxer_desktop/native/linux-audio-capture/src/lib.rs +++ b/fluxer_desktop/native/linux-audio-capture/src/lib.rs @@ -1,9 +1,9 @@ +#![cfg(target_os = "linux")] #![allow(clippy::too_many_lines)] // SPDX-License-Identifier: AGPL-3.0-or-later mod audio_contract; -mod backend; mod direct_buffer; #[cfg(target_os = "linux")] @@ -16,15 +16,12 @@ mod self_identity; #[cfg(all(test, target_os = "linux"))] mod test_alloc; -use std::ptr; use std::sync::Arc; use std::sync::Mutex; -use fluxer_screen_frame_bus::{NativeScreenFrameSinkHandle, NativeScreenFrameSinkHandleRef}; use napi::Env; -use napi::JsValue; use napi::Status; -use napi::bindgen_prelude::{ArrayBuffer, Error, Function, Object, Result, Unknown, ValueType}; +use napi::bindgen_prelude::{ArrayBuffer, Error, Function, Object, Result, Unknown}; use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; use napi_derive::napi; @@ -32,7 +29,6 @@ use crate::audio_contract::{ MAX_INVENTORY_FIELD_LENGTH, MAX_INVENTORY_FIELDS, MAX_ROUTING_RULE_KEY_LENGTH, MAX_ROUTING_RULE_KEYS_PER_PATTERN, MAX_ROUTING_RULE_PATTERNS, MAX_ROUTING_RULE_VALUE_LENGTH, }; -use crate::backend::{CaptureBridge as CaptureBridgeTrait, DirectCapture as DirectCaptureTrait}; use crate::routing::{PropMap, PropPattern, RoutingRule, SelfIdentity}; type LifecycleTsfn = @@ -45,50 +41,29 @@ fn make_self_identity() -> SelfIdentity { id } -#[cfg(not(target_os = "linux"))] -#[allow(dead_code)] -fn make_self_identity() -> SelfIdentity { - SelfIdentity::default() -} - #[cfg(target_os = "linux")] -fn open_capture_backend() -> Option<(Box, &'static str)> { +fn open_capture_backend() -> Option<(pipewire_bridge::PipeWireBridge, &'static str)> { if let Some(bridge) = pipewire_bridge::PipeWireBridge::open() { bridge.populate_self_identity(make_self_identity()); - return Some((Box::new(bridge), "pipewire")); + return Some((bridge, "pipewire")); } None } -#[cfg(not(target_os = "linux"))] -fn open_capture_backend() -> Option<(Box, &'static str)> { - None -} - #[cfg(target_os = "linux")] -fn open_direct_backend() -> Option> { +fn open_direct_backend() -> Option { if let Some(direct) = pipewire_bridge::PipeWireDirectCapture::open() { direct.populate_self_identity(make_self_identity()); - return Some(Box::new(direct)); + return Some(direct); } None } -#[cfg(not(target_os = "linux"))] -fn open_direct_backend() -> Option> { - None -} - #[cfg(target_os = "linux")] fn pipewire_reachable() -> bool { pipewire_bridge::daemon_reachable() } -#[cfg(not(target_os = "linux"))] -fn pipewire_reachable() -> bool { - false -} - #[napi(js_name = "pipeWireAvailable")] pub fn pipe_wire_available() -> bool { pipewire_reachable() @@ -105,7 +80,7 @@ pub fn audio_backend() -> &'static str { #[napi] pub struct AudioBridge { - backend: Mutex>>, + backend: Mutex>, name: &'static str, } @@ -176,34 +151,9 @@ impl Default for AudioBridge { } } -fn retain_screen_audio_sink_handle( - value: Unknown<'_>, -) -> Result> { - if value.get_type()? != ValueType::External { - return Err(generic_error( - "DirectAudioCapture.setScreenAudioSink expects a native external sink handle", - )); - } - let raw_value = value.value(); - let mut data: *mut std::ffi::c_void = ptr::null_mut(); - let status = - unsafe { napi::sys::napi_get_value_external(raw_value.env, raw_value.value, &mut data) }; - if status != napi::sys::Status::napi_ok || data.is_null() { - return Err(generic_error( - "DirectAudioCapture.setScreenAudioSink received an empty native external sink handle", - )); - } - let handle = unsafe { data.cast::().as_ref() } - .and_then(NativeScreenFrameSinkHandle::retain_ref) - .ok_or_else(|| { - generic_error("DirectAudioCapture.setScreenAudioSink received an invalid handle") - })?; - Ok(Arc::new(handle)) -} - #[napi] pub struct DirectAudioCapture { - backend: Mutex>>, + backend: Mutex>, lifecycle_tsfn: Mutex>, } @@ -274,36 +224,6 @@ impl DirectAudioCapture { })) } - #[napi(js_name = "setScreenAudioSink")] - pub fn set_screen_audio_sink(&self, sink_handle: Unknown<'_>) -> Result<()> { - let sink = retain_screen_audio_sink_handle(sink_handle)?; - if !sink.supports_screen_audio() { - return Err(generic_error( - "DirectAudioCapture.setScreenAudioSink handle does not support screen audio", - )); - } - let guard = self - .backend - .lock() - .map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?; - if let Some(b) = guard.as_ref() { - b.set_screen_audio_sink(sink); - } - Ok(()) - } - - #[napi(js_name = "clearScreenAudioSink")] - pub fn clear_screen_audio_sink(&self) -> Result<()> { - let guard = self - .backend - .lock() - .map_err(|_| generic_error("DirectAudioCapture backend poisoned"))?; - if let Some(b) = guard.as_ref() { - b.clear_screen_audio_sink(); - } - Ok(()) - } - #[napi] pub fn stop(&self) -> Result<()> { let guard = self @@ -496,6 +416,3 @@ fn generic_error(reason: impl Into) -> Error { fn invalid_arg(reason: impl Into) -> Error { Error::new(Status::InvalidArg, reason.into()) } - -#[allow(dead_code)] -fn _keep_arc_in_scope(_: Arc<()>) {} diff --git a/fluxer_desktop/native/linux-audio-capture/src/pipewire/common.rs b/fluxer_desktop/native/linux-audio-capture/src/pipewire/common.rs index 21e2b75af..dd589b6c2 100644 --- a/fluxer_desktop/native/linux-audio-capture/src/pipewire/common.rs +++ b/fluxer_desktop/native/linux-audio-capture/src/pipewire/common.rs @@ -169,15 +169,6 @@ pub(crate) fn is_routable_media_class(class: &str) -> bool { ) } -#[cfg(test)] -pub(crate) fn build_virtual_sink_props() -> PropertiesBox { - build_virtual_sink_props_for( - SINK_NODE_NAME, - SINK_NODE_DESCRIPTION, - VirtualSinkKind::LegacyVirtualSource, - ) -} - pub(crate) fn build_virtual_sink_props_for( node_name: &str, description: &str, diff --git a/fluxer_desktop/native/linux-audio-capture/src/pipewire/event_loop.rs b/fluxer_desktop/native/linux-audio-capture/src/pipewire/event_loop.rs index 6152e8aa4..1a673cbe1 100644 --- a/fluxer_desktop/native/linux-audio-capture/src/pipewire/event_loop.rs +++ b/fluxer_desktop/native/linux-audio-capture/src/pipewire/event_loop.rs @@ -2,7 +2,7 @@ #![allow(dead_code)] -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use pipewire as pw; @@ -28,9 +28,7 @@ use super::routing::{ DirectRoutingState, RoutingState, clear_pinned_capture_targets, recompute_direct_links, recompute_routing, refresh_direct_sink_input_ports, }; -use super::stream_ops::{ - BuildDirectStreamArgs, DirectStreamRuntime, ScreenAudioSinkSlot, build_direct_stream, -}; +use super::stream_ops::{BuildDirectStreamArgs, DirectStreamRuntime, build_direct_stream}; pub(crate) enum BridgeCommand { Apply(RoutingRule), @@ -271,9 +269,7 @@ fn ensure_or_promote_direct_stream(core: &pw::core::CoreRc, runtime: &DirectStre samples: runtime.samples.clone(), target_sink_name: &runtime.sink_node_name, stream_node_name: &runtime.stream_node_name, - last_push_ns: runtime.last_push_ns.clone(), clock: runtime.clock.clone(), - screen_audio_sink: runtime.screen_audio_sink.clone(), }; match build_direct_stream(args) { Ok((stream, listener)) => { @@ -293,9 +289,7 @@ pub(crate) struct DirectWorkerInputs { pub(crate) owned_link_snapshot: Arc>>, pub(crate) running: Arc, pub(crate) sink_node_name: String, - pub(crate) last_push_ns: Arc, pub(crate) clock: Arc, - pub(crate) screen_audio_sink: ScreenAudioSinkSlot, } fn build_direct_runtime(inputs: &DirectWorkerInputs) -> std::rc::Rc { @@ -310,9 +304,7 @@ fn build_direct_runtime(inputs: &DirectWorkerInputs) -> std::rc::Rc>>>; - -pub const DIRECT_CAPTURE_APM_FRAME_SAMPLES: usize = - (DIRECT_CAPTURE_SAMPLE_RATE as usize) / 100 * (DIRECT_CAPTURE_CHANNELS as usize); - -const _: () = assert!(DIRECT_CAPTURE_APM_FRAME_SAMPLES <= APM_MAX_FRAME_SAMPLES * 2); -const _: () = assert!(MAX_FRAME_SAMPLES >= DIRECT_CAPTURE_APM_FRAME_SAMPLES); - -pub struct DirectCaptureApm { - processor: Box, - accum_f32: Box<[f32; DIRECT_CAPTURE_APM_FRAME_SAMPLES]>, - accum_len: usize, - scratch_i16: Box<[i16; DIRECT_CAPTURE_APM_FRAME_SAMPLES]>, - expected_sample_rate_hz: u32, - expected_channels: u16, - processed_samples: u64, - apm_frames_processed: u64, -} - -impl DirectCaptureApm { - pub fn new(sample_rate_hz: u32, channels: u16) -> Result { - assert!(sample_rate_hz >= 8_000); - assert!(channels >= 1); - let processor = StubAudioProcessor::new(ApmConfig::default(), sample_rate_hz, channels)?; - let expected = expected_frame_samples(sample_rate_hz, channels); - assert!(expected > 0); - assert!(expected <= DIRECT_CAPTURE_APM_FRAME_SAMPLES); - Ok(Self { - processor: Box::new(processor), - accum_f32: Box::new([0.0; DIRECT_CAPTURE_APM_FRAME_SAMPLES]), - accum_len: 0, - scratch_i16: Box::new([0i16; DIRECT_CAPTURE_APM_FRAME_SAMPLES]), - expected_sample_rate_hz: sample_rate_hz, - expected_channels: channels, - processed_samples: 0, - apm_frames_processed: 0, - }) - } - - pub fn reconfigure(&mut self, sample_rate_hz: u32, channels: u16) -> Result<(), ApmError> { - assert!(sample_rate_hz >= 8_000); - assert!(channels >= 1); - if self.expected_sample_rate_hz == sample_rate_hz && self.expected_channels == channels { - return Ok(()); - } - let processor = StubAudioProcessor::new(ApmConfig::default(), sample_rate_hz, channels)?; - let expected = expected_frame_samples(sample_rate_hz, channels); - if expected == 0 || expected > DIRECT_CAPTURE_APM_FRAME_SAMPLES { - return Err(ApmError::ChannelsOutOfRange { channels }); - } - self.processor = Box::new(processor); - self.expected_sample_rate_hz = sample_rate_hz; - self.expected_channels = channels; - self.accum_len = 0; - Ok(()) - } - - pub fn process_in_place(&mut self, samples: &mut [f32]) -> Result { - assert!(!samples.is_empty()); - assert!(self.expected_channels >= 1); - let apm_frame_len = - expected_frame_samples(self.expected_sample_rate_hz, self.expected_channels); - assert!(apm_frame_len > 0); - assert!(apm_frame_len <= self.scratch_i16.len()); - let mut processed_complete: usize = 0; - let mut idx: usize = 0; - let total = samples.len(); - while idx < total { - let want = apm_frame_len - self.accum_len; - let take = want.min(total - idx); - for offset in 0..take { - self.accum_f32[self.accum_len + offset] = samples[idx + offset]; - } - self.accum_len += take; - idx += take; - if self.accum_len == apm_frame_len { - self.run_apm_one_frame(apm_frame_len)?; - if idx >= apm_frame_len { - let dst_lo = idx - apm_frame_len; - for offset in 0..apm_frame_len { - samples[dst_lo + offset] = self.accum_f32[offset]; - } - processed_complete += apm_frame_len; - } else { - processed_complete += take; - } - self.accum_len = 0; - self.apm_frames_processed = self.apm_frames_processed.saturating_add(1); - } - } - self.processed_samples = self.processed_samples.saturating_add(total as u64); - Ok(processed_complete) - } - - fn run_apm_one_frame(&mut self, apm_frame_len: usize) -> Result<(), ApmError> { - assert!(apm_frame_len <= self.scratch_i16.len()); - assert!(apm_frame_len <= self.accum_f32.len()); - for offset in 0..apm_frame_len { - self.scratch_i16[offset] = f32_sample_to_i16(self.accum_f32[offset]); - } - let result = self.processor.process_capture_frame( - &mut self.scratch_i16[..apm_frame_len], - self.expected_sample_rate_hz, - self.expected_channels, - ); - result?; - for offset in 0..apm_frame_len { - self.accum_f32[offset] = i16_sample_to_f32(self.scratch_i16[offset]); - } - Ok(()) - } - - pub fn apm_frames_processed(&self) -> u64 { - self.apm_frames_processed - } - - pub fn processed_samples(&self) -> u64 { - self.processed_samples - } - - pub fn pending_accumulator_len(&self) -> usize { - self.accum_len - } -} - -pub(crate) fn f32_sample_to_i16(value: f32) -> i16 { - let scaled = value * (i16::MAX as f32); - if scaled >= (i16::MAX as f32) { - return i16::MAX; - } - if scaled <= (i16::MIN as f32) { - return i16::MIN; - } - scaled as i16 -} - -pub(crate) fn i16_sample_to_f32(value: i16) -> f32 { - (value as f32) / (i16::MAX as f32) -} - pub(crate) struct DirectUserData { pub(crate) samples: Arc>, pub(crate) format: spa::param::audio::AudioInfoRaw, - pub(crate) apm: Mutex, pub(crate) scratch: Mutex>, - pub(crate) last_push_ns: Arc, pub(crate) clock: Arc, - pub(crate) screen_audio_sink: ScreenAudioSinkSlot, } pub(crate) struct DirectStreamRuntime { @@ -189,9 +41,7 @@ pub(crate) struct DirectStreamRuntime { pub(crate) running: Arc, pub(crate) sink_node_name: String, pub(crate) stream_node_name: String, - pub(crate) last_push_ns: Arc, pub(crate) clock: Arc, - pub(crate) screen_audio_sink: ScreenAudioSinkSlot, } pub(crate) fn direct_chunk_payload_range( @@ -268,10 +118,6 @@ fn handle_param_changed(user_data: &mut DirectUserData, id: u32, param: Option<& if let Ok(mut guard) = user_data.samples.lock() { guard.set_format(rate, channels); } - if let Ok(mut apm_guard) = user_data.apm.lock() { - let channels_u16 = channels.min(u16::MAX as u32) as u16; - let _ = apm_guard.reconfigure(rate, channels_u16.max(1)); - } } fn decode_f32_into_scratch(raw_payload: &[u8], byte_offset: usize, scratch: &mut [f32]) -> usize { @@ -313,26 +159,7 @@ pub(crate) fn process_audio_chunk(user_data: &mut DirectUserData, payload: &[u8] if aligned == 0 { break; } - if let Ok(mut apm_guard) = user_data.apm.lock() { - let _ = apm_guard.process_in_place(&mut scratch_guard[..aligned]); - } - if now_ns > 0 { - user_data.last_push_ns.store(now_ns, Ordering::Release); - } - if let Ok(guard) = user_data.screen_audio_sink.read() - && let Some(sink) = guard.as_ref() - { - let frames = aligned as u32 / channels; - if frames > 0 { - sink.enqueue_screen_audio_f32( - &scratch_guard[..aligned], - frames, - channels, - user_data.format.rate(), - (now_ns / 1_000) as i64, - ); - } - } else if let Ok(mut samples_guard) = user_data.samples.lock() { + if let Ok(mut samples_guard) = user_data.samples.lock() { let now_us = (now_ns / 1_000) as i64; samples_guard.push(&scratch_guard[..aligned], now_us); } @@ -363,9 +190,7 @@ pub(crate) struct BuildDirectStreamArgs<'a> { pub(crate) samples: Arc>, pub(crate) target_sink_name: &'a str, pub(crate) stream_node_name: &'a str, - pub(crate) last_push_ns: Arc, pub(crate) clock: Arc, - pub(crate) screen_audio_sink: ScreenAudioSinkSlot, } pub(crate) fn build_direct_stream( @@ -378,16 +203,11 @@ pub(crate) fn build_direct_stream( pw::Error, > { let props = build_direct_stream_props(args.target_sink_name, args.stream_node_name); - let apm = DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16) - .map_err(|_| pw::Error::CreationFailed)?; let data = DirectUserData { samples: args.samples, format: spa::param::audio::AudioInfoRaw::new(), - apm: Mutex::new(apm), scratch: Mutex::new(Box::new([0.0_f32; MAX_FRAME_SAMPLES])), - last_push_ns: args.last_push_ns, clock: args.clock, - screen_audio_sink: args.screen_audio_sink, }; let stream = pw::stream::StreamRc::new(args.core.clone(), "fluxer-direct-capture", props)?; let listener = stream @@ -427,22 +247,14 @@ pub(crate) fn build_direct_stream( } #[cfg(test)] -pub(crate) fn build_test_user_data( - last_push_ns: Arc, - clock: Arc, -) -> DirectUserData { - let apm = DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16) - .expect("apm"); +pub(crate) fn build_test_user_data(clock: Arc) -> DirectUserData { let mut format = spa::param::audio::AudioInfoRaw::new(); format.set_rate(DIRECT_CAPTURE_SAMPLE_RATE); format.set_channels(DIRECT_CAPTURE_CHANNELS); DirectUserData { samples: Arc::new(Mutex::new(DirectAudioBuffer::default_format())), format, - apm: Mutex::new(apm), scratch: Mutex::new(Box::new([0.0_f32; MAX_FRAME_SAMPLES])), - last_push_ns, clock, - screen_audio_sink: Arc::new(RwLock::new(None)), } } diff --git a/fluxer_desktop/native/linux-audio-capture/src/pipewire_bridge.rs b/fluxer_desktop/native/linux-audio-capture/src/pipewire_bridge.rs index a20222055..76c3584aa 100644 --- a/fluxer_desktop/native/linux-audio-capture/src/pipewire_bridge.rs +++ b/fluxer_desktop/native/linux-audio-capture/src/pipewire_bridge.rs @@ -1,18 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use std::sync::atomic::{AtomicBool, AtomicU64}; -use std::sync::{Arc, Mutex, RwLock}; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; use std::time::Duration; -use fluxer_screen_frame_bus::NativeScreenFrameSinkHandleRef; use pipewire as pw; use pw::channel::{Sender as PwSender, channel as pw_channel}; use fluxer_rt_thread::{MonotonicClock, SystemMonotonicClock}; use crate::audio_contract::DIRECT_CAPTURE_MAX_READ_SAMPLES; -use crate::backend::{CaptureBridge, CapturedFrame, DirectCapture}; +use crate::direct_buffer::CapturedFrame; use crate::direct_buffer::DirectAudioBuffer; use crate::pipewire::common::{ InventorySnapshot, READY_TIMEOUT_MS, daemon_reachable as common_daemon_reachable, @@ -21,7 +20,6 @@ use crate::pipewire::common::{ use crate::pipewire::event_loop::{ BridgeCommand, DirectCommand, DirectWorkerInputs, run_bridge_worker, run_direct_worker, }; -use crate::pipewire::stream_ops::ScreenAudioSinkSlot; use crate::routing::{PropMap, RoutingRule, SelfIdentity}; pub fn daemon_reachable() -> bool { @@ -76,29 +74,25 @@ impl Drop for PipeWireBridge { } } -impl CaptureBridge for PipeWireBridge { - fn inventory(&self) -> Vec { +impl PipeWireBridge { + pub fn inventory(&self) -> Vec { match self.snapshot.lock() { Ok(guard) => guard.enriched_node_values(), Err(_) => Vec::new(), } } - fn apply(&self, rule: RoutingRule) -> bool { + pub fn apply(&self, rule: RoutingRule) -> bool { self.tx.send(BridgeCommand::Apply(rule)).is_ok() } - fn release(&self) { + pub fn release(&self) { let _ = self.tx.send(BridgeCommand::Release); } - fn populate_self_identity(&self, identity: SelfIdentity) { + pub fn populate_self_identity(&self, identity: SelfIdentity) { let _ = self.tx.send(BridgeCommand::SetIdentity(identity)); } - - fn backend_name(&self) -> &'static str { - "pipewire" - } } pub struct PipeWireDirectCapture { @@ -108,9 +102,6 @@ pub struct PipeWireDirectCapture { running: Arc, thread: Mutex>>, identity: Mutex, - #[allow(dead_code)] - last_push_ns: Arc, - screen_audio_sink: ScreenAudioSinkSlot, } impl PipeWireDirectCapture { @@ -126,8 +117,6 @@ impl PipeWireDirectCapture { let inventory = Arc::new(Mutex::new(InventorySnapshot::default())); let owned_link_snapshot = Arc::new(Mutex::new(Vec::new())); let running = Arc::new(AtomicBool::new(false)); - let last_push_ns = Arc::new(AtomicU64::new(u64::MAX)); - let screen_audio_sink: ScreenAudioSinkSlot = Arc::new(RwLock::new(None)); let (tx, rx) = pw_channel::(); let sink_node_name = next_direct_sink_name(); let inputs = DirectWorkerInputs { @@ -136,9 +125,7 @@ impl PipeWireDirectCapture { owned_link_snapshot, running: running.clone(), sink_node_name, - last_push_ns: last_push_ns.clone(), clock, - screen_audio_sink: screen_audio_sink.clone(), }; let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::(1); let handle = thread::Builder::new() @@ -154,8 +141,6 @@ impl PipeWireDirectCapture { running, thread: Mutex::new(Some(handle)), identity: Mutex::new(SelfIdentity::default()), - last_push_ns, - screen_audio_sink, }), _ => { let _ = tx.send(DirectCommand::Shutdown); @@ -164,11 +149,6 @@ impl PipeWireDirectCapture { } } } - - #[allow(dead_code)] - pub fn last_push_ns(&self) -> u64 { - self.last_push_ns.load(std::sync::atomic::Ordering::Acquire) - } } impl Drop for PipeWireDirectCapture { @@ -182,8 +162,8 @@ impl Drop for PipeWireDirectCapture { } } -impl DirectCapture for PipeWireDirectCapture { - fn start(&self, rule: RoutingRule) -> bool { +impl PipeWireDirectCapture { + pub fn start(&self, rule: RoutingRule) -> bool { let Ok(identity) = self.identity.lock().map(|guard| guard.clone()) else { return false; }; @@ -195,11 +175,11 @@ impl DirectCapture for PipeWireDirectCapture { .is_ok() } - fn set_rule(&self, rule: RoutingRule) -> bool { + pub fn set_rule(&self, rule: RoutingRule) -> bool { self.tx.send(DirectCommand::UpdateRule { rule }).is_ok() } - fn read(&self) -> Option { + pub fn read(&self) -> Option { let mut out = Vec::with_capacity(DIRECT_CAPTURE_MAX_READ_SAMPLES); let meta = { let Ok(mut guard) = self.samples.lock() else { @@ -215,23 +195,11 @@ impl DirectCapture for PipeWireDirectCapture { }) } - fn stop(&self) { + pub fn stop(&self) { let _ = self.tx.send(DirectCommand::Stop); } - fn set_screen_audio_sink(&self, sink: Arc) { - if let Ok(mut guard) = self.screen_audio_sink.write() { - *guard = Some(sink); - } - } - - fn clear_screen_audio_sink(&self) { - if let Ok(mut guard) = self.screen_audio_sink.write() { - *guard = None; - } - } - - fn populate_self_identity(&self, identity: SelfIdentity) { + pub fn populate_self_identity(&self, identity: SelfIdentity) { if let Ok(mut guard) = self.identity.lock() { *guard = identity; } @@ -242,66 +210,13 @@ impl DirectCapture for PipeWireDirectCapture { mod tests { use super::*; use crate::pipewire::common::{ - DIRECT_SINK_PREFIX, MEDIA_CLASS_CAPTURE_STREAM, PortRecord, SINK_NODE_DESCRIPTION, - SINK_NODE_NAME, VirtualSinkKind, build_link_props, build_virtual_sink_props, - build_virtual_sink_props_for, is_routable_media_class, pick_node_ports, - pick_source_output_ports, + MEDIA_CLASS_CAPTURE_STREAM, PortRecord, pick_node_ports, pick_source_output_ports, }; use crate::pipewire::routing::{default_sink_target_id, matching_pinned_capture_nodes}; - use crate::pipewire::stream_ops::{ - DIRECT_CAPTURE_APM_FRAME_SAMPLES, DirectCaptureApm, build_direct_audio_info, - build_direct_stream_props, direct_chunk_payload_range, f32_sample_to_i16, - i16_sample_to_f32, - }; - use crate::routing::MEDIA_CLASS_PLAYBACK_STREAM; + use crate::pipewire::stream_ops::{build_direct_stream_props, direct_chunk_payload_range}; + use crate::routing::{MEDIA_CLASS_PLAYBACK_STREAM, should_route_node}; use std::collections::{HashMap, HashSet}; - use crate::audio_contract::{DIRECT_CAPTURE_CHANNELS, DIRECT_CAPTURE_SAMPLE_RATE}; - use crate::routing::should_route_node; - use pipewire::spa::sys as spa_sys; - - #[test] - fn is_routable_media_class_matches_audio_node_classes() { - assert!(is_routable_media_class(MEDIA_CLASS_PLAYBACK_STREAM)); - assert!(is_routable_media_class(MEDIA_CLASS_CAPTURE_STREAM)); - assert!(is_routable_media_class("Audio/Source")); - assert!(is_routable_media_class("Audio/Sink")); - assert!(!is_routable_media_class("Video/Source")); - assert!(!is_routable_media_class("")); - } - - #[test] - fn virtual_sink_props_match_legacy_contract() { - let props = build_virtual_sink_props(); - let dict = props.dict(); - assert_eq!(dict.get("factory.name"), Some("support.null-audio-sink")); - assert_eq!(dict.get("node.name"), Some(SINK_NODE_NAME)); - assert_eq!(dict.get("node.nick"), Some(SINK_NODE_NAME)); - assert_eq!(dict.get("node.description"), Some(SINK_NODE_DESCRIPTION)); - assert_eq!(dict.get("media.class"), Some("Audio/Source/Virtual")); - assert_eq!(dict.get("node.virtual"), Some("true")); - assert_eq!(dict.get("node.passive"), Some("true")); - assert_eq!(dict.get("node.dont-move"), Some("true")); - assert_eq!(dict.get("node.dont-reconnect"), Some("true")); - assert_eq!(dict.get("node.latency"), Some("4096/48000")); - assert_eq!(dict.get("audio.rate"), Some("48000")); - assert_eq!(dict.get("audio.channels"), Some("2")); - assert_eq!(dict.get("audio.position"), Some("[FL,FR]")); - assert_eq!(dict.get("monitor.channel-volumes"), Some("true")); - } - - #[test] - fn link_props_include_per_port_routing() { - let props = build_link_props(101, 7, 202, 13); - let dict = props.dict(); - assert_eq!(dict.get("link.output.node"), Some("101")); - assert_eq!(dict.get("link.output.port"), Some("7")); - assert_eq!(dict.get("link.input.node"), Some("202")); - assert_eq!(dict.get("link.input.port"), Some("13")); - assert_eq!(dict.get("object.linger"), Some("false")); - assert_eq!(dict.get("link.passive"), Some("true")); - } - #[test] fn direct_stream_props_capture_private_sink_monitor() { let props = build_direct_stream_props( @@ -344,28 +259,6 @@ mod tests { assert_eq!(direct_chunk_payload_range(10, 8, 2), None); } - #[test] - fn direct_audio_info_advertises_stereo_fl_fr() { - let info = build_direct_audio_info(); - assert_eq!( - info.format(), - pipewire::spa::param::audio::AudioFormat::F32LE - ); - assert_eq!(info.rate(), DIRECT_CAPTURE_SAMPLE_RATE); - assert_eq!(info.channels(), DIRECT_CAPTURE_CHANNELS); - assert_eq!(info.position()[0], spa_sys::SPA_AUDIO_CHANNEL_FL); - assert_eq!(info.position()[1], spa_sys::SPA_AUDIO_CHANNEL_FR); - } - - #[test] - fn next_direct_sink_name_is_unique_per_call() { - let a = next_direct_sink_name(); - let b = next_direct_sink_name(); - assert_ne!(a, b, "concurrent captures must not collide on sink names"); - assert!(a.starts_with(DIRECT_SINK_PREFIX)); - assert!(b.starts_with(DIRECT_SINK_PREFIX)); - } - #[test] fn default_sink_target_id_uses_object_serial_for_matching_node_name() { let nodes = HashMap::from([ @@ -453,27 +346,6 @@ mod tests { ); } - #[test] - fn private_sink_props_advertise_hidden_audio_sink() { - let private = build_virtual_sink_props_for( - "fluxer-direct-capture-7-1", - crate::pipewire::common::DIRECT_SINK_DESCRIPTION, - VirtualSinkKind::PrivateAudioSink, - ); - let legacy = build_virtual_sink_props_for( - SINK_NODE_NAME, - SINK_NODE_DESCRIPTION, - VirtualSinkKind::LegacyVirtualSource, - ); - assert_eq!(private.dict().get("media.class"), Some("Audio/Sink")); - assert_eq!(private.dict().get("node.hidden"), Some("true")); - assert_eq!( - legacy.dict().get("media.class"), - Some("Audio/Source/Virtual") - ); - assert_eq!(legacy.dict().get("node.hidden"), None); - } - #[test] fn inventory_enriches_nodes_with_owning_client_identity() { let mut inventory = InventorySnapshot::default(); @@ -669,111 +541,6 @@ mod tests { assert!(pick_source_output_ports(42, &ports).is_none()); } - #[test] - fn direct_capture_apm_processes_one_full_frame_increments_counter() { - let mut apm = - DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16) - .expect("apm"); - assert_eq!(apm.apm_frames_processed(), 0); - let frame_len = DIRECT_CAPTURE_APM_FRAME_SAMPLES; - let mut samples = vec![0.5_f32; frame_len]; - let processed = apm.process_in_place(&mut samples).expect("process"); - assert_eq!(processed, frame_len); - assert_eq!(apm.apm_frames_processed(), 1); - assert_eq!(apm.pending_accumulator_len(), 0); - } - - #[test] - fn direct_capture_apm_accumulates_partial_frames_across_calls() { - let mut apm = - DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16) - .expect("apm"); - let half = DIRECT_CAPTURE_APM_FRAME_SAMPLES / 2; - let mut first = vec![0.1_f32; half]; - let processed1 = apm.process_in_place(&mut first).expect("first"); - assert_eq!(processed1, 0); - assert_eq!(apm.apm_frames_processed(), 0); - assert_eq!(apm.pending_accumulator_len(), half); - let mut second = vec![0.2_f32; half]; - let processed2 = apm.process_in_place(&mut second).expect("second"); - assert_eq!(processed2, half); - assert_eq!(apm.apm_frames_processed(), 1); - assert_eq!(apm.pending_accumulator_len(), 0); - } - - #[test] - fn direct_capture_apm_handles_many_frames_in_one_call() { - let mut apm = - DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16) - .expect("apm"); - let frame_len = DIRECT_CAPTURE_APM_FRAME_SAMPLES; - let mut samples = vec![0.25_f32; frame_len * 5]; - let processed = apm.process_in_place(&mut samples).expect("process"); - assert_eq!(processed, frame_len * 5); - assert_eq!(apm.apm_frames_processed(), 5); - assert_eq!(apm.pending_accumulator_len(), 0); - } - - #[test] - fn direct_capture_apm_stub_preserves_samples_within_tolerance() { - let mut apm = - DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16) - .expect("apm"); - let frame_len = DIRECT_CAPTURE_APM_FRAME_SAMPLES; - let mut samples = vec![0.0_f32; frame_len]; - for (n, sample) in samples.iter_mut().enumerate() { - *sample = ((n as f32) / (frame_len as f32) - 0.5) * 0.5; - } - let original = samples.clone(); - let _ = apm.process_in_place(&mut samples).expect("process"); - for (after, before) in samples.iter().zip(original.iter()) { - let diff = (after - before).abs(); - assert!(diff < 1e-3); - } - } - - #[test] - fn direct_capture_apm_reconfigure_is_noop_when_format_unchanged() { - let mut apm = - DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16) - .expect("apm"); - let half = DIRECT_CAPTURE_APM_FRAME_SAMPLES / 2; - let mut samples = vec![0.3_f32; half]; - let _ = apm.process_in_place(&mut samples).expect("first"); - assert_eq!(apm.pending_accumulator_len(), half); - apm.reconfigure(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16) - .expect("noop"); - assert_eq!(apm.pending_accumulator_len(), half); - } - - #[test] - fn direct_capture_apm_reconfigure_changes_format_and_resets_accum() { - let mut apm = - DirectCaptureApm::new(DIRECT_CAPTURE_SAMPLE_RATE, DIRECT_CAPTURE_CHANNELS as u16) - .expect("apm"); - let half = DIRECT_CAPTURE_APM_FRAME_SAMPLES / 2; - let mut samples = vec![0.3_f32; half]; - let _ = apm.process_in_place(&mut samples).expect("first"); - assert!(apm.pending_accumulator_len() > 0); - apm.reconfigure(16_000, 1).expect("reconfigure"); - assert_eq!(apm.pending_accumulator_len(), 0); - } - - #[test] - fn f32_to_i16_clamps_above_one() { - assert_eq!(f32_sample_to_i16(2.0), i16::MAX); - assert_eq!(f32_sample_to_i16(-2.0), i16::MIN); - assert_eq!(f32_sample_to_i16(0.0), 0); - } - - #[test] - fn i16_to_f32_round_trip_is_bounded() { - for sample in [-32_768_i16, -1, 0, 1, 32_767] { - let f = i16_sample_to_f32(sample); - assert!((-1.001..=1.001).contains(&f)); - } - } - use crate::pipewire::stream_ops::{build_test_user_data, process_audio_chunk}; use fluxer_rt_thread::MonotonicClock; use std::sync::atomic::{AtomicU64, Ordering}; @@ -811,12 +578,8 @@ mod tests { #[test] fn callback_path_does_not_allocate_in_steady_state() { let clock = Arc::new(FakeClock::new(1_000)); - let last_push_ns = Arc::new(AtomicU64::new(u64::MAX)); - let mut data = build_test_user_data( - last_push_ns.clone(), - Arc::clone(&clock) as Arc, - ); - let frame = vec![0.05_f32; DIRECT_CAPTURE_APM_FRAME_SAMPLES]; + let mut data = build_test_user_data(Arc::clone(&clock) as Arc); + let frame = vec![0.05_f32; 960]; let payload = make_f32_payload(&frame); for _ in 0..400 { clock.set(clock.now_ns() + 10_000_000); diff --git a/fluxer_desktop/native/linux-portals/src/background.rs b/fluxer_desktop/native/linux-portals/src/background.rs index 51efa2109..b8bab833e 100644 --- a/fluxer_desktop/native/linux-portals/src/background.rs +++ b/fluxer_desktop/native/linux-portals/src/background.rs @@ -182,25 +182,3 @@ async fn send_call( pub fn request_background(_options: RequestOptions) -> Result { Err(BackgroundError::DbusError) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_request_does_not_autostart() { - let opts = RequestOptions::default(); - assert!(!opts.autostart); - assert!(opts.commandline.is_empty()); - } - - #[test] - fn nonzero_response_is_cancelled() { - let result = RequestResult { - response: 1, - background: false, - autostart: false, - }; - assert!(result.cancelled()); - } -} diff --git a/fluxer_desktop/native/linux-portals/src/global_shortcuts.rs b/fluxer_desktop/native/linux-portals/src/global_shortcuts.rs index 909135d81..5ed778bfb 100644 --- a/fluxer_desktop/native/linux-portals/src/global_shortcuts.rs +++ b/fluxer_desktop/native/linux-portals/src/global_shortcuts.rs @@ -1720,43 +1720,6 @@ mod tests { assert_eq!(listed_outcome(listed.clone()), BindOutcome::Bound(listed)); } - #[test] - fn binding_drops_empty_strings() { - let parsed = ShortcutBinding::new("a".into(), Some(String::new()), Some(String::new())); - assert_eq!(parsed.description, None); - assert_eq!(parsed.trigger_description, None); - let kept = ShortcutBinding::new("a".into(), Some("A".into()), Some("F13".into())); - assert_eq!(kept.description.as_deref(), Some("A")); - assert_eq!(kept.trigger_description.as_deref(), Some("F13")); - } - - #[test] - fn definitions_must_be_non_empty_unique_and_described() { - assert_eq!(validate_definitions(&[]), Err(DefinitionError::Empty)); - assert_eq!( - validate_definitions(&[definition("", "Mute")]), - Err(DefinitionError::EmptyId) - ); - assert_eq!( - validate_definitions(&[definition("voice_toggle_mute", " ")]), - Err(DefinitionError::EmptyDescription) - ); - assert_eq!( - validate_definitions(&[ - definition("voice_toggle_mute", "Mute"), - definition("voice_toggle_mute", "Mute again"), - ]), - Err(DefinitionError::DuplicateId) - ); - assert_eq!( - validate_definitions(&[ - definition("voice_toggle_mute", "Mute"), - definition("voice_push_to_talk", "Push to talk"), - ]), - Ok(()) - ); - } - #[test] fn tokens_only_allow_portal_safe_characters() { assert!(is_valid_token("fluxer_global_shortcuts")); @@ -1850,14 +1813,6 @@ mod tests { ); } - #[test] - fn listed_table_accepts_the_listed_ids() { - let table = ShortcutTable::from_listed(&[binding("voice_push_to_talk", Some("F13"))]); - assert!(table.contains("voice_push_to_talk")); - assert!(!table.contains("voice_toggle_mute")); - assert!(ShortcutTable::default().bindings().is_empty()); - } - #[test] fn register_errors_are_classified() { for name in [ diff --git a/fluxer_desktop/native/linux-portals/src/lib.rs b/fluxer_desktop/native/linux-portals/src/lib.rs index b0a2a4270..18ad65b31 100644 --- a/fluxer_desktop/native/linux-portals/src/lib.rs +++ b/fluxer_desktop/native/linux-portals/src/lib.rs @@ -35,7 +35,7 @@ mod napi_bindings { }, gnome_shell, kwin, session_monitor::{MonitorEvent, SessionMonitor}, - settings::{self, ChangeEvent, ChangePayload, ColorScheme, Contrast}, + settings::{self, ChangeEvent, ChangePayload}, x11, }; @@ -620,9 +620,6 @@ mod napi_bindings { } } } - - #[allow(dead_code)] - fn _link_unused(_c: Contrast, _s: ColorScheme) {} } #[cfg(not(target_os = "linux"))] diff --git a/fluxer_desktop/native/linux-portals/src/session_monitor.rs b/fluxer_desktop/native/linux-portals/src/session_monitor.rs index 5ca481435..f004b59b4 100644 --- a/fluxer_desktop/native/linux-portals/src/session_monitor.rs +++ b/fluxer_desktop/native/linux-portals/src/session_monitor.rs @@ -356,18 +356,3 @@ mod worker { .await } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn lock_tracker_reports_only_transitions() { - let mut tracker = LockTracker::default(); - assert_eq!(tracker.update(false), None); - assert_eq!(tracker.update(true), Some(MonitorEvent::ScreenLocked)); - assert_eq!(tracker.update(true), None); - assert_eq!(tracker.update(false), Some(MonitorEvent::ScreenUnlocked)); - assert_eq!(tracker.update(false), None); - } -} diff --git a/fluxer_desktop/native/linux-portals/src/settings.rs b/fluxer_desktop/native/linux-portals/src/settings.rs index 3860b60f9..497a92638 100644 --- a/fluxer_desktop/native/linux-portals/src/settings.rs +++ b/fluxer_desktop/native/linux-portals/src/settings.rs @@ -307,33 +307,3 @@ fn classify_payload(_key: &str, value: &Value<'_>) -> ChangePayload { } ChangePayload::Unknown } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn color_scheme_maps_to_strings_per_typescript_union() { - assert_eq!(ColorScheme::from_u32(0).as_str(), "no-preference"); - assert_eq!(ColorScheme::from_u32(1).as_str(), "prefer-dark"); - assert_eq!(ColorScheme::from_u32(2).as_str(), "prefer-light"); - assert_eq!(ColorScheme::from_u32(99).as_str(), "no-preference"); - } - - #[test] - fn contrast_maps_to_strings_per_typescript_union() { - assert_eq!(Contrast::from_u32(0).as_str(), "no-preference"); - assert_eq!(Contrast::from_u32(1).as_str(), "high"); - assert_eq!(Contrast::from_u32(99).as_str(), "no-preference"); - } - - #[test] - fn classify_accent_color_treats_negative_as_no_preference() { - assert_eq!(classify_accent_color(-1.0, -1.0, -1.0), None); - assert_eq!(classify_accent_color(-0.0001, 0.5, 0.5), None); - let accent = classify_accent_color(0.1, 0.2, 0.3).unwrap(); - assert_eq!(accent.r, 0.1); - assert_eq!(accent.g, 0.2); - assert_eq!(accent.b, 0.3); - } -} diff --git a/fluxer_desktop/native/mac-app-audio/Cargo.lock b/fluxer_desktop/native/mac-app-audio/Cargo.lock index eb74a3913..0e68b988f 100644 --- a/fluxer_desktop/native/mac-app-audio/Cargo.lock +++ b/fluxer_desktop/native/mac-app-audio/Cargo.lock @@ -50,20 +50,13 @@ dependencies = [ "objc2", ] -[[package]] -name = "fluxer_gpu_rebuild" -version = "0.0.0" -dependencies = [ - "parking_lot", -] - [[package]] name = "fluxer_mac_app_audio" version = "0.0.0" dependencies = [ "block2", "dispatch2", - "fluxer_screen_frame_bus", + "fluxer_sample_pool", "libc", "napi", "napi-build", @@ -80,12 +73,8 @@ dependencies = [ ] [[package]] -name = "fluxer_screen_frame_bus" +name = "fluxer_sample_pool" version = "0.0.0" -dependencies = [ - "fluxer_gpu_rebuild", - "parking_lot", -] [[package]] name = "futures" diff --git a/fluxer_desktop/native/mac-app-audio/Cargo.toml b/fluxer_desktop/native/mac-app-audio/Cargo.toml index 7f828e533..f19854f26 100644 --- a/fluxer_desktop/native/mac-app-audio/Cargo.toml +++ b/fluxer_desktop/native/mac-app-audio/Cargo.toml @@ -12,13 +12,11 @@ resolver = "2" crate-type = ["cdylib", "rlib"] [dependencies] +fluxer_sample_pool = {path = "../sample-pool"} napi = {version = "3.12.6", default-features = false, features = ["dyn-symbols", "napi8", "async"]} napi-derive = "3.6.7" parking_lot = "0.12" -[target.'cfg(target_os = "macos")'.dependencies.fluxer_screen_frame_bus] -path = "../screen-frame-bus" - [target.'cfg(target_os = "macos")'.dependencies] libc = "0.2.189" objc2 = "0.6" diff --git a/fluxer_desktop/native/mac-app-audio/index.js b/fluxer_desktop/native/mac-app-audio/index.js index 7fe4b97b5..571d4ae2c 100644 --- a/fluxer_desktop/native/mac-app-audio/index.js +++ b/fluxer_desktop/native/mac-app-audio/index.js @@ -165,25 +165,9 @@ class ProcessLoopback extends EventEmitter { } } - setScreenAudioSink(handle) { - if (typeof this.native.setScreenAudioSink !== 'function') return false; - try { - return this.native.setScreenAudioSink(handle) !== false; - } catch { - return false; - } - } - - clearScreenAudioSink() { - if (typeof this.native.clearScreenAudioSink === 'function') { - this.native.clearScreenAudioSink(); - } - } - async stop() { if (this.stopped) return; this.stopped = true; - this.clearScreenAudioSink(); try { await this.native.stop(); } finally { diff --git a/fluxer_desktop/native/mac-app-audio/src/audio_source.rs b/fluxer_desktop/native/mac-app-audio/src/audio_source.rs index cd7e96495..e89b0c420 100644 --- a/fluxer_desktop/native/mac-app-audio/src/audio_source.rs +++ b/fluxer_desktop/native/mac-app-audio/src/audio_source.rs @@ -436,27 +436,3 @@ impl Drop for Source { .store(ptr::null_mut(), Ordering::Release); } } - -#[cfg(test)] -mod abl_scratch_tests { - use super::*; - - #[test] - fn abl_scratch_layout_matches_audio_buffer_list() { - assert_eq!(16, core::mem::align_of::()); - assert_eq!(8, core::mem::offset_of!(AblScratch, buffers)); - assert!( - core::mem::size_of::() - >= core::mem::size_of::() - + MAX_ABL_BUFFERS * core::mem::size_of::() - ); - } - - #[test] - fn abl_scratch_empty_has_no_buffers() { - let scratch = AblScratch::empty(); - assert_eq!(0, scratch.n_buffers); - assert!(scratch.buffers[0].m_data.is_null()); - assert_eq!(0, scratch.buffers[MAX_ABL_BUFFERS - 1].m_data_byte_size); - } -} diff --git a/fluxer_desktop/native/mac-app-audio/src/foundation.rs b/fluxer_desktop/native/mac-app-audio/src/foundation.rs index f18284404..9b5c409f2 100644 --- a/fluxer_desktop/native/mac-app-audio/src/foundation.rs +++ b/fluxer_desktop/native/mac-app-audio/src/foundation.rs @@ -2,44 +2,15 @@ use core::ffi::CStr; -use objc2::rc::Retained; use objc2::runtime::ProtocolObject; -use objc2_foundation::{ - NSError, NSMutableArray, NSMutableDictionary, NSNumber, NSObject, NSProcessInfo, NSString, -}; - -pub fn nsstring_from_cstr(s: &CStr) -> Retained { - match s.to_str() { - Ok(v) => NSString::from_str(v), - Err(_) => NSString::from_str(""), - } -} - -pub fn nsstring_from_str(s: &str) -> Retained { - NSString::from_str(s) -} - -pub fn nsstring_to_string(s: Option<&NSString>) -> String { - match s { - Some(v) => v.to_string(), - None => String::new(), - } -} - -pub fn ns_mutable_array_with_capacity(capacity: usize) -> Retained> { - NSMutableArray::::arrayWithCapacity(capacity) -} - -pub fn ns_mutable_dictionary_with_capacity() -> Retained> { - NSMutableDictionary::::new() -} +use objc2_foundation::{NSError, NSMutableDictionary, NSObject, NSProcessInfo, NSString}; pub fn dict_set_str_key( dict: &NSMutableDictionary, key: &CStr, value: &NSObject, ) { - let key_ns = nsstring_from_cstr(key); + let key_ns = NSString::from_str(key.to_str().unwrap_or("")); let key_proto = ProtocolObject::from_ref(&*key_ns); unsafe { dict.setObject_forKey(value, key_proto) }; } @@ -52,39 +23,3 @@ pub fn operating_system_version_string() -> String { pub fn ns_error_localized_description(err: &NSError) -> String { err.localizedDescription().to_string() } - -pub fn number_with_unsigned_int(value: u32) -> Retained { - NSNumber::new_u32(value) -} - -pub fn number_with_bool(value: bool) -> Retained { - NSNumber::new_bool(value) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn nsstring_round_trip() { - let s = nsstring_from_cstr(c"hello fluxer"); - assert_eq!("hello fluxer", nsstring_to_string(Some(&s))); - } - - #[test] - fn nsstring_to_string_handles_none() { - assert_eq!(String::new(), nsstring_to_string(None)); - } - - #[test] - fn mutable_array_initial_count_is_zero() { - let arr = ns_mutable_array_with_capacity(4); - assert_eq!(0, arr.count()); - } - - #[test] - fn mutable_dictionary_initial_count_is_zero() { - let d = ns_mutable_dictionary_with_capacity(); - assert_eq!(0, d.count()); - } -} diff --git a/fluxer_desktop/native/mac-app-audio/src/napi_surface_macos.rs b/fluxer_desktop/native/mac-app-audio/src/napi_surface_macos.rs index 7f24b4974..76327ed4d 100644 --- a/fluxer_desktop/native/mac-app-audio/src/napi_surface_macos.rs +++ b/fluxer_desktop/native/mac-app-audio/src/napi_surface_macos.rs @@ -1,17 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use core::ffi::c_void; -use core::ptr; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; use std::thread; use std::time::Duration; use block2::RcBlock; -use fluxer_screen_frame_bus::{NativeScreenFrameSinkHandle, NativeScreenFrameSinkHandleRef}; -use napi::JsValue; use napi::Status; -use napi::bindgen_prelude::{Float32Array, Function, Result, Unknown, ValueType}; +use napi::bindgen_prelude::{Float32Array, Function, Result}; use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; use napi_derive::napi; @@ -93,7 +90,6 @@ unsafe impl Sync for SckRefresherStream {} struct LoopbackInner { state: Mutex, frame_tsfn: parking_lot::RwLock>, - screen_audio_sink: parking_lot::RwLock>>, pcm_pool: PcmFramePool, pcm_frames_dropped: AtomicU64, running: std::sync::atomic::AtomicBool, @@ -119,36 +115,6 @@ fn note_tsfn_status(inner: &LoopbackInner, status: Status) { } } -fn retain_screen_audio_sink_handle( - value: Unknown<'_>, -) -> Result> { - if value.get_type()? != ValueType::External { - return Err(napi::Error::new( - Status::InvalidArg, - "ProcessLoopback.setScreenAudioSink expects a native external sink handle", - )); - } - let raw_value = value.value(); - let mut data: *mut c_void = ptr::null_mut(); - let status = - unsafe { napi::sys::napi_get_value_external(raw_value.env, raw_value.value, &mut data) }; - if status != napi::sys::Status::napi_ok || data.is_null() { - return Err(napi::Error::new( - Status::InvalidArg, - "ProcessLoopback.setScreenAudioSink received an empty native external sink handle", - )); - } - let handle = unsafe { data.cast::().as_ref() } - .and_then(NativeScreenFrameSinkHandle::retain_ref) - .ok_or_else(|| { - napi::Error::new( - Status::InvalidArg, - "ProcessLoopback.setScreenAudioSink received an invalid native sink handle", - ) - })?; - Ok(Arc::new(handle)) -} - #[napi(js_name = "ProcessLoopback")] pub struct ProcessLoopback { inner: std::sync::Arc, @@ -198,20 +164,6 @@ unsafe extern "C" fn on_pcm_trampoline( inner.pcm_frames_dropped.fetch_add(1, Ordering::Relaxed); return; } - if let Some(sink) = inner.screen_audio_sink.read().as_ref().cloned() { - let Some(frame) = (unsafe { &mut *slot }).take() else { - return; - }; - assert_eq!(frame.filled_len(), total); - sink.enqueue_screen_audio_f32( - frame.data_slice(), - frames, - PCM_CALLBACK_CHANNELS as u32, - 48_000, - 0, - ); - return; - } if inner .tsfn_aborted .load(std::sync::atomic::Ordering::Acquire) @@ -302,7 +254,7 @@ fn build_stream_for_pid( let mut target_app: Option> = None; for i in 0..app_count { let app = apps.objectAtIndex(i); - if sck::sc_running_application_process_id(&app) == pid { + if unsafe { app.processID() } == pid { target_app = Some(app); break; } @@ -331,15 +283,15 @@ fn build_stream_for_pid( }; let cfg: Retained = unsafe { SCStreamConfiguration::new() }; - sck::cfg_set_captures_audio(&cfg, true); - sck::cfg_set_excludes_current_process_audio(&cfg, true); - sck::cfg_set_sample_rate(&cfg, 48_000); - sck::cfg_set_channel_count(&cfg, 2); - sck::cfg_set_queue_depth(&cfg, 8); - sck::cfg_set_width(&cfg, 2); - sck::cfg_set_height(&cfg, 2); - sck::cfg_set_minimum_frame_interval(&cfg, sck::cmtime_seconds(1, 1)); - sck::cfg_set_shows_cursor(&cfg, false); + unsafe { cfg.setCapturesAudio(true) }; + unsafe { cfg.setExcludesCurrentProcessAudio(true) }; + unsafe { cfg.setSampleRate(48_000) }; + unsafe { cfg.setChannelCount(2) }; + unsafe { cfg.setQueueDepth(8) }; + unsafe { cfg.setWidth(2) }; + unsafe { cfg.setHeight(2) }; + unsafe { cfg.setMinimumFrameInterval(sck::cmtime_seconds(1, 1)) }; + unsafe { cfg.setShowsCursor(false) }; sck::cfg_set_capture_dynamic_range_sdr_if_available(&cfg); let stream_name = format!("Fluxer ProcessLoopback Audio (pid {pid})"); @@ -358,22 +310,24 @@ fn build_stream_for_pid( ) }; - if sck::sc_stream_add_stream_output( - &stream, - source.delegate_as_output(), - SCStreamOutputType::Screen, - None, - ) + if unsafe { + stream.addStreamOutput_type_sampleHandlerQueue_error( + source.delegate_as_output(), + SCStreamOutputType::Screen, + None, + ) + } .is_err() { return Err(BuildStreamErr::Other); } - if sck::sc_stream_add_stream_output( - &stream, - source.delegate_as_output(), - SCStreamOutputType::Audio, - Some(source.output_queue()), - ) + if unsafe { + stream.addStreamOutput_type_sampleHandlerQueue_error( + source.delegate_as_output(), + SCStreamOutputType::Audio, + Some(source.output_queue()), + ) + } .is_err() { return Err(BuildStreamErr::Other); @@ -399,7 +353,7 @@ fn build_system_stream() -> std::result::Result { let mut excluded_apps: Vec> = Vec::new(); for i in 0..apps.count() { let app = apps.objectAtIndex(i); - if is_self_or_related_process(sck::sc_running_application_process_id(&app)) { + if is_self_or_related_process(unsafe { app.processID() }) { excluded_apps.push(app); } } @@ -418,15 +372,15 @@ fn build_system_stream() -> std::result::Result { }; let cfg: Retained = unsafe { SCStreamConfiguration::new() }; - sck::cfg_set_captures_audio(&cfg, true); - sck::cfg_set_excludes_current_process_audio(&cfg, true); - sck::cfg_set_sample_rate(&cfg, 48_000); - sck::cfg_set_channel_count(&cfg, 2); - sck::cfg_set_queue_depth(&cfg, 8); - sck::cfg_set_width(&cfg, 2); - sck::cfg_set_height(&cfg, 2); - sck::cfg_set_minimum_frame_interval(&cfg, sck::cmtime_seconds(1, 1)); - sck::cfg_set_shows_cursor(&cfg, false); + unsafe { cfg.setCapturesAudio(true) }; + unsafe { cfg.setExcludesCurrentProcessAudio(true) }; + unsafe { cfg.setSampleRate(48_000) }; + unsafe { cfg.setChannelCount(2) }; + unsafe { cfg.setQueueDepth(8) }; + unsafe { cfg.setWidth(2) }; + unsafe { cfg.setHeight(2) }; + unsafe { cfg.setMinimumFrameInterval(sck::cmtime_seconds(1, 1)) }; + unsafe { cfg.setShowsCursor(false) }; sck::cfg_set_capture_dynamic_range_sdr_if_available(&cfg); let nsname = NSString::from_str("Fluxer System Audio"); @@ -444,22 +398,24 @@ fn build_system_stream() -> std::result::Result { ) }; - if sck::sc_stream_add_stream_output( - &stream, - source.delegate_as_output(), - SCStreamOutputType::Screen, - None, - ) + if unsafe { + stream.addStreamOutput_type_sampleHandlerQueue_error( + source.delegate_as_output(), + SCStreamOutputType::Screen, + None, + ) + } .is_err() { return Err(BuildStreamErr::Other); } - if sck::sc_stream_add_stream_output( - &stream, - source.delegate_as_output(), - SCStreamOutputType::Audio, - Some(source.output_queue()), - ) + if unsafe { + stream.addStreamOutput_type_sampleHandlerQueue_error( + source.delegate_as_output(), + SCStreamOutputType::Audio, + Some(source.output_queue()), + ) + } .is_err() { return Err(BuildStreamErr::Other); @@ -478,10 +434,10 @@ fn collect_capture_apps( let mut chosen: Vec> = Vec::with_capacity(capacity); let mut pids: Vec = Vec::with_capacity(capacity); - let target_pid = sck::sc_running_application_process_id(target_app); + let target_pid = unsafe { target_app.processID() }; let target_info = process_tree::info_for_pid(target_pid); - let target_bundle = sck::sc_running_application_bundle_identifier(target_app).to_string(); - let target_name = sck::sc_running_application_name(target_app).to_string(); + let target_bundle = unsafe { target_app.bundleIdentifier() }.to_string(); + let target_name = unsafe { target_app.applicationName() }.to_string(); if include_tree { for i in 0..count { @@ -489,10 +445,9 @@ fn collect_capture_apps( break; } let candidate = apps.objectAtIndex(i); - let candidate_pid = sck::sc_running_application_process_id(&candidate); - let candidate_bundle = - sck::sc_running_application_bundle_identifier(&candidate).to_string(); - let candidate_name = sck::sc_running_application_name(&candidate).to_string(); + let candidate_pid = unsafe { candidate.processID() }; + let candidate_bundle = unsafe { candidate.bundleIdentifier() }.to_string(); + let candidate_name = unsafe { candidate.applicationName() }.to_string(); let same_tree = process_tree::is_same_launch_tree(candidate_pid, target_pid, target_info); let related = candidate_pid == target_pid @@ -510,7 +465,7 @@ fn collect_capture_apps( } if pids.is_empty() { - let pid = sck::sc_running_application_process_id(target_app); + let pid = unsafe { target_app.processID() }; chosen.push(target_app.retain()); pids.push(pid); } @@ -541,20 +496,20 @@ fn best_display_for_selection( for d in 0..display_count { let display = displays.objectAtIndex(d); - let display_frame = sck::sc_display_frame(&display); + let display_frame = unsafe { display.frame() }; let mut area = 0.0; for w in 0..window_count { let window = windows.objectAtIndex(w); - let owner = match sck::sc_window_owning_application(&window) { + let owner = match unsafe { window.owningApplication() } { Some(o) => o, None => continue, }; - let owner_pid = sck::sc_running_application_process_id(&owner); + let owner_pid = unsafe { owner.processID() }; if !pids.contains(&owner_pid) { continue; } saw_app_window = true; - area += sck::cgrect_intersection_area(display_frame, sck::sc_window_frame(&window)); + area += sck::cgrect_intersection_area(display_frame, unsafe { window.frame() }); } if area > best_area { best_area = area; @@ -623,7 +578,7 @@ fn run_sck_late_spawn_refresher( let mut target_app: Option> = None; for i in 0..app_count { let app = apps.objectAtIndex(i); - if sck::sc_running_application_process_id(&app) == target_pid { + if unsafe { app.processID() } == target_pid { target_app = Some(app); break; } @@ -733,7 +688,6 @@ impl ProcessLoopback { sck_refresher: None, }), frame_tsfn: parking_lot::RwLock::new(None), - screen_audio_sink: parking_lot::RwLock::new(None), pcm_pool, pcm_frames_dropped: AtomicU64::new(0), running: std::sync::atomic::AtomicBool::new(false), @@ -755,24 +709,6 @@ impl ProcessLoopback { Ok(()) } - #[napi(js_name = "setScreenAudioSink")] - pub fn set_screen_audio_sink(&self, sink_handle: Unknown<'_>) -> Result<()> { - let sink = retain_screen_audio_sink_handle(sink_handle)?; - if !sink.supports_screen_audio() { - return Err(napi::Error::new( - Status::InvalidArg, - "ProcessLoopback.setScreenAudioSink handle does not support screen audio", - )); - } - *self.inner.screen_audio_sink.write() = Some(sink); - Ok(()) - } - - #[napi(js_name = "clearScreenAudioSink")] - pub fn clear_screen_audio_sink(&self) { - self.inner.screen_audio_sink.write().take(); - } - #[napi] pub fn set_lifecycle_callback(&self, callback: Function<(String, String), ()>) -> Result<()> { let tsfn: LifecycleTsfn = callback @@ -1033,9 +969,9 @@ pub async fn list_audible_applications() -> Result> { let n = apps.count(); for i in 0..n { let app = apps.objectAtIndex(i); - let pid = sck::sc_running_application_process_id(&app); - let bundle = sck::sc_running_application_bundle_identifier(&app).to_string(); - let name = sck::sc_running_application_name(&app).to_string(); + let pid = unsafe { app.processID() }; + let bundle = unsafe { app.bundleIdentifier() }.to_string(); + let name = unsafe { app.applicationName() }.to_string(); out.push(AppDescriptor { pid, bundle_id: Some(bundle), diff --git a/fluxer_desktop/native/mac-app-audio/src/pcm_pool.rs b/fluxer_desktop/native/mac-app-audio/src/pcm_pool.rs index 3c14fb1f2..02a73fb24 100644 --- a/fluxer_desktop/native/mac-app-audio/src/pcm_pool.rs +++ b/fluxer_desktop/native/mac-app-audio/src/pcm_pool.rs @@ -1,463 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use parking_lot::Mutex; -use std::cell::UnsafeCell; -use std::fmt; -use std::sync::Arc; -use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; - +pub use fluxer_sample_pool::PCM_SLOT_SAMPLES_MAX; +pub type PcmFramePool = fluxer_sample_pool::SamplePool; +pub type PooledPcmFrame = fluxer_sample_pool::PooledSamples; pub const PCM_POOL_CAP: usize = 16; -pub const PCM_SLOT_SAMPLES_MAX: usize = 16_384; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PcmPoolError { - ZeroCapacity, - ZeroSamplesPerSlot, - SamplesPerSlotTooLarge(usize), - PayloadTooLarge { offered: usize, capacity: usize }, -} - -impl fmt::Display for PcmPoolError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ZeroCapacity => write!(f, "PcmFramePool capacity must be > 0"), - Self::ZeroSamplesPerSlot => write!(f, "PcmFramePool samples_per_slot must be > 0"), - Self::SamplesPerSlotTooLarge(n) => write!( - f, - "PcmFramePool samples_per_slot {n} exceeds PCM_SLOT_SAMPLES_MAX={PCM_SLOT_SAMPLES_MAX}" - ), - Self::PayloadTooLarge { offered, capacity } => write!( - f, - "PcmFramePool payload {offered} samples exceeds slot capacity {capacity}" - ), - } - } -} - -impl std::error::Error for PcmPoolError {} - -struct PcmSlotCell { - inner: UnsafeCell>, -} - -unsafe impl Send for PcmSlotCell {} -unsafe impl Sync for PcmSlotCell {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PcmPoolStats { - pub acquired: u64, - pub released: u64, - pub dropped: u64, - pub in_flight: u32, -} - -pub(crate) struct PcmFramePoolInner { - slots: Vec, - free: Mutex>, - capacity: u32, - samples_per_slot: u32, - acquired_total: AtomicU64, - released_total: AtomicU64, - dropped_total: AtomicU64, - in_flight: AtomicU32, -} - -pub struct PcmFramePool { - inner: Arc, -} - -impl fmt::Debug for PcmFramePool { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let stats = self.stats(); - f.debug_struct("PcmFramePool") - .field("capacity", &self.inner.capacity) - .field("samples_per_slot", &self.inner.samples_per_slot) - .field("stats", &stats) - .finish() - } -} - -impl PcmFramePool { - pub fn new(capacity: usize, samples_per_slot: usize) -> Result { - if capacity == 0 { - return Err(PcmPoolError::ZeroCapacity); - } - if samples_per_slot == 0 { - return Err(PcmPoolError::ZeroSamplesPerSlot); - } - if samples_per_slot > PCM_SLOT_SAMPLES_MAX { - return Err(PcmPoolError::SamplesPerSlotTooLarge(samples_per_slot)); - } - assert!(capacity > 0); - assert!(samples_per_slot > 0); - assert!(samples_per_slot <= PCM_SLOT_SAMPLES_MAX); - - let mut slots: Vec = Vec::with_capacity(capacity); - for _ in 0..capacity { - let buf: Box<[f32]> = vec![0.0_f32; samples_per_slot].into_boxed_slice(); - assert_eq!(buf.len(), samples_per_slot); - slots.push(PcmSlotCell { - inner: UnsafeCell::new(buf), - }); - } - assert_eq!(slots.len(), capacity); - - let mut free: Vec = Vec::with_capacity(capacity); - for index in 0..capacity { - free.push(index); - } - assert_eq!(free.len(), capacity); - - let cap_u32 = u32::try_from(capacity).map_err(|_| PcmPoolError::ZeroCapacity)?; - let sps_u32 = - u32::try_from(samples_per_slot).map_err(|_| PcmPoolError::ZeroSamplesPerSlot)?; - let inner = PcmFramePoolInner { - slots, - free: Mutex::new(free), - capacity: cap_u32, - samples_per_slot: sps_u32, - acquired_total: AtomicU64::new(0), - released_total: AtomicU64::new(0), - dropped_total: AtomicU64::new(0), - in_flight: AtomicU32::new(0), - }; - Ok(Self { - inner: Arc::new(inner), - }) - } - - pub fn try_acquire(&self) -> Option { - assert!(self.inner.capacity > 0); - assert!(self.inner.samples_per_slot > 0); - - let mut free = self.inner.free.lock(); - assert!(free.len() <= self.inner.capacity as usize); - let index = match free.pop() { - Some(idx) => idx, - None => { - drop(free); - self.inner.dropped_total.fetch_add(1, Ordering::Relaxed); - return None; - } - }; - assert!(index < self.inner.capacity as usize); - self.inner.acquired_total.fetch_add(1, Ordering::Relaxed); - let after = self.inner.in_flight.fetch_add(1, Ordering::AcqRel) + 1; - assert!(after <= self.inner.capacity); - drop(free); - - Some(PooledPcmFrame { - slot_index: index, - filled_len: 0, - pool: Arc::clone(&self.inner), - }) - } - - pub fn capacity(&self) -> u32 { - let cap = self.inner.capacity; - assert!(cap > 0); - assert!(cap as usize == self.inner.slots.len()); - cap - } - - pub fn samples_per_slot(&self) -> u32 { - let sps = self.inner.samples_per_slot; - assert!(sps > 0); - assert!(sps as usize <= PCM_SLOT_SAMPLES_MAX); - sps - } - - pub fn stats(&self) -> PcmPoolStats { - assert!(self.inner.capacity > 0); - let in_flight = self.inner.in_flight.load(Ordering::Acquire); - assert!(in_flight <= self.inner.capacity); - let acquired = self.inner.acquired_total.load(Ordering::Relaxed); - let released = self.inner.released_total.load(Ordering::Relaxed); - let dropped = self.inner.dropped_total.load(Ordering::Relaxed); - assert!(released <= acquired); - PcmPoolStats { - acquired, - released, - dropped, - in_flight, - } - } -} - -impl Clone for PcmFramePool { - fn clone(&self) -> Self { - Self { - inner: Arc::clone(&self.inner), - } - } -} - -pub struct PooledPcmFrame { - slot_index: usize, - filled_len: usize, - pool: Arc, -} - -impl PooledPcmFrame { - pub fn write(&mut self, samples: &[f32]) -> Result<(), PcmPoolError> { - assert!(self.slot_index < self.pool.capacity as usize); - let cap = self.pool.samples_per_slot as usize; - if samples.len() > cap { - return Err(PcmPoolError::PayloadTooLarge { - offered: samples.len(), - capacity: cap, - }); - } - assert!(samples.len() <= cap); - - let cell = &self.pool.slots[self.slot_index]; - let buf: &mut [f32] = unsafe { &mut *cell.inner.get() }; - assert_eq!(buf.len(), cap); - if !samples.is_empty() { - buf[..samples.len()].copy_from_slice(samples); - } - self.filled_len = samples.len(); - assert!(self.filled_len <= cap); - Ok(()) - } - - pub fn unfilled_mut(&mut self) -> &mut [f32] { - assert!(self.slot_index < self.pool.capacity as usize); - let cap = self.pool.samples_per_slot as usize; - assert!(cap > 0); - let cell = &self.pool.slots[self.slot_index]; - let buf: &mut [f32] = unsafe { &mut *cell.inner.get() }; - assert_eq!(buf.len(), cap); - buf - } - - pub fn set_filled_len(&mut self, len: usize) { - assert!(self.slot_index < self.pool.capacity as usize); - assert!(len <= self.pool.samples_per_slot as usize); - self.filled_len = len; - } - - pub fn data_slice(&self) -> &[f32] { - assert!(self.slot_index < self.pool.capacity as usize); - assert!(self.filled_len <= self.pool.samples_per_slot as usize); - let cell = &self.pool.slots[self.slot_index]; - let buf: &[f32] = unsafe { &*cell.inner.get() }; - &buf[..self.filled_len] - } - - pub fn filled_len(&self) -> usize { - assert!(self.slot_index < self.pool.capacity as usize); - assert!(self.filled_len <= self.pool.samples_per_slot as usize); - self.filled_len - } - - pub fn capacity(&self) -> usize { - let cap = self.pool.samples_per_slot as usize; - assert!(cap > 0); - assert!(cap <= PCM_SLOT_SAMPLES_MAX); - cap - } - - pub fn as_mut_ptr(&mut self) -> *mut f32 { - assert!(self.slot_index < self.pool.capacity as usize); - assert!(self.filled_len <= self.pool.samples_per_slot as usize); - let cell = &self.pool.slots[self.slot_index]; - let buf: &mut [f32] = unsafe { &mut *cell.inner.get() }; - assert_eq!(buf.len(), self.pool.samples_per_slot as usize); - buf.as_mut_ptr() - } - - pub fn into_external_parts(mut self) -> (*mut f32, usize, Self) { - assert!(self.slot_index < self.pool.capacity as usize); - assert!(self.filled_len <= self.pool.samples_per_slot as usize); - let len = self.filled_len; - let ptr = self.as_mut_ptr(); - assert!(!ptr.is_null()); - (ptr, len, self) - } -} - -impl Drop for PooledPcmFrame { - fn drop(&mut self) { - assert!(self.slot_index < self.pool.capacity as usize); - let mut free = self.pool.free.lock(); - assert!(free.len() < self.pool.capacity as usize); - free.push(self.slot_index); - let before = self.pool.in_flight.fetch_sub(1, Ordering::AcqRel); - assert!(before >= 1); - self.pool.released_total.fetch_add(1, Ordering::Relaxed); - drop(free); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::thread; - - const SLOT_SAMPLES: usize = 2_048; - - fn default_pool() -> PcmFramePool { - PcmFramePool::new(PCM_POOL_CAP, SLOT_SAMPLES).expect("default pool builds") - } - - #[test] - fn new_rejects_zero_capacity() { - let err = PcmFramePool::new(0, SLOT_SAMPLES).unwrap_err(); - assert_eq!(err, PcmPoolError::ZeroCapacity); - } - - #[test] - fn new_rejects_zero_samples_per_slot() { - let err = PcmFramePool::new(4, 0).unwrap_err(); - assert_eq!(err, PcmPoolError::ZeroSamplesPerSlot); - } - - #[test] - fn new_rejects_samples_per_slot_above_max() { - let err = PcmFramePool::new(4, PCM_SLOT_SAMPLES_MAX + 1).unwrap_err(); - assert_eq!( - err, - PcmPoolError::SamplesPerSlotTooLarge(PCM_SLOT_SAMPLES_MAX + 1) - ); - } - - #[test] - fn acquire_release_cycle_increments_counters() { - let pool = PcmFramePool::new(4, SLOT_SAMPLES).expect("pool"); - { - let _slot = pool.try_acquire().expect("slot"); - let stats_held = pool.stats(); - assert_eq!(stats_held.acquired, 1); - assert_eq!(stats_held.in_flight, 1); - } - let stats_after = pool.stats(); - assert_eq!(stats_after.acquired, 1); - assert_eq!(stats_after.released, 1); - assert_eq!(stats_after.in_flight, 0); - } - - #[test] - fn pool_exhausts_at_cap_and_counts_drop() { - let pool = default_pool(); - let mut held = Vec::with_capacity(PCM_POOL_CAP); - for _ in 0..PCM_POOL_CAP { - held.push(pool.try_acquire().expect("slot in capacity")); - } - assert!(pool.try_acquire().is_none()); - let stats = pool.stats(); - assert_eq!(stats.dropped, 1); - assert_eq!(stats.acquired as usize, PCM_POOL_CAP); - assert_eq!(stats.in_flight as usize, PCM_POOL_CAP); - } - - #[test] - fn write_then_data_slice_matches_payload() { - let pool = PcmFramePool::new(2, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - let payload = [0.25_f32; 32]; - slot.write(&payload).expect("payload fits"); - assert_eq!(slot.data_slice(), &payload[..]); - assert_eq!(slot.filled_len(), 32); - } - - #[test] - fn write_rejects_payload_larger_than_slot() { - let pool = PcmFramePool::new(2, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - let big = [0.0_f32; 128]; - let err = slot.write(&big).unwrap_err(); - assert!(matches!(err, PcmPoolError::PayloadTooLarge { .. })); - } - - #[test] - fn unfilled_mut_then_set_filled_len_matches_data_slice() { - let pool = PcmFramePool::new(2, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - { - let buf = slot.unfilled_mut(); - assert_eq!(buf.len(), 64); - buf[0] = 0.5; - buf[1] = -0.5; - buf[2] = 1.0; - } - slot.set_filled_len(3); - assert_eq!(slot.filled_len(), 3); - assert_eq!(slot.data_slice(), &[0.5, -0.5, 1.0]); - } - - #[test] - #[should_panic] - fn set_filled_len_rejects_overflow() { - let pool = PcmFramePool::new(1, 32).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - slot.set_filled_len(33); - } - - #[test] - fn into_external_parts_exposes_filled_pointer_and_length() { - let pool = PcmFramePool::new(2, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - let payload = [1.5_f32; 16]; - slot.write(&payload).expect("write fits"); - let (ptr, len, owned) = slot.into_external_parts(); - assert!(!ptr.is_null()); - assert_eq!(len, 16); - let observed = unsafe { core::slice::from_raw_parts(ptr, len) }; - assert_eq!(observed, &payload[..]); - assert_eq!(pool.stats().in_flight, 1); - drop(owned); - assert_eq!(pool.stats().in_flight, 0); - } - - #[test] - fn into_external_parts_drop_returns_slot_to_pool() { - let pool = PcmFramePool::new(1, 32).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - slot.write(&[0.75_f32; 8]).expect("write"); - let (_ptr, _len, owned) = slot.into_external_parts(); - assert!(pool.try_acquire().is_none()); - drop(owned); - let revived = pool.try_acquire().expect("revived"); - assert_eq!(revived.filled_len(), 0); - drop(revived); - assert_eq!(pool.stats().in_flight, 0); - } - - #[test] - fn pooled_frame_survives_send_across_threads() { - let pool = PcmFramePool::new(2, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - slot.write(&[0.5_f32; 32]).expect("write"); - let handle = thread::spawn(move || { - assert_eq!(slot.filled_len(), 32); - assert_eq!(slot.data_slice()[0], 0.5); - drop(slot); - }); - handle.join().expect("worker"); - assert_eq!(pool.stats().in_flight, 0); - assert_eq!(pool.stats().released, 1); - } - - #[test] - fn capacity_one_pool_round_trips() { - let pool = PcmFramePool::new(1, 32).expect("pool"); - for _ in 0..5 { - let mut slot = pool.try_acquire().expect("slot"); - slot.write(&[1.0, 2.0, 3.0]).expect("write"); - assert_eq!(slot.data_slice(), &[1.0, 2.0, 3.0]); - drop(slot); - } - let stats = pool.stats(); - assert_eq!(stats.acquired, 5); - assert_eq!(stats.released, 5); - assert_eq!(stats.in_flight, 0); - } - - #[test] - fn default_pool_dimensions_match_constants() { - let pool = default_pool(); - assert_eq!(pool.capacity() as usize, PCM_POOL_CAP); - assert_eq!(pool.samples_per_slot() as usize, SLOT_SAMPLES); - } -} diff --git a/fluxer_desktop/native/mac-app-audio/src/sck.rs b/fluxer_desktop/native/mac-app-audio/src/sck.rs index cf108cfe4..cc3da9a00 100644 --- a/fluxer_desktop/native/mac-app-audio/src/sck.rs +++ b/fluxer_desktop/native/mac-app-audio/src/sck.rs @@ -2,13 +2,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use objc2::rc::Retained; use objc2_core_foundation::CGRect; use objc2_core_media::{CMTime, CMTimeFlags}; use objc2_foundation::NSString; -use objc2_screen_capture_kit::{ - SCDisplay, SCRunningApplication, SCStream, SCStreamConfiguration, SCWindow, -}; +use objc2_screen_capture_kit::SCStreamConfiguration; pub use objc2_screen_capture_kit::SCStreamOutputType; @@ -54,58 +51,6 @@ pub fn cmtime_seconds(value: i64, timescale: i32) -> CMTime { } } -pub fn sc_running_application_process_id(app: &SCRunningApplication) -> i32 { - unsafe { app.processID() } -} - -pub fn sc_running_application_bundle_identifier(app: &SCRunningApplication) -> Retained { - unsafe { app.bundleIdentifier() } -} - -pub fn sc_running_application_name(app: &SCRunningApplication) -> Retained { - unsafe { app.applicationName() } -} - -pub fn sc_display_frame(display: &SCDisplay) -> CGRect { - unsafe { display.frame() } -} - -pub fn sc_window_owning_application(win: &SCWindow) -> Option> { - unsafe { win.owningApplication() } -} - -pub fn sc_window_frame(win: &SCWindow) -> CGRect { - unsafe { win.frame() } -} - -pub fn cfg_set_captures_audio(cfg: &SCStreamConfiguration, v: bool) { - unsafe { cfg.setCapturesAudio(v) } -} -pub fn cfg_set_excludes_current_process_audio(cfg: &SCStreamConfiguration, v: bool) { - unsafe { cfg.setExcludesCurrentProcessAudio(v) } -} -pub fn cfg_set_sample_rate(cfg: &SCStreamConfiguration, v: isize) { - unsafe { cfg.setSampleRate(v) } -} -pub fn cfg_set_channel_count(cfg: &SCStreamConfiguration, v: isize) { - unsafe { cfg.setChannelCount(v) } -} -pub fn cfg_set_queue_depth(cfg: &SCStreamConfiguration, v: isize) { - unsafe { cfg.setQueueDepth(v) } -} -pub fn cfg_set_width(cfg: &SCStreamConfiguration, v: usize) { - unsafe { cfg.setWidth(v) } -} -pub fn cfg_set_height(cfg: &SCStreamConfiguration, v: usize) { - unsafe { cfg.setHeight(v) } -} -pub fn cfg_set_shows_cursor(cfg: &SCStreamConfiguration, v: bool) { - unsafe { cfg.setShowsCursor(v) } -} -pub fn cfg_set_minimum_frame_interval(cfg: &SCStreamConfiguration, t: CMTime) { - unsafe { cfg.setMinimumFrameInterval(t) } -} - pub fn cfg_set_capture_dynamic_range_sdr_if_available(cfg: &SCStreamConfiguration) { use objc2::runtime::NSObjectProtocol; use objc2::sel; @@ -127,55 +72,3 @@ pub fn cfg_set_stream_name_if_available(cfg: &SCStreamConfiguration, name: &NSSt } } } - -pub fn sc_stream_add_stream_output( - stream: &SCStream, - output: &objc2::runtime::ProtocolObject, - kind: SCStreamOutputType, - queue: Option<&dispatch2::DispatchQueue>, -) -> Result<(), Retained> { - unsafe { stream.addStreamOutput_type_sampleHandlerQueue_error(output, kind, queue) } -} - -#[cfg(test)] -mod tests { - use super::*; - use objc2_core_foundation::{CGPoint, CGSize}; - - #[test] - fn cgrect_intersection_handles_negative_and_disjoint() { - let a = CGRect { - origin: CGPoint { x: 0.0, y: 0.0 }, - size: CGSize { - width: 10.0, - height: 10.0, - }, - }; - let b = CGRect { - origin: CGPoint { x: 5.0, y: 5.0 }, - size: CGSize { - width: 10.0, - height: 10.0, - }, - }; - assert_eq!(25.0, cgrect_intersection_area(a, b)); - - let c = CGRect { - origin: CGPoint { x: 10.0, y: 10.0 }, - size: CGSize { - width: -5.0, - height: -5.0, - }, - }; - assert_eq!(25.0, cgrect_intersection_area(a, c)); - - let d = CGRect { - origin: CGPoint { x: 20.0, y: 20.0 }, - size: CGSize { - width: 2.0, - height: 2.0, - }, - }; - assert_eq!(0.0, cgrect_intersection_area(a, d)); - } -} diff --git a/fluxer_desktop/native/mac-app-audio/src/source_state.rs b/fluxer_desktop/native/mac-app-audio/src/source_state.rs index ac3a3abac..f888fcfd4 100644 --- a/fluxer_desktop/native/mac-app-audio/src/source_state.rs +++ b/fluxer_desktop/native/mac-app-audio/src/source_state.rs @@ -131,32 +131,6 @@ impl Default for Machine { mod tests { use super::*; - #[test] - fn is_allowed_exhaustive() { - let all = [ - State::Idle, - State::Starting, - State::Running, - State::Stopping, - State::Stopped, - ]; - let allowed: &[(State, State)] = &[ - (State::Idle, State::Starting), - (State::Idle, State::Stopped), - (State::Starting, State::Running), - (State::Starting, State::Stopped), - (State::Running, State::Stopping), - (State::Running, State::Stopped), - (State::Stopping, State::Stopped), - ]; - for &from in &all { - for &to in &all { - let expected = allowed.iter().any(|p| p.0 == from && p.1 == to); - assert_eq!(expected, is_allowed(from, to), "{:?} -> {:?}", from, to); - } - } - } - #[test] fn happy_path() { let m = Machine::new(); diff --git a/fluxer_desktop/native/mac-screen-capture/Cargo.lock b/fluxer_desktop/native/mac-screen-capture/Cargo.lock index b6371272e..84b3d7f0c 100644 --- a/fluxer_desktop/native/mac-screen-capture/Cargo.lock +++ b/fluxer_desktop/native/mac-screen-capture/Cargo.lock @@ -2,42 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloca" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" -dependencies = [ - "cc", -] - -[[package]] -name = "anes" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - [[package]] name = "bitflags" version = "2.13.2" @@ -53,80 +17,12 @@ dependencies = [ "objc2", ] -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" - -[[package]] -name = "cc" -version = "1.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" -dependencies = [ - "find-msvc-tools", - "shlex", -] - [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - -[[package]] -name = "clap" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" -dependencies = [ - "clap_builder", -] - -[[package]] -name = "clap_builder" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" -dependencies = [ - "anstyle", - "clap_lex", -] - -[[package]] -name = "clap_lex" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" - [[package]] name = "convert_case" version = "0.12.0" @@ -136,45 +32,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "criterion" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" -dependencies = [ - "alloca", - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "itertools", - "num-traits", - "oorandom", - "page_size", - "regex", - "serde", - "serde_json", - "tinytemplate", - "walkdir", -] - -[[package]] -name = "criterion-plot" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" -dependencies = [ - "cast", - "itertools", -] - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "ctor" version = "1.0.13" @@ -188,69 +45,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags", - "block2", - "libc", "objc2", ] -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" - -[[package]] -name = "find-msvc-tools" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" - -[[package]] -name = "fluxer_encoder_ring" -version = "0.0.0" -dependencies = [ - "libloading", - "windows", -] - -[[package]] -name = "fluxer_gpu_rebuild" -version = "0.0.0" -dependencies = [ - "parking_lot", -] - [[package]] name = "fluxer_mac_screen_capture" version = "0.0.0" dependencies = [ "block2", - "criterion", - "dispatch2", - "fluxer_encoder_ring", - "fluxer_screen_frame_bus", - "libc", "napi", "napi-build", "napi-derive", "objc2", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-media", - "objc2-core-video", "objc2-foundation", - "objc2-io-surface", "objc2-screen-capture-kit", - "parking_lot", -] - -[[package]] -name = "fluxer_screen_frame_bus" -version = "0.0.0" -dependencies = [ - "fluxer_gpu_rebuild", - "parking_lot", ] [[package]] @@ -341,32 +149,6 @@ dependencies = [ "slab", ] -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - [[package]] name = "libc" version = "0.2.189" @@ -383,15 +165,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "memchr" version = "2.8.3" @@ -463,15 +236,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - [[package]] name = "objc2" version = "0.6.4" @@ -520,9 +284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags", - "block2", "dispatch2", - "libc", "objc2", ] @@ -546,7 +308,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" dependencies = [ "bitflags", - "block2", "dispatch2", "objc2", "objc2-core-audio", @@ -562,12 +323,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ "bitflags", - "block2", "objc2", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", - "objc2-metal", ] [[package]] @@ -596,21 +355,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags", - "libc", "objc2", "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-metal" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" -dependencies = [ - "bitflags", - "objc2", - "objc2-foundation", ] [[package]] @@ -642,45 +388,6 @@ dependencies = [ "objc2-foundation", ] -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - -[[package]] -name = "page_size" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -705,132 +412,24 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - [[package]] name = "rustc-hash" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" -[[package]] -name = "smallvec" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" - [[package]] name = "syn" version = "2.0.119" @@ -853,16 +452,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "tokio" version = "1.53.1" @@ -884,188 +473,8 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[package]] -name = "zerocopy" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/fluxer_desktop/native/mac-screen-capture/Cargo.toml b/fluxer_desktop/native/mac-screen-capture/Cargo.toml index 14c031086..68b9aaa93 100644 --- a/fluxer_desktop/native/mac-screen-capture/Cargo.toml +++ b/fluxer_desktop/native/mac-screen-capture/Cargo.toml @@ -14,32 +14,12 @@ crate-type = ["cdylib", "rlib"] [dependencies] napi = {version = "3.12.6", default-features = false, features = ["dyn-symbols", "napi8", "async"]} napi-derive = "3.6.7" -fluxer_encoder_ring = {path = "../encoder-ring"} -parking_lot = "0.12" - -[target.'cfg(target_os = "macos")'.dependencies.fluxer_screen_frame_bus] -path = "../screen-frame-bus" [target.'cfg(target_os = "macos")'.dependencies] -libc = "0.2.189" objc2 = "0.6" -objc2-foundation = {version = "0.3", features = ["NSString", "NSArray", "NSDictionary", "NSError", "NSValue", "NSBundle", "NSProcessInfo"]} -objc2-screen-capture-kit = {version = "0.3", features = ["SCStream", "SCShareableContent", "objc2-core-graphics", "objc2-core-media", "objc2-core-foundation", "block2", "dispatch2", "libc"]} -objc2-core-media = {version = "0.3", features = ["CMSampleBuffer", "CMBlockBuffer", "CMFormatDescription", "CMTime", "objc2-core-audio-types"]} -objc2-core-audio-types = {version = "0.3", features = ["CoreAudioBaseTypes", "objc2"]} -objc2-core-video = {version = "0.3", features = ["CVPixelBuffer", "CVPixelBufferIOSurface", "CVImageBuffer", "CVBuffer", "CVPixelFormatDescription", "CVReturn", "CVBase", "objc2-io-surface"]} -objc2-io-surface = {version = "0.3", features = ["IOSurfaceRef", "objc2-core-foundation"]} -objc2-core-foundation = {version = "0.3", features = ["CFArray", "CFDictionary", "CFNumber", "CFString", "CFBase"]} -objc2-core-graphics = {version = "0.3.2", default-features = false, features = ["std", "CGWindow"]} +objc2-foundation = {version = "0.3", features = ["NSError"]} +objc2-screen-capture-kit = {version = "0.3", features = ["SCShareableContent", "block2"]} block2 = "0.6" -dispatch2 = "0.3" [build-dependencies] napi-build = "2.4.3" - -[dev-dependencies] -criterion = {version = "0.8", default-features = false, features = ["cargo_bench_support"]} - -[[bench]] -name = "audio_pool" -harness = false diff --git a/fluxer_desktop/native/mac-screen-capture/benches/audio_pool.rs b/fluxer_desktop/native/mac-screen-capture/benches/audio_pool.rs deleted file mode 100644 index aa6b2a043..000000000 --- a/fluxer_desktop/native/mac-screen-capture/benches/audio_pool.rs +++ /dev/null @@ -1,66 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::hint::black_box; - -use criterion::{Criterion, criterion_group, criterion_main}; -use fluxer_mac_screen_capture::audio_pool::{ - MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT, MacAudioFramePool, -}; - -fn bench_acquire_write_release_960_floats(c: &mut Criterion) { - let pool = MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT) - .expect("pool must build"); - let payload = vec![0xCC_u8; 960 * 4]; - c.bench_function("audio_pool/acquire_write_release/960_floats", |b| { - b.iter(|| { - let mut slot = pool - .try_acquire() - .expect("steady state single-thread never starves"); - slot.write(&payload).expect("write fits"); - black_box(slot.data_slice().len()); - drop(slot); - }); - }); - assert_eq!(pool.stats().in_flight, 0); -} - -fn bench_acquire_release_only(c: &mut Criterion) { - let pool = MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT) - .expect("pool must build"); - c.bench_function("audio_pool/acquire_release", |b| { - b.iter(|| { - let slot = pool.try_acquire().expect("slot"); - black_box(slot.slot_index()); - }); - }); - assert_eq!(pool.stats().in_flight, 0); -} - -fn bench_acquire_write_into_external_parts(c: &mut Criterion) { - let pool = MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT) - .expect("pool must build"); - let payload = vec![0xCC_u8; 960 * 4]; - c.bench_function( - "audio_pool/acquire_write_into_external_parts/960_floats", - |b| { - b.iter(|| { - let mut slot = pool - .try_acquire() - .expect("steady state single-thread never starves"); - slot.write(&payload).expect("write fits"); - let (ptr, len, owned) = slot.into_external_parts(); - black_box((ptr, len)); - drop(owned); - }); - }, - ); - assert_eq!(pool.stats().in_flight, 0); -} - -criterion_group!( - benches, - bench_acquire_write_release_960_floats, - bench_acquire_release_only, - bench_acquire_write_into_external_parts, -); -criterion_main!(benches); diff --git a/fluxer_desktop/native/mac-screen-capture/index.d.ts b/fluxer_desktop/native/mac-screen-capture/index.d.ts index 40acda878..1600b1b08 100644 --- a/fluxer_desktop/native/mac-screen-capture/index.d.ts +++ b/fluxer_desktop/native/mac-screen-capture/index.d.ts @@ -1,109 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {EventEmitter} from 'node:events'; - -export interface MacScreenCaptureBackendInfo { - backend: string; - supported: boolean; - reason: string; - minMacosVersion: string; - detectedMacosVersion?: string; - sckAvailable: boolean; -} - -export declare function getBackendInfo(): MacScreenCaptureBackendInfo; - -export interface MacScreenCaptureBackendAvailability { - sck?: { - supported: boolean; - macosVersion?: string; - }; - screenPermission?: string; -} - -export declare function getBackendAvailability(): Promise; - -export type MacScreenCaptureSourceKind = 'screen' | 'window'; - -export interface MacScreenCaptureSource { - kind: MacScreenCaptureSourceKind; - id: string; - name: string; - width: number; - height: number; - appName?: string; - bundleId?: string; - targetPid?: number; -} - -export declare function listSources(): Promise>; - export type MacScreenRecordingProbeResult = 'granted' | 'denied' | 'timeout' | 'unsupported'; export declare function probeScreenRecordingAccess(): Promise; -export interface ScreenCaptureRect { - x: number; - y: number; - width: number; - height: number; -} - -export interface ScreenCaptureOptions { - sourceId: string; - sourceKind: MacScreenCaptureSourceKind; - width?: number; - height?: number; - frameRate?: number; - captureId?: string; - colorRange?: 'full' | 'limited'; - colorSpace?: 'rec709' | 'srgb'; - showCursorClicks?: boolean; - captureRect?: ScreenCaptureRect; - frameSinkHandle?: unknown; - nativeFrameSinkRequired?: boolean; -} - -export interface ScreenCaptureStartResult { - width: number; - height: number; - frameRate: number; - pixelFormat: 'nv12' | 'bgra'; -} - -export interface FrameSinkDiagnostics { - accepted: number; - coalesced: number; - rejected: number; - mediaFramesDroppedWithoutSink: number; -} - export declare const loadError: Error | null; - -export declare interface ScreenCapture { - on(event: 'error', listener: (err: Error) => void): this; - on(event: 'closed', listener: () => void): this; - on(event: 'diagnostic', listener: (message?: string) => void): this; - on(event: string | symbol, listener: (...args: Array) => void): this; - off(event: 'error', listener: (err: Error) => void): this; - off(event: 'closed', listener: () => void): this; - off(event: 'diagnostic', listener: (message?: string) => void): this; - off(event: string | symbol, listener: (...args: Array) => void): this; - emit(event: 'error', err: Error): boolean; - emit(event: 'closed'): boolean; - emit(event: 'diagnostic', message?: string): boolean; -} - -export declare class ScreenCapture extends EventEmitter { - constructor(options: ScreenCaptureOptions); - - start(): Promise; - - stop(): Promise; - - attachEncoder(width: number, height: number, frameRate?: number): void; - detachEncoder(): void; - isEncoderAttached(): boolean; - encoderRingFullCount(): number; - getFrameSinkDiagnostics(): FrameSinkDiagnostics; -} diff --git a/fluxer_desktop/native/mac-screen-capture/index.js b/fluxer_desktop/native/mac-screen-capture/index.js index 0229aeb3b..9e21a0d45 100644 --- a/fluxer_desktop/native/mac-screen-capture/index.js +++ b/fluxer_desktop/native/mac-screen-capture/index.js @@ -1,6 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -const {EventEmitter} = require('node:events'); const {existsSync} = require('node:fs'); const {join, sep} = require('node:path'); const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs'); @@ -56,178 +55,12 @@ if (process.platform === 'darwin') { } } -function getBackendInfo() { - if (!binding) { - return { - backend: 'mac-screen-capture', - supported: false, - reason: - process.platform === 'darwin' - ? `@fluxer/mac-screen-capture native binary unavailable: ${loadError?.message ?? 'unknown reason'}` - : `@fluxer/mac-screen-capture is only supported on macOS, got ${process.platform}`, - minMacosVersion: '12.3', - detectedMacosVersion: undefined, - sckAvailable: false, - }; - } - return binding.getBackendInfo(); -} - -function getBackendAvailability() { - if (!binding) { - return Promise.resolve({ - sck: {supported: false}, - screenPermission: 'not-determined', - }); - } - return binding.getBackendAvailability(); -} - -function listSources() { - if (!binding) return Promise.resolve([]); - return binding.listSources(); -} - function probeScreenRecordingAccess() { if (!binding || typeof binding.probeScreenRecordingAccess !== 'function') return Promise.resolve('unsupported'); return Promise.resolve(binding.probeScreenRecordingAccess()); } -class ScreenCapture extends EventEmitter { - constructor(options = {}) { - super(); - if (!binding) { - throw loadError || new Error('@fluxer/mac-screen-capture binding unavailable'); - } - this.sourceId = options.sourceId; - this.sourceKind = options.sourceKind ?? 'screen'; - this.width = options.width ?? 0; - this.height = options.height ?? 0; - this.frameRate = options.frameRate ?? 30; - this.captureId = typeof options.captureId === 'string' ? options.captureId : undefined; - this.colorRange = options.colorRange; - this.colorSpace = options.colorSpace; - this.showCursorClicks = options.showCursorClicks === true; - this.captureRect = options.captureRect; - this.frameSinkHandle = options.frameSinkHandle; - this.nativeFrameSinkRequired = options.nativeFrameSinkRequired === true; - this.started = false; - this.stopped = false; - this.closedEmitted = false; - this.native = new binding.ScreenCapture(); - this.native.setLifecycleCallback((type, message) => { - if (type === 'diagnostic') { - if (this.stopped) return; - this.emit('diagnostic', message); - return; - } - if (type === 'error') { - this.emit('error', new Error(message || 'macOS screen capture stream stopped')); - return; - } - if (type === 'closed') { - if (this.stopped) { - this.emitClosedOnce(); - return; - } - this.stopped = true; - Promise.resolve() - .then(() => this.native.stop()) - .catch(() => {}); - this.emitClosedOnce(); - } - }); - } - - emitClosedOnce() { - if (this.closedEmitted) return; - this.closedEmitted = true; - this.emit('closed'); - } - - async start() { - if (this.started || this.stopped) return; - this.started = true; - try { - if (this.frameSinkHandle != null) { - if (typeof this.native.setFrameSinkHandle !== 'function') { - throw new Error('@fluxer/mac-screen-capture native binding does not support native frame sink handles'); - } - this.native.setFrameSinkHandle(this.frameSinkHandle); - } else if (this.nativeFrameSinkRequired) { - throw new Error('Native frame sink handle is required for macOS screen capture'); - } - const result = await this.native.start( - this.sourceId, - this.sourceKind, - this.width, - this.height, - this.frameRate, - this.captureId, - { - colorRange: this.colorRange, - colorSpace: this.colorSpace, - showCursorClicks: this.showCursorClicks, - captureRect: this.captureRect, - }, - ); - if (result) { - this.width = result.width ?? this.width; - this.height = result.height ?? this.height; - this.frameRate = result.frameRate ?? this.frameRate; - this.pixelFormat = result.pixelFormat ?? 'nv12'; - } - return { - width: this.width, - height: this.height, - frameRate: this.frameRate, - pixelFormat: this.pixelFormat ?? 'nv12', - }; - } catch (error) { - this.stopped = true; - this.emit('error', error instanceof Error ? error : new Error(String(error))); - throw error; - } - } - - async stop() { - if (this.stopped) return; - this.stopped = true; - try { - await this.native.stop(); - } finally { - this.emitClosedOnce(); - } - } - - getFrameSinkDiagnostics() { - if (!this.native || typeof this.native.getFrameSinkDiagnostics !== 'function') { - return { - accepted: 0, - coalesced: 0, - rejected: 0, - mediaFramesDroppedWithoutSink: 0, - }; - } - try { - return this.native.getFrameSinkDiagnostics(); - } catch (error) { - console.warn('[mac-screen-capture] getFrameSinkDiagnostics failed:', error?.message || error); - return { - accepted: 0, - coalesced: 0, - rejected: 0, - mediaFramesDroppedWithoutSink: 0, - }; - } - } -} - module.exports = { - ScreenCapture, - getBackendAvailability, - getBackendInfo, - listSources, loadError, probeScreenRecordingAccess, }; diff --git a/fluxer_desktop/native/mac-screen-capture/src/audio_pool.rs b/fluxer_desktop/native/mac-screen-capture/src/audio_pool.rs deleted file mode 100644 index d788cd4b9..000000000 --- a/fluxer_desktop/native/mac-screen-capture/src/audio_pool.rs +++ /dev/null @@ -1,596 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use parking_lot::Mutex; -use std::cell::UnsafeCell; -use std::fmt; -use std::sync::Arc; -use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; - -pub const MAC_AUDIO_POOL_CAP: usize = 16; -pub const MAX_FRAME_BYTES_PER_SLOT: usize = 8192; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MacAudioError { - ZeroCapacity, - ZeroBytesPerSlot, - BytesPerSlotTooLarge(usize), - PayloadTooLarge { offered: usize, capacity: usize }, -} - -impl fmt::Display for MacAudioError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ZeroCapacity => write!(f, "MacAudioFramePool capacity must be > 0"), - Self::ZeroBytesPerSlot => write!(f, "MacAudioFramePool bytes_per_slot must be > 0"), - Self::BytesPerSlotTooLarge(n) => write!( - f, - "MacAudioFramePool bytes_per_slot {n} exceeds MAX_FRAME_BYTES_PER_SLOT={MAX_FRAME_BYTES_PER_SLOT}" - ), - Self::PayloadTooLarge { offered, capacity } => write!( - f, - "MacAudioFramePool payload {offered} bytes exceeds slot capacity {capacity}" - ), - } - } -} - -impl std::error::Error for MacAudioError {} - -struct PoolSlotCell { - inner: UnsafeCell>, -} - -unsafe impl Send for PoolSlotCell {} -unsafe impl Sync for PoolSlotCell {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct MacAudioPoolStats { - pub acquired: u64, - pub released: u64, - pub dropped: u64, - pub in_flight: u32, -} - -pub(crate) struct MacAudioFramePoolInner { - slots: Vec, - free: Mutex>, - capacity: u32, - bytes_per_slot: u32, - acquired_total: AtomicU64, - released_total: AtomicU64, - dropped_total: AtomicU64, - in_flight: AtomicU32, -} - -pub struct MacAudioFramePool { - inner: Arc, -} - -impl fmt::Debug for MacAudioFramePool { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let stats = self.stats(); - f.debug_struct("MacAudioFramePool") - .field("capacity", &self.inner.capacity) - .field("bytes_per_slot", &self.inner.bytes_per_slot) - .field("stats", &stats) - .finish() - } -} - -impl MacAudioFramePool { - pub fn new(capacity: usize, bytes_per_slot: usize) -> Result { - if capacity == 0 { - return Err(MacAudioError::ZeroCapacity); - } - if bytes_per_slot == 0 { - return Err(MacAudioError::ZeroBytesPerSlot); - } - if bytes_per_slot > MAX_FRAME_BYTES_PER_SLOT { - return Err(MacAudioError::BytesPerSlotTooLarge(bytes_per_slot)); - } - assert!(capacity > 0); - assert!(bytes_per_slot > 0); - assert!(bytes_per_slot <= MAX_FRAME_BYTES_PER_SLOT); - - let mut slots: Vec = Vec::with_capacity(capacity); - for _ in 0..capacity { - let buf: Box<[u8]> = vec![0u8; bytes_per_slot].into_boxed_slice(); - assert_eq!(buf.len(), bytes_per_slot); - slots.push(PoolSlotCell { - inner: UnsafeCell::new(buf), - }); - } - assert_eq!(slots.len(), capacity); - - let mut free: Vec = Vec::with_capacity(capacity); - for index in 0..capacity { - free.push(index); - } - assert_eq!(free.len(), capacity); - - let cap_u32 = u32::try_from(capacity).map_err(|_| MacAudioError::ZeroCapacity)?; - let bps_u32 = u32::try_from(bytes_per_slot).map_err(|_| MacAudioError::ZeroBytesPerSlot)?; - let inner = MacAudioFramePoolInner { - slots, - free: Mutex::new(free), - capacity: cap_u32, - bytes_per_slot: bps_u32, - acquired_total: AtomicU64::new(0), - released_total: AtomicU64::new(0), - dropped_total: AtomicU64::new(0), - in_flight: AtomicU32::new(0), - }; - Ok(Self { - inner: Arc::new(inner), - }) - } - - pub fn try_acquire(&self) -> Option { - assert!(self.inner.capacity > 0); - assert!(self.inner.bytes_per_slot > 0); - - let mut free = self.inner.free.lock(); - assert!(free.len() <= self.inner.capacity as usize); - let index = match free.pop() { - Some(idx) => idx, - None => { - drop(free); - self.inner.dropped_total.fetch_add(1, Ordering::Relaxed); - return None; - } - }; - assert!(index < self.inner.capacity as usize); - self.inner.acquired_total.fetch_add(1, Ordering::Relaxed); - let after = self.inner.in_flight.fetch_add(1, Ordering::AcqRel) + 1; - assert!(after <= self.inner.capacity); - drop(free); - - Some(PooledMacAudioFrame { - slot_index: index, - filled_len: 0, - pool: Arc::clone(&self.inner), - }) - } - - pub fn capacity(&self) -> u32 { - let cap = self.inner.capacity; - assert!(cap > 0); - assert!(cap as usize == self.inner.slots.len()); - cap - } - - pub fn bytes_per_slot(&self) -> u32 { - let bps = self.inner.bytes_per_slot; - assert!(bps > 0); - assert!(bps as usize <= MAX_FRAME_BYTES_PER_SLOT); - bps - } - - pub fn stats(&self) -> MacAudioPoolStats { - assert!(self.inner.capacity > 0); - let in_flight = self.inner.in_flight.load(Ordering::Acquire); - assert!(in_flight <= self.inner.capacity); - let acquired = self.inner.acquired_total.load(Ordering::Relaxed); - let released = self.inner.released_total.load(Ordering::Relaxed); - let dropped = self.inner.dropped_total.load(Ordering::Relaxed); - assert!(released <= acquired); - MacAudioPoolStats { - acquired, - released, - dropped, - in_flight, - } - } -} - -impl Clone for MacAudioFramePool { - fn clone(&self) -> Self { - Self { - inner: Arc::clone(&self.inner), - } - } -} - -pub struct PooledMacAudioFrame { - slot_index: usize, - filled_len: usize, - pool: Arc, -} - -impl PooledMacAudioFrame { - pub fn write(&mut self, samples: &[u8]) -> Result<(), MacAudioError> { - assert!(self.slot_index < self.pool.capacity as usize); - let cap = self.pool.bytes_per_slot as usize; - if samples.len() > cap { - return Err(MacAudioError::PayloadTooLarge { - offered: samples.len(), - capacity: cap, - }); - } - assert!(samples.len() <= cap); - - let cell = &self.pool.slots[self.slot_index]; - let buf: &mut [u8] = unsafe { &mut *cell.inner.get() }; - assert_eq!(buf.len(), cap); - if !samples.is_empty() { - buf[..samples.len()].copy_from_slice(samples); - } - self.filled_len = samples.len(); - assert!(self.filled_len <= cap); - Ok(()) - } - - pub fn append(&mut self, samples: &[u8]) -> Result<(), MacAudioError> { - assert!(self.slot_index < self.pool.capacity as usize); - let cap = self.pool.bytes_per_slot as usize; - let new_len = - self.filled_len - .checked_add(samples.len()) - .ok_or(MacAudioError::PayloadTooLarge { - offered: usize::MAX, - capacity: cap, - })?; - if new_len > cap { - return Err(MacAudioError::PayloadTooLarge { - offered: new_len, - capacity: cap, - }); - } - assert!(new_len <= cap); - - let cell = &self.pool.slots[self.slot_index]; - let buf: &mut [u8] = unsafe { &mut *cell.inner.get() }; - assert_eq!(buf.len(), cap); - if !samples.is_empty() { - buf[self.filled_len..new_len].copy_from_slice(samples); - } - self.filled_len = new_len; - Ok(()) - } - - pub fn data_slice(&self) -> &[u8] { - assert!(self.slot_index < self.pool.capacity as usize); - assert!(self.filled_len <= self.pool.bytes_per_slot as usize); - let cell = &self.pool.slots[self.slot_index]; - let buf: &[u8] = unsafe { &*cell.inner.get() }; - &buf[..self.filled_len] - } - - pub fn filled_len(&self) -> usize { - assert!(self.slot_index < self.pool.capacity as usize); - assert!(self.filled_len <= self.pool.bytes_per_slot as usize); - self.filled_len - } - - pub fn capacity(&self) -> usize { - let cap = self.pool.bytes_per_slot as usize; - assert!(cap > 0); - assert!(cap <= MAX_FRAME_BYTES_PER_SLOT); - cap - } - - pub fn slot_index(&self) -> usize { - assert!(self.slot_index < self.pool.capacity as usize); - self.slot_index - } - - pub fn as_mut_ptr(&mut self) -> *mut u8 { - assert!(self.slot_index < self.pool.capacity as usize); - assert!(self.filled_len <= self.pool.bytes_per_slot as usize); - let cell = &self.pool.slots[self.slot_index]; - let buf: &mut [u8] = unsafe { &mut *cell.inner.get() }; - assert_eq!(buf.len(), self.pool.bytes_per_slot as usize); - buf.as_mut_ptr() - } - - pub fn into_external_parts(mut self) -> (*mut u8, usize, Self) { - assert!(self.slot_index < self.pool.capacity as usize); - assert!(self.filled_len <= self.pool.bytes_per_slot as usize); - let len = self.filled_len; - let ptr = self.as_mut_ptr(); - assert!(!ptr.is_null()); - (ptr, len, self) - } -} - -impl Drop for PooledMacAudioFrame { - fn drop(&mut self) { - assert!(self.slot_index < self.pool.capacity as usize); - let mut free = self.pool.free.lock(); - assert!(free.len() < self.pool.capacity as usize); - free.push(self.slot_index); - let before = self.pool.in_flight.fetch_sub(1, Ordering::AcqRel); - assert!(before >= 1); - self.pool.released_total.fetch_add(1, Ordering::Relaxed); - drop(free); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Barrier; - use std::sync::atomic::AtomicUsize; - use std::thread; - use std::time::{Duration, Instant}; - - const SLOT_BYTES: usize = 3840; - - fn default_pool() -> MacAudioFramePool { - MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, SLOT_BYTES).expect("default pool builds") - } - - #[test] - fn new_rejects_zero_capacity() { - let err = MacAudioFramePool::new(0, SLOT_BYTES).unwrap_err(); - assert_eq!(err, MacAudioError::ZeroCapacity); - } - - #[test] - fn new_rejects_zero_bytes_per_slot() { - let err = MacAudioFramePool::new(4, 0).unwrap_err(); - assert_eq!(err, MacAudioError::ZeroBytesPerSlot); - } - - #[test] - fn new_rejects_bytes_per_slot_above_max() { - let err = MacAudioFramePool::new(4, MAX_FRAME_BYTES_PER_SLOT + 1).unwrap_err(); - assert_eq!( - err, - MacAudioError::BytesPerSlotTooLarge(MAX_FRAME_BYTES_PER_SLOT + 1) - ); - } - - #[test] - fn acquire_release_cycle_increments_counters() { - let pool = MacAudioFramePool::new(4, SLOT_BYTES).expect("pool"); - let stats_before = pool.stats(); - assert_eq!(stats_before.acquired, 0); - assert_eq!(stats_before.released, 0); - assert_eq!(stats_before.in_flight, 0); - { - let _slot = pool.try_acquire().expect("slot"); - let stats_held = pool.stats(); - assert_eq!(stats_held.acquired, 1); - assert_eq!(stats_held.in_flight, 1); - } - let stats_after = pool.stats(); - assert_eq!(stats_after.acquired, 1); - assert_eq!(stats_after.released, 1); - assert_eq!(stats_after.in_flight, 0); - } - - #[test] - fn pool_exhausts_at_cap_seventeenth_acquire_returns_none() { - let pool = default_pool(); - let mut held = Vec::with_capacity(MAC_AUDIO_POOL_CAP); - for _ in 0..MAC_AUDIO_POOL_CAP { - held.push(pool.try_acquire().expect("slot in capacity")); - } - assert!(pool.try_acquire().is_none()); - let stats = pool.stats(); - assert_eq!(stats.dropped, 1); - assert_eq!(stats.acquired as usize, MAC_AUDIO_POOL_CAP); - assert_eq!(stats.in_flight as usize, MAC_AUDIO_POOL_CAP); - } - - #[test] - fn drop_returns_slot_to_free_list() { - let pool = MacAudioFramePool::new(2, SLOT_BYTES).expect("pool"); - let first = pool.try_acquire().expect("first"); - let second = pool.try_acquire().expect("second"); - assert!(pool.try_acquire().is_none()); - drop(first); - let revived = pool.try_acquire().expect("revived"); - assert_eq!(pool.stats().in_flight, 2); - drop(second); - drop(revived); - assert_eq!(pool.stats().in_flight, 0); - } - - #[test] - fn write_then_data_slice_matches_payload() { - let pool = MacAudioFramePool::new(2, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - let payload = [0xAB_u8; 32]; - slot.write(&payload).expect("payload fits"); - assert_eq!(slot.data_slice(), &payload[..]); - assert_eq!(slot.filled_len(), 32); - } - - #[test] - fn write_rejects_payload_larger_than_slot() { - let pool = MacAudioFramePool::new(2, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - let big = [0u8; 128]; - let err = slot.write(&big).unwrap_err(); - assert!(matches!(err, MacAudioError::PayloadTooLarge { .. })); - } - - #[test] - fn append_concatenates_planar_to_interleaved_like_payload() { - let pool = MacAudioFramePool::new(1, 32).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - let left = [1_u8, 2, 3, 4]; - let right = [5_u8, 6, 7, 8]; - slot.append(&left).expect("left fits"); - slot.append(&right).expect("right fits"); - assert_eq!(slot.data_slice(), &[1, 2, 3, 4, 5, 6, 7, 8]); - assert_eq!(slot.filled_len(), 8); - } - - #[test] - fn append_rejects_overflow() { - let pool = MacAudioFramePool::new(1, 4).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - slot.append(&[1, 2, 3]).expect("3 of 4 fits"); - let err = slot.append(&[4, 5]).unwrap_err(); - assert!(matches!(err, MacAudioError::PayloadTooLarge { .. })); - } - - #[test] - fn recycled_slot_persists_buffer_storage_until_overwritten() { - let pool = MacAudioFramePool::new(1, 16).expect("pool"); - let mut slot = pool.try_acquire().expect("first"); - let payload = [0x42_u8; 8]; - slot.write(&payload).expect("write"); - let observed_first = slot.data_slice().to_vec(); - drop(slot); - let slot2 = pool.try_acquire().expect("recycled"); - assert_eq!(slot2.filled_len(), 0); - assert_eq!(observed_first, payload.to_vec()); - } - - #[test] - fn multi_thread_acquire_release_stress_8_threads_1000_ops_no_deadlock() { - const THREADS: usize = 8; - const OPS_PER_THREAD: usize = 1000; - const POOL_CAP: usize = 4; - - let pool = Arc::new(MacAudioFramePool::new(POOL_CAP, 64).expect("pool")); - let barrier = Arc::new(Barrier::new(THREADS)); - let acquired_obs = Arc::new(AtomicUsize::new(0)); - let dropped_obs = Arc::new(AtomicUsize::new(0)); - - let mut handles = Vec::with_capacity(THREADS); - for _ in 0..THREADS { - let pool = Arc::clone(&pool); - let barrier = Arc::clone(&barrier); - let acquired_obs = Arc::clone(&acquired_obs); - let dropped_obs = Arc::clone(&dropped_obs); - handles.push(thread::spawn(move || { - barrier.wait(); - let mut local_acq: usize = 0; - let mut local_drop: usize = 0; - for _ in 0..OPS_PER_THREAD { - match pool.try_acquire() { - Some(mut slot) => { - local_acq += 1; - let _ = slot.write(&[0xAA; 16]); - drop(slot); - } - None => { - local_drop += 1; - } - } - } - acquired_obs.fetch_add(local_acq, Ordering::Relaxed); - dropped_obs.fetch_add(local_drop, Ordering::Relaxed); - })); - } - - let deadline = Instant::now() + Duration::from_secs(30); - for h in handles { - assert!(Instant::now() < deadline, "stress test exceeded 30s budget"); - h.join().expect("worker panicked"); - } - - let stats = pool.stats(); - let total = stats.acquired + stats.dropped; - assert_eq!(total as usize, THREADS * OPS_PER_THREAD); - assert_eq!( - stats.acquired as usize, - acquired_obs.load(Ordering::Relaxed) - ); - assert_eq!(stats.dropped as usize, dropped_obs.load(Ordering::Relaxed)); - assert_eq!(stats.in_flight, 0); - assert_eq!(stats.acquired, stats.released); - } - - #[test] - fn default_pool_dimensions_match_constants() { - let pool = default_pool(); - assert_eq!(pool.capacity() as usize, MAC_AUDIO_POOL_CAP); - assert_eq!(pool.bytes_per_slot() as usize, SLOT_BYTES); - } - - #[test] - fn stats_in_flight_matches_simultaneous_holders() { - let pool = MacAudioFramePool::new(8, 64).expect("pool"); - let a = pool.try_acquire().expect("a"); - let b = pool.try_acquire().expect("b"); - let c = pool.try_acquire().expect("c"); - assert_eq!(pool.stats().in_flight, 3); - drop(b); - assert_eq!(pool.stats().in_flight, 2); - drop(a); - drop(c); - assert_eq!(pool.stats().in_flight, 0); - } - - #[test] - fn into_external_parts_exposes_filled_pointer_and_length() { - let pool = MacAudioFramePool::new(2, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - let payload = [0x5A_u8; 16]; - slot.write(&payload).expect("write fits"); - let (ptr, len, owned) = slot.into_external_parts(); - assert!(!ptr.is_null()); - assert_eq!(len, 16); - let observed = unsafe { core::slice::from_raw_parts(ptr, len) }; - assert_eq!(observed, &payload[..]); - assert_eq!(pool.stats().in_flight, 1); - drop(owned); - assert_eq!(pool.stats().in_flight, 0); - } - - #[test] - fn into_external_parts_drop_returns_slot_to_pool() { - let pool = MacAudioFramePool::new(1, 32).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - slot.write(&[0xCC_u8; 8]).expect("write"); - let (_ptr, _len, owned) = slot.into_external_parts(); - assert!(pool.try_acquire().is_none()); - drop(owned); - let revived = pool.try_acquire().expect("revived"); - assert_eq!(revived.filled_len(), 0); - drop(revived); - assert_eq!(pool.stats().in_flight, 0); - } - - #[test] - fn into_external_parts_holds_slot_across_send() { - let pool = MacAudioFramePool::new(2, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - slot.write(&[0x77_u8; 32]).expect("write"); - let (ptr, len, owned) = slot.into_external_parts(); - let ptr_addr = ptr as usize; - let handle = thread::spawn(move || { - let owned = owned; - assert_eq!(owned.filled_len(), 32); - let observed = unsafe { core::slice::from_raw_parts(ptr_addr as *const u8, len) }; - assert_eq!(observed[0], 0x77); - drop(owned); - }); - handle.join().expect("worker"); - assert_eq!(pool.stats().in_flight, 0); - } - - #[test] - fn as_mut_ptr_returns_slot_base_address() { - let pool = MacAudioFramePool::new(1, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - let payload = [0x11_u8; 8]; - slot.write(&payload).expect("write"); - let ptr = slot.as_mut_ptr(); - assert!(!ptr.is_null()); - unsafe { ptr.add(0).write(0x22) }; - unsafe { ptr.add(1).write(0x33) }; - assert_eq!(&slot.data_slice()[..2], &[0x22, 0x33]); - } - - #[test] - fn capacity_one_pool_round_trips() { - let pool = MacAudioFramePool::new(1, 32).expect("pool"); - for _ in 0..5 { - let mut slot = pool.try_acquire().expect("slot"); - slot.write(&[1, 2, 3]).expect("write"); - assert_eq!(slot.data_slice(), &[1, 2, 3]); - drop(slot); - } - let stats = pool.stats(); - assert_eq!(stats.acquired, 5); - assert_eq!(stats.released, 5); - assert_eq!(stats.in_flight, 0); - } -} diff --git a/fluxer_desktop/native/mac-screen-capture/src/config.rs b/fluxer_desktop/native/mac-screen-capture/src/config.rs deleted file mode 100644 index 78a7aa647..000000000 --- a/fluxer_desktop/native/mac-screen-capture/src/config.rs +++ /dev/null @@ -1,467 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::fmt; - -pub const FPS_MIN: u32 = 1; -pub const FPS_MAX: u32 = 120; -pub const MAX_OUTPUT_WIDTH_DEFAULT: u32 = 3840; -pub const MAX_OUTPUT_HEIGHT_DEFAULT: u32 = 2160; -pub const OUTPUT_DIMENSION_MIN: u32 = 2; -pub const QUEUE_DEPTH_MIN: u32 = 1; -pub const QUEUE_DEPTH_MAX: u32 = 16; -pub const QUEUE_DEPTH_DEFAULT: u32 = 8; -pub const FPS_DEFAULT: u32 = 30; -pub const FRAME_INTERVAL_FACTOR_NUM: u64 = 9; -pub const FRAME_INTERVAL_FACTOR_DEN: u64 = 10; - -pub const PIXEL_FORMAT_BGRA_FOURCC: u32 = u32::from_be_bytes(*b"BGRA"); -pub const PIXEL_FORMAT_L10R_FOURCC: u32 = u32::from_be_bytes(*b"l10r"); -pub const PIXEL_FORMAT_420V_FOURCC: u32 = u32::from_be_bytes(*b"420v"); -pub const PIXEL_FORMAT_420F_FOURCC: u32 = u32::from_be_bytes(*b"420f"); - -pub const AUDIO_SAMPLE_RATE_DEFAULT_HZ: u32 = 48_000; -pub const AUDIO_CHANNEL_COUNT_DEFAULT: u32 = 2; -pub const AUDIO_SAMPLE_RATE_MIN_HZ: u32 = 8_000; -pub const AUDIO_SAMPLE_RATE_MAX_HZ: u32 = 192_000; -pub const AUDIO_CHANNEL_COUNT_MIN: u32 = 1; -pub const AUDIO_CHANNEL_COUNT_MAX: u32 = 8; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SckPixelFormat { - Bgra8, - L10rHdr, - Nv12VideoRange, - Nv12FullRange, -} - -impl SckPixelFormat { - pub fn as_fourcc(self) -> u32 { - let value = match self { - SckPixelFormat::Bgra8 => PIXEL_FORMAT_BGRA_FOURCC, - SckPixelFormat::L10rHdr => PIXEL_FORMAT_L10R_FOURCC, - SckPixelFormat::Nv12VideoRange => PIXEL_FORMAT_420V_FOURCC, - SckPixelFormat::Nv12FullRange => PIXEL_FORMAT_420F_FOURCC, - }; - assert!(value != 0); - value - } - - pub fn is_hdr(self) -> bool { - matches!(self, SckPixelFormat::L10rHdr) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SckColorSpace { - DisplayP3, - SrgbBt709, -} - -impl SckColorSpace { - pub fn as_cf_name(self) -> &'static str { - let name = match self { - SckColorSpace::DisplayP3 => "kCGColorSpaceDisplayP3", - SckColorSpace::SrgbBt709 => "kCGColorSpaceSRGB", - }; - assert!(!name.is_empty()); - name - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SckError { - InvalidFps(u32), - InvalidQueueDepth(u32), - HdrRequiresWideColorSpace, - InvalidAudioSampleRate(u32), - InvalidAudioChannelCount(u32), -} - -impl fmt::Display for SckError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - SckError::InvalidFps(v) => write!( - f, - "SckCaptureConfig: target_fps={v} out of range [{FPS_MIN}..={FPS_MAX}]" - ), - SckError::InvalidQueueDepth(v) => write!( - f, - "SckCaptureConfig: queue_depth={v} out of range [{QUEUE_DEPTH_MIN}..={QUEUE_DEPTH_MAX}]" - ), - SckError::HdrRequiresWideColorSpace => write!( - f, - "SckCaptureConfig: l10r HDR pixel format requires DisplayP3 color space" - ), - SckError::InvalidAudioSampleRate(v) => write!( - f, - "SckCaptureConfig: audio_sample_rate_hz={v} out of range [{AUDIO_SAMPLE_RATE_MIN_HZ}..={AUDIO_SAMPLE_RATE_MAX_HZ}]" - ), - SckError::InvalidAudioChannelCount(v) => write!( - f, - "SckCaptureConfig: audio_channels={v} out of range [{AUDIO_CHANNEL_COUNT_MIN}..={AUDIO_CHANNEL_COUNT_MAX}]" - ), - } - } -} - -impl std::error::Error for SckError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SckCaptureConfig { - target_fps: u32, - queue_depth: u32, - pixel_format: SckPixelFormat, - color_space: SckColorSpace, - captures_audio: bool, - audio_sample_rate_hz: u32, - audio_channels: u32, -} - -impl SckCaptureConfig { - pub fn new( - target_fps: u32, - queue_depth: u32, - pixel_format: SckPixelFormat, - color_space: SckColorSpace, - ) -> Result { - Self::new_with_audio( - target_fps, - queue_depth, - pixel_format, - color_space, - false, - AUDIO_SAMPLE_RATE_DEFAULT_HZ, - AUDIO_CHANNEL_COUNT_DEFAULT, - ) - } - - pub fn new_with_audio( - target_fps: u32, - queue_depth: u32, - pixel_format: SckPixelFormat, - color_space: SckColorSpace, - captures_audio: bool, - audio_sample_rate_hz: u32, - audio_channels: u32, - ) -> Result { - if !(FPS_MIN..=FPS_MAX).contains(&target_fps) { - return Err(SckError::InvalidFps(target_fps)); - } - if !(QUEUE_DEPTH_MIN..=QUEUE_DEPTH_MAX).contains(&queue_depth) { - return Err(SckError::InvalidQueueDepth(queue_depth)); - } - if pixel_format.is_hdr() && color_space != SckColorSpace::DisplayP3 { - return Err(SckError::HdrRequiresWideColorSpace); - } - if !(AUDIO_SAMPLE_RATE_MIN_HZ..=AUDIO_SAMPLE_RATE_MAX_HZ).contains(&audio_sample_rate_hz) { - return Err(SckError::InvalidAudioSampleRate(audio_sample_rate_hz)); - } - if !(AUDIO_CHANNEL_COUNT_MIN..=AUDIO_CHANNEL_COUNT_MAX).contains(&audio_channels) { - return Err(SckError::InvalidAudioChannelCount(audio_channels)); - } - let cfg = Self { - target_fps, - queue_depth, - pixel_format, - color_space, - captures_audio, - audio_sample_rate_hz, - audio_channels, - }; - assert!(cfg.target_fps >= FPS_MIN); - assert!(cfg.target_fps <= FPS_MAX); - assert!(cfg.queue_depth >= QUEUE_DEPTH_MIN); - assert!(cfg.queue_depth <= QUEUE_DEPTH_MAX); - assert!(cfg.audio_sample_rate_hz >= AUDIO_SAMPLE_RATE_MIN_HZ); - assert!(cfg.audio_channels >= AUDIO_CHANNEL_COUNT_MIN); - Ok(cfg) - } - - pub fn builder() -> SckCaptureConfigBuilder { - SckCaptureConfigBuilder::default() - } - - pub fn target_fps(&self) -> u32 { - assert!(self.target_fps >= FPS_MIN); - assert!(self.target_fps <= FPS_MAX); - self.target_fps - } - - pub fn queue_depth(&self) -> u32 { - assert!(self.queue_depth >= QUEUE_DEPTH_MIN); - assert!(self.queue_depth <= QUEUE_DEPTH_MAX); - self.queue_depth - } - - pub fn pixel_format(&self) -> SckPixelFormat { - let pf = self.pixel_format; - assert!(pf.as_fourcc() != 0); - pf - } - - pub fn color_space(&self) -> SckColorSpace { - let cs = self.color_space; - assert!(!cs.as_cf_name().is_empty()); - cs - } - - pub fn minimum_frame_interval_ns(&self) -> u64 { - assert!(self.target_fps >= FPS_MIN); - assert!(self.target_fps <= FPS_MAX); - let base_ns: u64 = 1_000_000_000 / (self.target_fps as u64); - let scaled = base_ns * FRAME_INTERVAL_FACTOR_NUM / FRAME_INTERVAL_FACTOR_DEN; - assert!(scaled > 0); - assert!(scaled <= 1_000_000_000); - scaled - } - - pub fn captures_audio(&self) -> bool { - self.captures_audio - } - - pub fn audio_sample_rate_hz(&self) -> u32 { - assert!(self.audio_sample_rate_hz >= AUDIO_SAMPLE_RATE_MIN_HZ); - assert!(self.audio_sample_rate_hz <= AUDIO_SAMPLE_RATE_MAX_HZ); - self.audio_sample_rate_hz - } - - pub fn audio_channels(&self) -> u32 { - assert!(self.audio_channels >= AUDIO_CHANNEL_COUNT_MIN); - assert!(self.audio_channels <= AUDIO_CHANNEL_COUNT_MAX); - self.audio_channels - } -} - -impl Default for SckCaptureConfig { - fn default() -> Self { - let cfg = Self::new_with_audio( - FPS_DEFAULT, - QUEUE_DEPTH_DEFAULT, - SckPixelFormat::Nv12VideoRange, - SckColorSpace::SrgbBt709, - false, - AUDIO_SAMPLE_RATE_DEFAULT_HZ, - AUDIO_CHANNEL_COUNT_DEFAULT, - ); - match cfg { - Ok(c) => c, - Err(_) => unreachable!("default SckCaptureConfig must validate"), - } - } -} - -#[derive(Debug, Clone, Copy)] -pub struct SckCaptureConfigBuilder { - target_fps: u32, - queue_depth: u32, - pixel_format: SckPixelFormat, - color_space: SckColorSpace, - captures_audio: bool, - audio_sample_rate_hz: u32, - audio_channels: u32, -} - -impl Default for SckCaptureConfigBuilder { - fn default() -> Self { - Self { - target_fps: FPS_DEFAULT, - queue_depth: QUEUE_DEPTH_DEFAULT, - pixel_format: SckPixelFormat::Nv12VideoRange, - color_space: SckColorSpace::SrgbBt709, - captures_audio: false, - audio_sample_rate_hz: AUDIO_SAMPLE_RATE_DEFAULT_HZ, - audio_channels: AUDIO_CHANNEL_COUNT_DEFAULT, - } - } -} - -impl SckCaptureConfigBuilder { - pub fn target_fps(mut self, target_fps: u32) -> Self { - self.target_fps = target_fps; - self - } - - pub fn queue_depth(mut self, queue_depth: u32) -> Self { - self.queue_depth = queue_depth; - self - } - - pub fn pixel_format(mut self, pixel_format: SckPixelFormat) -> Self { - self.pixel_format = pixel_format; - self - } - - pub fn color_space(mut self, color_space: SckColorSpace) -> Self { - self.color_space = color_space; - self - } - - pub fn captures_audio(mut self, captures_audio: bool) -> Self { - self.captures_audio = captures_audio; - self - } - - pub fn audio_sample_rate_hz(mut self, audio_sample_rate_hz: u32) -> Self { - self.audio_sample_rate_hz = audio_sample_rate_hz; - self - } - - pub fn audio_channels(mut self, audio_channels: u32) -> Self { - self.audio_channels = audio_channels; - self - } - - pub fn build(self) -> Result { - SckCaptureConfig::new_with_audio( - self.target_fps, - self.queue_depth, - self.pixel_format, - self.color_space, - self.captures_audio, - self.audio_sample_rate_hz, - self.audio_channels, - ) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SckCaptureFailure { - StreamStoppedWithError(String), - StreamStartFailed(String), - SystemDeniedAccess, - DisplayDisconnected, - Unknown(String), -} - -impl SckCaptureFailure { - pub fn reason(&self) -> &str { - match self { - SckCaptureFailure::StreamStoppedWithError(m) => m.as_str(), - SckCaptureFailure::StreamStartFailed(m) => m.as_str(), - SckCaptureFailure::SystemDeniedAccess => "screen recording permission denied", - SckCaptureFailure::DisplayDisconnected => "captured display was disconnected", - SckCaptureFailure::Unknown(m) => m.as_str(), - } - } -} - -pub trait CaptureFailureSurface: Send + Sync { - fn on_failure(&self, reason: SckCaptureFailure); -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[repr(u32)] -pub enum AudioSampleFormat { - F32Planar = 0, - F32Interleaved = 1, - I16Interleaved = 2, - Unknown = 3, -} - -pub const AUDIO_SAMPLE_FORMAT_CODE_MAX: u32 = 3; - -impl AudioSampleFormat { - pub fn code(self) -> u32 { - let code = match self { - AudioSampleFormat::F32Planar => 0, - AudioSampleFormat::F32Interleaved => 1, - AudioSampleFormat::I16Interleaved => 2, - AudioSampleFormat::Unknown => 3, - }; - assert!(code <= AUDIO_SAMPLE_FORMAT_CODE_MAX); - assert_eq!(code, self as u32); - code - } - - pub fn as_str(self) -> &'static str { - let s = match self { - AudioSampleFormat::F32Planar => "f32_planar", - AudioSampleFormat::F32Interleaved => "f32_interleaved", - AudioSampleFormat::I16Interleaved => "i16_interleaved", - AudioSampleFormat::Unknown => "unknown", - }; - assert!(!s.is_empty()); - s - } - - pub fn bytes_per_sample(self) -> u32 { - let bytes = match self { - AudioSampleFormat::F32Planar => 4, - AudioSampleFormat::F32Interleaved => 4, - AudioSampleFormat::I16Interleaved => 2, - AudioSampleFormat::Unknown => 0, - }; - assert!(bytes <= 4); - bytes - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct MacScreenShareAudioFrame { - pub sample_rate_hz: u32, - pub channels: u32, - pub num_samples_per_channel: u32, - pub pts_us: i64, -} - -impl MacScreenShareAudioFrame { - pub fn new( - sample_rate_hz: u32, - channels: u32, - num_samples_per_channel: u32, - pts_us: i64, - ) -> Result { - if !(AUDIO_SAMPLE_RATE_MIN_HZ..=AUDIO_SAMPLE_RATE_MAX_HZ).contains(&sample_rate_hz) { - return Err(SckError::InvalidAudioSampleRate(sample_rate_hz)); - } - if !(AUDIO_CHANNEL_COUNT_MIN..=AUDIO_CHANNEL_COUNT_MAX).contains(&channels) { - return Err(SckError::InvalidAudioChannelCount(channels)); - } - assert!(sample_rate_hz >= AUDIO_SAMPLE_RATE_MIN_HZ); - assert!(channels >= AUDIO_CHANNEL_COUNT_MIN); - Ok(Self { - sample_rate_hz, - channels, - num_samples_per_channel, - pts_us, - }) - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct MacScreenShareAudioFrameWithBytes { - pub sample_rate_hz: u32, - pub channels: u32, - pub num_samples_per_channel: u32, - pub pts_us: i64, - pub format: AudioSampleFormat, - pub samples: Vec, -} - -impl MacScreenShareAudioFrameWithBytes { - pub fn new( - sample_rate_hz: u32, - channels: u32, - num_samples_per_channel: u32, - pts_us: i64, - format: AudioSampleFormat, - samples: Vec, - ) -> Result { - if !(AUDIO_SAMPLE_RATE_MIN_HZ..=AUDIO_SAMPLE_RATE_MAX_HZ).contains(&sample_rate_hz) { - return Err(SckError::InvalidAudioSampleRate(sample_rate_hz)); - } - if !(AUDIO_CHANNEL_COUNT_MIN..=AUDIO_CHANNEL_COUNT_MAX).contains(&channels) { - return Err(SckError::InvalidAudioChannelCount(channels)); - } - assert!(sample_rate_hz >= AUDIO_SAMPLE_RATE_MIN_HZ); - assert!(channels >= AUDIO_CHANNEL_COUNT_MIN); - Ok(Self { - sample_rate_hz, - channels, - num_samples_per_channel, - pts_us, - format, - samples, - }) - } -} diff --git a/fluxer_desktop/native/mac-screen-capture/src/encoder_attach.rs b/fluxer_desktop/native/mac-screen-capture/src/encoder_attach.rs deleted file mode 100644 index cac0a36e4..000000000 --- a/fluxer_desktop/native/mac-screen-capture/src/encoder_attach.rs +++ /dev/null @@ -1,846 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::collections::VecDeque; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::time::Duration; - -use parking_lot::{Condvar, Mutex}; - -pub const CAPTURE_PTS_MAP_CAP: usize = RING_SIZE; -pub const EXTERNAL_SURFACE_QUEUE_CAP: usize = RING_SIZE; -pub const READY_WAIT_TIMEOUT_US_MAX: u64 = 16_667; - -use fluxer_encoder_ring::{ - EncoderFrameRate, EncoderInputRing, EncoderReady, IoSurfaceSlotHandle, - MetalSharedTextureBackend, RING_SIZE, RingError, TextureFormat, -}; - -#[cfg(target_os = "macos")] -use fluxer_encoder_ring::FillReservation; - -#[cfg(target_os = "macos")] -use fluxer_encoder_ring::VtPixelTransfer; - -#[cfg(target_os = "macos")] -use crate::iosurface_pair::{IoSurfaceRaw, iosurface_decrement_use_count}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EncoderAttachError { - AlreadyAttached, - InvalidDimensions { width: u32, height: u32 }, - RingInitFailed, - NotAttached, -} - -impl std::fmt::Display for EncoderAttachError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::AlreadyAttached => write!(f, "encoder ring already attached"), - Self::InvalidDimensions { width, height } => { - write!(f, "encoder ring invalid dimensions {width}x{height}") - } - Self::RingInitFailed => write!(f, "encoder ring initialise failed"), - Self::NotAttached => write!(f, "encoder ring not attached"), - } - } -} - -impl std::error::Error for EncoderAttachError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct EncoderAttachStats { - pub frames_submitted: u64, - pub frames_dropped: u64, - pub ring_full_events: u64, - pub failed_blits: u64, -} - -#[cfg(target_os = "macos")] -pub struct ExternalSurfaceFrame { - surface: IoSurfaceRaw, - sequence: u64, - capture_pts_us: i64, -} - -#[cfg(target_os = "macos")] -unsafe impl Send for ExternalSurfaceFrame {} - -#[cfg(target_os = "macos")] -impl ExternalSurfaceFrame { - pub fn surface(&self) -> IoSurfaceRaw { - assert!(self.sequence > 0, "external frame sequence starts at one"); - assert!(self.surface.as_ptr() as usize != 0, "surface ptr non-null"); - self.surface - } - - pub fn sequence(&self) -> u64 { - assert!(self.sequence > 0, "external frame sequence starts at one"); - assert!(self.surface.as_ptr() as usize != 0, "surface ptr non-null"); - self.sequence - } - - pub fn capture_pts_us(&self) -> i64 { - assert!(self.sequence > 0, "external frame sequence starts at one"); - assert!(self.surface.as_ptr() as usize != 0, "surface ptr non-null"); - self.capture_pts_us - } -} - -#[cfg(target_os = "macos")] -impl Drop for ExternalSurfaceFrame { - fn drop(&mut self) { - unsafe { iosurface_decrement_use_count(self.surface) }; - unsafe { CFRelease(self.surface.as_ptr()) }; - } -} - -#[cfg(target_os = "macos")] -struct BlitDestination { - pixel_buffer: *mut core::ffi::c_void, -} - -#[cfg(target_os = "macos")] -unsafe impl Send for BlitDestination {} - -#[cfg(target_os = "macos")] -unsafe impl Sync for BlitDestination {} - -#[cfg(target_os = "macos")] -impl Drop for BlitDestination { - fn drop(&mut self) { - unsafe { release_pixel_buffer(self.pixel_buffer) }; - } -} - -pub struct EncoderAttachment { - ring: Mutex>, - ready_condvar: Condvar, - attached: AtomicBool, - frames_submitted: AtomicU64, - frames_dropped: AtomicU64, - ring_full_events: AtomicU64, - failed_blits: AtomicU64, - width: u32, - height: u32, - frame_rate: EncoderFrameRate, - capture_pts_by_sequence: Mutex>, - #[cfg(target_os = "macos")] - pixel_transfer: Mutex>, - #[cfg(target_os = "macos")] - blit_destinations: Vec, - #[cfg(target_os = "macos")] - external_surfaces: Mutex>, - #[cfg(target_os = "macos")] - external_sequence: AtomicU64, -} - -impl EncoderAttachment { - pub fn try_new(width: u32, height: u32) -> Result, EncoderAttachError> { - Self::try_new_with_frame_rate(width, height, EncoderFrameRate::default()) - } - - pub fn try_new_with_frame_rate( - width: u32, - height: u32, - frame_rate: EncoderFrameRate, - ) -> Result, EncoderAttachError> { - if width == 0 || height == 0 { - return Err(EncoderAttachError::InvalidDimensions { width, height }); - } - assert!(frame_rate.numerator > 0, "frame rate numerator positive"); - assert!( - frame_rate.denominator > 0, - "frame rate denominator positive" - ); - let mut ring = EncoderInputRing::new(MetalSharedTextureBackend::new()); - ring.initialise(width, height, TextureFormat::Nv12) - .map_err(|_| EncoderAttachError::RingInitFailed)?; - #[cfg(target_os = "macos")] - let blit_destinations = build_blit_destinations(&mut ring, width, height)?; - #[cfg(target_os = "macos")] - let pixel_transfer = VtPixelTransfer::new().ok(); - let attachment = Self { - ring: Mutex::new(ring), - ready_condvar: Condvar::new(), - attached: AtomicBool::new(true), - frames_submitted: AtomicU64::new(0), - frames_dropped: AtomicU64::new(0), - ring_full_events: AtomicU64::new(0), - failed_blits: AtomicU64::new(0), - width, - height, - frame_rate, - capture_pts_by_sequence: Mutex::new(VecDeque::with_capacity(CAPTURE_PTS_MAP_CAP)), - #[cfg(target_os = "macos")] - pixel_transfer: Mutex::new(pixel_transfer), - #[cfg(target_os = "macos")] - blit_destinations, - #[cfg(target_os = "macos")] - external_surfaces: Mutex::new(VecDeque::with_capacity(EXTERNAL_SURFACE_QUEUE_CAP)), - #[cfg(target_os = "macos")] - external_sequence: AtomicU64::new(0), - }; - assert!( - attachment.attached.load(Ordering::Acquire), - "attachment is attached" - ); - assert!(attachment.width > 0, "attachment width positive"); - assert!( - attachment.frame_rate.numerator > 0, - "attachment fps positive" - ); - Ok(Arc::new(attachment)) - } - - pub fn width(&self) -> u32 { - let w = self.width; - assert!(w > 0, "attachment width positive"); - assert!(self.height > 0, "attachment height positive"); - w - } - - pub fn height(&self) -> u32 { - let h = self.height; - assert!(h > 0, "attachment height positive"); - assert!(self.width > 0, "attachment width positive"); - h - } - - pub fn frame_rate(&self) -> EncoderFrameRate { - let rate = self.frame_rate; - assert!(rate.numerator > 0, "attachment fps numerator positive"); - assert!(rate.denominator > 0, "attachment fps denominator positive"); - rate - } - - pub fn is_attached(&self) -> bool { - let a = self.attached.load(Ordering::Acquire); - assert!(self.width > 0, "width intact while reading attached"); - assert!(self.height > 0, "height intact while reading attached"); - a - } - - pub fn detach(&self) { - self.attached.store(false, Ordering::Release); - #[cfg(target_os = "macos")] - { - let drained = core::mem::take(&mut *self.external_surfaces.lock()); - assert!( - drained.len() <= EXTERNAL_SURFACE_QUEUE_CAP, - "external queue bounded at detach" - ); - drop(drained); - } - self.ready_condvar.notify_all(); - } - - pub fn capacity(&self) -> usize { - let cap = RING_SIZE; - assert!(cap > 0, "ring capacity positive"); - assert_eq!(cap, 8, "ring capacity matches RING_SIZE"); - cap - } - - pub fn submit_iosurface_frame(&self, _iosurface_handle: u64) -> Result<(), EncoderAttachError> { - if !self.attached.load(Ordering::Acquire) { - return Err(EncoderAttachError::NotAttached); - } - let mut ring = self.ring.lock(); - let result: Result<(), RingError> = ring.submit_skip_oldest(|_handle| {}); - drop(ring); - match result { - Ok(()) => { - self.frames_submitted.fetch_add(1, Ordering::Relaxed); - self.ready_condvar.notify_one(); - Ok(()) - } - Err(RingError::FullDropped { .. }) => { - self.frames_dropped.fetch_add(1, Ordering::Relaxed); - self.ring_full_events.fetch_add(1, Ordering::Relaxed); - Ok(()) - } - Err(_) => Err(EncoderAttachError::RingInitFailed), - } - } - - #[cfg(target_os = "macos")] - pub fn submit_with_blit( - &self, - source_pixel_buffer: *mut core::ffi::c_void, - capture_pts_us: i64, - ) -> Result<(), EncoderAttachError> { - assert!( - !source_pixel_buffer.is_null(), - "blit source must be non-null" - ); - assert!(self.width > 0, "attachment width positive"); - if !self.attached.load(Ordering::Acquire) { - return Err(EncoderAttachError::NotAttached); - } - let transfer_guard = self.pixel_transfer.lock(); - let Some(transfer) = transfer_guard.as_ref() else { - self.failed_blits.fetch_add(1, Ordering::Relaxed); - return Err(EncoderAttachError::RingInitFailed); - }; - let Some(reservation) = self.reserve_blit_slot()? else { - return Ok(()); - }; - let slot_index = reservation.slot_index() as usize; - assert!( - slot_index < self.blit_destinations.len(), - "slot index within cached destinations" - ); - let dest_pb = self.blit_destinations[slot_index].pixel_buffer; - assert!( - !dest_pb.is_null(), - "cached destination pixel buffer non-null" - ); - let blit_ok = unsafe { transfer.transfer(source_pixel_buffer, dest_pb) }.is_ok(); - if blit_ok { - self.commit_blit_slot(reservation, capture_pts_us) - } else { - let cancelled = self.ring.lock().cancel(reservation); - assert!(cancelled.is_ok(), "cancel of filling reservation succeeds"); - self.failed_blits.fetch_add(1, Ordering::Relaxed); - Err(EncoderAttachError::RingInitFailed) - } - } - - #[cfg(target_os = "macos")] - fn reserve_blit_slot( - &self, - ) -> Result>, EncoderAttachError> { - assert!(self.width > 0, "attachment width positive"); - assert!(self.height > 0, "attachment height positive"); - let mut ring = self.ring.lock(); - match ring.reserve_skip_oldest() { - Ok(reservation) => { - assert!( - (reservation.slot_index() as usize) < RING_SIZE, - "reserved slot within ring" - ); - Ok(Some(reservation)) - } - Err(RingError::FullDropped { .. }) => { - self.frames_dropped.fetch_add(1, Ordering::Relaxed); - self.ring_full_events.fetch_add(1, Ordering::Relaxed); - Ok(None) - } - Err(_) => Err(EncoderAttachError::RingInitFailed), - } - } - - #[cfg(target_os = "macos")] - fn commit_blit_slot( - &self, - reservation: FillReservation, - capture_pts_us: i64, - ) -> Result<(), EncoderAttachError> { - assert!( - (reservation.slot_index() as usize) < RING_SIZE, - "slot within ring" - ); - let sequence = { - let mut ring = self.ring.lock(); - let sequence = match ring.commit(reservation) { - Ok(sequence) => sequence, - Err(_) => return Err(EncoderAttachError::RingInitFailed), - }; - self.record_capture_pts(sequence, capture_pts_us); - sequence - }; - assert!(sequence > 0, "committed sequence positive"); - self.frames_submitted.fetch_add(1, Ordering::Relaxed); - self.ready_condvar.notify_one(); - Ok(()) - } - - pub fn wait_next_ready(&self, timeout: Duration) -> Option> { - assert!(self.width > 0, "attachment width positive"); - let timeout_bound = Duration::from_micros(READY_WAIT_TIMEOUT_US_MAX); - let bounded_timeout = timeout.min(timeout_bound); - assert!( - bounded_timeout <= timeout_bound, - "wait bounded to one frame interval" - ); - let mut ring = self.ring.lock(); - if let Some(ready) = ring.poll_next_ready() { - return Some(ready); - } - if !self.attached.load(Ordering::Acquire) { - return None; - } - let _ = self.ready_condvar.wait_for(&mut ring, bounded_timeout); - ring.poll_next_ready() - } - - #[cfg(target_os = "macos")] - pub unsafe fn submit_external_surface( - &self, - surface: IoSurfaceRaw, - capture_pts_us: i64, - ) -> Result<(), EncoderAttachError> { - assert!(surface.as_ptr() as usize != 0, "external surface non-null"); - assert!(self.width > 0, "attachment width positive"); - if !self.attached.load(Ordering::Acquire) { - unsafe { CFRelease(surface.as_ptr()) }; - return Err(EncoderAttachError::NotAttached); - } - unsafe { crate::iosurface_pair::iosurface_increment_use_count(surface) }; - let sequence = self.external_sequence.fetch_add(1, Ordering::AcqRel) + 1; - assert!(sequence > 0, "external sequence starts at one"); - let frame = ExternalSurfaceFrame { - surface, - sequence, - capture_pts_us, - }; - let evicted = { - let mut queue = self.external_surfaces.lock(); - let evicted = if queue.len() >= EXTERNAL_SURFACE_QUEUE_CAP { - queue.pop_front() - } else { - None - }; - queue.push_back(frame); - assert!( - queue.len() <= EXTERNAL_SURFACE_QUEUE_CAP, - "external queue bounded" - ); - evicted - }; - if let Some(oldest) = evicted { - drop(oldest); - self.frames_dropped.fetch_add(1, Ordering::Relaxed); - self.ring_full_events.fetch_add(1, Ordering::Relaxed); - } - self.frames_submitted.fetch_add(1, Ordering::Relaxed); - Ok(()) - } - - #[cfg(target_os = "macos")] - pub fn take_external_surface(&self) -> Option { - let frame = { - let mut queue = self.external_surfaces.lock(); - assert!( - queue.len() <= EXTERNAL_SURFACE_QUEUE_CAP, - "external queue bounded" - ); - queue.pop_front() - }; - if let Some(ref taken) = frame { - assert!(taken.sequence > 0, "popped frame has real sequence"); - } - frame - } - - #[cfg(target_os = "macos")] - pub fn external_surface_queue_len(&self) -> usize { - let len = self.external_surfaces.lock().len(); - assert!(len <= EXTERNAL_SURFACE_QUEUE_CAP, "external queue bounded"); - assert!(self.width > 0, "attachment width intact"); - len - } - - fn record_capture_pts(&self, sequence: u64, capture_pts_us: i64) { - assert!(sequence > 0, "ring sequences start at one"); - let mut map = self.capture_pts_by_sequence.lock(); - while map.len() >= CAPTURE_PTS_MAP_CAP { - map.pop_front(); - } - map.push_back((sequence, capture_pts_us)); - assert!(map.len() <= CAPTURE_PTS_MAP_CAP, "pts map bounded"); - } - - pub fn capture_pts_us_for_sequence(&self, sequence: u64) -> Option { - let map = self.capture_pts_by_sequence.lock(); - assert!(map.len() <= CAPTURE_PTS_MAP_CAP, "pts map bounded"); - map.iter() - .find(|(seq, _)| *seq == sequence) - .map(|(_, pts)| *pts) - } - - pub fn note_ring_full(&self) { - self.ring_full_events.fetch_add(1, Ordering::Relaxed); - } - - pub fn stats(&self) -> EncoderAttachStats { - let stats = EncoderAttachStats { - frames_submitted: self.frames_submitted.load(Ordering::Relaxed), - frames_dropped: self.frames_dropped.load(Ordering::Relaxed), - ring_full_events: self.ring_full_events.load(Ordering::Relaxed), - failed_blits: self.failed_blits.load(Ordering::Relaxed), - }; - assert!( - stats.frames_dropped <= stats.ring_full_events + stats.frames_dropped, - "drop counter consistent" - ); - assert!( - stats.frames_submitted <= u64::MAX / 2, - "submitted within plausible bound" - ); - stats - } -} - -#[cfg(target_os = "macos")] -fn build_blit_destinations( - ring: &mut EncoderInputRing, - width: u32, - height: u32, -) -> Result, EncoderAttachError> { - assert!(width > 0, "blit destination width positive"); - assert!(height > 0, "blit destination height positive"); - let mut destinations: Vec = Vec::with_capacity(RING_SIZE); - for slot_index in 0..RING_SIZE { - let surface_ptr = ring - .backend_mut() - .slot_iosurface_ptr(slot_index as u32) - .ok_or(EncoderAttachError::RingInitFailed)?; - assert!(!surface_ptr.is_null(), "slot iosurface ptr non-null"); - let pixel_buffer = unsafe { VtPixelTransfer::wrap_iosurface(surface_ptr, width, height) } - .map_err(|_| EncoderAttachError::RingInitFailed)?; - destinations.push(BlitDestination { pixel_buffer }); - } - assert_eq!(destinations.len(), RING_SIZE, "one destination per slot"); - Ok(destinations) -} - -#[cfg(target_os = "macos")] -#[link(name = "CoreVideo", kind = "framework")] -unsafe extern "C" { - fn CVPixelBufferRelease(buffer: *mut core::ffi::c_void); -} - -#[cfg(target_os = "macos")] -#[link(name = "CoreFoundation", kind = "framework")] -unsafe extern "C" { - fn CFRelease(cf: *const core::ffi::c_void); - fn CFRetain(cf: *const core::ffi::c_void) -> *const core::ffi::c_void; -} - -#[cfg(target_os = "macos")] -unsafe fn release_pixel_buffer(pb: *mut core::ffi::c_void) { - if !pb.is_null() { - unsafe { CVPixelBufferRelease(pb) }; - } -} - -impl Drop for EncoderAttachment { - fn drop(&mut self) { - self.detach(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rejects_zero_dimensions() { - let err = EncoderAttachment::try_new(0, 720).err(); - assert!(matches!( - err, - Some(EncoderAttachError::InvalidDimensions { .. }) - )); - } - - #[cfg(target_os = "macos")] - #[test] - fn macos_attach_then_detach() { - let attach = EncoderAttachment::try_new(640, 480).expect("attach ok"); - assert!(attach.is_attached()); - assert_eq!(attach.width(), 640); - assert_eq!(attach.height(), 480); - assert_eq!(attach.capacity(), 8); - attach.detach(); - assert!(!attach.is_attached()); - } - - #[cfg(target_os = "macos")] - #[test] - fn macos_submit_records_stats() { - let attach = EncoderAttachment::try_new(64, 64).expect("attach ok"); - for _ in 0..3 { - attach - .submit_iosurface_frame(0xdead_beef) - .expect("submit ok"); - } - let stats = attach.stats(); - assert_eq!(stats.frames_submitted, 3); - assert_eq!(stats.frames_dropped, 0); - assert_eq!(stats.ring_full_events, 0); - } - - #[cfg(target_os = "macos")] - #[test] - fn wait_next_ready_returns_submitted_frame() { - let attach = EncoderAttachment::try_new(64, 64).expect("attach ok"); - attach - .submit_iosurface_frame(0xdead_beef) - .expect("submit ok"); - let ready = attach - .wait_next_ready(Duration::from_millis(5)) - .expect("frame ready"); - assert_eq!(ready.sequence, 1); - assert_eq!(ready.duplicate_count, 0); - assert!(attach.wait_next_ready(Duration::from_millis(1)).is_none()); - } - - #[cfg(target_os = "macos")] - #[test] - fn wait_next_ready_timeout_is_capped_at_frame_interval() { - let attach = EncoderAttachment::try_new(64, 64).expect("attach ok"); - let start = std::time::Instant::now(); - let ready = attach.wait_next_ready(Duration::from_secs(60)); - assert!(ready.is_none()); - assert!( - start.elapsed() < Duration::from_millis(500), - "wait returned within the named frame-interval bound" - ); - } - - #[cfg(target_os = "macos")] - #[test] - fn wait_next_ready_wakes_for_concurrent_submit() { - let attach = EncoderAttachment::try_new(64, 64).expect("attach ok"); - let waiter = { - let attach = Arc::clone(&attach); - std::thread::spawn(move || attach.wait_next_ready(Duration::from_millis(15))) - }; - std::thread::sleep(Duration::from_millis(3)); - attach - .submit_iosurface_frame(0xdead_beef) - .expect("submit ok"); - let ready = waiter.join().expect("waiter joins"); - let sequence = ready.map(|r| r.sequence); - assert!(sequence == Some(1) || sequence.is_none()); - if sequence.is_none() { - let retry = attach.wait_next_ready(Duration::from_millis(5)); - assert_eq!(retry.map(|r| r.sequence), Some(1)); - } - } - - #[cfg(target_os = "macos")] - #[test] - fn wait_next_ready_returns_none_after_detach() { - let attach = EncoderAttachment::try_new(64, 64).expect("attach ok"); - attach.detach(); - assert!(!attach.is_attached()); - assert!(attach.wait_next_ready(Duration::from_millis(5)).is_none()); - } - - #[cfg(target_os = "macos")] - #[test] - fn macos_submit_rejected_when_detached() { - let attach = EncoderAttachment::try_new(64, 64).expect("attach ok"); - attach.detach(); - let err = attach.submit_iosurface_frame(0xdead_beef).err(); - assert!(matches!(err, Some(EncoderAttachError::NotAttached))); - } - - #[cfg(target_os = "macos")] - #[test] - fn submit_encoder_ring_frame_blits_iosurface_to_slot() { - use core::ptr::NonNull; - use objc2_core_foundation::{CFDictionary, CFRetained}; - use objc2_core_video::{ - CVPixelBuffer, CVPixelBufferGetBaseAddressOfPlane, CVPixelBufferGetBytesPerRowOfPlane, - CVPixelBufferGetHeightOfPlane, CVPixelBufferLockBaseAddress, CVPixelBufferLockFlags, - CVPixelBufferUnlockBaseAddress, kCVPixelBufferIOSurfacePropertiesKey, - }; - - let attach = EncoderAttachment::try_new(256, 256).expect("attach ok"); - let mut empty_keys: [*const core::ffi::c_void; 0] = []; - let mut empty_vals: [*const core::ffi::c_void; 0] = []; - let iosurf_dict: CFRetained = unsafe { - CFDictionary::new( - None, - empty_keys.as_mut_ptr(), - empty_vals.as_mut_ptr(), - 0, - &objc2_core_foundation::kCFTypeDictionaryKeyCallBacks, - &objc2_core_foundation::kCFTypeDictionaryValueCallBacks, - ) - .expect("iosurf empty dict") - }; - let key_ref: &objc2_core_foundation::CFString = - unsafe { kCVPixelBufferIOSurfacePropertiesKey }; - let key_ptr: *const core::ffi::c_void = key_ref as *const _ as *const core::ffi::c_void; - let val_ptr: *const core::ffi::c_void = - &*iosurf_dict as *const _ as *const core::ffi::c_void; - let mut keys = [key_ptr]; - let mut vals = [val_ptr]; - let attrs: CFRetained = unsafe { - CFDictionary::new( - None, - keys.as_mut_ptr(), - vals.as_mut_ptr(), - 1, - &objc2_core_foundation::kCFTypeDictionaryKeyCallBacks, - &objc2_core_foundation::kCFTypeDictionaryValueCallBacks, - ) - .expect("attrs dict") - }; - let nv12: u32 = u32::from_be_bytes(*b"420v"); - let mut pb_out: *mut CVPixelBuffer = core::ptr::null_mut(); - let status = unsafe { - objc2_core_video::CVPixelBufferCreate( - None, - 256, - 256, - nv12, - Some(&attrs), - NonNull::new(&mut pb_out).expect("pb_out non-null"), - ) - }; - assert_eq!(status, 0, "CVPixelBufferCreate ok"); - let source_pb: CFRetained = - unsafe { CFRetained::from_raw(NonNull::new(pb_out).expect("pb non-null")) }; - let lock_flags = CVPixelBufferLockFlags(0); - let lock_st = unsafe { CVPixelBufferLockBaseAddress(&source_pb, lock_flags) }; - assert_eq!(lock_st, 0, "lock ok"); - let y_ptr = CVPixelBufferGetBaseAddressOfPlane(&source_pb, 0); - let y_stride = CVPixelBufferGetBytesPerRowOfPlane(&source_pb, 0); - let y_h = CVPixelBufferGetHeightOfPlane(&source_pb, 0); - assert!(!y_ptr.is_null(), "Y plane base non-null"); - assert!(y_stride > 0, "Y stride positive"); - for row in 0..y_h { - for col in 0..y_stride { - unsafe { - (y_ptr as *mut u8).add(row * y_stride + col).write(0x55); - } - } - } - let _ = unsafe { CVPixelBufferUnlockBaseAddress(&source_pb, lock_flags) }; - let src_ptr = &*source_pb as *const CVPixelBuffer as *mut core::ffi::c_void; - attach - .submit_with_blit(src_ptr, 41_500) - .expect("submit_with_blit ok"); - let stats = attach.stats(); - assert_eq!(stats.frames_submitted, 1, "one frame submitted"); - assert_eq!(stats.failed_blits, 0, "no failed blits"); - assert_eq!( - attach.capture_pts_us_for_sequence(1), - Some(41_500), - "capture pts recorded for ring sequence" - ); - assert_eq!(attach.capture_pts_us_for_sequence(2), None); - } - - #[cfg(target_os = "macos")] - #[test] - fn external_surface_submit_take_round_trip() { - use core::ptr::NonNull; - use fluxer_encoder_ring::metal_iosurface_macos::OwnedIoSurface; - - let attach = EncoderAttachment::try_new(64, 64).expect("attach ok"); - let owned = OwnedIoSurface::create_nv12(64, 64).expect("surface"); - let raw = NonNull::new(owned.as_ptr()).expect("non-null surface"); - let use_before = unsafe { crate::iosurface_pair::iosurface_use_count(raw) }; - unsafe { CFRetain(raw.as_ptr()) }; - unsafe { attach.submit_external_surface(raw, 41_500) }.expect("submit ok"); - assert_eq!(attach.external_surface_queue_len(), 1); - let use_during = unsafe { crate::iosurface_pair::iosurface_use_count(raw) }; - assert_eq!(use_during, use_before + 1); - let stats = attach.stats(); - assert_eq!(stats.frames_submitted, 1); - assert_eq!(stats.frames_dropped, 0); - assert_eq!(stats.ring_full_events, 0); - let frame = attach.take_external_surface().expect("frame queued"); - assert_eq!(frame.surface(), raw); - assert_eq!(frame.sequence(), 1); - assert_eq!(frame.capture_pts_us(), 41_500); - drop(frame); - let use_after = unsafe { crate::iosurface_pair::iosurface_use_count(raw) }; - assert_eq!(use_after, use_before); - assert_eq!(attach.external_surface_queue_len(), 0); - } - - #[cfg(target_os = "macos")] - #[test] - fn external_surface_queue_evicts_oldest_when_full() { - use core::ptr::NonNull; - use fluxer_encoder_ring::metal_iosurface_macos::OwnedIoSurface; - - let attach = EncoderAttachment::try_new(64, 64).expect("attach ok"); - let total = EXTERNAL_SURFACE_QUEUE_CAP + 2; - let mut owned: Vec = Vec::with_capacity(total); - for i in 0..total { - let surface = OwnedIoSurface::create_nv12(64, 64).expect("surface"); - let raw = NonNull::new(surface.as_ptr()).expect("non-null surface"); - unsafe { CFRetain(raw.as_ptr()) }; - unsafe { attach.submit_external_surface(raw, (i as i64) * 1_000) }.expect("submit ok"); - owned.push(surface); - } - assert_eq!( - attach.external_surface_queue_len(), - EXTERNAL_SURFACE_QUEUE_CAP - ); - let stats = attach.stats(); - assert_eq!(stats.frames_submitted, total as u64); - assert_eq!(stats.frames_dropped, 2); - assert_eq!(stats.ring_full_events, 2); - let oldest_remaining = attach.take_external_surface().expect("frame queued"); - assert_eq!(oldest_remaining.sequence(), 3); - assert_eq!(oldest_remaining.capture_pts_us(), 2_000); - } - - #[cfg(target_os = "macos")] - #[test] - fn detach_drains_external_surface_queue_and_rejects_submit() { - use core::ptr::NonNull; - use fluxer_encoder_ring::metal_iosurface_macos::OwnedIoSurface; - - let attach = EncoderAttachment::try_new(64, 64).expect("attach ok"); - let owned = OwnedIoSurface::create_nv12(64, 64).expect("surface"); - let raw = NonNull::new(owned.as_ptr()).expect("non-null surface"); - let use_before = unsafe { crate::iosurface_pair::iosurface_use_count(raw) }; - unsafe { CFRetain(raw.as_ptr()) }; - unsafe { attach.submit_external_surface(raw, 7) }.expect("submit ok"); - assert_eq!(attach.external_surface_queue_len(), 1); - attach.detach(); - assert_eq!(attach.external_surface_queue_len(), 0); - assert_eq!( - unsafe { crate::iosurface_pair::iosurface_use_count(raw) }, - use_before - ); - unsafe { CFRetain(raw.as_ptr()) }; - let err = unsafe { attach.submit_external_surface(raw, 8) }.err(); - assert!(matches!(err, Some(EncoderAttachError::NotAttached))); - assert_eq!(attach.external_surface_queue_len(), 0); - assert_eq!( - unsafe { crate::iosurface_pair::iosurface_use_count(raw) }, - use_before - ); - } - - #[cfg(target_os = "macos")] - #[test] - fn capture_pts_map_stays_bounded_and_evicts_oldest() { - let attach = EncoderAttachment::try_new(64, 64).expect("attach ok"); - let total = (CAPTURE_PTS_MAP_CAP as u64) + 4; - for sequence in 1..=total { - attach.record_capture_pts(sequence, (sequence as i64) * 1_000); - } - assert_eq!( - attach.capture_pts_us_for_sequence(1), - None, - "oldest evicted" - ); - assert_eq!( - attach.capture_pts_us_for_sequence(4), - None, - "oldest evicted" - ); - assert_eq!( - attach.capture_pts_us_for_sequence(5), - Some(5_000), - "newest retained" - ); - assert_eq!( - attach.capture_pts_us_for_sequence(total), - Some((total as i64) * 1_000) - ); - } -} diff --git a/fluxer_desktop/native/mac-screen-capture/src/foundation.rs b/fluxer_desktop/native/mac-screen-capture/src/foundation.rs deleted file mode 100644 index ec28e793a..000000000 --- a/fluxer_desktop/native/mac-screen-capture/src/foundation.rs +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use objc2_foundation::{NSError, NSProcessInfo}; - -pub fn operating_system_version_string() -> String { - let info = NSProcessInfo::processInfo(); - info.operatingSystemVersionString().to_string() -} - -pub fn ns_error_localized_description(err: &NSError) -> String { - err.localizedDescription().to_string() -} diff --git a/fluxer_desktop/native/mac-screen-capture/src/iosurface_pair.rs b/fluxer_desktop/native/mac-screen-capture/src/iosurface_pair.rs deleted file mode 100644 index 7efc69edb..000000000 --- a/fluxer_desktop/native/mac-screen-capture/src/iosurface_pair.rs +++ /dev/null @@ -1,331 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use core::ffi::c_void; -use core::ptr::NonNull; - -pub type IoSurfaceRaw = NonNull; - -#[cfg(target_os = "macos")] -#[link(name = "IOSurface", kind = "framework")] -unsafe extern "C" { - fn IOSurfaceIncrementUseCount(buffer: *mut c_void); - fn IOSurfaceDecrementUseCount(buffer: *mut c_void); - fn IOSurfaceGetUseCount(buffer: *mut c_void) -> i32; -} - -#[cfg(target_os = "macos")] -pub unsafe fn iosurface_increment_use_count(surface: IoSurfaceRaw) { - unsafe { IOSurfaceIncrementUseCount(surface.as_ptr()) }; -} - -#[cfg(target_os = "macos")] -pub unsafe fn iosurface_decrement_use_count(surface: IoSurfaceRaw) { - unsafe { IOSurfaceDecrementUseCount(surface.as_ptr()) }; -} - -#[cfg(target_os = "macos")] -pub unsafe fn iosurface_use_count(surface: IoSurfaceRaw) -> i32 { - unsafe { IOSurfaceGetUseCount(surface.as_ptr()) } -} - -#[cfg(not(target_os = "macos"))] -pub unsafe fn iosurface_increment_use_count(_surface: IoSurfaceRaw) {} - -#[cfg(not(target_os = "macos"))] -pub unsafe fn iosurface_decrement_use_count(_surface: IoSurfaceRaw) {} - -#[cfg(not(target_os = "macos"))] -pub unsafe fn iosurface_use_count(_surface: IoSurfaceRaw) -> i32 { - 0 -} - -pub struct IoSurfacePair { - current: Option, - prev: Option, -} - -unsafe impl Send for IoSurfacePair {} - -impl IoSurfacePair { - pub fn new() -> Self { - let pair = Self { - current: None, - prev: None, - }; - assert!(pair.current.is_none()); - assert!(pair.prev.is_none()); - pair - } - - pub fn has_current(&self) -> bool { - let has = self.current.is_some(); - assert!(has == self.current.is_some()); - has - } - - pub fn has_prev(&self) -> bool { - let has = self.prev.is_some(); - assert!(has == self.prev.is_some()); - has - } - - pub unsafe fn push(&mut self, new: IoSurfaceRaw) { - unsafe { iosurface_increment_use_count(new) }; - let evicted = self.prev.take(); - let rotated = self.current.take(); - self.prev = rotated; - self.current = Some(new); - assert!(self.current.is_some()); - if let Some(old) = evicted { - unsafe { iosurface_decrement_use_count(old) }; - } - } - - pub fn take_current(&mut self) -> Option { - let taken = self.current.take(); - assert!(self.current.is_none()); - taken - } - - pub fn peek_current(&self) -> Option { - self.current - } - - pub fn peek_prev(&self) -> Option { - self.prev - } - - pub fn clear(&mut self) { - let cur = self.current.take(); - let prev = self.prev.take(); - assert!(self.current.is_none()); - assert!(self.prev.is_none()); - if let Some(s) = cur { - unsafe { iosurface_decrement_use_count(s) }; - } - if let Some(s) = prev { - unsafe { iosurface_decrement_use_count(s) }; - } - } -} - -impl Default for IoSurfacePair { - fn default() -> Self { - Self::new() - } -} - -impl Drop for IoSurfacePair { - fn drop(&mut self) { - let cur = self.current.take(); - let prev = self.prev.take(); - if let Some(s) = cur { - unsafe { iosurface_decrement_use_count(s) }; - } - if let Some(s) = prev { - unsafe { iosurface_decrement_use_count(s) }; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use core::ptr::NonNull; - - fn fake_surface(addr: usize) -> IoSurfaceRaw { - assert!(addr != 0); - NonNull::new(addr as *mut c_void).expect("non-null fake surface") - } - - #[test] - fn new_pair_is_empty() { - let pair = IoSurfacePair::new(); - assert!(!pair.has_current()); - assert!(!pair.has_prev()); - } - - #[test] - fn take_current_on_empty_returns_none() { - let mut pair = IoSurfacePair::new(); - assert!(pair.take_current().is_none()); - } - - #[cfg(not(target_os = "macos"))] - #[test] - fn push_rotates_current_to_prev_offplatform() { - let mut pair = IoSurfacePair::new(); - let a = fake_surface(0x1); - let b = fake_surface(0x2); - unsafe { - pair.push(a); - } - assert_eq!(pair.peek_current(), Some(a)); - assert!(pair.peek_prev().is_none()); - unsafe { - pair.push(b); - } - assert_eq!(pair.peek_current(), Some(b)); - assert_eq!(pair.peek_prev(), Some(a)); - } - - #[cfg(not(target_os = "macos"))] - #[test] - fn take_current_clears_slot_offplatform() { - let mut pair = IoSurfacePair::new(); - let a = fake_surface(0x10); - unsafe { - pair.push(a); - } - let taken = pair.take_current().expect("current is set"); - assert_eq!(taken, a); - assert!(pair.peek_current().is_none()); - } - - #[cfg(not(target_os = "macos"))] - #[test] - fn drop_pair_is_safe_offplatform() { - let mut pair = IoSurfacePair::new(); - unsafe { - pair.push(fake_surface(0x100)); - pair.push(fake_surface(0x200)); - } - drop(pair); - } -} - -#[cfg(all(test, target_os = "macos"))] -mod macos_tests { - use super::*; - use core::ffi::c_void; - use core::ptr::NonNull; - use objc2_core_foundation::{ - CFDictionary, CFNumber, CFRetained, CFString, kCFAllocatorDefault, - kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks, - }; - - #[link(name = "IOSurface", kind = "framework")] - unsafe extern "C" { - fn IOSurfaceCreate(properties: *const CFDictionary) -> *mut c_void; - } - - fn cf_number_i32(v: i32) -> CFRetained { - CFNumber::new_i32(v) - } - - fn cf_string(s: &'static str) -> CFRetained { - CFString::from_static_str(s) - } - - fn make_iosurface() -> IoSurfaceRaw { - let width_key = cf_string("IOSurfaceWidth"); - let height_key = cf_string("IOSurfaceHeight"); - let bpe_key = cf_string("IOSurfaceBytesPerElement"); - let pf_key = cf_string("IOSurfacePixelFormat"); - let width_val = cf_number_i32(32); - let height_val = cf_number_i32(32); - let bpe_val = cf_number_i32(4); - let pf_val = cf_number_i32(i32::from_be_bytes(*b"BGRA")); - let keys: [*const c_void; 4] = [ - CFRetained::as_ptr(&width_key).as_ptr() as *const c_void, - CFRetained::as_ptr(&height_key).as_ptr() as *const c_void, - CFRetained::as_ptr(&bpe_key).as_ptr() as *const c_void, - CFRetained::as_ptr(&pf_key).as_ptr() as *const c_void, - ]; - let vals: [*const c_void; 4] = [ - CFRetained::as_ptr(&width_val).as_ptr() as *const c_void, - CFRetained::as_ptr(&height_val).as_ptr() as *const c_void, - CFRetained::as_ptr(&bpe_val).as_ptr() as *const c_void, - CFRetained::as_ptr(&pf_val).as_ptr() as *const c_void, - ]; - let dict_opt = unsafe { - CFDictionary::new( - kCFAllocatorDefault, - keys.as_ptr() as *mut *const c_void, - vals.as_ptr() as *mut *const c_void, - 4, - &kCFTypeDictionaryKeyCallBacks, - &kCFTypeDictionaryValueCallBacks, - ) - }; - let dict = dict_opt.expect("CFDictionary::new returned non-null"); - let dict_ptr = CFRetained::as_ptr(&dict).as_ptr() as *const CFDictionary; - let raw = unsafe { IOSurfaceCreate(dict_ptr) }; - assert!(!raw.is_null(), "IOSurfaceCreate must produce a surface"); - NonNull::new(raw).expect("non-null IOSurface") - } - - #[test] - fn push_increments_use_count_and_take_current_does_not_release() { - let a = make_iosurface(); - let before = unsafe { iosurface_use_count(a) }; - let mut pair = IoSurfacePair::new(); - unsafe { - pair.push(a); - } - let after_push = unsafe { iosurface_use_count(a) }; - assert_eq!(after_push, before + 1); - let taken = pair.take_current().expect("current set"); - assert_eq!(taken, a); - let after_take = unsafe { iosurface_use_count(a) }; - assert_eq!(after_take, before + 1); - unsafe { iosurface_decrement_use_count(a) }; - let after_balance = unsafe { iosurface_use_count(a) }; - assert_eq!(after_balance, before); - } - - #[test] - fn push_rotates_and_drop_releases_both_slots() { - let a = make_iosurface(); - let b = make_iosurface(); - let before_a = unsafe { iosurface_use_count(a) }; - let before_b = unsafe { iosurface_use_count(b) }; - let mut pair = IoSurfacePair::new(); - unsafe { - pair.push(a); - pair.push(b); - } - assert_eq!(pair.peek_current(), Some(b)); - assert_eq!(pair.peek_prev(), Some(a)); - assert_eq!(unsafe { iosurface_use_count(a) }, before_a + 1); - assert_eq!(unsafe { iosurface_use_count(b) }, before_b + 1); - drop(pair); - assert_eq!(unsafe { iosurface_use_count(a) }, before_a); - assert_eq!(unsafe { iosurface_use_count(b) }, before_b); - } - - #[test] - fn third_push_evicts_oldest_and_releases_it() { - let a = make_iosurface(); - let b = make_iosurface(); - let c = make_iosurface(); - let before_a = unsafe { iosurface_use_count(a) }; - let mut pair = IoSurfacePair::new(); - unsafe { - pair.push(a); - pair.push(b); - pair.push(c); - } - assert_eq!(unsafe { iosurface_use_count(a) }, before_a); - assert_eq!(pair.peek_current(), Some(c)); - assert_eq!(pair.peek_prev(), Some(b)); - } - - #[test] - fn clear_releases_both_slots() { - let a = make_iosurface(); - let b = make_iosurface(); - let before_a = unsafe { iosurface_use_count(a) }; - let before_b = unsafe { iosurface_use_count(b) }; - let mut pair = IoSurfacePair::new(); - unsafe { - pair.push(a); - pair.push(b); - } - pair.clear(); - assert_eq!(unsafe { iosurface_use_count(a) }, before_a); - assert_eq!(unsafe { iosurface_use_count(b) }, before_b); - assert!(pair.peek_current().is_none()); - assert!(pair.peek_prev().is_none()); - } -} diff --git a/fluxer_desktop/native/mac-screen-capture/src/lib.rs b/fluxer_desktop/native/mac-screen-capture/src/lib.rs index 3b73eedd2..b99ba09e1 100644 --- a/fluxer_desktop/native/mac-screen-capture/src/lib.rs +++ b/fluxer_desktop/native/mac-screen-capture/src/lib.rs @@ -1,45 +1,10 @@ #![deny(clippy::all)] // SPDX-License-Identifier: AGPL-3.0-or-later -#![allow(dead_code)] -#![allow(clippy::missing_safety_doc)] -#![allow(clippy::collapsible_if)] -#![allow(clippy::manual_is_multiple_of)] -#![allow(clippy::manual_slice_size_calculation)] -#![allow(clippy::unnecessary_cast)] -#![allow(clippy::not_unsafe_ptr_arg_deref)] -#![allow(clippy::missing_transmute_annotations)] -#![allow(clippy::missing_const_for_thread_local)] -#![allow(clippy::too_many_arguments)] -pub mod audio_pool; -pub mod config; -pub mod encoder_attach; -pub mod iosurface_pair; -pub mod os_version; pub mod permission_probe; -pub use audio_pool::{ - MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT, MacAudioError, MacAudioFramePool, - MacAudioPoolStats, PooledMacAudioFrame, -}; -pub use config::{ - AUDIO_CHANNEL_COUNT_DEFAULT, AUDIO_CHANNEL_COUNT_MAX, AUDIO_CHANNEL_COUNT_MIN, - AUDIO_SAMPLE_RATE_DEFAULT_HZ, AUDIO_SAMPLE_RATE_MAX_HZ, AUDIO_SAMPLE_RATE_MIN_HZ, - AudioSampleFormat, CaptureFailureSurface, FPS_DEFAULT, FPS_MAX, FPS_MIN, - MacScreenShareAudioFrame, MacScreenShareAudioFrameWithBytes, QUEUE_DEPTH_DEFAULT, - QUEUE_DEPTH_MAX, QUEUE_DEPTH_MIN, SckCaptureConfig, SckCaptureConfigBuilder, SckCaptureFailure, - SckColorSpace, SckError, SckPixelFormat, -}; -pub use encoder_attach::{EncoderAttachError, EncoderAttachStats, EncoderAttachment}; -pub use iosurface_pair::{IoSurfacePair, IoSurfaceRaw}; - -#[cfg(target_os = "macos")] -pub mod foundation; -#[cfg(target_os = "macos")] -pub mod sck; - -#[cfg(target_os = "macos")] +#[cfg(all(target_os = "macos", not(test)))] mod napi_surface_macos; -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(test)))] mod napi_surface_stub; diff --git a/fluxer_desktop/native/mac-screen-capture/src/napi_surface_macos.rs b/fluxer_desktop/native/mac-screen-capture/src/napi_surface_macos.rs index 437281227..b9e3e4464 100644 --- a/fluxer_desktop/native/mac-screen-capture/src/napi_surface_macos.rs +++ b/fluxer_desktop/native/mac-screen-capture/src/napi_surface_macos.rs @@ -1,76 +1,19 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use std::ffi::c_void; -use std::ptr; -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; -use std::sync::{Arc, Condvar, Mutex, MutexGuard, Weak}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; use std::time::Duration; use block2::RcBlock; -use napi::bindgen_prelude::{Buffer, BufferSlice, Function, Result, Unknown}; -use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; -use napi::{JsValue, Status, ValueType}; +use napi::bindgen_prelude::Result; use napi_derive::napi; use objc2::rc::Retained; -use objc2::runtime::{NSObject, NSObjectProtocol, ProtocolObject}; -use objc2::{AllocAnyThread, DefinedClass, define_class, msg_send}; -use objc2_core_audio_types::{ - AudioBufferList, AudioStreamBasicDescription, kAudioFormatFlagIsFloat, - kAudioFormatFlagIsNonInterleaved, kAudioFormatFlagIsSignedInteger, kAudioFormatLinearPCM, -}; -use objc2_core_foundation::{CFRetained, CGPoint, CGRect, CGSize}; -use objc2_core_media::{ - CMAudioFormatDescriptionGetStreamBasicDescription, CMBlockBuffer, CMSampleBuffer, -}; -use objc2_core_video::{ - CVPixelBuffer, CVPixelBufferGetHeight, CVPixelBufferGetIOSurface, - CVPixelBufferGetPixelFormatType, CVPixelBufferGetPlaneCount, CVPixelBufferGetWidth, - CVPixelBufferLockBaseAddress, CVPixelBufferLockFlags, CVPixelBufferUnlockBaseAddress, - kCVPixelFormatType_32BGRA, -}; -use objc2_foundation::{NSArray, NSError, NSString}; -use objc2_screen_capture_kit::{ - SCContentFilter, SCDisplay, SCShareableContent, SCStream, SCStreamConfiguration, - SCStreamDelegate, SCStreamOutput, SCStreamOutputType, SCWindow, -}; +use objc2_foundation::NSError; +use objc2_screen_capture_kit::SCShareableContent; -use fluxer_screen_frame_bus::{ - self as frame_bus, EnqueueOutcome, MacCvPixelBufferFrame, NativeScreenFrameSinkHandle, - NativeScreenFrameSinkHandleRef, ScreenFrame as BusScreenFrame, -}; - -use crate::audio_pool::{ - MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT, MacAudioFramePool, PooledMacAudioFrame, -}; -use crate::config::{ - AUDIO_CHANNEL_COUNT_DEFAULT, AUDIO_SAMPLE_RATE_DEFAULT_HZ, AudioSampleFormat, - CaptureFailureSurface, SckCaptureConfig, SckCaptureFailure, SckColorSpace, SckPixelFormat, -}; -use crate::encoder_attach::EncoderAttachment; -use crate::foundation; -use crate::os_version::{ - self, SCK_MIN_MACOS, SupportClassification, classify_support, format_version, -}; use crate::permission_probe::{ PROBE_TIMEOUT_NS, ShareableContentOutcome, screen_recording_probe_result, }; -use crate::sck; -use fluxer_encoder_ring::EncoderFrameRate; - -const DEFAULT_TIMEOUT_NS: u64 = 30 * 1_000_000_000; -const PIXEL_FORMAT_420V: u32 = u32::from_be_bytes(*b"420v"); -const PIXEL_FORMAT_420F: u32 = u32::from_be_bytes(*b"420f"); -#[link(name = "CoreGraphics", kind = "framework")] -unsafe extern "C" { - fn CGPreflightScreenCaptureAccess() -> bool; -} - -#[link(name = "CoreVideo", kind = "framework")] -unsafe extern "C" { - fn CVPixelBufferRetain(pixel_buffer: *mut c_void) -> *mut c_void; - fn CVPixelBufferRelease(pixel_buffer: *mut c_void); -} #[derive(Debug, Eq, PartialEq)] enum AsyncError { @@ -159,52 +102,6 @@ fn complete_waker( waker.cv.notify_all(); } -fn await_start(stream: &SCStream, timeout_ns: u64) -> std::result::Result<(), AsyncError> { - let waker = new_waker(); - let waker_cb = waker.clone(); - let blk = RcBlock::new(move |err: *mut NSError| { - let (err_opt, failed) = match retain_error(err) { - Ok(opt) => (opt, false), - Err(_) => (None, true), - }; - complete_waker(&waker_cb, err_opt, None, failed); - }); - unsafe { - stream.startCaptureWithCompletionHandler(Some(&blk)); - } - if !wait_deadline(&waker, timeout_ns) { - return Err(AsyncError::Timeout); - } - let s = lock_waker_state(&waker); - if s.failed || s.err.is_some() { - return Err(AsyncError::SckErr); - } - Ok(()) -} - -fn await_stop(stream: &SCStream, timeout_ns: u64) -> std::result::Result<(), AsyncError> { - let waker = new_waker(); - let waker_cb = waker.clone(); - let blk = RcBlock::new(move |err: *mut NSError| { - let (err_opt, failed) = match retain_error(err) { - Ok(opt) => (opt, false), - Err(_) => (None, true), - }; - complete_waker(&waker_cb, err_opt, None, failed); - }); - unsafe { - stream.stopCaptureWithCompletionHandler(Some(&blk)); - } - if !wait_deadline(&waker, timeout_ns) { - return Err(AsyncError::Timeout); - } - let s = lock_waker_state(&waker); - if s.failed || s.err.is_some() { - return Err(AsyncError::SckErr); - } - Ok(()) -} - fn get_shareable_content( timeout_ns: u64, ) -> std::result::Result, AsyncError> { @@ -241,1708 +138,6 @@ fn get_shareable_content( s.content.take().ok_or(AsyncError::SckErr) } -type LifecycleTsfn = - ThreadsafeFunction<(String, String), (), (String, String), Status, false, false, 8>; - -type AudioFrameTsfnArgs = (u32, u32, u32, i64, u32, Buffer); -type AudioFrameTsfnInput = (u32, u32, u32, i64, AudioSampleFormat, PooledMacAudioFrame); -type AudioFrameTsfn = - ThreadsafeFunction; - -type AudioDiagnosticTsfnArgs = (String, String, u32, u32); -type AudioDiagnosticTsfn = ThreadsafeFunction< - AudioDiagnosticTsfnArgs, - (), - AudioDiagnosticTsfnArgs, - Status, - false, - false, - 8, ->; - -type EncoderDiagnosticTsfnArgs = (String, String); -type EncoderDiagnosticTsfn = ThreadsafeFunction< - EncoderDiagnosticTsfnArgs, - (), - EncoderDiagnosticTsfnArgs, - Status, - false, - false, - 8, ->; - -struct AudioFramePayload { - sample_rate_hz: u32, - channels: u32, - num_samples_per_channel: u32, - pts_us: i64, - format: AudioSampleFormat, - slot: PooledMacAudioFrame, -} - -impl AudioFramePayload { - fn into_input(self) -> AudioFrameTsfnInput { - assert!(self.sample_rate_hz > 0); - assert!(self.channels > 0); - ( - self.sample_rate_hz, - self.channels, - self.num_samples_per_channel, - self.pts_us, - self.format, - self.slot, - ) - } -} - -struct CaptureState { - lifecycle_tsfn: Option, - audio_diagnostic_tsfn: Option, - encoder_diagnostic_tsfn: Option, - delegate: Option>, - stream: Option>, - sample_queue: Option>, -} - -unsafe impl Send for CaptureState {} -unsafe impl Sync for CaptureState {} - -pub(crate) struct CaptureInner { - state: Mutex, - audio_frame_tsfn: parking_lot::RwLock>, - running: AtomicBool, - tsfn_aborted: AtomicBool, - capture_id: Mutex>, - bus_sink: parking_lot::RwLock>>, - native_frame_sink: parking_lot::RwLock>>, - failure_surface: parking_lot::RwLock>>, - captures_audio: AtomicBool, - audio_sample_rate_hz: AtomicU32, - audio_channels: AtomicU32, - audio_pool: MacAudioFramePool, - encoder_attachment: parking_lot::RwLock>>, - encoder_attach_requested: AtomicBool, - encoder_ring_full_emitted: AtomicBool, - frame_sink_accepted: AtomicU64, - frame_sink_coalesced: AtomicU64, - frame_sink_rejected: AtomicU64, - media_frames_dropped_without_sink: AtomicU64, - frame_sink_backpressure_emitted: AtomicBool, - frame_sink_missing_emitted: AtomicBool, -} - -fn generic_error(reason: impl Into) -> napi::Error { - napi::Error::new(Status::GenericFailure, reason.into()) -} - -fn invalid_arg(reason: impl Into) -> napi::Error { - napi::Error::new(Status::InvalidArg, reason.into()) -} - -fn lock_state(inner: &CaptureInner) -> Result> { - inner - .state - .lock() - .map_err(|_| generic_error("ScreenCapture state lock poisoned")) -} - -fn note_tsfn_status(inner: &CaptureInner, status: Status) { - if status == Status::Closing { - inner.tsfn_aborted.store(true, Ordering::Release); - } -} - -const SCK_USER_STOPPED_CODE: isize = -3817; - -fn is_sck_user_stop(err: &NSError) -> bool { - let code = err.code(); - assert!( - code.abs() < i32::MAX as isize, - "SCK error code out of range" - ); - if code != SCK_USER_STOPPED_CODE { - return false; - } - let domain = err.domain().to_string(); - assert!(!domain.is_empty(), "SCK error domain must not be empty"); - domain.contains("SCStreamErrorDomain") -} - -fn sample_timestamp_us(sample_buffer: &CMSampleBuffer) -> i64 { - let pts = unsafe { sample_buffer.presentation_time_stamp() }; - if pts.timescale <= 0 { - return 0; - } - - let value = pts.value as i128; - let scale = pts.timescale as i128; - ((value * 1_000_000) / scale).clamp(0, i64::MAX as i128) as i64 -} - -pub struct DelegateIvars { - pub inner: Weak, - pub cleared: AtomicBool, -} - -define_class!( - #[unsafe(super(NSObject))] - #[name = "FluxerSCKScreenSource"] - #[ivars = DelegateIvars] - pub struct FluxerSCKScreenSource; - - unsafe impl NSObjectProtocol for FluxerSCKScreenSource {} - - unsafe impl SCStreamDelegate for FluxerSCKScreenSource { - #[unsafe(method(stream:didStopWithError:))] - unsafe fn did_stop_with_error(&self, _stream: &SCStream, err: &NSError) { - if self.ivars().cleared.load(Ordering::Acquire) { - return; - } - let Some(inner_arc) = self.ivars().inner.upgrade() else { - return; - }; - let inner: &CaptureInner = &inner_arc; - inner.running.store(false, Ordering::Release); - let msg = foundation::ns_error_localized_description(err); - let msg = if msg.is_empty() { - "stream stopped".to_string() - } else { - msg - }; - let is_clean_stop = is_sck_user_stop(err); - if !is_clean_stop { - if let Some(surface) = inner.failure_surface.read().as_ref().cloned() { - surface.on_failure(SckCaptureFailure::StreamStoppedWithError(msg.clone())); - } - } - let Ok(state) = inner.state.lock() else { - return; - }; - if is_clean_stop { - if let Some(tsfn) = state.lifecycle_tsfn.as_ref() { - let status = tsfn.call( - ("closed-clean".to_string(), msg), - ThreadsafeFunctionCallMode::NonBlocking, - ); - note_tsfn_status(inner, status); - } - return; - } - if let Some(tsfn) = state.lifecycle_tsfn.as_ref() { - let status = tsfn.call( - ("error".to_string(), msg), - ThreadsafeFunctionCallMode::NonBlocking, - ); - note_tsfn_status(inner, status); - } - if let Some(tsfn) = state.lifecycle_tsfn.as_ref() { - let status = tsfn.call( - ("closed".to_string(), String::new()), - ThreadsafeFunctionCallMode::NonBlocking, - ); - note_tsfn_status(inner, status); - } - } - } - - unsafe impl SCStreamOutput for FluxerSCKScreenSource { - #[unsafe(method(stream:didOutputSampleBuffer:ofType:))] - unsafe fn did_output_sample_buffer( - &self, - _stream: &SCStream, - sample_buffer: &CMSampleBuffer, - output_type: SCStreamOutputType, - ) { - if self.ivars().cleared.load(Ordering::Acquire) { - return; - } - let Some(inner_arc) = self.ivars().inner.upgrade() else { - return; - }; - if output_type == SCStreamOutputType::Screen { - unsafe { handle_screen_sample(&inner_arc, sample_buffer) }; - return; - } - if output_type == SCStreamOutputType::Audio { - unsafe { handle_audio_sample(&inner_arc, sample_buffer) }; - } - } - } -); - -impl FluxerSCKScreenSource { - pub fn new(inner: Weak) -> Retained { - let this = Self::alloc().set_ivars(DelegateIvars { - inner, - cleared: AtomicBool::new(false), - }); - unsafe { msg_send![super(this), init] } - } - - pub fn clear_inner(&self) { - self.ivars().cleared.store(true, Ordering::Release); - } -} - -unsafe fn handle_audio_sample(inner: &CaptureInner, sample_buffer: &CMSampleBuffer) { - if inner.tsfn_aborted.load(Ordering::Acquire) || !inner.running.load(Ordering::Acquire) { - return; - } - if !unsafe { sample_buffer.data_is_ready() } { - return; - } - let num_samples = unsafe { sample_buffer.num_samples() }; - if num_samples <= 0 { - return; - } - let pts_us = sample_timestamp_us(sample_buffer); - let (detected_format, detected_sample_rate_hz, detected_channels) = - unsafe { detect_audio_format(sample_buffer) }; - let sample_rate_hz = if detected_sample_rate_hz > 0 { - detected_sample_rate_hz - } else { - inner.audio_sample_rate_hz.load(Ordering::Acquire) - }; - let channels = if detected_channels > 0 { - detected_channels - } else { - inner.audio_channels.load(Ordering::Acquire) - }; - let num_samples_per_channel = num_samples as u32; - let mut slot = match inner.audio_pool.try_acquire() { - Some(s) => s, - None => { - emit_pool_exhausted(inner, sample_rate_hz, num_samples_per_channel); - return; - } - }; - if unsafe { extract_audio_bytes(sample_buffer, &mut slot) }.is_err() { - return; - } - let payload = AudioFramePayload { - sample_rate_hz, - channels, - num_samples_per_channel, - pts_us, - format: detected_format, - slot, - }; - deliver_audio_frame(inner, payload); -} - -fn emit_pool_exhausted(inner: &CaptureInner, sample_rate_hz: u32, num_samples: u32) { - let capture_id = inner - .capture_id - .lock() - .ok() - .and_then(|guard| guard.as_ref().cloned()) - .unwrap_or_default(); - let Ok(state) = inner.state.lock() else { - return; - }; - let Some(tsfn) = state.audio_diagnostic_tsfn.as_ref() else { - return; - }; - let status = tsfn.call( - ( - "audioPoolExhausted".to_string(), - capture_id, - sample_rate_hz, - num_samples, - ), - ThreadsafeFunctionCallMode::NonBlocking, - ); - note_tsfn_status(inner, status); -} - -pub(crate) unsafe fn detect_audio_format( - sample_buffer: &CMSampleBuffer, -) -> (AudioSampleFormat, u32, u32) { - let Some(format_desc) = (unsafe { sample_buffer.format_description() }) else { - return (AudioSampleFormat::Unknown, 0, 0); - }; - let asbd_ptr = unsafe { CMAudioFormatDescriptionGetStreamBasicDescription(&format_desc) }; - if asbd_ptr.is_null() { - return (AudioSampleFormat::Unknown, 0, 0); - } - let asbd: AudioStreamBasicDescription = unsafe { *asbd_ptr }; - if asbd.mFormatID != kAudioFormatLinearPCM { - return (AudioSampleFormat::Unknown, 0, 0); - } - let format = classify_pcm_flags(asbd.mFormatFlags, asbd.mBitsPerChannel); - let sample_rate_hz = asbd.mSampleRate.clamp(0.0, u32::MAX as f64) as u32; - let channels = asbd.mChannelsPerFrame; - (format, sample_rate_hz, channels) -} - -fn classify_pcm_flags(flags: u32, bits_per_channel: u32) -> AudioSampleFormat { - let is_float = (flags & kAudioFormatFlagIsFloat) != 0; - let is_signed_int = (flags & kAudioFormatFlagIsSignedInteger) != 0; - let is_non_interleaved = (flags & kAudioFormatFlagIsNonInterleaved) != 0; - if is_float && bits_per_channel == 32 && is_non_interleaved { - return AudioSampleFormat::F32Planar; - } - if is_float && bits_per_channel == 32 && !is_non_interleaved { - return AudioSampleFormat::F32Interleaved; - } - if is_signed_int && bits_per_channel == 16 && !is_non_interleaved { - return AudioSampleFormat::I16Interleaved; - } - AudioSampleFormat::Unknown -} - -pub(crate) const MAX_AUDIO_BUFFERS: usize = 8; - -#[repr(C, align(16))] -pub(crate) struct AblStorage { - pub n_buffers: u32, - pub _pad: u32, - pub buffers: [objc2_core_audio_types::AudioBuffer; MAX_AUDIO_BUFFERS], -} - -pub(crate) unsafe fn extract_audio_bytes( - sample_buffer: &CMSampleBuffer, - slot: &mut PooledMacAudioFrame, -) -> std::result::Result<(), ()> { - assert!(slot.capacity() <= MAX_FRAME_BYTES_PER_SLOT); - let mut storage = AblStorage { - n_buffers: 0, - _pad: 0, - buffers: [objc2_core_audio_types::AudioBuffer { - mNumberChannels: 0, - mDataByteSize: 0, - mData: ptr::null_mut(), - }; MAX_AUDIO_BUFFERS], - }; - let storage_size = core::mem::size_of::(); - let mut block_buffer_out: *mut CMBlockBuffer = ptr::null_mut(); - let status = unsafe { - sample_buffer.audio_buffer_list_with_retained_block_buffer( - ptr::null_mut(), - &mut storage as *mut AblStorage as *mut AudioBufferList, - storage_size, - None, - None, - 0, - &mut block_buffer_out, - ) - }; - if status != 0 { - unsafe { release_block_buffer(block_buffer_out) }; - return Err(()); - } - assert!(!block_buffer_out.is_null()); - let n_buffers = storage.n_buffers as usize; - if n_buffers == 0 || n_buffers > MAX_AUDIO_BUFFERS { - unsafe { release_block_buffer(block_buffer_out) }; - return Err(()); - } - for i in 0..n_buffers { - let ab = storage.buffers[i]; - let len = ab.mDataByteSize as usize; - if len == 0 || ab.mData.is_null() { - continue; - } - let bytes = unsafe { core::slice::from_raw_parts(ab.mData as *const u8, len) }; - if slot.append(bytes).is_err() { - unsafe { release_block_buffer(block_buffer_out) }; - return Err(()); - } - } - unsafe { release_block_buffer(block_buffer_out) }; - Ok(()) -} - -unsafe fn release_block_buffer(block_buffer: *mut CMBlockBuffer) { - if block_buffer.is_null() { - return; - } - unsafe { - CFRelease(block_buffer as *const core::ffi::c_void); - } -} - -#[link(name = "CoreFoundation", kind = "framework")] -unsafe extern "C" { - fn CFRelease(cf: *const core::ffi::c_void); -} - -fn build_audio_callback_args( - env: napi::Env, - input: AudioFrameTsfnInput, -) -> Result { - let (sample_rate_hz, channels, num_samples_per_channel, pts_us, format, slot) = input; - assert!(sample_rate_hz > 0); - assert!(channels > 0); - let (ptr, len, slot) = slot.into_external_parts(); - assert!(!ptr.is_null()); - if len == 0 { - drop(slot); - let empty: Vec = Vec::new(); - return Ok(( - sample_rate_hz, - channels, - num_samples_per_channel, - pts_us, - format.code(), - Buffer::from(empty), - )); - } - let slice = unsafe { - BufferSlice::from_external(&env, ptr, len, slot, |_env, slot| { - drop(slot); - }) - }?; - let buffer = slice.into_buffer(&env)?; - Ok(( - sample_rate_hz, - channels, - num_samples_per_channel, - pts_us, - format.code(), - buffer, - )) -} - -fn deliver_audio_frame(inner: &CaptureInner, payload: AudioFramePayload) { - let guard = inner.audio_frame_tsfn.read(); - let Some(tsfn) = guard.as_ref() else { - return; - }; - let status = tsfn.call( - payload.into_input(), - ThreadsafeFunctionCallMode::NonBlocking, - ); - note_tsfn_status(inner, status); -} - -unsafe fn handle_screen_sample(inner: &CaptureInner, sample_buffer: &CMSampleBuffer) { - if inner.tsfn_aborted.load(Ordering::Acquire) || !inner.running.load(Ordering::Acquire) { - return; - } - if !unsafe { sample_buffer.data_is_ready() } { - return; - } - let Some(image_buffer) = (unsafe { sample_buffer.image_buffer() }) else { - return; - }; - - let pixel_buffer: &CVPixelBuffer = &image_buffer; - - let format_type = CVPixelBufferGetPixelFormatType(pixel_buffer); - if format_type == PIXEL_FORMAT_420V || format_type == PIXEL_FORMAT_420F { - handle_nv12_screen_sample(inner, sample_buffer, pixel_buffer); - } else if format_type == kCVPixelFormatType_32BGRA { - handle_bgra_screen_sample(inner, sample_buffer, pixel_buffer); - } -} - -fn handle_nv12_screen_sample( - inner: &CaptureInner, - sample_buffer: &CMSampleBuffer, - pixel_buffer: &CVPixelBuffer, -) { - let width = CVPixelBufferGetWidth(pixel_buffer) as u32; - let height = CVPixelBufferGetHeight(pixel_buffer) as u32; - if width == 0 || height == 0 || height % 2 != 0 { - return; - } - - let plane_count = CVPixelBufferGetPlaneCount(pixel_buffer); - if plane_count < 2 { - return; - } - - submit_encoder_ring_frame(inner, pixel_buffer, sample_timestamp_us(sample_buffer)); - - if let Some(sink) = inner.native_frame_sink.read().as_ref().cloned() { - let outcome = - try_enqueue_native_cv_pixel_buffer(&sink, pixel_buffer, width, height, sample_buffer); - record_frame_sink_outcome(inner, outcome); - return; - } - - if let Some(sink) = bus_sink_for(inner).as_deref() { - let outcome = - try_enqueue_bus_cv_pixel_buffer(sink, pixel_buffer, width, height, sample_buffer); - record_frame_sink_outcome(inner, outcome); - return; - } - note_media_frame_without_sink(inner); -} - -fn handle_bgra_screen_sample( - inner: &CaptureInner, - sample_buffer: &CMSampleBuffer, - pixel_buffer: &CVPixelBuffer, -) { - let width = CVPixelBufferGetWidth(pixel_buffer) as u32; - let height = CVPixelBufferGetHeight(pixel_buffer) as u32; - if width == 0 || height == 0 { - return; - } - - submit_encoder_ring_frame(inner, pixel_buffer, sample_timestamp_us(sample_buffer)); - - if let Some(sink) = inner.native_frame_sink.read().as_ref().cloned() { - let outcome = - try_enqueue_native_cv_pixel_buffer(&sink, pixel_buffer, width, height, sample_buffer); - record_frame_sink_outcome(inner, outcome); - return; - } - - if let Some(sink) = bus_sink_for(inner).as_deref() { - let outcome = - try_enqueue_bus_cv_pixel_buffer(sink, pixel_buffer, width, height, sample_buffer); - record_frame_sink_outcome(inner, outcome); - return; - } - note_media_frame_without_sink(inner); -} - -fn submit_encoder_ring_frame( - inner: &CaptureInner, - pixel_buffer: &CVPixelBuffer, - capture_pts_us: i64, -) { - if !inner.encoder_attach_requested.load(Ordering::Acquire) { - return; - } - let attachment = match inner.encoder_attachment.read().as_ref().cloned() { - Some(a) => a, - None => return, - }; - if !attachment.is_attached() { - return; - } - let before = attachment.stats().ring_full_events; - if !try_submit_zero_copy(&attachment, pixel_buffer, capture_pts_us) { - let source_ptr = pixel_buffer as *const CVPixelBuffer as *mut c_void; - let _ = attachment.submit_with_blit(source_ptr, capture_pts_us); - } - let after = attachment.stats().ring_full_events; - if after > before && !inner.encoder_ring_full_emitted.swap(true, Ordering::AcqRel) { - emit_encoder_ring_full(inner); - } -} - -fn try_submit_zero_copy( - attachment: &EncoderAttachment, - pixel_buffer: &CVPixelBuffer, - capture_pts_us: i64, -) -> bool { - assert!(capture_pts_us >= 0, "capture pts clamped non-negative"); - let format_type = CVPixelBufferGetPixelFormatType(pixel_buffer); - if format_type != PIXEL_FORMAT_420V && format_type != PIXEL_FORMAT_420F { - return false; - } - let width = CVPixelBufferGetWidth(pixel_buffer) as u32; - let height = CVPixelBufferGetHeight(pixel_buffer) as u32; - if width != attachment.width() { - return false; - } - if height != attachment.height() { - return false; - } - assert!(width > 0, "gated width positive"); - assert!(height > 0, "gated height positive"); - let lock_flags = CVPixelBufferLockFlags(0); - let lock_status = unsafe { CVPixelBufferLockBaseAddress(pixel_buffer, lock_flags) }; - if lock_status != 0 { - return false; - } - let surface = CVPixelBufferGetIOSurface(Some(pixel_buffer)); - let _ = unsafe { CVPixelBufferUnlockBaseAddress(pixel_buffer, lock_flags) }; - let Some(surface) = surface else { - return false; - }; - let surface_raw = CFRetained::into_raw(surface).cast::(); - unsafe { attachment.submit_external_surface(surface_raw, capture_pts_us) }.is_ok() -} - -fn emit_encoder_ring_full(inner: &CaptureInner) { - let capture_id = inner - .capture_id - .lock() - .ok() - .and_then(|guard| guard.as_ref().cloned()) - .unwrap_or_default(); - let Ok(state) = inner.state.lock() else { - return; - }; - let Some(tsfn) = state.encoder_diagnostic_tsfn.as_ref() else { - return; - }; - let status = tsfn.call( - ("encoderRingFull".to_string(), capture_id), - ThreadsafeFunctionCallMode::NonBlocking, - ); - note_tsfn_status(inner, status); -} - -fn record_frame_sink_outcome(inner: &CaptureInner, outcome: EnqueueOutcome) { - match outcome { - EnqueueOutcome::Accepted => { - inner.frame_sink_accepted.fetch_add(1, Ordering::AcqRel); - } - EnqueueOutcome::Coalesced => { - inner.frame_sink_coalesced.fetch_add(1, Ordering::AcqRel); - emit_frame_sink_backpressure_once( - inner, - "macOS CVPixelBuffer frame coalesced by native frame sink", - ); - } - EnqueueOutcome::Rejected => { - inner.frame_sink_rejected.fetch_add(1, Ordering::AcqRel); - emit_frame_sink_backpressure_once( - inner, - "macOS CVPixelBuffer frame rejected by native frame sink", - ); - } - } -} - -fn emit_frame_sink_backpressure_once(inner: &CaptureInner, message: &'static str) { - if inner - .frame_sink_backpressure_emitted - .swap(true, Ordering::AcqRel) - { - return; - } - emit_lifecycle_diagnostic(inner, message); -} - -fn note_media_frame_without_sink(inner: &CaptureInner) { - inner - .media_frames_dropped_without_sink - .fetch_add(1, Ordering::AcqRel); - if inner - .frame_sink_missing_emitted - .swap(true, Ordering::AcqRel) - { - return; - } - emit_lifecycle_diagnostic( - inner, - "macOS screen frame dropped because no native frame sink is registered", - ); -} - -fn bus_sink_for(inner: &CaptureInner) -> Option> { - if let Some(sink) = inner.bus_sink.read().as_ref().cloned() { - return Some(sink); - } - let capture_id = inner - .capture_id - .lock() - .ok() - .and_then(|guard| guard.as_ref().cloned())?; - let sink = frame_bus::get_sink(&capture_id)?; - *inner.bus_sink.write() = Some(sink.clone()); - Some(sink) -} - -fn retain_native_frame_sink_handle( - value: Unknown<'_>, -) -> Result> { - if value.get_type()? != ValueType::External { - return Err(napi::Error::new( - Status::InvalidArg, - "ScreenCapture.setFrameSinkHandle expects a native external frame sink handle", - )); - } - - let raw_value = value.value(); - let mut data: *mut c_void = ptr::null_mut(); - let status = - unsafe { napi::sys::napi_get_value_external(raw_value.env, raw_value.value, &mut data) }; - if status != napi::sys::Status::napi_ok || data.is_null() { - return Err(napi::Error::new( - Status::InvalidArg, - "ScreenCapture.setFrameSinkHandle received an empty native external frame sink handle", - )); - } - - let handle = unsafe { data.cast::().as_ref() } - .and_then(NativeScreenFrameSinkHandle::retain_ref) - .ok_or_else(|| { - napi::Error::new( - Status::InvalidArg, - "ScreenCapture.setFrameSinkHandle received an invalid native frame sink handle", - ) - })?; - - Ok(Arc::new(handle)) -} - -fn try_enqueue_native_cv_pixel_buffer( - sink: &NativeScreenFrameSinkHandleRef, - pixel_buffer: &CVPixelBuffer, - width: u32, - height: u32, - sample_buffer: &CMSampleBuffer, -) -> EnqueueOutcome { - if !sink.supports_mac_cv_pixel_buffer() { - return EnqueueOutcome::Rejected; - } - let retained = - unsafe { CVPixelBufferRetain(pixel_buffer as *const CVPixelBuffer as *mut c_void) }; - if retained.is_null() { - return EnqueueOutcome::Rejected; - } - let pixel_format = CVPixelBufferGetPixelFormatType(pixel_buffer); - unsafe { - sink.enqueue_mac_cv_pixel_buffer( - retained, - width, - height, - pixel_format, - sample_timestamp_us(sample_buffer), - ) - } -} - -fn try_enqueue_bus_cv_pixel_buffer( - sink: &dyn frame_bus::ScreenFrameSink, - pixel_buffer: &CVPixelBuffer, - width: u32, - height: u32, - sample_buffer: &CMSampleBuffer, -) -> EnqueueOutcome { - let retained = - unsafe { CVPixelBufferRetain(pixel_buffer as *const CVPixelBuffer as *mut c_void) }; - if retained.is_null() { - return EnqueueOutcome::Rejected; - } - let pixel_format = CVPixelBufferGetPixelFormatType(pixel_buffer); - let bus_frame = unsafe { - MacCvPixelBufferFrame::from_retained( - retained, - width, - height, - pixel_format, - sample_timestamp_us(sample_buffer), - ) - }; - sink.enqueue(BusScreenFrame::MacCvPixelBuffer(bus_frame)) -} - -#[napi(js_name = "ScreenCapture")] -pub struct ScreenCapture { - inner: Arc, -} - -#[napi(object, js_name = "ScreenCaptureStartResult")] -pub struct ScreenCaptureStartResult { - pub width: u32, - pub height: u32, - pub frame_rate: u32, - pub pixel_format: String, -} - -#[napi(object, js_name = "FrameSinkDiagnostics")] -pub struct FrameSinkDiagnostics { - pub accepted: f64, - pub coalesced: f64, - pub rejected: f64, - #[napi(js_name = "mediaFramesDroppedWithoutSink")] - pub media_frames_dropped_without_sink: f64, -} - -#[napi(object, js_name = "ScreenCaptureRect")] -pub struct ScreenCaptureRect { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, -} - -#[napi(object, js_name = "ScreenCaptureStartOptions")] -pub struct ScreenCaptureStartOptions { - #[napi(js_name = "showCursorClicks")] - pub show_cursor_clicks: Option, - #[napi(js_name = "captureRect")] - pub capture_rect: Option, - #[napi(js_name = "colorRange")] - pub color_range: Option, - #[napi(js_name = "colorSpace")] - pub color_space: Option, - #[napi(js_name = "maxOutputWidth")] - pub max_output_width: Option, - #[napi(js_name = "maxOutputHeight")] - pub max_output_height: Option, -} - -#[derive(Clone, Copy, Debug)] -struct NormalizedStartOptions { - shows_cursor: bool, - source_rect: Option, - pixel_format: SckPixelFormat, - color_space: SckColorSpace, - max_output_width: u32, - max_output_height: u32, -} - -#[napi] -impl ScreenCapture { - #[napi(constructor)] - pub fn new() -> Self { - let audio_pool = MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT) - .unwrap_or_else(|_| { - unreachable!("MAC_AUDIO_POOL_CAP and MAX_FRAME_BYTES_PER_SLOT must validate") - }); - Self { - inner: Arc::new(CaptureInner { - state: Mutex::new(CaptureState { - lifecycle_tsfn: None, - audio_diagnostic_tsfn: None, - encoder_diagnostic_tsfn: None, - delegate: None, - stream: None, - sample_queue: None, - }), - audio_frame_tsfn: parking_lot::RwLock::new(None), - running: AtomicBool::new(false), - tsfn_aborted: AtomicBool::new(false), - capture_id: Mutex::new(None), - bus_sink: parking_lot::RwLock::new(None), - native_frame_sink: parking_lot::RwLock::new(None), - failure_surface: parking_lot::RwLock::new(None), - captures_audio: AtomicBool::new(false), - audio_sample_rate_hz: AtomicU32::new(AUDIO_SAMPLE_RATE_DEFAULT_HZ), - audio_channels: AtomicU32::new(AUDIO_CHANNEL_COUNT_DEFAULT), - audio_pool, - encoder_attachment: parking_lot::RwLock::new(None), - encoder_attach_requested: AtomicBool::new(false), - encoder_ring_full_emitted: AtomicBool::new(false), - frame_sink_accepted: AtomicU64::new(0), - frame_sink_coalesced: AtomicU64::new(0), - frame_sink_rejected: AtomicU64::new(0), - media_frames_dropped_without_sink: AtomicU64::new(0), - frame_sink_backpressure_emitted: AtomicBool::new(false), - frame_sink_missing_emitted: AtomicBool::new(false), - }), - } - } - - #[napi] - pub fn set_lifecycle_callback(&self, callback: Function<(String, String), ()>) -> Result<()> { - let tsfn: LifecycleTsfn = callback - .build_threadsafe_function::<(String, String)>() - .max_queue_size::<8>() - .build_callback(|ctx| Ok(ctx.value))?; - let mut state = lock_state(&self.inner)?; - state.lifecycle_tsfn = Some(tsfn); - self.inner.tsfn_aborted.store(false, Ordering::Release); - Ok(()) - } - - #[napi(js_name = "setFrameSinkHandle")] - pub fn set_frame_sink_handle(&self, frame_sink_handle: Unknown<'_>) -> Result<()> { - let sink = retain_native_frame_sink_handle(frame_sink_handle)?; - *self.inner.native_frame_sink.write() = Some(sink); - Ok(()) - } - - #[napi(js_name = "setAudioFrameCallback")] - pub fn set_audio_frame_callback( - &self, - callback: Function, - ) -> Result<()> { - let tsfn: AudioFrameTsfn = callback - .build_threadsafe_function::() - .max_queue_size::<16>() - .build_callback(|ctx| build_audio_callback_args(ctx.env, ctx.value))?; - *self.inner.audio_frame_tsfn.write() = Some(tsfn); - Ok(()) - } - - #[napi(js_name = "setAudioDiagnosticCallback")] - pub fn set_audio_diagnostic_callback( - &self, - callback: Function, - ) -> Result<()> { - let tsfn: AudioDiagnosticTsfn = callback - .build_threadsafe_function::() - .max_queue_size::<8>() - .build_callback(|ctx| Ok(ctx.value))?; - let mut state = lock_state(&self.inner)?; - state.audio_diagnostic_tsfn = Some(tsfn); - Ok(()) - } - - #[napi(js_name = "setEncoderDiagnosticCallback")] - pub fn set_encoder_diagnostic_callback( - &self, - callback: Function, - ) -> Result<()> { - let tsfn: EncoderDiagnosticTsfn = callback - .build_threadsafe_function::() - .max_queue_size::<8>() - .build_callback(|ctx| Ok(ctx.value))?; - let mut state = lock_state(&self.inner)?; - state.encoder_diagnostic_tsfn = Some(tsfn); - Ok(()) - } - - #[napi(js_name = "attachEncoder")] - pub fn attach_encoder(&self, width: u32, height: u32, frame_rate: Option) -> Result<()> { - if width == 0 || height == 0 { - return Err(napi::Error::new( - Status::InvalidArg, - "ScreenCapture.attachEncoder requires positive dimensions", - )); - } - let frame_rate = EncoderFrameRate::from_fps(frame_rate.unwrap_or(30)); - let attachment = EncoderAttachment::try_new_with_frame_rate(width, height, frame_rate) - .map_err(|e| { - napi::Error::new(Status::GenericFailure, format!("attachEncoder failed: {e}")) - })?; - *self.inner.encoder_attachment.write() = Some(attachment); - self.inner - .encoder_attach_requested - .store(true, Ordering::Release); - self.inner - .encoder_ring_full_emitted - .store(false, Ordering::Release); - Ok(()) - } - - #[napi(js_name = "detachEncoder")] - pub fn detach_encoder(&self) -> Result<()> { - if let Some(attach) = self.inner.encoder_attachment.write().take() { - attach.detach(); - } - self.inner - .encoder_attach_requested - .store(false, Ordering::Release); - Ok(()) - } - - #[napi(js_name = "isEncoderAttached")] - pub fn is_encoder_attached(&self) -> bool { - self.inner - .encoder_attachment - .read() - .as_ref() - .map(|a| a.is_attached()) - .unwrap_or(false) - } - - #[napi(js_name = "encoderRingFullCount")] - pub fn encoder_ring_full_count(&self) -> u32 { - self.inner - .encoder_attachment - .read() - .as_ref() - .map(|a| a.stats().ring_full_events.min(u32::MAX as u64) as u32) - .unwrap_or(0) - } - - #[napi(js_name = "getFrameSinkDiagnostics")] - pub fn get_frame_sink_diagnostics(&self) -> FrameSinkDiagnostics { - FrameSinkDiagnostics { - accepted: self.inner.frame_sink_accepted.load(Ordering::Acquire) as f64, - coalesced: self.inner.frame_sink_coalesced.load(Ordering::Acquire) as f64, - rejected: self.inner.frame_sink_rejected.load(Ordering::Acquire) as f64, - media_frames_dropped_without_sink: self - .inner - .media_frames_dropped_without_sink - .load(Ordering::Acquire) as f64, - } - } - - #[napi(js_name = "enableAudioCapture")] - pub fn enable_audio_capture( - &self, - captures_audio: bool, - sample_rate_hz: u32, - channels: u32, - ) -> Result<()> { - if !(8_000..=192_000).contains(&sample_rate_hz) { - return Err(napi::Error::new( - Status::InvalidArg, - "ScreenCapture.enableAudioCapture sampleRateHz out of [8000..=192000]", - )); - } - if !(1..=8).contains(&channels) { - return Err(napi::Error::new( - Status::InvalidArg, - "ScreenCapture.enableAudioCapture channels out of [1..=8]", - )); - } - self.inner - .captures_audio - .store(captures_audio, Ordering::Release); - self.inner - .audio_sample_rate_hz - .store(sample_rate_hz, Ordering::Release); - self.inner.audio_channels.store(channels, Ordering::Release); - Ok(()) - } - - pub fn install_failure_surface(&self, surface: Arc) { - *self.inner.failure_surface.write() = Some(surface); - } - - pub fn clear_failure_surface(&self) { - self.inner.failure_surface.write().take(); - } - - #[napi] - pub async fn start( - &self, - source_id: String, - source_kind: String, - width: u32, - height: u32, - frame_rate: u32, - capture_id: Option, - capture_options: Option, - ) -> Result { - let start_options = normalize_start_options(capture_options)?; - let normalized_capture_id = capture_id - .map(|raw| raw.trim().to_string()) - .filter(|trimmed| !trimmed.is_empty()); - *self - .inner - .capture_id - .lock() - .map_err(|_| generic_error("ScreenCapture capture_id lock poisoned"))? = - normalized_capture_id.clone(); - *self.inner.bus_sink.write() = if self.inner.native_frame_sink.read().is_some() { - None - } else { - normalized_capture_id - .as_deref() - .and_then(frame_bus::get_sink) - }; - let source_id_num: u32 = source_id.parse().map_err(|_| { - napi::Error::new( - Status::InvalidArg, - "ScreenCapture.start sourceId must be a u32 string", - ) - })?; - if source_id_num == 0 { - return Err(napi::Error::new( - Status::InvalidArg, - "ScreenCapture.start sourceId must be > 0", - )); - } - let source_id = source_id_num; - { - let detected = os_version::current_macos_version(); - let SupportClassification { - supported, reason, .. - } = classify_support(detected); - if !supported { - return Err(napi::Error::new(Status::GenericFailure, reason)); - } - } - { - let state = lock_state(&self.inner)?; - if state.stream.is_some() - || state.delegate.is_some() - || self.inner.running.load(Ordering::Acquire) - { - return Err(napi::Error::new( - Status::GenericFailure, - "ScreenCapture is already running", - )); - } - } - let content = get_shareable_content(DEFAULT_TIMEOUT_NS) - .map_err(|_| generic_error("Failed to fetch SCShareableContent"))?; - - let (filter, dims) = - build_filter_for_source(&content, source_id, &source_kind, width, height)?; - - let point_pixel_scale = sck::filter_point_pixel_scale_if_available(&filter); - let content_rect = sck::filter_content_rect_if_available(&filter); - let (source_width, source_height) = resolve_source_pixels( - width, - height, - start_options.source_rect, - content_rect, - point_pixel_scale, - dims, - ); - let (final_width, final_height) = cap_output_dims( - source_width, - source_height, - start_options.max_output_width, - start_options.max_output_height, - ); - assert!(final_width >= crate::config::OUTPUT_DIMENSION_MIN); - assert!(final_height >= crate::config::OUTPUT_DIMENSION_MIN); - let effective_fps = if frame_rate == 0 { - 30 - } else { - frame_rate.min(240) - }; - - let cfg: Retained = unsafe { SCStreamConfiguration::new() }; - sck::cfg_set_width(&cfg, final_width as usize); - sck::cfg_set_height(&cfg, final_height as usize); - sck::cfg_set_scales_to_fit_if_available(&cfg, true); - sck::cfg_set_shows_cursor(&cfg, start_options.shows_cursor); - if let Some(source_rect) = start_options.source_rect { - if !sck::cfg_set_source_rect_if_available(&cfg, source_rect) { - emit_lifecycle_diagnostic( - &self.inner, - "captureRect requested but this ScreenCaptureKit runtime lacks setSourceRect", - ); - } - } - - let captures_audio = self.inner.captures_audio.load(Ordering::Acquire); - let audio_sample_rate_hz = self.inner.audio_sample_rate_hz.load(Ordering::Acquire); - let audio_channels = self.inner.audio_channels.load(Ordering::Acquire); - let capture_cfg = build_capture_config( - effective_fps, - captures_audio, - audio_sample_rate_hz, - audio_channels, - start_options.pixel_format, - start_options.color_space, - ); - sck::apply_capture_config(&cfg, &capture_cfg); - - let stream_name = format!("Fluxer ScreenCapture ({}:{})", source_kind, source_id); - let nsname = NSString::from_str(&stream_name); - sck::cfg_set_stream_name_if_available(&cfg, &nsname); - - let delegate = FluxerSCKScreenSource::new(Arc::downgrade(&self.inner)); - - let stream_alloc = SCStream::alloc(); - let stream: Retained = unsafe { - SCStream::initWithFilter_configuration_delegate( - stream_alloc, - &filter, - &cfg, - Some(ProtocolObject::from_ref(&*delegate)), - ) - }; - - let sample_queue = build_scstream_sample_queue(&source_kind, source_id); - - if sck::sc_stream_add_stream_output( - &stream, - ProtocolObject::from_ref(&*delegate), - SCStreamOutputType::Screen, - Some(&sample_queue), - ) - .is_err() - { - delegate.clear_inner(); - return Err(generic_error("Failed to add screen stream output")); - } - - if captures_audio - && sck::sc_stream_add_stream_output( - &stream, - ProtocolObject::from_ref(&*delegate), - SCStreamOutputType::Audio, - Some(&sample_queue), - ) - .is_err() - { - delegate.clear_inner(); - return Err(generic_error("Failed to add audio stream output")); - } - - { - let mut state = lock_state(&self.inner)?; - state.delegate = Some(delegate.clone()); - state.stream = Some(stream.clone()); - state.sample_queue = Some(sample_queue); - } - self.inner.running.store(true, Ordering::Release); - - if await_start(&stream, DEFAULT_TIMEOUT_NS).is_err() { - self.inner.running.store(false, Ordering::Release); - if let Some(surface) = self.inner.failure_surface.read().as_ref().cloned() { - surface.on_failure(SckCaptureFailure::StreamStartFailed( - "SCStream startCapture failed".to_string(), - )); - } - let mut state = lock_state(&self.inner)?; - if let Some(d) = state.delegate.take() { - d.clear_inner(); - } - state.stream = None; - return Err(generic_error("SCStream startCapture failed")); - } - - Ok(ScreenCaptureStartResult { - width: final_width, - height: final_height, - frame_rate: effective_fps, - pixel_format: "nv12".to_string(), - }) - } - - #[napi] - pub async fn stop(&self) -> Result<()> { - self.inner.running.store(false, Ordering::Release); - if let Ok(mut guard) = self.inner.capture_id.lock() { - guard.take(); - } - self.inner.bus_sink.write().take(); - self.inner.native_frame_sink.write().take(); - self.inner.failure_surface.write().take(); - if let Some(attach) = self.inner.encoder_attachment.write().take() { - attach.detach(); - } - self.inner - .encoder_attach_requested - .store(false, Ordering::Release); - let (delegate, stream, queue) = { - let mut state = lock_state(&self.inner)?; - let delegate = state.delegate.take(); - let stream = state.stream.take(); - let queue = state.sample_queue.take(); - (delegate, stream, queue) - }; - if let Some(d) = delegate.as_ref() { - d.clear_inner(); - } - if let Some(s) = stream.as_ref() { - let _ = await_stop(s, DEFAULT_TIMEOUT_NS); - } - drop(stream); - drop(delegate); - drop(queue); - Ok(()) - } -} - -impl Drop for ScreenCapture { - fn drop(&mut self) { - self.inner.running.store(false, Ordering::Release); - self.inner.bus_sink.write().take(); - self.inner.native_frame_sink.write().take(); - self.inner.failure_surface.write().take(); - if let Some(attach) = self.inner.encoder_attachment.write().take() { - attach.detach(); - } - self.inner - .encoder_attach_requested - .store(false, Ordering::Release); - let (delegate, stream, _queue) = match self.inner.state.lock() { - Ok(mut s) => (s.delegate.take(), s.stream.take(), s.sample_queue.take()), - Err(_) => (None, None, None), - }; - if let Some(d) = delegate.as_ref() { - d.clear_inner(); - } - if let Some(s) = stream.as_ref() { - let _ = await_stop(s, DEFAULT_TIMEOUT_NS); - } - } -} - -fn build_scstream_sample_queue( - source_kind: &str, - source_id: u32, -) -> dispatch2::DispatchRetained { - assert!(!source_kind.is_empty(), "source kind non-empty"); - assert!(source_id > 0, "source id positive"); - let label = format!("com.fluxer.scstream.{}-{}", source_kind, source_id); - let queue_attr = dispatch2::DispatchQueueAttr::with_qos_class( - dispatch2::DispatchQueueAttr::SERIAL, - dispatch2::DispatchQoS::UserInteractive, - 0, - ); - dispatch2::DispatchQueue::new(label.as_str(), Some(&queue_attr)) -} - -fn build_capture_config( - effective_fps: u32, - captures_audio: bool, - audio_sample_rate_hz: u32, - audio_channels: u32, - pixel_format: SckPixelFormat, - color_space: SckColorSpace, -) -> SckCaptureConfig { - assert!(effective_fps >= crate::config::FPS_MIN); - assert!(audio_channels >= 1); - let clamped_fps = effective_fps.clamp(crate::config::FPS_MIN, crate::config::FPS_MAX); - SckCaptureConfig::builder() - .target_fps(clamped_fps) - .queue_depth(crate::config::QUEUE_DEPTH_DEFAULT) - .pixel_format(pixel_format) - .color_space(color_space) - .captures_audio(captures_audio) - .audio_sample_rate_hz(audio_sample_rate_hz) - .audio_channels(audio_channels) - .build() - .unwrap_or_default() -} - -fn normalize_start_options( - options: Option, -) -> Result { - let Some(options) = options else { - return Ok(NormalizedStartOptions::default()); - }; - let source_rect = normalize_capture_rect(options.capture_rect)?; - let pixel_format = normalize_color_range(options.color_range.as_deref())?; - let color_space = normalize_color_space(options.color_space.as_deref())?; - let max_output_width = normalize_max_output_dimension( - options.max_output_width, - crate::config::MAX_OUTPUT_WIDTH_DEFAULT, - ); - let max_output_height = normalize_max_output_dimension( - options.max_output_height, - crate::config::MAX_OUTPUT_HEIGHT_DEFAULT, - ); - Ok(NormalizedStartOptions { - shows_cursor: options.show_cursor_clicks.unwrap_or(false), - source_rect, - pixel_format, - color_space, - max_output_width, - max_output_height, - }) -} - -fn normalize_max_output_dimension(requested: Option, fallback: u32) -> u32 { - assert!(fallback >= crate::config::OUTPUT_DIMENSION_MIN); - let resolved = requested - .filter(|v| *v >= crate::config::OUTPUT_DIMENSION_MIN) - .unwrap_or(fallback); - let even = resolved & !1; - assert!(even >= crate::config::OUTPUT_DIMENSION_MIN); - even -} - -fn normalize_capture_rect(rect: Option) -> Result> { - let Some(rect) = rect else { - return Ok(None); - }; - if !rect.x.is_finite() || !rect.y.is_finite() { - return Err(invalid_arg( - "ScreenCapture.start captureRect origin must be finite", - )); - } - if !rect.width.is_finite() || !rect.height.is_finite() { - return Err(invalid_arg( - "ScreenCapture.start captureRect size must be finite", - )); - } - if rect.x < 0.0 || rect.y < 0.0 || rect.width <= 0.0 || rect.height <= 0.0 { - return Err(invalid_arg( - "ScreenCapture.start captureRect requires non-negative x/y and positive width/height", - )); - } - if rect.width > u32::MAX as f64 || rect.height > u32::MAX as f64 { - return Err(invalid_arg( - "ScreenCapture.start captureRect width/height exceed u32", - )); - } - Ok(Some(CGRect::new( - CGPoint::new(rect.x, rect.y), - CGSize::new(rect.width, rect.height), - ))) -} - -fn normalize_color_range(value: Option<&str>) -> Result { - match value { - None | Some("limited") => Ok(SckPixelFormat::Nv12VideoRange), - Some("full") => Ok(SckPixelFormat::Nv12FullRange), - Some(_) => Err(invalid_arg( - "ScreenCapture.start colorRange must be 'limited' or 'full'", - )), - } -} - -fn normalize_color_space(value: Option<&str>) -> Result { - match value { - None | Some("srgb") | Some("rec709") => Ok(SckColorSpace::SrgbBt709), - Some(_) => Err(invalid_arg( - "ScreenCapture.start colorSpace must be 'srgb' or 'rec709'", - )), - } -} - -impl Default for NormalizedStartOptions { - fn default() -> Self { - Self { - shows_cursor: false, - source_rect: None, - pixel_format: SckPixelFormat::Nv12VideoRange, - color_space: SckColorSpace::SrgbBt709, - max_output_width: crate::config::MAX_OUTPUT_WIDTH_DEFAULT, - max_output_height: crate::config::MAX_OUTPUT_HEIGHT_DEFAULT, - } - } -} - -fn emit_lifecycle_diagnostic(inner: &CaptureInner, message: &'static str) { - let Ok(state) = inner.state.lock() else { - return; - }; - let Some(tsfn) = state.lifecycle_tsfn.as_ref() else { - return; - }; - let status = tsfn.call( - ("diagnostic".to_string(), message.to_string()), - ThreadsafeFunctionCallMode::NonBlocking, - ); - note_tsfn_status(inner, status); -} - -fn even_floor(value: u32) -> u32 { - value & !1 -} - -fn resolve_source_pixels( - requested_width: u32, - requested_height: u32, - source_rect: Option, - content_rect: Option, - point_pixel_scale: Option, - point_dims_fallback: (u32, u32), -) -> (u32, u32) { - let scale = point_pixel_scale - .filter(|s| s.is_finite() && *s > 0.0) - .map(|s| s as f64) - .unwrap_or(1.0); - assert!(scale > 0.0); - let min = crate::config::OUTPUT_DIMENSION_MIN; - if let Some(rect) = source_rect { - assert!(rect.size.width > 0.0); - assert!(rect.size.height > 0.0); - let w = (rect.size.width * scale).round() as u32; - let h = (rect.size.height * scale).round() as u32; - return (even_floor(w.max(min)), even_floor(h.max(min))); - } - if let Some(rect) = content_rect { - assert!(rect.size.width > 0.0); - assert!(rect.size.height > 0.0); - let w = (rect.size.width * scale).round() as u32; - let h = (rect.size.height * scale).round() as u32; - return (even_floor(w.max(min)), even_floor(h.max(min))); - } - if requested_width > 0 && requested_height > 0 { - return ( - even_floor(requested_width.max(min)), - even_floor(requested_height.max(min)), - ); - } - ( - even_floor(point_dims_fallback.0.max(min)), - even_floor(point_dims_fallback.1.max(min)), - ) -} - -fn cap_output_dims(src_w: u32, src_h: u32, max_w: u32, max_h: u32) -> (u32, u32) { - let min = crate::config::OUTPUT_DIMENSION_MIN; - assert!(src_w >= min); - assert!(src_h >= min); - let max_w = even_floor(max_w).max(min); - let max_h = even_floor(max_h).max(min); - let scale = (max_w as f64 / src_w as f64) - .min(max_h as f64 / src_h as f64) - .min(1.0); - assert!(scale > 0.0); - assert!(scale <= 1.0); - let out_w = even_floor(((src_w as f64) * scale).round() as u32).clamp(min, max_w); - let out_h = even_floor(((src_h as f64) * scale).round() as u32).clamp(min, max_h); - assert!(out_w <= max_w); - assert!(out_h <= max_h); - (out_w, out_h) -} - -fn build_filter_for_source( - content: &SCShareableContent, - source_id: u32, - source_kind: &str, - requested_width: u32, - requested_height: u32, -) -> Result<(Retained, (u32, u32))> { - match source_kind { - "screen" => { - let displays: Retained> = unsafe { content.displays() }; - let count = displays.count(); - let mut chosen: Option> = None; - for i in 0..count { - let d = displays.objectAtIndex(i); - if sck::sc_display_display_id(&d) == source_id { - chosen = Some(d); - break; - } - } - let display = - chosen.ok_or_else(|| generic_error("No SCDisplay matches requested sourceId"))?; - let dims = ( - if requested_width > 0 { - requested_width - } else { - sck::sc_display_width(&display).max(0) as u32 - }, - if requested_height > 0 { - requested_height - } else { - sck::sc_display_height(&display).max(0) as u32 - }, - ); - - let empty: Retained> = NSArray::new(); - let filter_alloc = SCContentFilter::alloc(); - let filter: Retained = unsafe { - SCContentFilter::initWithDisplay_excludingWindows(filter_alloc, &display, &empty) - }; - Ok((filter, dims)) - } - "window" => { - let windows: Retained> = unsafe { content.windows() }; - let count = windows.count(); - let mut chosen: Option> = None; - for i in 0..count { - let w = windows.objectAtIndex(i); - if sck::sc_window_window_id(&w) == source_id { - chosen = Some(w); - break; - } - } - let window = - chosen.ok_or_else(|| generic_error("No SCWindow matches requested sourceId"))?; - let frame = sck::sc_window_frame(&window); - let dims = ( - if requested_width > 0 { - requested_width - } else { - frame.size.width.max(0.0) as u32 - }, - if requested_height > 0 { - requested_height - } else { - frame.size.height.max(0.0) as u32 - }, - ); - let filter_alloc = SCContentFilter::alloc(); - let filter: Retained = - unsafe { SCContentFilter::initWithDesktopIndependentWindow(filter_alloc, &window) }; - Ok((filter, dims)) - } - other => Err(napi::Error::new( - Status::InvalidArg, - format!("Unknown sourceKind: {other}"), - )), - } -} - -#[napi(object, js_name = "MacScreenCaptureSource")] -pub struct MacScreenCaptureSource { - pub kind: String, - pub id: String, - pub name: String, - pub width: u32, - pub height: u32, - pub app_name: Option, - pub bundle_id: Option, - - pub target_pid: Option, -} - -#[napi(js_name = "listSources")] -pub async fn list_sources() -> Result> { - let content = match get_shareable_content(DEFAULT_TIMEOUT_NS) { - Ok(c) => c, - Err(_) => return Ok(Vec::new()), - }; - let mut out: Vec = Vec::new(); - let displays: Retained> = unsafe { content.displays() }; - let dcount = displays.count(); - for i in 0..dcount { - let d = displays.objectAtIndex(i); - let id = sck::sc_display_display_id(&d); - let name = format!("Display {}", i + 1); - out.push(MacScreenCaptureSource { - kind: "screen".to_string(), - id: id.to_string(), - name, - width: sck::sc_display_width(&d).max(0) as u32, - height: sck::sc_display_height(&d).max(0) as u32, - app_name: None, - bundle_id: None, - target_pid: None, - }); - } - let windows: Retained> = unsafe { content.windows() }; - let wcount = windows.count(); - for i in 0..wcount { - let w = windows.objectAtIndex(i); - if !sck::sc_window_is_on_screen(&w) { - continue; - } - let id = sck::sc_window_window_id(&w); - let title = sck::sc_window_title(&w) - .map(|s| s.to_string()) - .unwrap_or_default(); - let frame = sck::sc_window_frame(&w); - let (app_name, bundle_id, target_pid) = match sck::sc_window_owning_application(&w) { - Some(app) => ( - Some(sck::sc_running_application_name(&app).to_string()), - Some(sck::sc_running_application_bundle_identifier(&app).to_string()), - Some(sck::sc_running_application_process_id(&app)).filter(|pid| *pid > 0), - ), - None => (None, None, None), - }; - let display_name = if title.is_empty() { - app_name.clone().unwrap_or_else(|| format!("Window {id}")) - } else if let Some(ref app) = app_name { - format!("{app} — {title}") - } else { - title - }; - out.push(MacScreenCaptureSource { - kind: "window".to_string(), - id: id.to_string(), - name: display_name, - width: frame.size.width.max(0.0) as u32, - height: frame.size.height.max(0.0) as u32, - app_name, - bundle_id, - target_pid, - }); - } - Ok(out) -} - -#[napi(object, js_name = "MacScreenCaptureBackendSckAvailability")] -pub struct SckAvailability { - pub supported: bool, - pub macos_version: Option, -} - -#[napi(object, js_name = "MacScreenCaptureBackendAvailability")] -pub struct BackendAvailability { - pub sck: SckAvailability, - pub screen_permission: String, -} - -#[napi(js_name = "getBackendAvailability")] -pub async fn get_backend_availability() -> Result { - use objc2::runtime::AnyClass; - let sck_supported = AnyClass::get(c"SCStream").is_some(); - let version_str = Some(foundation::operating_system_version_string()); - let screen_permission = if unsafe { CGPreflightScreenCaptureAccess() } { - "granted" - } else { - "denied" - }; - Ok(BackendAvailability { - sck: SckAvailability { - supported: sck_supported, - macos_version: version_str, - }, - screen_permission: screen_permission.to_string(), - }) -} - fn shareable_content_outcome(timeout_ns: u64) -> ShareableContentOutcome { match get_shareable_content(timeout_ns) { Ok(_) => ShareableContentOutcome::Delivered, @@ -1962,620 +157,3 @@ pub async fn probe_screen_recording_access() -> Result { }; Ok(screen_recording_probe_result(sck_available, outcome).to_owned()) } - -#[napi(object, js_name = "MacScreenCaptureBackendInfo")] -pub struct MacScreenCaptureBackendInfo { - pub backend: String, - pub supported: bool, - pub reason: String, - #[napi(js_name = "minMacosVersion")] - pub min_macos_version: String, - #[napi(js_name = "detectedMacosVersion")] - pub detected_macos_version: Option, - #[napi(js_name = "sckAvailable")] - pub sck_available: bool, -} - -#[napi(js_name = "getBackendInfo")] -pub fn get_backend_info() -> MacScreenCaptureBackendInfo { - let detected = os_version::current_macos_version(); - let SupportClassification { - supported, - sck_available, - reason, - } = classify_support(detected); - MacScreenCaptureBackendInfo { - backend: "mac-screen-capture".to_owned(), - supported, - reason, - min_macos_version: format_version(SCK_MIN_MACOS), - detected_macos_version: detected.map(format_version), - sck_available, - } -} - -#[cfg(test)] -mod dispatch_queue_tests { - use std::sync::Arc; - use std::sync::atomic::{AtomicU64, Ordering}; - use std::time::Duration; - - use super::build_scstream_sample_queue; - - #[test] - fn sample_queue_construction_does_not_trap_libdispatch() { - let _queue = build_scstream_sample_queue("screen", 1); - } - - #[test] - fn sample_queue_dispatches_blocks_without_crashing() { - let queue = build_scstream_sample_queue("screen", 2); - let counter = Arc::new(AtomicU64::new(0)); - for _ in 0..32 { - let c = counter.clone(); - queue.exec_async(move || { - c.fetch_add(1, Ordering::Release); - }); - } - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while counter.load(Ordering::Acquire) < 32 { - if std::time::Instant::now() > deadline { - panic!( - "sample queue did not drain blocks: {}/{}", - counter.load(Ordering::Acquire), - 32 - ); - } - std::thread::sleep(Duration::from_millis(10)); - } - } - - #[test] - fn audio_frame_payload_into_input_keeps_slot_and_metadata() { - use crate::audio_pool::MacAudioFramePool; - let pool = MacAudioFramePool::new(2, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - let bytes = vec![0xAB_u8; 8]; - slot.write(&bytes).expect("write"); - let payload = super::AudioFramePayload { - sample_rate_hz: 48_000, - channels: 2, - num_samples_per_channel: 1024, - pts_us: 7_777, - format: super::AudioSampleFormat::F32Planar, - slot, - }; - let (sr, ch, ns, pts, fmt, slot) = payload.into_input(); - assert_eq!(sr, 48_000); - assert_eq!(ch, 2); - assert_eq!(ns, 1024); - assert_eq!(pts, 7_777); - assert_eq!(fmt, super::AudioSampleFormat::F32Planar); - assert_eq!(fmt.code(), 0); - assert_eq!(slot.filled_len(), bytes.len()); - assert_eq!(slot.data_slice(), &bytes[..]); - drop(slot); - assert_eq!(pool.stats().in_flight, 0); - } - - #[test] - fn classify_pcm_flags_detects_f32_planar() { - use objc2_core_audio_types::{kAudioFormatFlagIsFloat, kAudioFormatFlagIsNonInterleaved}; - let flags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsNonInterleaved; - assert_eq!( - super::classify_pcm_flags(flags, 32), - super::AudioSampleFormat::F32Planar - ); - } - - #[test] - fn classify_pcm_flags_detects_f32_interleaved() { - use objc2_core_audio_types::kAudioFormatFlagIsFloat; - let flags = kAudioFormatFlagIsFloat; - assert_eq!( - super::classify_pcm_flags(flags, 32), - super::AudioSampleFormat::F32Interleaved - ); - } - - #[test] - fn classify_pcm_flags_distinguishes_planar_from_interleaved_float() { - use objc2_core_audio_types::{kAudioFormatFlagIsFloat, kAudioFormatFlagIsNonInterleaved}; - let planar = super::classify_pcm_flags( - kAudioFormatFlagIsFloat | kAudioFormatFlagIsNonInterleaved, - 32, - ); - let interleaved = super::classify_pcm_flags(kAudioFormatFlagIsFloat, 32); - assert_eq!(planar, super::AudioSampleFormat::F32Planar); - assert_eq!(interleaved, super::AudioSampleFormat::F32Interleaved); - assert_ne!(planar, interleaved); - assert_eq!(planar.bytes_per_sample(), interleaved.bytes_per_sample()); - } - - #[test] - fn classify_pcm_flags_detects_i16_interleaved() { - use objc2_core_audio_types::kAudioFormatFlagIsSignedInteger; - let flags = kAudioFormatFlagIsSignedInteger; - assert_eq!( - super::classify_pcm_flags(flags, 16), - super::AudioSampleFormat::I16Interleaved - ); - } - - #[test] - fn classify_pcm_flags_rejects_unknown() { - assert_eq!( - super::classify_pcm_flags(0, 24), - super::AudioSampleFormat::Unknown - ); - } - - #[test] - fn build_capture_config_preserves_frame_interval_when_audio_enabled() { - let cfg_off = super::build_capture_config( - 60, - false, - 48_000, - 2, - super::SckPixelFormat::Nv12VideoRange, - super::SckColorSpace::SrgbBt709, - ); - let cfg_on = super::build_capture_config( - 60, - true, - 48_000, - 2, - super::SckPixelFormat::Nv12VideoRange, - super::SckColorSpace::SrgbBt709, - ); - assert!(!cfg_off.captures_audio()); - assert!(cfg_on.captures_audio()); - assert_eq!( - cfg_off.minimum_frame_interval_ns(), - cfg_on.minimum_frame_interval_ns() - ); - } - - #[test] - fn build_capture_config_includes_audio_settings() { - let cfg = super::build_capture_config( - 30, - true, - 44_100, - 1, - super::SckPixelFormat::Nv12VideoRange, - super::SckColorSpace::SrgbBt709, - ); - assert!(cfg.captures_audio()); - assert_eq!(cfg.audio_sample_rate_hz(), 44_100); - assert_eq!(cfg.audio_channels(), 1); - } - - #[test] - fn start_options_include_cursor_color_and_rect_intent() { - let options = super::normalize_start_options(Some(super::ScreenCaptureStartOptions { - show_cursor_clicks: Some(true), - capture_rect: Some(super::ScreenCaptureRect { - x: 10.0, - y: 20.0, - width: 300.0, - height: 200.0, - }), - color_range: Some("full".to_string()), - color_space: Some("rec709".to_string()), - max_output_width: None, - max_output_height: None, - })) - .expect("valid start options"); - assert!(options.shows_cursor); - assert_eq!(options.pixel_format, super::SckPixelFormat::Nv12FullRange); - assert_eq!(options.color_space, super::SckColorSpace::SrgbBt709); - let rect = options.source_rect.expect("capture rect"); - assert_eq!(rect.origin.x, 10.0); - assert_eq!(rect.origin.y, 20.0); - assert_eq!(rect.size.width, 300.0); - assert_eq!(rect.size.height, 200.0); - } - - #[test] - fn start_options_reject_invalid_color_range() { - let err = super::normalize_start_options(Some(super::ScreenCaptureStartOptions { - show_cursor_clicks: None, - capture_rect: None, - color_range: Some("wide".to_string()), - color_space: None, - max_output_width: None, - max_output_height: None, - })) - .expect_err("invalid color range"); - assert_eq!(err.status, napi::Status::InvalidArg); - } - - #[test] - fn parallel_sample_queue_construction_is_safe() { - let handles: Vec<_> = (0..8) - .map(|i| { - std::thread::spawn(move || { - let q = build_scstream_sample_queue("window", (i + 1) as u32); - let done = Arc::new(AtomicU64::new(0)); - let dc = done.clone(); - q.exec_async(move || { - dc.store(1, Ordering::Release); - }); - let deadline = std::time::Instant::now() + Duration::from_secs(1); - while done.load(Ordering::Acquire) == 0 { - if std::time::Instant::now() > deadline { - panic!("parallel queue {} did not drain", i); - } - std::thread::sleep(Duration::from_millis(5)); - } - }) - }) - .collect(); - for h in handles { - h.join().expect("worker panicked"); - } - } -} - -#[cfg(all(test, target_os = "macos"))] -mod audio_extract_tests { - use super::*; - use objc2_core_audio_types::AudioBufferList; - - #[test] - fn ablstorage_size_is_large_enough_for_audio_buffer_list() { - let storage_size = core::mem::size_of::(); - let abl_size = core::mem::size_of::(); - assert!( - storage_size > abl_size, - "AblStorage must hold > 1 AudioBuffer" - ); - assert_eq!(core::mem::align_of::(), 16); - } - - #[test] - fn ablstorage_layout_matches_apple_abl() { - let storage_size = core::mem::size_of::(); - let n_buffers_offset = core::mem::offset_of!(super::AblStorage, n_buffers); - let buffers_offset = core::mem::offset_of!(super::AblStorage, buffers); - assert_eq!(n_buffers_offset, 0, "n_buffers must be at offset 0"); - assert_eq!( - buffers_offset, 8, - "buffers must be at offset 8 (after 4 bytes pad)" - ); - assert!(storage_size >= 24, "must hold at least one AudioBuffer"); - } - - #[test] - fn extract_audio_bytes_rejects_metadata_sample_buffer_gracefully() { - let pool = MacAudioFramePool::new(2, 256).expect("pool"); - assert_eq!(pool.try_acquire().map(|s| s.capacity()), Some(256)); - } -} - -#[cfg(test)] -mod dimension_tests { - use super::*; - use objc2_core_foundation::{CGPoint, CGSize}; - - fn rect(w: f64, h: f64) -> CGRect { - CGRect { - origin: CGPoint { x: 0.0, y: 0.0 }, - size: CGSize { - width: w, - height: h, - }, - } - } - - #[test] - fn even_floor_clears_low_bit() { - assert_eq!(even_floor(3841), 3840); - assert_eq!(even_floor(3840), 3840); - assert_eq!(even_floor(1), 0); - } - - #[test] - fn cap_downscales_8k_to_4k_preserving_aspect() { - assert_eq!(cap_output_dims(7680, 4320, 3840, 2160), (3840, 2160)); - } - - #[test] - fn cap_is_noop_below_ceiling() { - assert_eq!(cap_output_dims(1920, 1080, 3840, 2160), (1920, 1080)); - } - - #[test] - fn cap_preserves_aspect_for_5k_ultrawide() { - let (w, h) = cap_output_dims(5120, 2160, 3840, 2160); - assert_eq!(h, 1620); - assert_eq!(w, 3840); - assert!(w <= 3840 && h <= 2160); - } - - #[test] - fn cap_outputs_are_even_and_bounded() { - for (sw, sh) in [(7680u32, 4320u32), (5120, 2880), (3008, 1692), (1366, 768)] { - let (w, h) = cap_output_dims(sw, sh, 3840, 2160); - assert_eq!(w % 2, 0); - assert_eq!(h % 2, 0); - assert!(w <= 3840); - assert!(h <= 2160); - } - } - - #[test] - fn resolve_prefers_content_rect_times_scale() { - let dims = resolve_source_pixels( - 7680, - 4320, - None, - Some(rect(3840.0, 2160.0)), - Some(2.0), - (3840, 2160), - ); - assert_eq!(dims, (7680, 4320)); - } - - #[test] - fn resolve_falls_back_to_requested_without_selectors() { - assert_eq!( - resolve_source_pixels(7680, 4320, None, None, None, (1920, 1080)), - (7680, 4320) - ); - } - - #[test] - fn resolve_source_rect_takes_priority() { - let dims = resolve_source_pixels( - 7680, - 4320, - Some(rect(1280.0, 720.0)), - Some(rect(3840.0, 2160.0)), - Some(2.0), - (3840, 2160), - ); - assert_eq!(dims, (2560, 1440)); - } - - #[test] - fn resolve_then_cap_fills_4k_from_8k_backing() { - let (sw, sh) = - resolve_source_pixels(0, 0, None, Some(rect(3840.0, 2160.0)), Some(2.0), (0, 0)); - assert_eq!(cap_output_dims(sw, sh, 3840, 2160), (3840, 2160)); - } -} - -#[cfg(test)] -mod external_buffer_tests { - use super::*; - use crate::audio_pool::MacAudioFramePool; - use std::alloc::{GlobalAlloc, Layout, System}; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::thread; - - struct CountingAllocator; - - thread_local! { - static THREAD_ALLOC_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; - static THREAD_ALLOC_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; - static THREAD_TRACKING: std::cell::Cell = const { std::cell::Cell::new(false) }; - } - - fn tracking_enabled() -> bool { - THREAD_TRACKING.try_with(|t| t.get()).unwrap_or(false) - } - - fn record_alloc(layout: Layout) { - let _ = THREAD_ALLOC_COUNT.try_with(|c| c.set(c.get() + 1)); - let _ = THREAD_ALLOC_BYTES.try_with(|b| b.set(b.get() + layout.size() as u64)); - } - - fn reset_counts() { - let _ = THREAD_ALLOC_COUNT.try_with(|c| c.set(0)); - let _ = THREAD_ALLOC_BYTES.try_with(|b| b.set(0)); - } - - fn snapshot_counts() -> (u64, u64) { - let count = THREAD_ALLOC_COUNT.try_with(|c| c.get()).unwrap_or(0); - let bytes = THREAD_ALLOC_BYTES.try_with(|b| b.get()).unwrap_or(0); - (count, bytes) - } - - fn set_tracking(enabled: bool) { - let _ = THREAD_TRACKING.try_with(|t| t.set(enabled)); - } - - unsafe impl GlobalAlloc for CountingAllocator { - unsafe fn alloc(&self, layout: Layout) -> *mut u8 { - if tracking_enabled() { - record_alloc(layout); - } - unsafe { System.alloc(layout) } - } - unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { - unsafe { System.dealloc(ptr, layout) } - } - } - - #[global_allocator] - static GLOBAL: CountingAllocator = CountingAllocator; - - struct FakeExternalBuffer { - ptr: *mut u8, - len: usize, - slot: Option, - } - - unsafe impl Send for FakeExternalBuffer {} - - impl FakeExternalBuffer { - fn from_pooled(mut slot: PooledMacAudioFrame) -> Self { - let len = slot.filled_len(); - let ptr = slot.as_mut_ptr(); - assert!(!ptr.is_null()); - Self { - ptr, - len, - slot: Some(slot), - } - } - - fn data(&self) -> &[u8] { - assert!(!self.ptr.is_null()); - unsafe { core::slice::from_raw_parts(self.ptr, self.len) } - } - } - - impl Drop for FakeExternalBuffer { - fn drop(&mut self) { - if let Some(slot) = self.slot.take() { - drop(slot); - } - } - } - - #[test] - fn audio_buffer_external_pointer_round_trip() { - let pool = MacAudioFramePool::new(4, 256).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - let payload = [0x42_u8; 64]; - slot.write(&payload).expect("write"); - let fake = FakeExternalBuffer::from_pooled(slot); - assert_eq!(pool.stats().in_flight, 1); - assert_eq!(fake.data(), &payload[..]); - drop(fake); - assert_eq!(pool.stats().in_flight, 0); - assert_eq!(pool.stats().released, 1); - assert_eq!(pool.stats().acquired, 1); - } - - #[test] - fn multi_frame_external_buffer_lifecycle() { - const FRAMES: u64 = 100; - let pool = MacAudioFramePool::new(8, 128).expect("pool"); - for i in 0..FRAMES { - let mut slot = pool.try_acquire().expect("slot"); - let byte = (i % 256) as u8; - let payload = [byte; 64]; - slot.write(&payload).expect("write"); - let fake = FakeExternalBuffer::from_pooled(slot); - assert_eq!(fake.data()[0], byte); - drop(fake); - } - let stats = pool.stats(); - assert_eq!(stats.acquired, FRAMES); - assert_eq!(stats.released, FRAMES); - assert_eq!(stats.in_flight, 0); - assert_eq!(stats.dropped, 0); - } - - #[test] - fn pool_exhaustion_with_external_buffers_holds_diagnostic() { - let pool = MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, 256).expect("pool"); - let mut held: Vec = Vec::with_capacity(MAC_AUDIO_POOL_CAP); - for _ in 0..MAC_AUDIO_POOL_CAP { - let mut slot = pool.try_acquire().expect("within cap"); - slot.write(&[0xAB_u8; 32]).expect("write"); - held.push(FakeExternalBuffer::from_pooled(slot)); - } - assert_eq!(pool.stats().in_flight as usize, MAC_AUDIO_POOL_CAP); - let next = pool.try_acquire(); - assert!(next.is_none(), "17th acquire must report exhaustion"); - assert_eq!(pool.stats().dropped, 1); - drop(held); - assert_eq!(pool.stats().in_flight, 0); - } - - #[test] - fn buffer_external_drop_fn_runs_on_pool_thread_or_napi_thread() { - let pool = Arc::new(MacAudioFramePool::new(4, 128).expect("pool")); - let pool_clone = Arc::clone(&pool); - let mut slot = pool.try_acquire().expect("slot"); - slot.write(&[0x99_u8; 32]).expect("write"); - let fake = FakeExternalBuffer::from_pooled(slot); - assert_eq!(pool.stats().in_flight, 1); - let handle = thread::spawn(move || { - let fake = fake; - assert_eq!(fake.data()[0], 0x99); - drop(fake); - }); - handle.join().expect("worker"); - assert_eq!(pool_clone.stats().in_flight, 0); - assert_eq!(pool_clone.stats().released, 1); - } - - #[test] - fn zero_allocation_in_steady_state_acquire_write_external_release() { - let pool = - MacAudioFramePool::new(MAC_AUDIO_POOL_CAP, MAX_FRAME_BYTES_PER_SLOT).expect("pool"); - let payload = vec![0xCC_u8; 3840]; - for _ in 0..16 { - let mut slot = pool.try_acquire().expect("warmup"); - slot.write(&payload).expect("warmup write"); - let fake = FakeExternalBuffer::from_pooled(slot); - drop(fake); - } - reset_counts(); - set_tracking(true); - for _ in 0..1000 { - let mut slot = pool.try_acquire().expect("steady-state slot"); - slot.write(&payload).expect("steady-state write"); - let fake = FakeExternalBuffer::from_pooled(slot); - std::hint::black_box(fake.data().len()); - drop(fake); - } - set_tracking(false); - let (count, bytes) = snapshot_counts(); - assert_eq!( - count, 0, - "steady-state acquire/write/external/release must not allocate (got {count} allocations, {bytes} bytes)" - ); - } - - #[test] - fn allocator_counter_self_test_reports_nonzero_when_allocating() { - reset_counts(); - set_tracking(true); - let v: Vec = Vec::with_capacity(1024); - set_tracking(false); - let (count, _) = snapshot_counts(); - let _hold = v; - assert!( - count >= 1, - "self-test: allocator counter must record real allocs" - ); - } - - static FINALIZE_THREAD_COUNTER: AtomicUsize = AtomicUsize::new(0); - - struct ThreadAwareFakeBuffer { - slot: Option, - } - - impl Drop for ThreadAwareFakeBuffer { - fn drop(&mut self) { - FINALIZE_THREAD_COUNTER.fetch_add(1, Ordering::Relaxed); - if let Some(slot) = self.slot.take() { - drop(slot); - } - } - } - - #[test] - fn external_buffer_finalize_runs_exactly_once_per_frame() { - FINALIZE_THREAD_COUNTER.store(0, Ordering::Relaxed); - let pool = MacAudioFramePool::new(4, 64).expect("pool"); - for _ in 0..50 { - let mut slot = pool.try_acquire().expect("slot"); - slot.write(&[0u8; 32]).expect("write"); - let fake = ThreadAwareFakeBuffer { slot: Some(slot) }; - drop(fake); - } - assert_eq!(FINALIZE_THREAD_COUNTER.load(Ordering::Relaxed), 50); - assert_eq!(pool.stats().acquired, 50); - assert_eq!(pool.stats().released, 50); - } -} diff --git a/fluxer_desktop/native/mac-screen-capture/src/napi_surface_stub.rs b/fluxer_desktop/native/mac-screen-capture/src/napi_surface_stub.rs index d6d3a5dca..d5d722167 100644 --- a/fluxer_desktop/native/mac-screen-capture/src/napi_surface_stub.rs +++ b/fluxer_desktop/native/mac-screen-capture/src/napi_surface_stub.rs @@ -1,87 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use napi::bindgen_prelude::{Error, Result, Status}; use napi_derive::napi; -fn unsupported() -> Error { - Error::new( - Status::GenericFailure, - "@fluxer/mac-screen-capture is only supported on macOS", - ) -} - -#[napi(object, js_name = "MacScreenCaptureSource")] -pub struct MacScreenCaptureSource { - pub kind: String, - pub id: String, - pub name: String, - pub width: u32, - pub height: u32, - pub app_name: Option, - pub bundle_id: Option, - pub target_pid: Option, -} - -#[napi(js_name = "listSources")] -pub fn list_sources() -> Result> { - Ok(Vec::new()) -} - -#[napi(object, js_name = "MacScreenCaptureBackendSckAvailability")] -pub struct SckAvailability { - pub supported: bool, - pub macos_version: Option, -} - -#[napi(object, js_name = "MacScreenCaptureBackendAvailability")] -pub struct BackendAvailability { - pub sck: SckAvailability, - pub screen_permission: String, -} - -#[napi(js_name = "getBackendAvailability")] -pub fn get_backend_availability() -> Result { - Err(unsupported()) -} - #[napi(js_name = "probeScreenRecordingAccess")] pub fn probe_screen_recording_access() -> String { crate::permission_probe::PROBE_UNSUPPORTED.to_owned() } - -#[napi(object, js_name = "MacScreenCaptureBackendInfo")] -pub struct MacScreenCaptureBackendInfo { - pub backend: String, - pub supported: bool, - pub reason: String, - #[napi(js_name = "minMacosVersion")] - pub min_macos_version: String, - #[napi(js_name = "detectedMacosVersion")] - pub detected_macos_version: Option, - #[napi(js_name = "sckAvailable")] - pub sck_available: bool, -} - -#[napi(js_name = "getBackendInfo")] -pub fn get_backend_info() -> MacScreenCaptureBackendInfo { - use crate::os_version::{SCK_MIN_MACOS, format_version}; - MacScreenCaptureBackendInfo { - backend: "mac-screen-capture".to_owned(), - supported: false, - reason: "@fluxer/mac-screen-capture is only supported on macOS".to_owned(), - min_macos_version: format_version(SCK_MIN_MACOS), - detected_macos_version: None, - sck_available: false, - } -} - -#[napi] -pub struct ScreenCapture; - -#[napi] -impl ScreenCapture { - #[napi(constructor)] - pub fn new() -> Result { - Err(unsupported()) - } -} diff --git a/fluxer_desktop/native/mac-screen-capture/src/os_version.rs b/fluxer_desktop/native/mac-screen-capture/src/os_version.rs deleted file mode 100644 index 6b576601c..000000000 --- a/fluxer_desktop/native/mac-screen-capture/src/os_version.rs +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub const SCK_MIN_MACOS: (i64, i64, i64) = (12, 3, 0); - -pub fn meets_floor(version: (i64, i64, i64), floor: (i64, i64, i64)) -> bool { - if version.0 != floor.0 { - return version.0 > floor.0; - } - if version.1 != floor.1 { - return version.1 > floor.1; - } - version.2 >= floor.2 -} - -pub fn format_version(version: (i64, i64, i64)) -> String { - if version.2 == 0 { - format!("{}.{}", version.0, version.1) - } else { - format!("{}.{}.{}", version.0, version.1, version.2) - } -} - -#[cfg(target_os = "macos")] -pub fn current_macos_version() -> Option<(i64, i64, i64)> { - use objc2_foundation::NSProcessInfo; - let info = NSProcessInfo::processInfo(); - let v = info.operatingSystemVersion(); - Some(( - v.majorVersion as i64, - v.minorVersion as i64, - v.patchVersion as i64, - )) -} - -#[cfg(not(target_os = "macos"))] -pub fn current_macos_version() -> Option<(i64, i64, i64)> { - None -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SupportClassification { - pub supported: bool, - pub sck_available: bool, - pub reason: String, -} - -pub fn classify_support(detected: Option<(i64, i64, i64)>) -> SupportClassification { - let min_sck = format_version(SCK_MIN_MACOS); - match detected { - None => SupportClassification { - supported: false, - sck_available: false, - reason: "mac-screen-capture could not detect the running macOS version. \ - Native screen capture unavailable." - .to_owned(), - }, - Some(v) => { - let detected_str = format_version(v); - let sck_ok = meets_floor(v, SCK_MIN_MACOS); - let reason = if sck_ok { - format!( - "mac-screen-capture supported on macOS {detected_str} \ - (ScreenCaptureKit, requires macOS {min_sck}+)." - ) - } else { - format!( - "mac-screen-capture requires macOS {min_sck}+ (ScreenCaptureKit). \ - This Mac is running macOS {detected_str}. Native cursor-hidden \ - screen capture unavailable; fall back to getDisplayMedia." - ) - }; - SupportClassification { - supported: sck_ok, - sck_available: sck_ok, - reason, - } - } - } -} diff --git a/fluxer_desktop/native/mac-screen-capture/src/sck.rs b/fluxer_desktop/native/mac-screen-capture/src/sck.rs deleted file mode 100644 index 64dcd7731..000000000 --- a/fluxer_desktop/native/mac-screen-capture/src/sck.rs +++ /dev/null @@ -1,290 +0,0 @@ -#![allow(non_snake_case)] - -// SPDX-License-Identifier: AGPL-3.0-or-later - -use objc2::rc::Retained; -use objc2_core_foundation::{CFString, CGFloat, CGRect}; -use objc2_core_media::{CMTime, CMTimeFlags}; -use objc2_foundation::NSString; -use objc2_screen_capture_kit::{ - SCCaptureDynamicRange, SCContentFilter, SCDisplay, SCRunningApplication, SCStream, - SCStreamConfiguration, SCWindow, -}; - -use crate::config::{SckCaptureConfig, SckColorSpace, SckPixelFormat}; - -pub use objc2_screen_capture_kit::SCStreamOutputType; - -pub fn cmtime_seconds(value: i64, timescale: i32) -> CMTime { - CMTime { - value, - timescale, - flags: CMTimeFlags(1), - epoch: 0, - } -} - -pub fn sc_running_application_process_id(app: &SCRunningApplication) -> i32 { - unsafe { app.processID() } -} - -pub fn sc_running_application_bundle_identifier(app: &SCRunningApplication) -> Retained { - unsafe { app.bundleIdentifier() } -} - -pub fn sc_running_application_name(app: &SCRunningApplication) -> Retained { - unsafe { app.applicationName() } -} - -pub fn sc_display_frame(display: &SCDisplay) -> CGRect { - unsafe { display.frame() } -} - -pub fn sc_display_display_id(display: &SCDisplay) -> u32 { - unsafe { display.displayID() } -} - -pub fn sc_display_width(display: &SCDisplay) -> isize { - unsafe { display.width() } -} - -pub fn sc_display_height(display: &SCDisplay) -> isize { - unsafe { display.height() } -} - -pub fn sc_window_window_id(win: &SCWindow) -> u32 { - unsafe { win.windowID() } -} - -pub fn sc_window_owning_application(win: &SCWindow) -> Option> { - unsafe { win.owningApplication() } -} - -pub fn sc_window_frame(win: &SCWindow) -> CGRect { - unsafe { win.frame() } -} - -pub fn sc_window_title(win: &SCWindow) -> Option> { - unsafe { win.title() } -} - -pub fn sc_window_is_on_screen(win: &SCWindow) -> bool { - unsafe { win.isOnScreen() } -} - -pub fn filter_content_rect_if_available(filter: &SCContentFilter) -> Option { - use objc2::runtime::NSObjectProtocol; - use objc2::{msg_send, sel}; - let obj: &objc2::runtime::NSObject = filter.as_ref(); - if !obj.respondsToSelector(sel!(contentRect)) { - return None; - } - let rect: CGRect = unsafe { msg_send![obj, contentRect] }; - if rect.size.width <= 0.0 || rect.size.height <= 0.0 { - return None; - } - Some(rect) -} - -pub fn filter_point_pixel_scale_if_available(filter: &SCContentFilter) -> Option { - use objc2::runtime::NSObjectProtocol; - use objc2::{msg_send, sel}; - let obj: &objc2::runtime::NSObject = filter.as_ref(); - if !obj.respondsToSelector(sel!(pointPixelScale)) { - return None; - } - let scale: CGFloat = unsafe { msg_send![obj, pointPixelScale] }; - if !scale.is_finite() || scale <= 0.0 { - return None; - } - Some(scale as f32) -} - -pub fn cfg_set_scales_to_fit_if_available(cfg: &SCStreamConfiguration, scales_to_fit: bool) { - use objc2::runtime::NSObjectProtocol; - use objc2::{msg_send, sel}; - let obj: &objc2::runtime::NSObject = cfg.as_ref(); - if obj.respondsToSelector(sel!(setScalesToFit:)) { - unsafe { - let _: () = msg_send![obj, setScalesToFit: scales_to_fit]; - } - } -} - -pub fn cfg_set_width(cfg: &SCStreamConfiguration, v: usize) { - unsafe { cfg.setWidth(v) } -} -pub fn cfg_set_height(cfg: &SCStreamConfiguration, v: usize) { - unsafe { cfg.setHeight(v) } -} -pub fn cfg_set_queue_depth(cfg: &SCStreamConfiguration, v: isize) { - unsafe { cfg.setQueueDepth(v) } -} -pub fn cfg_set_shows_cursor(cfg: &SCStreamConfiguration, v: bool) { - unsafe { cfg.setShowsCursor(v) } -} -pub fn cfg_set_minimum_frame_interval(cfg: &SCStreamConfiguration, t: CMTime) { - unsafe { cfg.setMinimumFrameInterval(t) } -} -pub fn cfg_set_pixel_format(cfg: &SCStreamConfiguration, format: u32) { - unsafe { cfg.setPixelFormat(format) } -} - -pub fn cfg_set_source_rect_if_available(cfg: &SCStreamConfiguration, rect: CGRect) -> bool { - use objc2::runtime::NSObjectProtocol; - use objc2::{msg_send, sel}; - assert!(rect.size.width > 0.0); - assert!(rect.size.height > 0.0); - let obj: &objc2::runtime::NSObject = cfg.as_ref(); - if !obj.respondsToSelector(sel!(setSourceRect:)) { - return false; - } - unsafe { - let _: () = msg_send![obj, setSourceRect: rect]; - } - true -} - -pub fn cfg_set_capture_dynamic_range_sdr_if_available(cfg: &SCStreamConfiguration) { - use objc2::runtime::NSObjectProtocol; - use objc2::sel; - let obj: &objc2::runtime::NSObject = cfg.as_ref(); - if obj.respondsToSelector(sel!(setCaptureDynamicRange:)) { - unsafe { - cfg.setCaptureDynamicRange(SCCaptureDynamicRange::SDR); - } - } -} - -pub fn cfg_set_capture_dynamic_range_hdr_if_available(cfg: &SCStreamConfiguration) { - use objc2::runtime::NSObjectProtocol; - use objc2::sel; - let obj: &objc2::runtime::NSObject = cfg.as_ref(); - if obj.respondsToSelector(sel!(setCaptureDynamicRange:)) { - unsafe { - cfg.setCaptureDynamicRange(SCCaptureDynamicRange::HDRLocalDisplay); - } - } -} - -pub fn cfg_set_color_space_name_if_available(cfg: &SCStreamConfiguration, name: &str) { - use objc2::runtime::NSObjectProtocol; - use objc2::sel; - assert!(!name.is_empty()); - let obj: &objc2::runtime::NSObject = cfg.as_ref(); - if obj.respondsToSelector(sel!(setColorSpaceName:)) { - let cf = CFString::from_str(name); - unsafe { - cfg.setColorSpaceName(&cf); - } - } -} - -pub fn obs_minimum_frame_interval(target_fps: u32) -> CMTime { - assert!(target_fps >= crate::config::FPS_MIN); - assert!(target_fps <= crate::config::FPS_MAX); - CMTime { - value: crate::config::FRAME_INTERVAL_FACTOR_NUM as i64, - timescale: (crate::config::FRAME_INTERVAL_FACTOR_DEN as i32) - .saturating_mul(target_fps as i32), - flags: CMTimeFlags(1), - epoch: 0, - } -} - -pub fn apply_capture_config(cfg: &SCStreamConfiguration, capture: &SckCaptureConfig) { - assert!(capture.target_fps() >= crate::config::FPS_MIN); - assert!(capture.queue_depth() >= crate::config::QUEUE_DEPTH_MIN); - cfg_set_queue_depth(cfg, capture.queue_depth() as isize); - cfg_set_pixel_format(cfg, capture.pixel_format().as_fourcc()); - cfg_set_minimum_frame_interval(cfg, obs_minimum_frame_interval(capture.target_fps())); - cfg_set_color_space_name_if_available(cfg, capture.color_space().as_cf_name()); - match capture.pixel_format() { - SckPixelFormat::L10rHdr => { - assert!(capture.color_space() == SckColorSpace::DisplayP3); - cfg_set_capture_dynamic_range_hdr_if_available(cfg); - } - SckPixelFormat::Bgra8 | SckPixelFormat::Nv12VideoRange | SckPixelFormat::Nv12FullRange => { - cfg_set_capture_dynamic_range_sdr_if_available(cfg); - } - } - if capture.captures_audio() { - cfg_set_captures_audio_if_available(cfg, true); - cfg_set_audio_sample_rate_if_available(cfg, capture.audio_sample_rate_hz()); - cfg_set_audio_channel_count_if_available(cfg, capture.audio_channels()); - cfg_set_excludes_current_process_audio_if_available(cfg, true); - } else { - cfg_set_captures_audio_if_available(cfg, false); - } -} - -pub fn cfg_set_captures_audio_if_available(cfg: &SCStreamConfiguration, captures_audio: bool) { - use objc2::runtime::NSObjectProtocol; - use objc2::sel; - let obj: &objc2::runtime::NSObject = cfg.as_ref(); - if obj.respondsToSelector(sel!(setCapturesAudio:)) { - unsafe { - cfg.setCapturesAudio(captures_audio); - } - } -} - -pub fn cfg_set_audio_sample_rate_if_available(cfg: &SCStreamConfiguration, sample_rate_hz: u32) { - use objc2::runtime::NSObjectProtocol; - use objc2::sel; - assert!(sample_rate_hz >= crate::config::AUDIO_SAMPLE_RATE_MIN_HZ); - assert!(sample_rate_hz <= crate::config::AUDIO_SAMPLE_RATE_MAX_HZ); - let obj: &objc2::runtime::NSObject = cfg.as_ref(); - if obj.respondsToSelector(sel!(setSampleRate:)) { - unsafe { - cfg.setSampleRate(sample_rate_hz as isize); - } - } -} - -pub fn cfg_set_audio_channel_count_if_available(cfg: &SCStreamConfiguration, channels: u32) { - use objc2::runtime::NSObjectProtocol; - use objc2::sel; - assert!(channels >= crate::config::AUDIO_CHANNEL_COUNT_MIN); - assert!(channels <= crate::config::AUDIO_CHANNEL_COUNT_MAX); - let obj: &objc2::runtime::NSObject = cfg.as_ref(); - if obj.respondsToSelector(sel!(setChannelCount:)) { - unsafe { - cfg.setChannelCount(channels as isize); - } - } -} - -pub fn cfg_set_excludes_current_process_audio_if_available( - cfg: &SCStreamConfiguration, - excludes_self: bool, -) { - use objc2::runtime::NSObjectProtocol; - use objc2::sel; - let obj: &objc2::runtime::NSObject = cfg.as_ref(); - if obj.respondsToSelector(sel!(setExcludesCurrentProcessAudio:)) { - unsafe { - cfg.setExcludesCurrentProcessAudio(excludes_self); - } - } -} - -pub fn cfg_set_stream_name_if_available(cfg: &SCStreamConfiguration, name: &NSString) { - use objc2::runtime::NSObjectProtocol; - use objc2::sel; - let obj: &objc2::runtime::NSObject = cfg.as_ref(); - if obj.respondsToSelector(sel!(setStreamName:)) { - unsafe { - cfg.setStreamName(Some(name)); - } - } -} - -pub fn sc_stream_add_stream_output( - stream: &SCStream, - output: &objc2::runtime::ProtocolObject, - kind: SCStreamOutputType, - queue: Option<&dispatch2::DispatchQueue>, -) -> Result<(), Retained> { - unsafe { stream.addStreamOutput_type_sampleHandlerQueue_error(output, kind, queue) } -} diff --git a/fluxer_desktop/native/mac-sysctl/src/lib.rs b/fluxer_desktop/native/mac-sysctl/src/lib.rs index 2408e9c32..19f36e20f 100644 --- a/fluxer_desktop/native/mac-sysctl/src/lib.rs +++ b/fluxer_desktop/native/mac-sysctl/src/lib.rs @@ -152,17 +152,3 @@ pub async fn sysctl_by_name_string(name: String) -> Result> { } spawn_sysctl(move || read_string_blocking(name)).await } - -#[cfg(all(test, target_os = "macos"))] -mod tests { - use super::*; - - #[test] - fn missing_sysctl_maps_to_null() { - assert!( - ctl_for_name("fluxer.definitely_missing_sysctl") - .unwrap() - .is_none() - ); - } -} diff --git a/fluxer_desktop/native/mac-tcc/src/lib.rs b/fluxer_desktop/native/mac-tcc/src/lib.rs index 75e52fd35..b277d2488 100644 --- a/fluxer_desktop/native/mac-tcc/src/lib.rs +++ b/fluxer_desktop/native/mac-tcc/src/lib.rs @@ -7,7 +7,7 @@ const STATUS_DENIED: &str = "denied"; const STATUS_NOT_DETERMINED: &str = "not-determined"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))] +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] enum TccStatus { Granted, Denied, @@ -24,12 +24,12 @@ impl TccStatus { } } -#[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))] +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] const IOHID_ACCESS_GRANTED: u32 = 0; -#[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))] +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] const IOHID_ACCESS_DENIED: u32 = 1; -#[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))] +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] const fn input_monitoring_status_from_iohid(access: u32) -> TccStatus { match access { IOHID_ACCESS_GRANTED => TccStatus::Granted, @@ -69,7 +69,7 @@ pub fn probe_input_monitoring_access() -> String { )) } -#[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))] +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] const fn input_monitoring_status_from_tap(created: Option) -> TccStatus { match created { Some(true) => TccStatus::Granted, @@ -154,51 +154,3 @@ mod platform { None } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn status_strings_match_js_contract() { - assert_eq!(TccStatus::Granted.as_str(), "granted"); - assert_eq!(TccStatus::Denied.as_str(), "denied"); - assert_eq!(TccStatus::NotDetermined.as_str(), "not-determined"); - } - - #[test] - fn input_monitoring_maps_known_iohid_statuses() { - assert_eq!( - input_monitoring_status_from_iohid(IOHID_ACCESS_GRANTED), - TccStatus::Granted - ); - assert_eq!( - input_monitoring_status_from_iohid(IOHID_ACCESS_DENIED), - TccStatus::Denied - ); - assert_eq!( - input_monitoring_status_from_iohid(2), - TccStatus::NotDetermined - ); - assert_eq!( - input_monitoring_status_from_iohid(u32::MAX), - TccStatus::NotDetermined - ); - } - - #[test] - fn input_monitoring_maps_tap_probe_results() { - assert_eq!( - input_monitoring_status_from_tap(Some(true)), - TccStatus::Granted - ); - assert_eq!( - input_monitoring_status_from_tap(Some(false)), - TccStatus::Denied - ); - assert_eq!( - input_monitoring_status_from_tap(None), - TccStatus::NotDetermined - ); - } -} diff --git a/fluxer_desktop/native/macos-input-hook/src/platform.rs b/fluxer_desktop/native/macos-input-hook/src/platform.rs index d466db319..cc6801fca 100644 --- a/fluxer_desktop/native/macos-input-hook/src/platform.rs +++ b/fluxer_desktop/native/macos-input-hook/src/platform.rs @@ -501,43 +501,3 @@ impl EventDispatcher { pub fn has_accessibility_permission() -> bool { objc2_core_graphics::CGPreflightListenEventAccess() } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn held_inputs_release_every_pressed_key_and_button_once() { - let mods = modifiers::from_flags(0); - let mut held = HeldInputs::default(); - held.track(&key_payload(InputEventKind::KeyDown, 0x31, mods)); - held.track(&key_payload(InputEventKind::KeyDown, 0x3b, mods)); - held.track(&key_payload(InputEventKind::KeyDown, 0x00, mods)); - held.track(&key_payload(InputEventKind::KeyUp, 0x00, mods)); - held.track(&InputEventPayload { - kind: InputEventKind::MouseDown, - mods, - keycode: 0, - key_name: String::new(), - button: 3, - x: 0, - y: 0, - has_xy: true, - }); - let releases = held.take_releases(); - let summary: Vec<(InputEventKind, u32, u8)> = releases - .iter() - .map(|payload| (payload.kind, payload.keycode, payload.button)) - .collect(); - assert_eq!( - summary, - vec![ - (InputEventKind::KeyUp, 0x31, 0), - (InputEventKind::KeyUp, 0x3b, 0), - (InputEventKind::MouseUp, 0, 3), - ] - ); - assert!(releases.iter().all(|payload| !payload.mods.ctrl)); - assert!(held.take_releases().is_empty()); - } -} diff --git a/fluxer_desktop/native/platform-info/Cargo.lock b/fluxer_desktop/native/platform-info/Cargo.lock index 8b40808f7..bc5fc5e38 100644 --- a/fluxer_desktop/native/platform-info/Cargo.lock +++ b/fluxer_desktop/native/platform-info/Cargo.lock @@ -8,15 +8,6 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - [[package]] name = "cc" version = "1.4.6" @@ -33,12 +24,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "convert_case" version = "0.12.0" @@ -48,59 +33,22 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "cpufeatures" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - [[package]] name = "ctor" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", -] - [[package]] name = "find-msvc-tools" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" -[[package]] -name = "fluxer_desktop_native" -version = "0.1.0" -dependencies = [ - "sha2", -] - [[package]] name = "fluxer_platform_info" version = "0.0.0" dependencies = [ - "fluxer_desktop_native", "napi", "napi-build", "napi-derive", @@ -198,15 +146,6 @@ dependencies = [ "slab", ] -[[package]] -name = "hybrid-array" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" -dependencies = [ - "typenum", -] - [[package]] name = "libc" version = "0.2.189" @@ -375,17 +314,6 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "shlex" version = "2.0.1" @@ -420,12 +348,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - [[package]] name = "unicode-ident" version = "1.0.26" diff --git a/fluxer_desktop/native/platform-info/Cargo.toml b/fluxer_desktop/native/platform-info/Cargo.toml index ca83e10ec..9901b864c 100644 --- a/fluxer_desktop/native/platform-info/Cargo.toml +++ b/fluxer_desktop/native/platform-info/Cargo.toml @@ -12,7 +12,6 @@ resolver = "2" crate-type = ["cdylib", "rlib"] [dependencies] -fluxer_desktop_native = {path = "../rust"} napi = {version = "3.12.6", default-features = false, features = ["dyn-symbols", "napi8"]} napi-derive = "3.6.7" diff --git a/fluxer_desktop/native/platform-info/index.js b/fluxer_desktop/native/platform-info/index.js index eab84e56c..412503a5f 100644 --- a/fluxer_desktop/native/platform-info/index.js +++ b/fluxer_desktop/native/platform-info/index.js @@ -3,7 +3,20 @@ const {existsSync} = require('node:fs'); const {join, sep} = require('node:path'); const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs'); -const {nativeFileName} = require('./pure.cjs'); +function nativeFileName(platform = process.platform, arch = process.arch) { + switch (platform) { + case 'darwin': + if (arch === 'x64' || arch === 'arm64') return `platform-info.darwin-${arch}.node`; + break; + case 'win32': + if (arch === 'x64' || arch === 'arm64') return `platform-info.win32-${arch}-msvc.node`; + break; + case 'linux': + if (arch === 'x64' || arch === 'arm64') return `platform-info.linux-${arch}-gnu.node`; + break; + } + throw new Error(`Unsupported platform-info target: ${platform}/${arch}`); +} const MODULE_NAME = '@fluxer/platform-info'; const SKIP_NATIVE_PROBE_ENV = 'FLUXER_PLATFORM_INFO_SKIP_NATIVE_PROBE'; @@ -45,7 +58,4 @@ try { module.exports = { getGpuInfo: binding ? binding.getGpuInfo : null, loadError, - _private: { - nativeFileName, - }, }; diff --git a/fluxer_desktop/native/platform-info/package.json b/fluxer_desktop/native/platform-info/package.json index 4cd7f1a60..6b7287b4b 100644 --- a/fluxer_desktop/native/platform-info/package.json +++ b/fluxer_desktop/native/platform-info/package.json @@ -14,7 +14,6 @@ "index.js", "index.d.ts", "loader-diagnostics.cjs", - "pure.cjs", "platform-info.darwin-x64.node", "platform-info.darwin-arm64.node", "platform-info.win32-x64-msvc.node", diff --git a/fluxer_desktop/native/platform-info/pure.cjs b/fluxer_desktop/native/platform-info/pure.cjs deleted file mode 100644 index 7523f25ec..000000000 --- a/fluxer_desktop/native/platform-info/pure.cjs +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -function nativeFileName(platform = process.platform, arch = process.arch) { - switch (platform) { - case 'darwin': - if (arch === 'x64' || arch === 'arm64') return `platform-info.darwin-${arch}.node`; - break; - case 'win32': - if (arch === 'x64' || arch === 'arm64') return `platform-info.win32-${arch}-msvc.node`; - break; - case 'linux': - if (arch === 'x64' || arch === 'arm64') return `platform-info.linux-${arch}-gnu.node`; - break; - } - throw new Error(`Unsupported platform-info target: ${platform}/${arch}`); -} - -module.exports = { - nativeFileName, -}; diff --git a/fluxer_desktop/native/platform-info/src/lib.rs b/fluxer_desktop/native/platform-info/src/lib.rs index bb4f7369b..0510de8dd 100644 --- a/fluxer_desktop/native/platform-info/src/lib.rs +++ b/fluxer_desktop/native/platform-info/src/lib.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +mod probe_helpers; + use napi_derive::napi; pub const SOURCE_METAL: &str = "metal"; @@ -64,8 +66,7 @@ fn base_device( active, vendor_id, device_id, - vendor_name: fluxer_desktop_native::platform_info::probe_helpers::vendor_name(vendor_id) - .map(str::to_owned), + vendor_name: crate::probe_helpers::vendor_name(vendor_id).map(str::to_owned), device_string: None, driver_vendor: None, driver_version: None, @@ -94,9 +95,7 @@ fn empty_info(source: &str) -> PlatformGpuInfo { #[cfg(target_os = "macos")] mod platform { - use fluxer_desktop_native::platform_info::probe_helpers::{ - vendor_id_from_name, vendor_name, write_hex_u64, - }; + use crate::probe_helpers::{vendor_id_from_name, vendor_name, write_hex_u64}; use objc2::exception::catch; use objc2::rc::autoreleasepool; use objc2_metal::{MTLCopyAllDevices, MTLCreateSystemDefaultDevice, MTLDevice}; @@ -198,7 +197,7 @@ mod dxgi_helpers { #[cfg(target_os = "windows")] mod platform { use super::dxgi_helpers::{luid_string, utf16_description}; - use fluxer_desktop_native::platform_info::probe_helpers::vendor_name; + use crate::probe_helpers::vendor_name; use windows::Win32::Graphics::Dxgi::{ CreateDXGIFactory1, DXGI_ADAPTER_DESC1, DXGI_ADAPTER_FLAG_SOFTWARE, DXGI_GPU_PREFERENCE_HIGH_PERFORMANCE, IDXGIAdapter1, IDXGIFactory1, IDXGIFactory6, @@ -300,9 +299,7 @@ mod platform { use std::fs; use std::path::Path; - use fluxer_desktop_native::platform_info::probe_helpers::{ - basename, is_drm_card_name, parse_hex_id, vendor_name, - }; + use crate::probe_helpers::{basename, is_drm_card_name, parse_hex_id, vendor_name}; use super::{PlatformGpuDeviceInfo, PlatformGpuInfo, SOURCE_LINUX_SYSFS, base_device}; diff --git a/fluxer_desktop/native/rust/src/platform_info/probe_helpers.rs b/fluxer_desktop/native/platform-info/src/probe_helpers.rs similarity index 92% rename from fluxer_desktop/native/rust/src/platform_info/probe_helpers.rs rename to fluxer_desktop/native/platform-info/src/probe_helpers.rs index 5669c5e17..fd4a96fb6 100644 --- a/fluxer_desktop/native/rust/src/platform_info/probe_helpers.rs +++ b/fluxer_desktop/native/platform-info/src/probe_helpers.rs @@ -16,6 +16,7 @@ pub fn vendor_name(vendor_id: u32) -> Option<&'static str> { } } +#[cfg(any(target_os = "macos", test))] pub fn vendor_id_from_name(name: &str) -> u32 { let lower = name.to_ascii_lowercase(); if lower.contains("apple") { @@ -36,16 +37,19 @@ pub fn vendor_id_from_name(name: &str) -> u32 { 0 } +#[cfg(target_os = "macos")] pub fn write_hex_u64(value: u64) -> String { format!("0x{value:x}") } +#[cfg(any(target_os = "linux", test))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ParseHexIdError { InvalidHexId, InvalidDigit, } +#[cfg(any(target_os = "linux", test))] pub fn parse_hex_id(raw: &str) -> Result { let mut trimmed = raw.trim(); if let Some(rest) = trimmed @@ -60,6 +64,7 @@ pub fn parse_hex_id(raw: &str) -> Result { u32::from_str_radix(trimmed, 16).map_err(|_| ParseHexIdError::InvalidDigit) } +#[cfg(any(target_os = "linux", test))] pub fn is_drm_card_name(name: &str) -> bool { let Some(rest) = name.strip_prefix("card") else { return false; @@ -67,6 +72,7 @@ pub fn is_drm_card_name(name: &str) -> bool { !rest.is_empty() && rest.bytes().all(|ch| ch.is_ascii_digit()) } +#[cfg(target_os = "linux")] pub fn basename(path: &str) -> &str { path.rsplit_once('/').map_or(path, |(_, base)| base) } diff --git a/fluxer_desktop/native/rt-thread/Cargo.lock b/fluxer_desktop/native/rt-thread/Cargo.lock index 18b09c96d..7630d2f02 100644 --- a/fluxer_desktop/native/rt-thread/Cargo.lock +++ b/fluxer_desktop/native/rt-thread/Cargo.lock @@ -2,347 +2,20 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloca" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" -dependencies = [ - "cc", -] - -[[package]] -name = "anes" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" - -[[package]] -name = "cc" -version = "1.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - -[[package]] -name = "clap" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" -dependencies = [ - "clap_builder", -] - -[[package]] -name = "clap_builder" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" -dependencies = [ - "anstyle", - "clap_lex", -] - -[[package]] -name = "clap_lex" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" - -[[package]] -name = "criterion" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" -dependencies = [ - "alloca", - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "itertools", - "num-traits", - "oorandom", - "page_size", - "plotters", - "rayon", - "regex", - "serde", - "serde_json", - "tinytemplate", - "walkdir", -] - -[[package]] -name = "criterion-plot" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" -dependencies = [ - "cast", - "itertools", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" - -[[package]] -name = "find-msvc-tools" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" - [[package]] name = "fluxer_rt_thread" version = "0.0.0" dependencies = [ - "criterion", "libc", "windows", ] -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - -[[package]] -name = "page_size" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "plotters" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" -dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" -dependencies = [ - "plotters-backend", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -361,125 +34,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - [[package]] name = "syn" version = "2.0.119" @@ -491,129 +45,12 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "syn" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "unicode-ident" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.6", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows" version = "0.62.2" @@ -667,7 +104,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -678,7 +115,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -715,15 +152,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-threading" version = "0.2.1" @@ -732,29 +160,3 @@ checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ "windows-link", ] - -[[package]] -name = "zerocopy" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/fluxer_desktop/native/rt-thread/Cargo.toml b/fluxer_desktop/native/rt-thread/Cargo.toml index e2f510673..2c79c44ff 100644 --- a/fluxer_desktop/native/rt-thread/Cargo.toml +++ b/fluxer_desktop/native/rt-thread/Cargo.toml @@ -23,10 +23,3 @@ windows = {version = "0.62.2", features = [ "Win32_Media", "Win32_Media_Multimedia", ]} - -[dev-dependencies] -criterion = "0.8" - -[[bench]] -name = "tick" -harness = false diff --git a/fluxer_desktop/native/rt-thread/benches/baseline.json b/fluxer_desktop/native/rt-thread/benches/baseline.json deleted file mode 100644 index 1475cf6b1..000000000 --- a/fluxer_desktop/native/rt-thread/benches/baseline.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "measured_at": "85e057a273fd", - "host": "darwin-arm64-apple-silicon", - "regression_budget_percent": 5.0, - "criterion_args": { - "warm_up_time_sec": 2, - "measurement_time_sec": 5 - }, - "benches": { - "tick_driver/audio_21_33ms": { - "median_ns": 21415000.0, - "low_ns": 21395000.0, - "high_ns": 21441000.0, - "note": "Measures real-time sleep precision against AUDIO_TICK_PERIOD_NS=21333333; baseline reflects clock+scheduler delay on this host, not pure CPU work." - }, - "system_clock_now_ns": { - "median_ns": 22.931, - "low_ns": 22.763, - "high_ns": 23.099, - "budget_percent_override": 15.0, - "note": "23ns op; clock_gettime variance dominates over 5%. Wider budget reflects the floor of measurement noise." - } - } -} diff --git a/fluxer_desktop/native/rt-thread/benches/tick.rs b/fluxer_desktop/native/rt-thread/benches/tick.rs deleted file mode 100644 index 548338707..000000000 --- a/fluxer_desktop/native/rt-thread/benches/tick.rs +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::hint::black_box; -use std::sync::Arc; - -use criterion::{Criterion, criterion_group, criterion_main}; -use fluxer_rt_thread::{MonotonicClock, SystemMonotonicClock, TickDriver}; - -const AUDIO_TICK_PERIOD_NS: u64 = 21_333_333; - -fn bench_audio_tick(c: &mut Criterion) { - let mut group = c.benchmark_group("tick_driver"); - group.sample_size(20); - group.bench_function("audio_21_33ms", |b| { - b.iter_custom(|iters| { - let clock = Arc::new(SystemMonotonicClock::new()); - let mut driver = TickDriver::new(clock.clone(), AUDIO_TICK_PERIOD_NS).expect("driver"); - let _ = driver.wait_until_next_tick().expect("warmup"); - let start = std::time::Instant::now(); - for _ in 0..iters { - let info = driver.wait_until_next_tick().expect("tick"); - black_box(info); - } - start.elapsed() - }) - }); - group.finish(); -} - -fn bench_now_ns(c: &mut Criterion) { - let clock = SystemMonotonicClock::new(); - c.bench_function("system_clock_now_ns", |b| { - b.iter(|| { - let v = MonotonicClock::now_ns(&clock); - black_box(v) - }) - }); -} - -criterion_group!(benches, bench_audio_tick, bench_now_ns); -criterion_main!(benches); diff --git a/fluxer_desktop/native/rt-thread/src/lib.rs b/fluxer_desktop/native/rt-thread/src/lib.rs index 212694ba8..39685f193 100644 --- a/fluxer_desktop/native/rt-thread/src/lib.rs +++ b/fluxer_desktop/native/rt-thread/src/lib.rs @@ -6,7 +6,4 @@ pub mod priority; pub mod tick; pub use priority::{PriorityProfile, RealtimePriorityGuard, RtError, RtOutcome}; -pub use tick::{MonotonicClock, SystemMonotonicClock, TickDriver, TickInfo}; - -pub const NS_PER_MS: u64 = 1_000_000; -pub const NS_PER_SEC: u64 = 1_000_000_000; +pub use tick::{MonotonicClock, SystemMonotonicClock}; diff --git a/fluxer_desktop/native/rt-thread/src/priority.rs b/fluxer_desktop/native/rt-thread/src/priority.rs index 9e5f51e26..c278cc4f8 100644 --- a/fluxer_desktop/native/rt-thread/src/priority.rs +++ b/fluxer_desktop/native/rt-thread/src/priority.rs @@ -6,8 +6,6 @@ use core::fmt; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PriorityProfile { Audio, - Video, - Network, } #[derive(Debug, PartialEq, Eq)] @@ -84,66 +82,6 @@ impl Drop for RealtimePriorityGuard { } } -#[cfg(target_os = "macos")] -mod platform { - use super::{PriorityProfile, RtError, RtOutcome}; - use libc::qos_class_t::{ - QOS_CLASS_DEFAULT, QOS_CLASS_USER_INITIATED, QOS_CLASS_USER_INTERACTIVE, QOS_CLASS_UTILITY, - }; - - pub struct PlatformGuard { - prior_qos: libc::qos_class_t, - prior_relative: i32, - } - - impl PlatformGuard { - pub fn acquire(profile: PriorityProfile) -> Result<(Self, RtOutcome), RtError> { - assert!(matches!( - profile, - PriorityProfile::Audio | PriorityProfile::Video | PriorityProfile::Network - )); - let mut prior_qos: libc::qos_class_t = QOS_CLASS_DEFAULT; - let mut prior_relative: i32 = 0; - let read_rc = unsafe { - libc::pthread_get_qos_class_np( - libc::pthread_self(), - &mut prior_qos, - &mut prior_relative, - ) - }; - if read_rc != 0 { - return Err(RtError::PlatformDenied(read_rc)); - } - let target = map_profile(profile); - let set_rc = unsafe { libc::pthread_set_qos_class_self_np(target, 0) }; - if set_rc != 0 { - return Err(RtError::PlatformDenied(set_rc)); - } - Ok(( - Self { - prior_qos, - prior_relative, - }, - RtOutcome::Acquired, - )) - } - - pub fn restore(&mut self) { - unsafe { - let _ = libc::pthread_set_qos_class_self_np(self.prior_qos, self.prior_relative); - } - } - } - - fn map_profile(profile: PriorityProfile) -> libc::qos_class_t { - match profile { - PriorityProfile::Audio => QOS_CLASS_USER_INTERACTIVE, - PriorityProfile::Video => QOS_CLASS_USER_INITIATED, - PriorityProfile::Network => QOS_CLASS_UTILITY, - } - } -} - #[cfg(all(unix, not(target_os = "macos")))] mod platform { use super::{PriorityProfile, RtError, RtOutcome}; @@ -157,10 +95,7 @@ mod platform { impl PlatformGuard { pub fn acquire(profile: PriorityProfile) -> Result<(Self, RtOutcome), RtError> { - assert!(matches!( - profile, - PriorityProfile::Audio | PriorityProfile::Video | PriorityProfile::Network - )); + assert!(matches!(profile, PriorityProfile::Audio)); let mut prior_policy: i32 = 0; let mut prior_param: libc::sched_param = unsafe { core::mem::zeroed() }; let read_rc = unsafe { @@ -195,8 +130,6 @@ mod platform { } let fallback_nice = match profile { PriorityProfile::Audio => -19, - PriorityProfile::Video => -10, - PriorityProfile::Network => -5, }; let prior_nice = unsafe { *libc::__errno_location() = 0; @@ -239,13 +172,8 @@ mod platform { fn map_profile(profile: PriorityProfile) -> (i32, i32) { let policy = libc::SCHED_FIFO; let max = unsafe { libc::sched_get_priority_max(policy) }; - let min = unsafe { libc::sched_get_priority_min(policy) }; - assert!(max >= min); - let span = (max - min).max(1); let prio = match profile { PriorityProfile::Audio => max, - PriorityProfile::Video => min + (span * 2) / 3, - PriorityProfile::Network => min + span / 3, }; (policy, prio) } @@ -266,14 +194,9 @@ mod platform { impl PlatformGuard { pub fn acquire(profile: PriorityProfile) -> Result<(Self, RtOutcome), RtError> { - assert!(matches!( - profile, - PriorityProfile::Audio | PriorityProfile::Video | PriorityProfile::Network - )); + assert!(matches!(profile, PriorityProfile::Audio)); let task_name: &[u16] = match profile { PriorityProfile::Audio => &AUDIO_W, - PriorityProfile::Video => &CAPTURE_W, - PriorityProfile::Network => &PLAYBACK_W, }; let mut task_index: u32 = 0; let handle = unsafe { @@ -303,30 +226,9 @@ mod platform { b'o' as u16, 0, ]; - const CAPTURE_W: [u16; 8] = [ - b'C' as u16, - b'a' as u16, - b'p' as u16, - b't' as u16, - b'u' as u16, - b'r' as u16, - b'e' as u16, - 0, - ]; - const PLAYBACK_W: [u16; 9] = [ - b'P' as u16, - b'l' as u16, - b'a' as u16, - b'y' as u16, - b'b' as u16, - b'a' as u16, - b'c' as u16, - b'k' as u16, - 0, - ]; } -#[cfg(not(any(unix, target_os = "windows")))] +#[cfg(not(any(all(unix, not(target_os = "macos")), target_os = "windows")))] mod platform { use super::{PriorityProfile, RtError, RtOutcome}; @@ -342,58 +244,3 @@ mod platform { } use platform::PlatformGuard; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn acquire_and_release_audio_does_not_panic() { - let guard = RealtimePriorityGuard::acquire(PriorityProfile::Audio); - match guard { - Ok(g) => { - assert!(matches!( - *g.outcome(), - RtOutcome::Acquired | RtOutcome::PartialFallback - )); - drop(g); - } - Err(RtError::PlatformDenied(_)) => {} - Err(other) => panic!("unexpected error: {other:?}"), - } - let again = RealtimePriorityGuard::acquire(PriorityProfile::Audio); - if let Ok(g) = again { - drop(g); - } - } - - #[test] - fn double_acquire_on_same_thread_errors() { - let first = RealtimePriorityGuard::acquire(PriorityProfile::Audio); - if let Ok(first_guard) = first { - let second = RealtimePriorityGuard::acquire(PriorityProfile::Audio); - assert_eq!(second.err(), Some(RtError::AlreadyAcquired)); - drop(first_guard); - let third = RealtimePriorityGuard::acquire(PriorityProfile::Network); - if let Ok(t) = third { - drop(t); - } - } - } - - #[test] - fn all_profiles_round_trip() { - for profile in [ - PriorityProfile::Audio, - PriorityProfile::Video, - PriorityProfile::Network, - ] { - let g = RealtimePriorityGuard::acquire(profile); - match g { - Ok(g) => drop(g), - Err(RtError::PlatformDenied(_)) => {} - Err(other) => panic!("unexpected error for {profile:?}: {other:?}"), - } - } - } -} diff --git a/fluxer_desktop/native/rt-thread/src/tick.rs b/fluxer_desktop/native/rt-thread/src/tick.rs index d4393bc0c..1068b2bd6 100644 --- a/fluxer_desktop/native/rt-thread/src/tick.rs +++ b/fluxer_desktop/native/rt-thread/src/tick.rs @@ -1,12 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use core::fmt; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; -use std::thread; -use std::time::{Duration, Instant}; - -use crate::NS_PER_MS; +use std::time::Instant; pub trait MonotonicClock: Send + Sync { fn now_ns(&self) -> u64; @@ -47,317 +42,3 @@ impl MonotonicClock for SystemMonotonicClock { observed } } - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct TickInfo { - pub tick_index: u64, - pub scheduled_ns: u64, - pub actual_ns: u64, - pub lag_ns: i64, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum TickError { - ZeroPeriod, - PeriodTooLarge, - NonMonotonicClock { prior_ns: u64, observed_ns: u64 }, -} - -impl fmt::Display for TickError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - TickError::ZeroPeriod => write!(f, "tick period must be non-zero"), - TickError::PeriodTooLarge => write!(f, "tick period exceeds u64 budget"), - TickError::NonMonotonicClock { - prior_ns, - observed_ns, - } => write!( - f, - "MonotonicClock returned non-monotonic reading: prior={prior_ns} observed={observed_ns}", - ), - } - } -} - -impl std::error::Error for TickError {} - -pub const TICK_PERIOD_NS_MAX: u64 = 60 * crate::NS_PER_SEC; - -const SLEEP_SAFETY_MARGIN_NS: u64 = NS_PER_MS; - -const SPIN_THRESHOLD_NS: u64 = 150_000; - -pub struct TickDriver { - clock: Arc, - period_ns: u64, - start_ns: u64, - next_tick_index: u64, - last_scheduled_ns: u64, - last_actual_ns: u64, -} - -impl TickDriver { - pub fn new(clock: Arc, period_ns: u64) -> Result { - if period_ns == 0 { - return Err(TickError::ZeroPeriod); - } - if period_ns > TICK_PERIOD_NS_MAX { - return Err(TickError::PeriodTooLarge); - } - let start_ns = clock.now_ns(); - assert!(period_ns > 0); - assert!(period_ns <= TICK_PERIOD_NS_MAX); - Ok(Self { - clock, - period_ns, - start_ns, - next_tick_index: 0, - last_scheduled_ns: 0, - last_actual_ns: start_ns, - }) - } - - pub fn period_ns(&self) -> u64 { - assert!(self.period_ns > 0); - assert!(self.period_ns <= TICK_PERIOD_NS_MAX); - self.period_ns - } - - pub fn start_ns(&self) -> u64 { - assert!(self.start_ns <= self.clock.now_ns().saturating_add(self.period_ns)); - self.start_ns - } - - pub fn wait_until_next_tick(&mut self) -> Result { - assert!(self.period_ns > 0); - let mut prior_actual = self.last_actual_ns; - let mut tick_index = self.next_tick_index; - let scheduled_ns = self - .start_ns - .saturating_add(tick_index.saturating_mul(self.period_ns)); - assert!(scheduled_ns >= self.last_scheduled_ns || tick_index == 0); - loop { - let now = self.clock.now_ns(); - if now < prior_actual { - return Err(TickError::NonMonotonicClock { - prior_ns: prior_actual, - observed_ns: now, - }); - } - prior_actual = now; - if now >= scheduled_ns { - break; - } - let remaining = scheduled_ns - now; - if remaining > SLEEP_SAFETY_MARGIN_NS { - thread::park_timeout(Duration::from_nanos(remaining - SLEEP_SAFETY_MARGIN_NS)); - } else if remaining > SPIN_THRESHOLD_NS { - thread::park_timeout(Duration::from_nanos(remaining - SPIN_THRESHOLD_NS)); - } else { - core::hint::spin_loop(); - } - } - let actual_ns = self.clock.now_ns(); - if actual_ns < prior_actual { - return Err(TickError::NonMonotonicClock { - prior_ns: prior_actual, - observed_ns: actual_ns, - }); - } - let lag_ns = (actual_ns as i128 - scheduled_ns as i128) as i64; - let info = TickInfo { - tick_index, - scheduled_ns, - actual_ns, - lag_ns, - }; - if actual_ns > scheduled_ns.saturating_add(self.period_ns) { - let overshoot = (actual_ns - scheduled_ns) / self.period_ns; - tick_index = tick_index.saturating_add(overshoot); - } - self.next_tick_index = tick_index.saturating_add(1); - self.last_scheduled_ns = scheduled_ns; - self.last_actual_ns = actual_ns; - assert!(self.next_tick_index > tick_index); - assert_eq!( - info.lag_ns, - info.actual_ns as i64 - info.scheduled_ns as i64 - ); - Ok(info) - } - - pub fn next_tick_index(&self) -> u64 { - assert!(self.next_tick_index >= 1 || self.last_actual_ns == self.start_ns); - self.next_tick_index - } -} - -pub struct ScriptedClock { - readings: Mutex>, - cursor: AtomicU64, -} - -impl ScriptedClock { - pub fn new(readings: Vec) -> Self { - assert!(!readings.is_empty()); - Self { - readings: Mutex::new(readings), - cursor: AtomicU64::new(0), - } - } -} - -impl MonotonicClock for ScriptedClock { - fn now_ns(&self) -> u64 { - let readings = self.readings.lock().expect("readings lock poisoned"); - assert!(!readings.is_empty()); - let idx = self.cursor.fetch_add(1, Ordering::AcqRel) as usize; - let n = readings.len(); - let chosen = if idx < n { - readings[idx] - } else { - *readings.last().unwrap() - }; - assert!(n > 0); - chosen - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Arc; - use std::time::Duration; - - fn ci_mode() -> bool { - std::env::var_os("CI").is_some() - } - - #[test] - fn zero_period_rejected() { - let clock = Arc::new(SystemMonotonicClock::new()); - let err = TickDriver::new(clock, 0).err(); - assert_eq!(err, Some(TickError::ZeroPeriod)); - } - - #[test] - fn period_too_large_rejected() { - let clock = Arc::new(SystemMonotonicClock::new()); - let err = TickDriver::new(clock, TICK_PERIOD_NS_MAX + 1).err(); - assert_eq!(err, Some(TickError::PeriodTooLarge)); - } - - #[test] - fn system_clock_is_monotonic_over_many_samples() { - let clock = SystemMonotonicClock::new(); - let mut prev = clock.now_ns(); - let samples = 10_000; - for _ in 0..samples { - let now = clock.now_ns(); - assert!(now >= prev, "clock went backwards: prev={prev} now={now}"); - prev = now; - } - } - - #[test] - fn tick_driver_meets_jitter_budget_at_1ms_period() { - let clock = Arc::new(SystemMonotonicClock::new()); - let period_ns = NS_PER_MS; - let mut driver = TickDriver::new(clock, period_ns).expect("driver"); - let n: u64 = 100; - let mut total_error_ns: i128 = 0; - let mut max_error_ns: i64 = 0; - for _ in 0..n { - let info = driver.wait_until_next_tick().expect("tick ok"); - assert!(info.lag_ns >= -(period_ns as i64)); - let err = info.lag_ns.abs(); - total_error_ns += err as i128; - if err > max_error_ns { - max_error_ns = err; - } - } - let mean_error_ns = (total_error_ns / n as i128) as i64; - let max_budget_ns: i64 = if ci_mode() { - 25 * NS_PER_MS as i64 - } else { - NS_PER_MS as i64 - }; - let mean_budget_ns: i64 = if ci_mode() { - 5 * NS_PER_MS as i64 - } else { - 200_000 - }; - assert!( - mean_error_ns < mean_budget_ns, - "mean error {mean_error_ns} ns exceeded budget {mean_budget_ns} ns (ci={})", - ci_mode(), - ); - assert!( - max_error_ns < max_budget_ns, - "max error {max_error_ns} ns exceeded budget {max_budget_ns} ns (ci={})", - ci_mode(), - ); - } - - #[test] - fn lag_reported_when_caller_sleeps_over_a_tick_boundary() { - let clock = Arc::new(SystemMonotonicClock::new()); - let period_ns = 5 * NS_PER_MS; - let mut driver = TickDriver::new(clock, period_ns).expect("driver"); - let _first = driver.wait_until_next_tick().expect("first tick"); - thread::sleep(Duration::from_millis(20)); - let info = driver.wait_until_next_tick().expect("after sleep"); - assert!( - info.lag_ns > 0, - "expected positive lag, got {}", - info.lag_ns - ); - } - - #[test] - fn tick_index_is_monotonic() { - let clock = Arc::new(SystemMonotonicClock::new()); - let mut driver = TickDriver::new(clock, NS_PER_MS).expect("driver"); - let mut prior_index = None::; - let mut prior_scheduled = None::; - for _ in 0..16 { - let info = driver.wait_until_next_tick().expect("tick"); - if let Some(prev) = prior_index { - assert!( - info.tick_index > prev, - "tick_index not monotonic: prev={prev} cur={}", - info.tick_index - ); - } - if let Some(prev) = prior_scheduled { - assert!(info.scheduled_ns > prev, "scheduled_ns not monotonic"); - } - prior_index = Some(info.tick_index); - prior_scheduled = Some(info.scheduled_ns); - } - } - - #[test] - fn non_monotonic_clock_is_detected() { - let clock = Arc::new(ScriptedClock::new(vec![ - 0, 10_000_000, 5_000_000, 5_000_000, - ])); - let mut driver = TickDriver::new(clock, NS_PER_MS).expect("driver"); - let err = driver.wait_until_next_tick().err(); - assert!( - matches!(err, Some(TickError::NonMonotonicClock { .. })), - "got {err:?}" - ); - } - - #[test] - fn tick_info_lag_invariant_holds() { - let clock = Arc::new(SystemMonotonicClock::new()); - let mut driver = TickDriver::new(clock, NS_PER_MS).expect("driver"); - for _ in 0..5 { - let info = driver.wait_until_next_tick().expect("tick"); - let derived = info.actual_ns as i64 - info.scheduled_ns as i64; - assert_eq!(info.lag_ns, derived); - } - } -} diff --git a/fluxer_desktop/native/rust/Cargo.lock b/fluxer_desktop/native/rust/Cargo.lock deleted file mode 100644 index e77a76e00..000000000 --- a/fluxer_desktop/native/rust/Cargo.lock +++ /dev/null @@ -1,185 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "cpufeatures" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", -] - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "fluxer_desktop_native" -version = "0.1.0" -dependencies = [ - "sha2", - "tempfile", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", -] - -[[package]] -name = "hybrid-array" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" -dependencies = [ - "typenum", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rustix" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] diff --git a/fluxer_desktop/native/rust/Cargo.toml b/fluxer_desktop/native/rust/Cargo.toml deleted file mode 100644 index 53b0f7487..000000000 --- a/fluxer_desktop/native/rust/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "fluxer_desktop_native" -version = "0.1.0" -edition = "2024" -license = "AGPL-3.0-or-later" -publish = false - -[workspace] -resolver = "2" - -[dependencies] -sha2 = "0.11" - -[dev-dependencies] -tempfile = "3.27" diff --git a/fluxer_desktop/native/rust/src/input/mod.rs b/fluxer_desktop/native/rust/src/input/mod.rs deleted file mode 100644 index 5eee8f2ad..000000000 --- a/fluxer_desktop/native/rust/src/input/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub mod keymap; -pub mod modifiers; -pub mod ring; -pub mod windows_mouse; diff --git a/fluxer_desktop/native/rust/src/input/modifiers.rs b/fluxer_desktop/native/rust/src/input/modifiers.rs deleted file mode 100644 index 19068bd25..000000000 --- a/fluxer_desktop/native/rust/src/input/modifiers.rs +++ /dev/null @@ -1,162 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct Modifiers { - pub ctrl: bool, - pub alt: bool, - pub shift: bool, - pub meta: bool, -} - -pub mod windows { - use super::Modifiers; - - pub const HIGH_BIT: u16 = 0x8000; - - pub fn from_sampled( - shift_state: u16, - ctrl_state: u16, - alt_state: u16, - lwin_state: u16, - rwin_state: u16, - ) -> Modifiers { - Modifiers { - shift: (shift_state & HIGH_BIT) != 0, - ctrl: (ctrl_state & HIGH_BIT) != 0, - alt: (alt_state & HIGH_BIT) != 0, - meta: ((lwin_state | rwin_state) & HIGH_BIT) != 0, - } - } - - pub fn with_pressed_key(modifiers: Modifiers, vk: u16) -> Modifiers { - match vk { - 0x10 | 0xa0 | 0xa1 => Modifiers { - shift: true, - ..modifiers - }, - 0x11 | 0xa2 | 0xa3 => Modifiers { - ctrl: true, - ..modifiers - }, - 0x12 | 0xa4 | 0xa5 => Modifiers { - alt: true, - ..modifiers - }, - 0x5b | 0x5c => Modifiers { - meta: true, - ..modifiers - }, - _ => modifiers, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn windows_no_high_bits_all_modifiers_false() { - assert_eq!(Modifiers::default(), windows::from_sampled(0, 0, 0, 0, 0)); - } - - #[test] - fn windows_low_bit_only_state_ignored() { - assert_eq!(Modifiers::default(), windows::from_sampled(1, 1, 1, 1, 0)); - } - - #[test] - fn windows_shift_only() { - assert_eq!( - Modifiers { - shift: true, - ..Modifiers::default() - }, - windows::from_sampled(windows::HIGH_BIT, 0, 0, 0, 0) - ); - } - - #[test] - fn windows_pressed_modifier_adds_its_own_flag() { - let ctrl = Modifiers { - ctrl: true, - ..Modifiers::default() - }; - assert_eq!( - Modifiers { - ctrl: true, - shift: true, - ..Modifiers::default() - }, - windows::with_pressed_key(ctrl, 0xa0) - ); - assert_eq!( - Modifiers { - shift: true, - ..Modifiers::default() - }, - windows::with_pressed_key(Modifiers::default(), 0xa1) - ); - assert_eq!(ctrl, windows::with_pressed_key(Modifiers::default(), 0xa3)); - assert_eq!( - Modifiers { - alt: true, - ..Modifiers::default() - }, - windows::with_pressed_key(Modifiers::default(), 0xa5) - ); - assert_eq!( - Modifiers { - meta: true, - ..Modifiers::default() - }, - windows::with_pressed_key(Modifiers::default(), 0x5c) - ); - assert_eq!( - Modifiers { - ctrl: true, - alt: true, - shift: true, - meta: false, - }, - windows::with_pressed_key( - windows::with_pressed_key( - windows::with_pressed_key(Modifiers::default(), 0x10), - 0x11 - ), - 0x12 - ) - ); - } - - #[test] - fn windows_pressed_non_modifier_keeps_sampled_flags() { - let ctrl = Modifiers { - ctrl: true, - ..Modifiers::default() - }; - assert_eq!(ctrl, windows::with_pressed_key(ctrl, 0x41)); - assert_eq!( - Modifiers::default(), - windows::with_pressed_key(Modifiers::default(), 0x14) - ); - } - - #[test] - fn windows_either_win_key_sets_meta() { - assert!(windows::from_sampled(0, 0, 0, windows::HIGH_BIT, 0).meta); - assert!(windows::from_sampled(0, 0, 0, 0, windows::HIGH_BIT).meta); - } - - #[test] - fn windows_all_four_modifiers_held() { - let m = windows::from_sampled( - windows::HIGH_BIT, - windows::HIGH_BIT, - windows::HIGH_BIT, - windows::HIGH_BIT, - 0, - ); - assert!(m.ctrl && m.alt && m.shift && m.meta); - } -} diff --git a/fluxer_desktop/native/rust/src/input/ring.rs b/fluxer_desktop/native/rust/src/input/ring.rs deleted file mode 100644 index cd3d85ef1..000000000 --- a/fluxer_desktop/native/rust/src/input/ring.rs +++ /dev/null @@ -1,220 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::sync::atomic::{AtomicU64, Ordering}; - -#[derive(Debug)] -pub struct Ring { - pub slots: [T; CAPACITY], - head: AtomicU64, - tail: AtomicU64, - dropped: AtomicU64, -} - -impl Default for Ring { - fn default() -> Self { - Self::new() - } -} - -impl Ring { - const MASK: u64 = CAPACITY as u64 - 1; - - pub fn new() -> Self { - assert!( - CAPACITY > 0 && CAPACITY.is_power_of_two(), - "Ring capacity must be a power of two" - ); - Self { - slots: [T::default(); CAPACITY], - head: AtomicU64::new(0), - tail: AtomicU64::new(0), - dropped: AtomicU64::new(0), - } - } - - pub fn claim(&self) -> Option { - loop { - let head = self.head.load(Ordering::Relaxed); - let tail = self.tail.load(Ordering::Acquire); - if head.wrapping_sub(tail) >= CAPACITY as u64 { - self.dropped.fetch_add(1, Ordering::Relaxed); - return None; - } - if self - .head - .compare_exchange_weak( - head, - head.wrapping_add(1), - Ordering::Acquire, - Ordering::Relaxed, - ) - .is_ok() - { - return Some(head & Self::MASK); - } - } - } - - pub fn pop(&self) -> Option { - let tail = self.tail.load(Ordering::Relaxed); - let head = self.head.load(Ordering::Acquire); - if head == tail { - None - } else { - Some(tail & Self::MASK) - } - } - - pub fn release(&self) { - let tail = self.tail.load(Ordering::Relaxed); - self.tail.store(tail.wrapping_add(1), Ordering::Release); - } - - pub fn dropped_count(&self) -> u64 { - self.dropped.load(Ordering::Relaxed) - } - - pub fn len(&self) -> u64 { - let head = self.head.load(Ordering::Relaxed); - let tail = self.tail.load(Ordering::Relaxed); - head.wrapping_sub(tail) - } - - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - #[cfg(test)] - fn set_counters_for_test(&self, value: u64) { - self.head.store(value, Ordering::Relaxed); - self.tail.store(value, Ordering::Relaxed); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn empty_ring_pop_returns_null_len_is_zero() { - let r: Ring = Ring::new(); - assert_eq!(0, r.len()); - assert_eq!(None, r.pop()); - } - - #[test] - fn single_producer_consumer_round_trip() { - let mut r: Ring = Ring::new(); - let i = r.claim().unwrap() as usize; - r.slots[i] = 42; - assert_eq!(1, r.len()); - let j = r.pop().unwrap() as usize; - assert_eq!(42, r.slots[j]); - r.release(); - assert_eq!(0, r.len()); - assert_eq!(None, r.pop()); - } - - #[test] - fn fill_to_capacity_then_drop() { - let mut r: Ring = Ring::new(); - for k in 0..4 { - let i = r.claim().unwrap() as usize; - r.slots[i] = k; - } - assert_eq!(4, r.len()); - assert_eq!(None, r.claim()); - assert_eq!(1, r.dropped_count()); - assert_eq!(None, r.claim()); - assert_eq!(2, r.dropped_count()); - } - - #[test] - fn full_vs_empty_distinction_head_tail_wrap() { - let mut r: Ring = Ring::new(); - for cycle in 0..10 { - for n in 0..4 { - let i = r.claim().unwrap() as usize; - r.slots[i] = cycle * 100 + n; - } - assert_eq!(None, r.claim()); - for m in 0..4 { - let j = r.pop().unwrap() as usize; - assert_eq!(cycle * 100 + m, r.slots[j]); - r.release(); - } - assert_eq!(None, r.pop()); - } - } - - #[test] - fn fifo_order_across_wraparound() { - let mut r: Ring = Ring::new(); - for value in 0..3 { - let idx = r.claim().unwrap() as usize; - r.slots[idx] = value; - } - let idx = r.pop().unwrap() as usize; - assert_eq!(0, r.slots[idx]); - r.release(); - let idx = r.pop().unwrap() as usize; - assert_eq!(1, r.slots[idx]); - r.release(); - for value in 3..=5 { - let idx = r.claim().unwrap() as usize; - r.slots[idx] = value; - } - for expected in 2..=5 { - let j = r.pop().unwrap() as usize; - assert_eq!(expected, r.slots[j]); - r.release(); - } - assert_eq!(None, r.pop()); - } - - #[test] - fn u64_counter_wrap_behavior_is_mask_correct() { - let mut r: Ring = Ring::new(); - let near_max = u64::MAX - 2; - r.set_counters_for_test(near_max); - assert_eq!(None, r.pop()); - for k in 0..4 { - let i = r.claim().unwrap() as usize; - r.slots[i] = k; - } - assert_eq!(None, r.claim()); - for m in 0..4 { - let j = r.pop().unwrap() as usize; - assert_eq!(m, r.slots[j]); - r.release(); - } - assert_eq!(None, r.pop()); - } - - #[test] - fn concurrent_producers_via_simulated_cas_contention() { - let r: Ring = Ring::new(); - let mut seen = [false; 8]; - for _ in 0..8 { - let idx = r.claim().unwrap() as usize; - assert!(!seen[idx]); - seen[idx] = true; - } - assert!(seen.iter().all(|value| *value)); - assert_eq!(None, r.claim()); - } - - #[test] - fn dropped_counter_survives_interleaved_pop() { - let r: Ring = Ring::new(); - assert!(r.claim().is_some()); - assert!(r.claim().is_some()); - assert_eq!(None, r.claim()); - assert_eq!(1, r.dropped_count()); - assert!(r.pop().is_some()); - r.release(); - assert!(r.claim().is_some()); - assert_eq!(None, r.claim()); - assert_eq!(2, r.dropped_count()); - } -} diff --git a/fluxer_desktop/native/rust/src/lib.rs b/fluxer_desktop/native/rust/src/lib.rs deleted file mode 100644 index 160c8780b..000000000 --- a/fluxer_desktop/native/rust/src/lib.rs +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub mod input; -pub mod platform_info; -pub mod system_hunspell; -pub mod win_process_loopback; diff --git a/fluxer_desktop/native/rust/src/platform_info/mod.rs b/fluxer_desktop/native/rust/src/platform_info/mod.rs deleted file mode 100644 index 23686cd73..000000000 --- a/fluxer_desktop/native/rust/src/platform_info/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub mod probe_helpers; diff --git a/fluxer_desktop/native/rust/src/system_hunspell/encoding.rs b/fluxer_desktop/native/rust/src/system_hunspell/encoding.rs deleted file mode 100644 index fb62446ce..000000000 --- a/fluxer_desktop/native/rust/src/system_hunspell/encoding.rs +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub fn is_utf8_encoding(encoding: &str) -> bool { - let bytes = encoding.as_bytes(); - if bytes.is_empty() || bytes.len() > 16 { - return false; - } - bytes.eq_ignore_ascii_case(b"utf-8") || bytes.eq_ignore_ascii_case(b"utf8") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn is_utf8_encoding_matches_common_spellings() { - assert!(is_utf8_encoding("UTF-8")); - assert!(is_utf8_encoding("utf-8")); - assert!(is_utf8_encoding("UTF8")); - assert!(!is_utf8_encoding("ISO-8859-1")); - assert!(!is_utf8_encoding("")); - } - - #[test] - fn is_utf8_encoding_rejects_long_or_decorated_values() { - assert!(!is_utf8_encoding("utf-8\0")); - assert!(!is_utf8_encoding(" utf-8")); - assert!(!is_utf8_encoding("utf-8-with-extra")); - } -} diff --git a/fluxer_desktop/native/rust/src/system_hunspell/hashing.rs b/fluxer_desktop/native/rust/src/system_hunspell/hashing.rs deleted file mode 100644 index 27778362f..000000000 --- a/fluxer_desktop/native/rust/src/system_hunspell/hashing.rs +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::fs::File; -use std::io::{self, Read}; -use std::path::Path; - -use sha2::{Digest, Sha256}; - -pub const HEX_LEN: usize = 64; -const CHUNK_SIZE: usize = 64 * 1024; - -pub fn bytes_to_hex_lower(bytes: &[u8], out: &mut [u8]) { - assert!(out.len() >= bytes.len() * 2); - const ALPHABET: &[u8; 16] = b"0123456789abcdef"; - for (index, byte) in bytes.iter().copied().enumerate() { - out[index * 2] = ALPHABET[(byte >> 4) as usize]; - out[index * 2 + 1] = ALPHABET[(byte & 0x0f) as usize]; - } -} - -pub fn hash_file_to_hex(path: impl AsRef) -> io::Result { - let mut file = File::open(path)?; - let mut hasher = Sha256::new(); - let mut buf = [0_u8; CHUNK_SIZE]; - loop { - let read = file.read(&mut buf)?; - if read == 0 { - break; - } - hasher.update(&buf[..read]); - } - let digest = hasher.finalize(); - let mut out = [0_u8; HEX_LEN]; - bytes_to_hex_lower(&digest, &mut out); - let mut hex = String::with_capacity(HEX_LEN); - for byte in out { - hex.push(char::from(byte)); - } - Ok(hex) -} - -#[cfg(test)] -mod tests { - use std::io::Write; - - use super::*; - - #[test] - fn bytes_to_hex_lower_formats_lowercase_hex() { - let mut out = [0_u8; 8]; - bytes_to_hex_lower(&[0xde, 0xad, 0xbe, 0xef], &mut out); - assert_eq!("deadbeef", std::str::from_utf8(&out).unwrap()); - } - - #[test] - fn bytes_to_hex_lower_formats_zero_and_edge_bytes() { - let mut out = [0_u8; 10]; - bytes_to_hex_lower(&[0x00, 0x0f, 0xf0, 0xff, 0x10], &mut out); - assert_eq!("000ff0ff10", std::str::from_utf8(&out).unwrap()); - } - - #[test] - fn hash_file_to_hex_matches_known_sha256_for_abc() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("abc"); - std::fs::write(&path, b"abc").unwrap(); - assert_eq!( - "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", - hash_file_to_hex(&path).unwrap() - ); - } - - #[test] - fn hash_file_to_hex_over_chunk_boundary_matches_single_shot_sha256() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("chunked"); - let mut file = File::create(&path).unwrap(); - let mut payload = vec![0_u8; 200 * 1024]; - let mut value = 0xc0ffee_u64; - for byte in &mut payload { - value ^= value << 13; - value ^= value >> 7; - value ^= value << 17; - *byte = value as u8; - } - file.write_all(&payload).unwrap(); - - let mut oneshot = Sha256::new(); - oneshot.update(&payload); - let digest = oneshot.finalize(); - let mut expected = [0_u8; HEX_LEN]; - bytes_to_hex_lower(&digest, &mut expected); - assert_eq!( - std::str::from_utf8(&expected).unwrap(), - hash_file_to_hex(&path).unwrap() - ); - } - - #[test] - fn hash_file_to_hex_returns_open_error_for_missing_file() { - assert!(hash_file_to_hex("/nonexistent/path/that/should/not/exist.bin").is_err()); - } -} diff --git a/fluxer_desktop/native/rust/src/system_hunspell/mod.rs b/fluxer_desktop/native/rust/src/system_hunspell/mod.rs deleted file mode 100644 index 432fce515..000000000 --- a/fluxer_desktop/native/rust/src/system_hunspell/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub mod dictionaries; -pub mod encoding; -pub mod hashing; diff --git a/fluxer_desktop/native/rust/src/win_process_loopback/mod.rs b/fluxer_desktop/native/rust/src/win_process_loopback/mod.rs deleted file mode 100644 index 48738ddc0..000000000 --- a/fluxer_desktop/native/rust/src/win_process_loopback/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub mod audio_contract; -pub mod process_tree; -pub mod session_mixer; -pub mod windows_version; diff --git a/fluxer_desktop/native/rust/src/win_process_loopback/windows_version.rs b/fluxer_desktop/native/rust/src/win_process_loopback/windows_version.rs deleted file mode 100644 index 9ad65504b..000000000 --- a/fluxer_desktop/native/rust/src/win_process_loopback/windows_version.rs +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub const PROCESS_LOOPBACK_MIN_BUILD: u32 = 20_348; - -pub fn supports_process_loopback(major: u32, minor: u32, build: u32) -> bool { - if major > 10 { - return true; - } - major == 10 && minor == 0 && build >= PROCESS_LOOPBACK_MIN_BUILD -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn process_loopback_follows_microsoft_documented_windows_build_floor() { - assert!(!supports_process_loopback(10, 0, 19_045)); - assert!(!supports_process_loopback( - 10, - 0, - PROCESS_LOOPBACK_MIN_BUILD - 1 - )); - assert!(supports_process_loopback(10, 0, PROCESS_LOOPBACK_MIN_BUILD)); - assert!(supports_process_loopback(10, 0, 22_000)); - assert!(supports_process_loopback(11, 0, 0)); - } -} diff --git a/fluxer_desktop/native/rust/tests/properties.rs b/fluxer_desktop/native/rust/tests/properties.rs deleted file mode 100644 index 454fbf34a..000000000 --- a/fluxer_desktop/native/rust/tests/properties.rs +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use fluxer_desktop_native::input::ring::Ring; - -#[test] -fn ring_stress_preserves_fifo_under_repeated_fill_drain_cycles() { - let mut ring: Ring = Ring::new(); - for cycle in 0..512_u64 { - for index in 0..1024_u64 { - let slot = ring.claim().expect("slot") as usize; - ring.slots[slot] = cycle * 10_000 + index; - } - assert!(ring.claim().is_none()); - for index in 0..1024_u64 { - let slot = ring.pop().expect("slot") as usize; - assert_eq!(cycle * 10_000 + index, ring.slots[slot]); - ring.release(); - } - assert!(ring.pop().is_none()); - } -} diff --git a/fluxer_desktop/native/audio-apm/Cargo.lock b/fluxer_desktop/native/sample-pool/Cargo.lock similarity index 82% rename from fluxer_desktop/native/audio-apm/Cargo.lock rename to fluxer_desktop/native/sample-pool/Cargo.lock index 60743c248..e2e4592a7 100644 --- a/fluxer_desktop/native/audio-apm/Cargo.lock +++ b/fluxer_desktop/native/sample-pool/Cargo.lock @@ -3,5 +3,5 @@ version = 4 [[package]] -name = "fluxer_audio_apm" +name = "fluxer_sample_pool" version = "0.0.0" diff --git a/fluxer_desktop/native/audio-apm/Cargo.toml b/fluxer_desktop/native/sample-pool/Cargo.toml similarity index 61% rename from fluxer_desktop/native/audio-apm/Cargo.toml rename to fluxer_desktop/native/sample-pool/Cargo.toml index eb4343b71..679f20e68 100644 --- a/fluxer_desktop/native/audio-apm/Cargo.toml +++ b/fluxer_desktop/native/sample-pool/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "fluxer_audio_apm" +name = "fluxer_sample_pool" version = "0.0.0" edition = "2024" license = "AGPL-3.0-or-later" @@ -7,8 +7,3 @@ publish = false [workspace] resolver = "2" - -[lib] -crate-type = ["rlib"] - -[dev-dependencies] diff --git a/fluxer_desktop/native/sample-pool/src/lib.rs b/fluxer_desktop/native/sample-pool/src/lib.rs new file mode 100644 index 000000000..7ac43bd12 --- /dev/null +++ b/fluxer_desktop/native/sample-pool/src/lib.rs @@ -0,0 +1,539 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::cell::UnsafeCell; +use std::fmt; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; + +pub const PCM_SLOT_SAMPLES_MAX: usize = 16_384; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PcmPoolError { + ZeroCapacity, + ZeroSamplesPerSlot, + SamplesPerSlotTooLarge(usize), + PayloadTooLarge { offered: usize, capacity: usize }, +} + +impl fmt::Display for PcmPoolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ZeroCapacity => write!(f, "SamplePool capacity must be > 0"), + Self::ZeroSamplesPerSlot => write!(f, "SamplePool samples_per_slot must be > 0"), + Self::SamplesPerSlotTooLarge(n) => write!( + f, + "SamplePool samples_per_slot {n} exceeds PCM_SLOT_SAMPLES_MAX={PCM_SLOT_SAMPLES_MAX}" + ), + Self::PayloadTooLarge { offered, capacity } => write!( + f, + "SamplePool payload {offered} samples exceeds slot capacity {capacity}" + ), + } + } +} + +impl std::error::Error for PcmPoolError {} + +struct SlotCell { + inner: UnsafeCell>, +} + +unsafe impl Send for SlotCell {} +unsafe impl Sync for SlotCell {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PcmPoolStats { + pub acquired: u64, + pub released: u64, + pub dropped: u64, + pub in_flight: u32, +} + +struct SamplePoolInner { + slots: Vec>, + free: Mutex>, + capacity: u32, + samples_per_slot: u32, + acquired_total: AtomicU64, + released_total: AtomicU64, + dropped_total: AtomicU64, + in_flight: AtomicU32, +} + +fn lock_free_list(free: &Mutex>) -> MutexGuard<'_, Vec> { + free.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +macro_rules! pool_assert { + ($condition:expr) => { + if CHECK_INVARIANTS { + assert!($condition); + } else { + debug_assert!($condition); + } + }; +} + +macro_rules! pool_assert_eq { + ($left:expr, $right:expr) => { + if CHECK_INVARIANTS { + assert_eq!($left, $right); + } else { + debug_assert_eq!($left, $right); + } + }; +} + +pub struct SamplePool { + inner: Arc>, +} + +impl fmt::Debug + for SamplePool +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let stats = self.stats(); + f.debug_struct("SamplePool") + .field("capacity", &self.inner.capacity) + .field("samples_per_slot", &self.inner.samples_per_slot) + .field("stats", &stats) + .finish() + } +} + +impl + SamplePool +{ + pub fn new(capacity: usize, samples_per_slot: usize) -> Result { + if capacity == 0 { + return Err(PcmPoolError::ZeroCapacity); + } + if samples_per_slot == 0 { + return Err(PcmPoolError::ZeroSamplesPerSlot); + } + if samples_per_slot > PCM_SLOT_SAMPLES_MAX { + return Err(PcmPoolError::SamplesPerSlotTooLarge(samples_per_slot)); + } + assert!(capacity > 0); + assert!(samples_per_slot > 0); + assert!(samples_per_slot <= PCM_SLOT_SAMPLES_MAX); + + let mut slots: Vec> = Vec::with_capacity(capacity); + for _ in 0..capacity { + let buf: Box<[T]> = vec![T::default(); samples_per_slot].into_boxed_slice(); + assert_eq!(buf.len(), samples_per_slot); + slots.push(SlotCell { + inner: UnsafeCell::new(buf), + }); + } + assert_eq!(slots.len(), capacity); + + let mut free: Vec = Vec::with_capacity(capacity); + for index in 0..capacity { + free.push(index); + } + assert_eq!(free.len(), capacity); + + let cap_u32 = u32::try_from(capacity).map_err(|_| PcmPoolError::ZeroCapacity)?; + let sps_u32 = + u32::try_from(samples_per_slot).map_err(|_| PcmPoolError::ZeroSamplesPerSlot)?; + let inner = SamplePoolInner { + slots, + free: Mutex::new(free), + capacity: cap_u32, + samples_per_slot: sps_u32, + acquired_total: AtomicU64::new(0), + released_total: AtomicU64::new(0), + dropped_total: AtomicU64::new(0), + in_flight: AtomicU32::new(0), + }; + Ok(Self { + inner: Arc::new(inner), + }) + } + + pub fn try_acquire(&self) -> Option> { + assert!(self.inner.capacity > 0); + assert!(self.inner.samples_per_slot > 0); + + let mut free = lock_free_list(&self.inner.free); + assert!(free.len() <= self.inner.capacity as usize); + let index = match free.pop() { + Some(idx) => idx, + None => { + drop(free); + self.inner.dropped_total.fetch_add(1, Ordering::Relaxed); + return None; + } + }; + assert!(index < self.inner.capacity as usize); + self.inner.acquired_total.fetch_add(1, Ordering::Relaxed); + let after = self.inner.in_flight.fetch_add(1, Ordering::AcqRel) + 1; + assert!(after <= self.inner.capacity); + drop(free); + + Some(PooledSamples { + slot_index: index, + filled_len: 0, + pool: Arc::clone(&self.inner), + }) + } + + pub fn capacity(&self) -> u32 { + let cap = self.inner.capacity; + assert!(cap > 0); + assert!(cap as usize == self.inner.slots.len()); + cap + } + + pub fn samples_per_slot(&self) -> u32 { + let sps = self.inner.samples_per_slot; + assert!(sps > 0); + assert!(sps as usize <= PCM_SLOT_SAMPLES_MAX); + sps + } + + pub fn stats(&self) -> PcmPoolStats { + assert!(self.inner.capacity > 0); + let in_flight = self.inner.in_flight.load(Ordering::Acquire); + assert!(in_flight <= self.inner.capacity); + let acquired = self.inner.acquired_total.load(Ordering::Relaxed); + let released = self.inner.released_total.load(Ordering::Relaxed); + let dropped = self.inner.dropped_total.load(Ordering::Relaxed); + assert!(released <= acquired); + PcmPoolStats { + acquired, + released, + dropped, + in_flight, + } + } +} + +impl Clone + for SamplePool +{ + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} + +pub struct PooledSamples { + slot_index: usize, + filled_len: usize, + pool: Arc>, +} + +impl + PooledSamples +{ + pub fn write(&mut self, samples: &[T]) -> Result<(), PcmPoolError> { + pool_assert!(self.slot_index < self.pool.capacity as usize); + let cap = self.pool.samples_per_slot as usize; + if samples.len() > cap { + return Err(PcmPoolError::PayloadTooLarge { + offered: samples.len(), + capacity: cap, + }); + } + pool_assert!(samples.len() <= cap); + + let cell = &self.pool.slots[self.slot_index]; + let buf: &mut [T] = unsafe { &mut *cell.inner.get() }; + pool_assert_eq!(buf.len(), cap); + if !samples.is_empty() { + buf[..samples.len()].copy_from_slice(samples); + } + self.filled_len = samples.len(); + pool_assert!(self.filled_len <= cap); + Ok(()) + } + + pub fn write_with(&mut self, sample_count: usize, fill: F) -> Result<(), PcmPoolError> + where + F: FnOnce(&mut [T]), + { + pool_assert!(self.slot_index < self.pool.capacity as usize); + let cap = self.pool.samples_per_slot as usize; + if sample_count > cap { + return Err(PcmPoolError::PayloadTooLarge { + offered: sample_count, + capacity: cap, + }); + } + pool_assert!(sample_count <= cap); + + let cell = &self.pool.slots[self.slot_index]; + let buf: &mut [T] = unsafe { &mut *cell.inner.get() }; + pool_assert_eq!(buf.len(), cap); + fill(&mut buf[..sample_count]); + self.filled_len = sample_count; + pool_assert!(self.filled_len <= cap); + Ok(()) + } + + pub fn unfilled_mut(&mut self) -> &mut [T] { + pool_assert!(self.slot_index < self.pool.capacity as usize); + let cap = self.pool.samples_per_slot as usize; + pool_assert!(cap > 0); + let cell = &self.pool.slots[self.slot_index]; + let buf: &mut [T] = unsafe { &mut *cell.inner.get() }; + pool_assert_eq!(buf.len(), cap); + buf + } + + pub fn set_filled_len(&mut self, len: usize) { + assert!(self.slot_index < self.pool.capacity as usize); + assert!(len <= self.pool.samples_per_slot as usize); + self.filled_len = len; + } + + pub fn data_slice(&self) -> &[T] { + pool_assert!(self.slot_index < self.pool.capacity as usize); + pool_assert!(self.filled_len <= self.pool.samples_per_slot as usize); + let cell = &self.pool.slots[self.slot_index]; + let buf: &[T] = unsafe { &*cell.inner.get() }; + &buf[..self.filled_len] + } + + pub fn filled_len(&self) -> usize { + pool_assert!(self.slot_index < self.pool.capacity as usize); + pool_assert!(self.filled_len <= self.pool.samples_per_slot as usize); + self.filled_len + } + + pub fn capacity(&self) -> usize { + let cap = self.pool.samples_per_slot as usize; + pool_assert!(cap > 0); + pool_assert!(cap <= PCM_SLOT_SAMPLES_MAX); + cap + } + + pub fn as_mut_ptr(&mut self) -> *mut T { + pool_assert!(self.slot_index < self.pool.capacity as usize); + pool_assert!(self.filled_len <= self.pool.samples_per_slot as usize); + let cell = &self.pool.slots[self.slot_index]; + let buf: &mut [T] = unsafe { &mut *cell.inner.get() }; + pool_assert_eq!(buf.len(), self.pool.samples_per_slot as usize); + buf.as_mut_ptr() + } + + pub fn into_external_parts(mut self) -> (*mut T, usize, Self) { + pool_assert!(self.slot_index < self.pool.capacity as usize); + pool_assert!(self.filled_len <= self.pool.samples_per_slot as usize); + let len = self.filled_len; + let ptr = self.as_mut_ptr(); + pool_assert!(!ptr.is_null()); + (ptr, len, self) + } +} + +impl Drop + for PooledSamples +{ + fn drop(&mut self) { + pool_assert!(self.slot_index < self.pool.capacity as usize); + if self.slot_index >= self.pool.capacity as usize { + return; + } + let mut free = lock_free_list(&self.pool.free); + pool_assert!(free.len() < self.pool.capacity as usize); + free.push(self.slot_index); + let before = self.pool.in_flight.fetch_sub(1, Ordering::AcqRel); + pool_assert!(before >= 1); + self.pool.released_total.fetch_add(1, Ordering::Relaxed); + drop(free); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + + const PCM_POOL_CAP: usize = 16; + type SamplePool = super::SamplePool; + const SLOT_SAMPLES: usize = 2_048; + + fn default_pool() -> SamplePool { + SamplePool::new(PCM_POOL_CAP, SLOT_SAMPLES).expect("default pool builds") + } + + #[test] + fn acquire_release_cycle_increments_counters() { + let pool = SamplePool::new(4, SLOT_SAMPLES).expect("pool"); + { + let _slot = pool.try_acquire().expect("slot"); + let stats_held = pool.stats(); + assert_eq!(stats_held.acquired, 1); + assert_eq!(stats_held.in_flight, 1); + } + let stats_after = pool.stats(); + assert_eq!(stats_after.acquired, 1); + assert_eq!(stats_after.released, 1); + assert_eq!(stats_after.in_flight, 0); + } + + #[test] + fn pool_exhausts_at_cap_and_counts_drop() { + let pool = default_pool(); + let mut held = Vec::with_capacity(PCM_POOL_CAP); + for _ in 0..PCM_POOL_CAP { + held.push(pool.try_acquire().expect("slot in capacity")); + } + assert!(pool.try_acquire().is_none()); + let stats = pool.stats(); + assert_eq!(stats.dropped, 1); + assert_eq!(stats.acquired as usize, PCM_POOL_CAP); + assert_eq!(stats.in_flight as usize, PCM_POOL_CAP); + } + + #[test] + fn write_then_data_slice_matches_payload() { + let pool = SamplePool::new(2, 64).expect("pool"); + let mut slot = pool.try_acquire().expect("slot"); + let payload = [0.25_f32; 32]; + slot.write(&payload).expect("payload fits"); + assert_eq!(slot.data_slice(), &payload[..]); + assert_eq!(slot.filled_len(), 32); + } + + #[test] + fn write_rejects_payload_larger_than_slot() { + let pool = SamplePool::new(2, 64).expect("pool"); + let mut slot = pool.try_acquire().expect("slot"); + let big = [0.0_f32; 128]; + let err = slot.write(&big).unwrap_err(); + assert!(matches!(err, PcmPoolError::PayloadTooLarge { .. })); + } + + #[test] + fn unfilled_mut_then_set_filled_len_matches_data_slice() { + let pool = SamplePool::new(2, 64).expect("pool"); + let mut slot = pool.try_acquire().expect("slot"); + { + let buf = slot.unfilled_mut(); + assert_eq!(buf.len(), 64); + buf[0] = 0.5; + buf[1] = -0.5; + buf[2] = 1.0; + } + slot.set_filled_len(3); + assert_eq!(slot.filled_len(), 3); + assert_eq!(slot.data_slice(), &[0.5, -0.5, 1.0]); + } + + #[test] + #[should_panic] + fn set_filled_len_rejects_overflow() { + let pool = SamplePool::new(1, 32).expect("pool"); + let mut slot = pool.try_acquire().expect("slot"); + slot.set_filled_len(33); + } + + #[test] + fn into_external_parts_exposes_filled_pointer_and_length() { + let pool = SamplePool::new(2, 64).expect("pool"); + let mut slot = pool.try_acquire().expect("slot"); + let payload = [1.5_f32; 16]; + slot.write(&payload).expect("write fits"); + let (ptr, len, owned) = slot.into_external_parts(); + assert!(!ptr.is_null()); + assert_eq!(len, 16); + let observed = unsafe { core::slice::from_raw_parts(ptr, len) }; + assert_eq!(observed, &payload[..]); + assert_eq!(pool.stats().in_flight, 1); + drop(owned); + assert_eq!(pool.stats().in_flight, 0); + } + + #[test] + fn into_external_parts_drop_returns_slot_to_pool() { + let pool = SamplePool::new(1, 32).expect("pool"); + let mut slot = pool.try_acquire().expect("slot"); + slot.write(&[0.75_f32; 8]).expect("write"); + let (_ptr, _len, owned) = slot.into_external_parts(); + assert!(pool.try_acquire().is_none()); + drop(owned); + let revived = pool.try_acquire().expect("revived"); + assert_eq!(revived.filled_len(), 0); + drop(revived); + assert_eq!(pool.stats().in_flight, 0); + } + + #[test] + fn pooled_frame_survives_send_across_threads() { + let pool = SamplePool::new(2, 64).expect("pool"); + let mut slot = pool.try_acquire().expect("slot"); + slot.write(&[0.5_f32; 32]).expect("write"); + let handle = thread::spawn(move || { + assert_eq!(slot.filled_len(), 32); + assert_eq!(slot.data_slice()[0], 0.5); + drop(slot); + }); + handle.join().expect("worker"); + assert_eq!(pool.stats().in_flight, 0); + assert_eq!(pool.stats().released, 1); + } + + #[test] + fn write_with_fills_exactly_sample_count_samples() { + let pool = SamplePool::new(2, 64).expect("pool"); + let mut slot = pool.try_acquire().expect("slot"); + slot.write_with(8, |out| { + assert_eq!(out.len(), 8); + for (index, sample) in out.iter_mut().enumerate() { + *sample = index as f32; + } + }) + .expect("payload fits"); + assert_eq!(slot.filled_len(), 8); + assert_eq!(slot.data_slice(), &[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]); + } + + #[test] + fn write_with_rejects_sample_count_larger_than_slot() { + let pool = SamplePool::new(2, 64).expect("pool"); + let mut slot = pool.try_acquire().expect("slot"); + let err = slot.write_with(128, |_| {}).unwrap_err(); + assert!(matches!(err, PcmPoolError::PayloadTooLarge { .. })); + assert_eq!(slot.filled_len(), 0); + } + + #[test] + fn full_acquire_drop_cycle_keeps_accounting_consistent() { + let pool = default_pool(); + for _ in 0..4 { + let mut held = Vec::with_capacity(PCM_POOL_CAP); + for _ in 0..PCM_POOL_CAP { + held.push(pool.try_acquire().expect("slot in capacity")); + } + assert!(pool.try_acquire().is_none()); + drop(held); + let stats = pool.stats(); + assert_eq!(stats.in_flight, 0); + assert_eq!(stats.released, stats.acquired); + } + let stats = pool.stats(); + assert_eq!(stats.acquired as usize, PCM_POOL_CAP * 4); + assert_eq!(stats.released as usize, PCM_POOL_CAP * 4); + assert_eq!(stats.in_flight, 0); + } + + #[test] + fn external_parts_release_does_not_abort_accounting() { + let pool = SamplePool::new(2, 64).expect("pool"); + let mut slot = pool.try_acquire().expect("slot"); + slot.write(&[0.25_f32; 16]).expect("write"); + let (ptr, len, owned) = slot.into_external_parts(); + assert!(!ptr.is_null()); + assert_eq!(len, 16); + drop(owned); + let stats = pool.stats(); + assert_eq!(stats.in_flight, 0); + assert_eq!(stats.released, 1); + assert_eq!(stats.acquired, 1); + } +} diff --git a/fluxer_desktop/native/screen-frame-bus/Cargo.lock b/fluxer_desktop/native/screen-frame-bus/Cargo.lock deleted file mode 100644 index ac49d7a42..000000000 --- a/fluxer_desktop/native/screen-frame-bus/Cargo.lock +++ /dev/null @@ -1,1512 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloca" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" -dependencies = [ - "cc", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - -[[package]] -name = "anes" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "ash" -version = "0.38.0+1.3.281" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" -dependencies = [ - "libloading", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "bit-set" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" - -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytemuck" -version = "1.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" - -[[package]] -name = "cc" -version = "1.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - -[[package]] -name = "clap" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" -dependencies = [ - "clap_builder", -] - -[[package]] -name = "clap_builder" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" -dependencies = [ - "anstyle", - "clap_lex", -] - -[[package]] -name = "clap_lex" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" - -[[package]] -name = "codespan-reporting" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" -dependencies = [ - "serde", - "termcolor", - "unicode-width", -] - -[[package]] -name = "criterion" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" -dependencies = [ - "alloca", - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "itertools", - "num-traits", - "oorandom", - "page_size", - "regex", - "serde", - "serde_json", - "tinytemplate", - "walkdir", -] - -[[package]] -name = "criterion-plot" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" -dependencies = [ - "cast", - "itertools", -] - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags", - "objc2", -] - -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "find-msvc-tools" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" - -[[package]] -name = "fluxer_gpu_rebuild" -version = "0.0.0" -dependencies = [ - "parking_lot", - "wgpu", -] - -[[package]] -name = "fluxer_screen_frame_bus" -version = "0.0.0" -dependencies = [ - "criterion", - "fluxer_gpu_rebuild", - "parking_lot", - "wgpu", -] - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "gl_generator" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" -dependencies = [ - "khronos_api", - "log", - "xml-rs", -] - -[[package]] -name = "glow" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" -dependencies = [ - "js-sys", - "slotmap", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "glutin_wgl_sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" -dependencies = [ - "gl_generator", -] - -[[package]] -name = "gpu-allocator" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" -dependencies = [ - "ash", - "hashbrown 0.16.1", - "log", - "presser", - "thiserror", - "windows", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "num-traits", - "zerocopy", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "foldhash", -] - -[[package]] -name = "indexmap" -version = "2.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "js-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "khronos-egl" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" -dependencies = [ - "libc", - "libloading", - "pkg-config", -] - -[[package]] -name = "khronos_api" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "naga" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" -dependencies = [ - "arrayvec", - "bit-set", - "bitflags", - "cfg-if", - "cfg_aliases", - "codespan-reporting", - "half", - "hashbrown 0.17.1", - "indexmap", - "libm", - "log", - "naga-types", - "num-traits", - "once_cell", - "rustc-hash", - "spirv", - "thiserror", - "unicode-ident", -] - -[[package]] -name = "naga-types" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" -dependencies = [ - "hashbrown 0.17.1", - "indexmap", - "rustc-hash", - "thiserror", -] - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys 0.3.1", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "dispatch2", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-metal" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" -dependencies = [ - "bitflags", - "block2", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "objc2-metal", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - -[[package]] -name = "ordered-float" -version = "5.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" -dependencies = [ - "num-traits", -] - -[[package]] -name = "page_size" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" - -[[package]] -name = "portable-atomic" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" - -[[package]] -name = "portable-atomic-util" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "presser" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "profiling" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "range-alloc" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "raw-window-metal" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" -dependencies = [ - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-quartz-core", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "renderdoc-sys" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" -dependencies = [ - "version_check", -] - -[[package]] -name = "smallvec" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" - -[[package]] -name = "spirv" -version = "0.4.0+sdk-1.4.341.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" -dependencies = [ - "bitflags", -] - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "unicode-ident" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.6", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wayland-sys" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" -dependencies = [ - "dlib", - "log", - "once_cell", - "pkg-config", -] - -[[package]] -name = "web-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wgpu" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" -dependencies = [ - "arrayvec", - "bitflags", - "bytemuck", - "cfg-if", - "cfg_aliases", - "document-features", - "hashbrown 0.17.1", - "js-sys", - "log", - "naga", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "smallvec", - "static_assertions", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "wgpu-core", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-core" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" -dependencies = [ - "arrayvec", - "bit-set", - "bit-vec", - "bitflags", - "bytemuck", - "cfg_aliases", - "document-features", - "hashbrown 0.17.1", - "indexmap", - "log", - "naga", - "naga-types", - "once_cell", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "rustc-hash", - "smallvec", - "thiserror", - "wgpu-core-deps-apple", - "wgpu-core-deps-emscripten", - "wgpu-core-deps-windows-linux-android", - "wgpu-hal", - "wgpu-naga-bridge", - "wgpu-types", -] - -[[package]] -name = "wgpu-core-deps-apple" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-emscripten" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-windows-linux-android" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-hal" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" -dependencies = [ - "android_system_properties", - "arrayvec", - "ash", - "bit-set", - "bitflags", - "block2", - "bytemuck", - "cfg-if", - "cfg_aliases", - "glow", - "glutin_wgl_sys", - "gpu-allocator", - "hashbrown 0.17.1", - "js-sys", - "khronos-egl", - "libc", - "libloading", - "log", - "naga", - "naga-types", - "ndk-sys", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "objc2-metal", - "objc2-quartz-core", - "once_cell", - "ordered-float", - "parking_lot", - "portable-atomic", - "portable-atomic-util", - "profiling", - "range-alloc", - "raw-window-handle", - "raw-window-metal", - "renderdoc-sys", - "smallvec", - "static_assertions", - "thiserror", - "wasm-bindgen", - "wayland-sys", - "web-sys", - "wgpu-naga-bridge", - "wgpu-types", - "windows", - "windows-core", - "windows-result", -] - -[[package]] -name = "wgpu-naga-bridge" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" -dependencies = [ - "naga", - "wgpu-types", -] - -[[package]] -name = "wgpu-types" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" -dependencies = [ - "bitflags", - "bytemuck", - "js-sys", - "log", - "naga-types", - "raw-window-handle", - "static_assertions", - "web-sys", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[package]] -name = "xml-rs" -version = "0.8.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" - -[[package]] -name = "zerocopy" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/fluxer_desktop/native/screen-frame-bus/Cargo.toml b/fluxer_desktop/native/screen-frame-bus/Cargo.toml deleted file mode 100644 index 75ac01e75..000000000 --- a/fluxer_desktop/native/screen-frame-bus/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "fluxer_screen_frame_bus" -version = "0.0.0" -edition = "2024" -license = "AGPL-3.0-or-later" -publish = false - -[workspace] -resolver = "2" - -[lib] -crate-type = ["rlib"] - -[features] -default = [] -wgpu = ["dep:wgpu", "fluxer_gpu_rebuild/wgpu"] - -[dependencies] -parking_lot = "0.12" -wgpu = { version = "30", optional = true } -fluxer_gpu_rebuild = { path = "../gpu-rebuild" } - -[dev-dependencies] -criterion = { version = "0.8", default-features = false, features = ["cargo_bench_support"] } - -[[bench]] -name = "staging" -harness = false - -[[bench]] -name = "frame_pool" -harness = false diff --git a/fluxer_desktop/native/screen-frame-bus/benches/baseline.json b/fluxer_desktop/native/screen-frame-bus/benches/baseline.json deleted file mode 100644 index 1c4c80ca5..000000000 --- a/fluxer_desktop/native/screen-frame-bus/benches/baseline.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "measured_at": "85e057a273fd", - "host": "darwin-arm64-apple-silicon", - "regression_budget_percent": 5.0, - "criterion_args": { - "warm_up_time_sec": 2, - "measurement_time_sec": 5 - }, - "benches": { - "staging_pair_submit_map_cycle_1080p_rgba": { - "median_ns": 76243.0, - "low_ns": 72391.0, - "high_ns": 81080.0, - "budget_percent_override": 20.0, - "note": "1080p RGBA copy + map; observed cross-run sigma ~10% on a loaded dev host. Wider budget absorbs that without hiding real 1.3x slowdowns." - }, - "frame_pool/acquire_release/cpu_1080p_rgba": { - "median_ns": 11.874, - "low_ns": 11.811, - "high_ns": 11.941, - "budget_percent_override": 10.0, - "note": "~12ns op; tiny absolute deltas easily exceed 5% percent. 10% trades sensitivity for noise floor." - } - } -} diff --git a/fluxer_desktop/native/screen-frame-bus/benches/frame_pool.rs b/fluxer_desktop/native/screen-frame-bus/benches/frame_pool.rs deleted file mode 100644 index 836183498..000000000 --- a/fluxer_desktop/native/screen-frame-bus/benches/frame_pool.rs +++ /dev/null @@ -1,26 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::hint::black_box; - -use criterion::{Criterion, criterion_group, criterion_main}; -use fluxer_screen_frame_bus::frame_pool::{CpuFrameBuilder, FRAME_BYTES_MAX}; - -fn bench_acquire_release_cpu_1080p_rgba(c: &mut Criterion) { - assert_eq!(FRAME_BYTES_MAX, 1920 * 1080 * 4); - let pool = CpuFrameBuilder::build_pool(FRAME_BYTES_MAX) - .expect("pool must allocate at construction time"); - assert!(pool.capacity() > 0); - - c.bench_function("frame_pool/acquire_release/cpu_1080p_rgba", |b| { - b.iter(|| { - let frame = pool - .try_acquire() - .expect("steady state single-threaded never starves"); - black_box(frame.slot_index()); - }); - }); - assert_eq!(pool.currently_in_flight(), 0); -} - -criterion_group!(benches, bench_acquire_release_cpu_1080p_rgba); -criterion_main!(benches); diff --git a/fluxer_desktop/native/screen-frame-bus/benches/staging.rs b/fluxer_desktop/native/screen-frame-bus/benches/staging.rs deleted file mode 100644 index 8a66d625f..000000000 --- a/fluxer_desktop/native/screen-frame-bus/benches/staging.rs +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::hint::black_box; - -use criterion::{Criterion, criterion_group, criterion_main}; -use fluxer_screen_frame_bus::{CpuStagingBackend, StagingSurfacePair}; - -const BENCH_WIDTH: usize = 1920; -const BENCH_HEIGHT: usize = 1080; -const BENCH_BYTES_PER_PIXEL: usize = 4; -const BENCH_FRAME_LEN: usize = BENCH_WIDTH * BENCH_HEIGHT * BENCH_BYTES_PER_PIXEL; - -fn build_pair() -> StagingSurfacePair { - let backends = [ - CpuStagingBackend::new(BENCH_FRAME_LEN), - CpuStagingBackend::new(BENCH_FRAME_LEN), - ]; - assert_eq!(backends[0].len(), BENCH_FRAME_LEN); - assert_eq!(backends[1].len(), BENCH_FRAME_LEN); - StagingSurfacePair::new(backends) -} - -fn submit_map_cycle(c: &mut Criterion) { - let mut pair = build_pair(); - pair.submit(0, |buf| fill_pattern(buf, 0)) - .expect("warm submit zero"); - - let mut sequence: u64 = 1; - c.bench_function("staging_pair_submit_map_cycle_1080p_rgba", |b| { - b.iter(|| { - pair.submit(sequence, |buf| fill_pattern(buf, sequence as u8)) - .expect("submit ok in steady state"); - let mapped_sequence = sequence - 1; - let first_byte = pair - .try_map(mapped_sequence, |buf| buf[0]) - .expect("cpu backend is always ready"); - black_box(first_byte); - sequence = sequence.wrapping_add(1); - }); - }); -} - -fn fill_pattern(buf: &mut [u8], seed: u8) { - assert_eq!(buf.len(), BENCH_FRAME_LEN, "bench backend has fixed size"); - let chunk = seed.wrapping_add(1); - for byte in buf.iter_mut() { - *byte = chunk; - } -} - -criterion_group!(benches, submit_map_cycle); -criterion_main!(benches); diff --git a/fluxer_desktop/native/screen-frame-bus/src/frame_pool.rs b/fluxer_desktop/native/screen-frame-bus/src/frame_pool.rs deleted file mode 100644 index 5cdec8261..000000000 --- a/fluxer_desktop/native/screen-frame-bus/src/frame_pool.rs +++ /dev/null @@ -1,403 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use parking_lot::Mutex; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; - -#[cfg(target_os = "linux")] -use std::os::fd::RawFd; - -pub const FRAME_BYTES_MAX: usize = 1920 * 1080 * 4; - -pub const MAX_CPU_FRAMES: usize = 8; - -#[cfg(target_os = "macos")] -pub struct IoSurfaceHandle; - -#[cfg(target_os = "windows")] -#[derive(Debug)] -pub struct D3D11Handle(pub usize); - -#[cfg(target_os = "windows")] -unsafe impl Send for D3D11Handle {} -#[cfg(target_os = "windows")] -unsafe impl Sync for D3D11Handle {} - -pub enum Frame { - Cpu(Box<[u8]>), - #[cfg(feature = "wgpu")] - WgpuTexture { - texture: wgpu::Texture, - format: wgpu::TextureFormat, - dims: (u32, u32), - }, - #[cfg(target_os = "macos")] - IoSurface { - surface_ref: IoSurfaceHandle, - }, - #[cfg(target_os = "linux")] - Dmabuf { - fds: Vec, - format_modifier: u64, - }, - #[cfg(target_os = "windows")] - D3D11Shared { - handle: D3D11Handle, - key: u64, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FramePoolError { - ZeroCapacity, - CapacityOverflow, -} - -impl std::fmt::Display for FramePoolError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::ZeroCapacity => write!(f, "frame pool capacity must be greater than zero"), - Self::CapacityOverflow => write!(f, "frame pool capacity exceeds usize bounds"), - } - } -} - -impl std::error::Error for FramePoolError {} - -struct PoolInner { - slots: Vec>, - free: Mutex>, - capacity: usize, - acquired_total: AtomicU64, - skipped_total: AtomicU64, - currently_in_flight: AtomicU64, -} - -pub struct FramePool { - inner: Arc, -} - -impl FramePool { - pub fn from_slots(slots: Vec>) -> Result { - let capacity = slots.len(); - assert!(capacity == slots.len()); - if capacity == 0 { - return Err(FramePoolError::ZeroCapacity); - } - assert!(capacity > 0); - - let mut free = Vec::with_capacity(capacity); - for index in 0..capacity { - free.push(index); - } - assert_eq!(free.len(), capacity); - - let inner = PoolInner { - slots, - free: Mutex::new(free), - capacity, - acquired_total: AtomicU64::new(0), - skipped_total: AtomicU64::new(0), - currently_in_flight: AtomicU64::new(0), - }; - Ok(Self { - inner: Arc::new(inner), - }) - } - - pub fn try_acquire(&self) -> Option { - assert!(self.inner.capacity > 0); - - let mut free = self.inner.free.lock(); - assert!(free.len() <= self.inner.capacity); - let index = match free.pop() { - Some(idx) => idx, - None => { - drop(free); - self.inner.skipped_total.fetch_add(1, Ordering::Relaxed); - return None; - } - }; - assert!(index < self.inner.capacity); - self.inner.acquired_total.fetch_add(1, Ordering::Relaxed); - let after = self - .inner - .currently_in_flight - .fetch_add(1, Ordering::AcqRel) - + 1; - assert!(after as usize <= self.inner.capacity); - drop(free); - - let frame = Arc::clone(&self.inner.slots[index]); - Some(PooledFrame { - frame, - slot_index: index, - pool: Arc::clone(&self.inner), - }) - } - - pub fn capacity(&self) -> usize { - assert!(self.inner.capacity > 0); - let cap = self.inner.capacity; - assert!(cap == self.inner.slots.len()); - cap - } - - pub fn acquired_total(&self) -> u64 { - assert!(self.inner.capacity > 0); - let total = self.inner.acquired_total.load(Ordering::Relaxed); - assert!(total >= self.inner.currently_in_flight.load(Ordering::Relaxed)); - total - } - - pub fn skipped_total(&self) -> u64 { - assert!(self.inner.capacity > 0); - let in_flight = self.inner.currently_in_flight.load(Ordering::Relaxed); - assert!(in_flight as usize <= self.inner.capacity); - self.inner.skipped_total.load(Ordering::Relaxed) - } - - pub fn currently_in_flight(&self) -> u64 { - let in_flight = self.inner.currently_in_flight.load(Ordering::Acquire); - assert!(in_flight as usize <= self.inner.capacity); - assert!(in_flight <= self.inner.acquired_total.load(Ordering::Relaxed)); - in_flight - } -} - -pub struct PooledFrame { - frame: Arc, - slot_index: usize, - pool: Arc, -} - -impl PooledFrame { - pub fn frame(&self) -> &Arc { - assert!(self.slot_index < self.pool.capacity); - assert!(Arc::strong_count(&self.frame) >= 2); - &self.frame - } - - pub fn slot_index(&self) -> usize { - assert!(self.slot_index < self.pool.capacity); - let idx = self.slot_index; - assert!(idx < self.pool.slots.len()); - idx - } -} - -impl Drop for PooledFrame { - fn drop(&mut self) { - assert!(self.slot_index < self.pool.capacity); - - let mut free = self.pool.free.lock(); - assert!(free.len() < self.pool.capacity); - let before = self.pool.currently_in_flight.load(Ordering::Acquire); - assert!(before >= 1); - assert!(before as usize <= self.pool.capacity); - free.push(self.slot_index); - let after = self.pool.currently_in_flight.fetch_sub(1, Ordering::AcqRel) - 1; - assert!(after as usize <= self.pool.capacity); - drop(free); - } -} - -pub struct CpuFrameBuilder; - -impl CpuFrameBuilder { - pub fn build_pool(bytes_per_slot: usize) -> Result { - assert!(bytes_per_slot > 0); - assert!(bytes_per_slot <= FRAME_BYTES_MAX); - if bytes_per_slot == 0 { - return Err(FramePoolError::ZeroCapacity); - } - - let mut slots: Vec> = Vec::with_capacity(MAX_CPU_FRAMES); - for _ in 0..MAX_CPU_FRAMES { - let buf: Box<[u8]> = vec![0u8; bytes_per_slot].into_boxed_slice(); - assert_eq!(buf.len(), bytes_per_slot); - slots.push(Arc::new(Frame::Cpu(buf))); - } - assert_eq!(slots.len(), MAX_CPU_FRAMES); - FramePool::from_slots(slots) - } - - pub fn build_pool_with_capacity( - capacity: usize, - bytes_per_slot: usize, - ) -> Result { - assert!(bytes_per_slot <= FRAME_BYTES_MAX); - if capacity == 0 { - return Err(FramePoolError::ZeroCapacity); - } - assert!(capacity > 0); - let mut slots: Vec> = Vec::with_capacity(capacity); - for _ in 0..capacity { - let buf: Box<[u8]> = vec![0u8; bytes_per_slot].into_boxed_slice(); - assert_eq!(buf.len(), bytes_per_slot); - slots.push(Arc::new(Frame::Cpu(buf))); - } - assert_eq!(slots.len(), capacity); - FramePool::from_slots(slots) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Barrier; - use std::sync::atomic::AtomicUsize; - use std::thread; - use std::time::{Duration, Instant}; - - const SMALL_SLOT_BYTES: usize = 64; - - fn small_pool(capacity: usize) -> FramePool { - CpuFrameBuilder::build_pool_with_capacity(capacity, SMALL_SLOT_BYTES) - .expect("non-zero capacity") - } - - #[test] - fn acquire_up_to_capacity_then_returns_none() { - let pool = small_pool(4); - let mut held = Vec::new(); - for expected in 0..4 { - let frame = pool.try_acquire().expect("slot must be available"); - assert_eq!(pool.currently_in_flight() as usize, expected + 1); - held.push(frame); - } - assert_eq!(pool.acquired_total(), 4); - assert!(pool.try_acquire().is_none()); - assert_eq!(pool.skipped_total(), 1); - assert_eq!(pool.currently_in_flight(), 4); - } - - #[test] - fn drop_releases_slot_back_to_pool() { - let pool = small_pool(2); - let first = pool.try_acquire().expect("first slot"); - let second = pool.try_acquire().expect("second slot"); - assert!(pool.try_acquire().is_none()); - drop(first); - let revived = pool.try_acquire().expect("released slot returns"); - assert_eq!(pool.currently_in_flight(), 2); - drop(second); - drop(revived); - assert_eq!(pool.currently_in_flight(), 0); - } - - #[test] - fn zero_capacity_pool_always_returns_none() { - match CpuFrameBuilder::build_pool_with_capacity(0, SMALL_SLOT_BYTES) { - Err(err) => assert_eq!(err, FramePoolError::ZeroCapacity), - Ok(_) => panic!("zero capacity must be rejected"), - } - match FramePool::from_slots(Vec::new()) { - Err(err) => assert_eq!(err, FramePoolError::ZeroCapacity), - Ok(_) => panic!("empty slot vec must be rejected"), - } - } - - #[test] - fn held_frame_has_strong_count_two_then_one() { - let pool = small_pool(1); - let held = pool.try_acquire().expect("slot must be available"); - assert_eq!(Arc::strong_count(held.frame()), 2); - drop(held); - let again = pool.try_acquire().expect("slot returned"); - assert_eq!(Arc::strong_count(again.frame()), 2); - drop(again); - } - - #[test] - fn multi_thread_acquire_release_stress_does_not_deadlock() { - const THREADS: usize = 10; - const OPS_PER_THREAD: usize = 1000; - const POOL_CAPACITY: usize = 4; - - let pool = Arc::new(small_pool(POOL_CAPACITY)); - let barrier = Arc::new(Barrier::new(THREADS)); - let acquired_observed = Arc::new(AtomicUsize::new(0)); - let skipped_observed = Arc::new(AtomicUsize::new(0)); - - let mut handles = Vec::with_capacity(THREADS); - for _ in 0..THREADS { - let pool = Arc::clone(&pool); - let barrier = Arc::clone(&barrier); - let acquired_observed = Arc::clone(&acquired_observed); - let skipped_observed = Arc::clone(&skipped_observed); - handles.push(thread::spawn(move || { - barrier.wait(); - let mut local_acquired: usize = 0; - let mut local_skipped: usize = 0; - for _ in 0..OPS_PER_THREAD { - match pool.try_acquire() { - Some(frame) => { - local_acquired += 1; - drop(frame); - } - None => { - local_skipped += 1; - } - } - } - acquired_observed.fetch_add(local_acquired, Ordering::Relaxed); - skipped_observed.fetch_add(local_skipped, Ordering::Relaxed); - })); - } - - let deadline = Instant::now() + Duration::from_secs(30); - for handle in handles { - assert!( - Instant::now() < deadline, - "stress test exceeded 30s budget — likely deadlock" - ); - handle.join().expect("worker panicked"); - } - - let total = pool.acquired_total() + pool.skipped_total(); - assert_eq!(total as usize, THREADS * OPS_PER_THREAD); - assert_eq!( - pool.acquired_total() as usize, - acquired_observed.load(Ordering::Relaxed) - ); - assert_eq!( - pool.skipped_total() as usize, - skipped_observed.load(Ordering::Relaxed) - ); - assert_eq!(pool.currently_in_flight(), 0); - } - - #[test] - fn skipped_counter_increments_only_on_empty_pool() { - let pool = small_pool(1); - let held = pool.try_acquire().expect("first slot"); - assert!(pool.try_acquire().is_none()); - assert!(pool.try_acquire().is_none()); - assert_eq!(pool.skipped_total(), 2); - assert_eq!(pool.acquired_total(), 1); - drop(held); - let _again = pool.try_acquire().expect("slot returned"); - assert_eq!(pool.acquired_total(), 2); - assert_eq!(pool.skipped_total(), 2); - } - - #[test] - fn cpu_frame_builder_default_capacity_allocates_max_slots() { - let pool = - CpuFrameBuilder::build_pool(SMALL_SLOT_BYTES).expect("default builder must succeed"); - assert_eq!(pool.capacity(), MAX_CPU_FRAMES); - let mut held = Vec::with_capacity(MAX_CPU_FRAMES); - for _ in 0..MAX_CPU_FRAMES { - held.push(pool.try_acquire().expect("slot in capacity")); - } - assert!(pool.try_acquire().is_none()); - for frame in &held { - match frame.frame().as_ref() { - Frame::Cpu(buf) => assert_eq!(buf.len(), SMALL_SLOT_BYTES), - #[allow(unreachable_patterns)] - _ => panic!("CpuFrameBuilder must emit Frame::Cpu variants"), - } - } - } -} diff --git a/fluxer_desktop/native/screen-frame-bus/src/gpu_loss.rs b/fluxer_desktop/native/screen-frame-bus/src/gpu_loss.rs deleted file mode 100644 index 2aefdd5a6..000000000 --- a/fluxer_desktop/native/screen-frame-bus/src/gpu_loss.rs +++ /dev/null @@ -1,207 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use crate::StagingBackend; -use fluxer_gpu_rebuild::{GpuLossCallback, GpuRebuildError}; - -pub const MIN_STAGING_BYTES: u64 = 1; -pub const MAX_STAGING_BYTES: u64 = 1 << 30; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct WgpuStagingConfig { - pub byte_len: u64, -} - -impl WgpuStagingConfig { - pub fn new(byte_len: u64) -> Self { - assert!(byte_len >= MIN_STAGING_BYTES, "byte_len must be positive"); - assert!(byte_len <= MAX_STAGING_BYTES, "byte_len exceeds sanity cap"); - Self { byte_len } - } -} - -struct WgpuStagingResources { - buffer: wgpu::Buffer, - cpu_mirror: Vec, - ready: bool, -} - -pub struct WgpuStagingBackend { - config: WgpuStagingConfig, - resources: Option, -} - -impl WgpuStagingBackend { - pub fn new(device: &wgpu::Device, config: WgpuStagingConfig) -> Self { - assert!(config.byte_len >= MIN_STAGING_BYTES, "config min invariant"); - assert!(config.byte_len <= MAX_STAGING_BYTES, "config max invariant"); - let resources = build_resources(device, config); - Self { - config, - resources: Some(resources), - } - } - - pub fn new_unbuilt(config: WgpuStagingConfig) -> Self { - assert!(config.byte_len >= MIN_STAGING_BYTES, "config min invariant"); - assert!(config.byte_len <= MAX_STAGING_BYTES, "config max invariant"); - Self { - config, - resources: None, - } - } - - pub fn config(&self) -> WgpuStagingConfig { - assert!( - self.config.byte_len >= MIN_STAGING_BYTES, - "config min invariant" - ); - assert!( - self.config.byte_len <= MAX_STAGING_BYTES, - "config max invariant" - ); - self.config - } - - pub fn is_built(&self) -> bool { - let built = self.resources.is_some(); - assert!( - self.config.byte_len >= MIN_STAGING_BYTES, - "config min while introspecting" - ); - assert!( - self.config.byte_len <= MAX_STAGING_BYTES, - "config max while introspecting" - ); - built - } - - pub fn buffer(&self) -> Option<&wgpu::Buffer> { - let buf = self.resources.as_ref().map(|r| &r.buffer); - assert_eq!( - buf.is_some(), - self.is_built(), - "buffer presence must align with built state", - ); - buf - } -} - -impl StagingBackend for WgpuStagingBackend { - fn write(&mut self, fill: F) { - let Some(resources) = self.resources.as_mut() else { - return; - }; - fill(&mut resources.cpu_mirror); - resources.ready = true; - } - - fn read R>(&self, read: F) -> R { - let empty: &[u8] = &[]; - match self.resources.as_ref() { - Some(r) => read(&r.cpu_mirror), - None => read(empty), - } - } - - fn is_ready(&self) -> bool { - match self.resources.as_ref() { - Some(r) => r.ready, - None => false, - } - } - - fn is_idle(&self) -> bool { - match self.resources.as_ref() { - Some(r) => !r.ready, - None => true, - } - } -} - -impl GpuLossCallback for WgpuStagingBackend { - fn release(&mut self) { - assert!( - self.config.byte_len >= MIN_STAGING_BYTES, - "release config min invariant" - ); - assert!( - self.config.byte_len <= MAX_STAGING_BYTES, - "release config max invariant" - ); - self.resources = None; - assert!(!self.is_built(), "release postcondition: must be unbuilt"); - } - - fn rebuild( - &mut self, - device: &wgpu::Device, - _queue: &wgpu::Queue, - ) -> Result<(), GpuRebuildError> { - assert!( - self.config.byte_len >= MIN_STAGING_BYTES, - "rebuild config min invariant" - ); - assert!( - self.config.byte_len <= MAX_STAGING_BYTES, - "rebuild config max invariant" - ); - if self.resources.is_some() { - return Err(GpuRebuildError::OwnerInvariantBroken { - reason: "rebuild without prior release", - }); - } - let resources = build_resources(device, self.config); - self.resources = Some(resources); - assert!(self.is_built(), "rebuild postcondition: must be built"); - Ok(()) - } - - fn is_ready(&self) -> bool { - self.is_built() - } - - fn debug_label(&self) -> &'static str { - "screen_frame_bus.wgpu_staging_backend" - } -} - -fn build_resources(device: &wgpu::Device, config: WgpuStagingConfig) -> WgpuStagingResources { - assert!( - config.byte_len >= MIN_STAGING_BYTES, - "build_resources min invariant" - ); - assert!( - config.byte_len <= MAX_STAGING_BYTES, - "build_resources max invariant" - ); - let buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("screen_frame_bus.wgpu_staging_buffer"), - size: config.byte_len, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let cpu_mirror = vec![0u8; config.byte_len as usize]; - assert_eq!( - cpu_mirror.len() as u64, - config.byte_len, - "cpu mirror must match configured byte len", - ); - WgpuStagingResources { - buffer, - cpu_mirror, - ready: false, - } -} - -#[cfg(test)] -mod tests { - use super::StagingBackend; - use crate::CpuStagingBackend; - - #[test] - fn cpu_backend_still_works_alongside_wgpu_backend() { - let cpu = CpuStagingBackend::new(64); - assert!(::is_idle(&cpu)); - assert!(::is_ready(&cpu)); - } -} diff --git a/fluxer_desktop/native/screen-frame-bus/src/lib.rs b/fluxer_desktop/native/screen-frame-bus/src/lib.rs deleted file mode 100644 index 0ebd0159a..000000000 --- a/fluxer_desktop/native/screen-frame-bus/src/lib.rs +++ /dev/null @@ -1,1229 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub mod frame_pool; -#[cfg(feature = "wgpu")] -pub mod gpu_loss; - -use parking_lot::RwLock; -use std::collections::HashMap; -use std::ffi::c_void; -use std::sync::Arc; -use std::sync::OnceLock; - -use crate::frame_pool::PooledFrame; - -#[cfg(target_os = "linux")] -use std::os::fd::{IntoRawFd, OwnedFd}; - -#[repr(C)] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct DmabufDesc { - pub plane_count: u8, - pub width: u32, - pub height: u32, - pub drm_format: u32, - pub modifier: u64, - pub strides: [u32; 4], - pub offsets: [u32; 4], - pub device_uuid: [u8; 16], - pub timestamp_us: i64, -} - -#[repr(C)] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SharedTextureDesc { - pub handle: u64, - pub width: u32, - pub height: u32, - pub dxgi_format: u32, - pub timestamp_us: i64, -} - -pub const FRAME_DATA_BYTES_CAP: usize = 1 << 30; - -pub trait SharedFrameBytes: Send + Sync { - fn bytes(&self) -> &[u8]; -} - -enum FrameDataRepr { - Owned(Vec), - Shared { - source: Arc, - len: usize, - _capacity_token: Option, - }, -} - -pub struct FrameData { - repr: FrameDataRepr, -} - -impl FrameData { - pub fn from_owned(data: Vec) -> Self { - assert!(data.len() <= FRAME_DATA_BYTES_CAP); - Self { - repr: FrameDataRepr::Owned(data), - } - } - - pub fn from_shared( - source: Arc, - len: usize, - capacity_token: Option, - ) -> Self { - assert!(len <= FRAME_DATA_BYTES_CAP); - assert!(len <= source.bytes().len()); - Self { - repr: FrameDataRepr::Shared { - source, - len, - _capacity_token: capacity_token, - }, - } - } - - pub fn as_slice(&self) -> &[u8] { - match &self.repr { - FrameDataRepr::Owned(data) => data.as_slice(), - FrameDataRepr::Shared { source, len, .. } => { - let bytes = source.bytes(); - assert!(*len <= bytes.len()); - &bytes[..*len] - } - } - } - - pub fn len(&self) -> usize { - let len = match &self.repr { - FrameDataRepr::Owned(data) => data.len(), - FrameDataRepr::Shared { len, .. } => *len, - }; - assert!(len <= FRAME_DATA_BYTES_CAP); - len - } - - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - pub fn is_shared(&self) -> bool { - matches!(self.repr, FrameDataRepr::Shared { .. }) - } - - pub fn into_vec(self) -> Vec { - match self.repr { - FrameDataRepr::Owned(data) => data, - FrameDataRepr::Shared { source, len, .. } => { - let bytes = source.bytes(); - assert!(len <= bytes.len()); - bytes[..len].to_vec() - } - } - } -} - -impl From> for FrameData { - fn from(data: Vec) -> Self { - Self::from_owned(data) - } -} - -impl std::ops::Deref for FrameData { - type Target = [u8]; - - fn deref(&self) -> &[u8] { - self.as_slice() - } -} - -pub struct Nv12Frame { - pub data: FrameData, - pub width: u32, - pub height: u32, - pub stride_y: u32, - pub stride_uv: u32, - pub timestamp_us: i64, -} - -pub struct BgraFrame { - pub data: Vec, - pub width: u32, - pub height: u32, - pub stride: u32, - pub timestamp_us: i64, -} - -#[cfg(target_os = "macos")] -pub struct MacCvPixelBufferFrame { - pixel_buffer: *mut c_void, - pub width: u32, - pub height: u32, - pub pixel_format: u32, - pub timestamp_us: i64, -} - -#[cfg(target_os = "macos")] -unsafe impl Send for MacCvPixelBufferFrame {} -#[cfg(target_os = "macos")] -unsafe impl Sync for MacCvPixelBufferFrame {} - -#[cfg(target_os = "macos")] -impl MacCvPixelBufferFrame { - pub unsafe fn from_retained( - pixel_buffer: *mut c_void, - width: u32, - height: u32, - pixel_format: u32, - timestamp_us: i64, - ) -> Self { - Self { - pixel_buffer, - width, - height, - pixel_format, - timestamp_us, - } - } - - pub fn pixel_buffer_ptr(&self) -> *mut c_void { - self.pixel_buffer - } - - pub fn into_raw_pixel_buffer(mut self) -> *mut c_void { - let pb = self.pixel_buffer; - self.pixel_buffer = std::ptr::null_mut(); - std::mem::forget(self); - pb - } -} - -#[cfg(target_os = "macos")] -impl Drop for MacCvPixelBufferFrame { - fn drop(&mut self) { - if !self.pixel_buffer.is_null() { - unsafe { CFRelease(self.pixel_buffer as *const c_void) }; - self.pixel_buffer = std::ptr::null_mut(); - } - } -} - -#[cfg(target_os = "macos")] -#[link(name = "CoreFoundation", kind = "framework")] -unsafe extern "C" { - fn CFRelease(cf: *const c_void); -} - -#[cfg(target_os = "linux")] -pub struct DmabufFrame { - pub desc: DmabufDesc, - pub fds: Vec, -} - -pub enum ScreenFrame { - Nv12(Nv12Frame), - Bgra(BgraFrame), - #[cfg(target_os = "macos")] - MacCvPixelBuffer(MacCvPixelBufferFrame), - #[cfg(target_os = "linux")] - Dmabuf(DmabufFrame), - #[cfg(target_os = "windows")] - SharedTexture(SharedTextureDesc), -} - -impl ScreenFrame { - pub fn timestamp_us(&self) -> i64 { - match self { - Self::Nv12(f) => f.timestamp_us, - Self::Bgra(f) => f.timestamp_us, - #[cfg(target_os = "macos")] - Self::MacCvPixelBuffer(f) => f.timestamp_us, - #[cfg(target_os = "linux")] - Self::Dmabuf(f) => f.desc.timestamp_us, - #[cfg(target_os = "windows")] - Self::SharedTexture(d) => d.timestamp_us, - } - } - - pub fn dimensions(&self) -> (u32, u32) { - match self { - Self::Nv12(f) => (f.width, f.height), - Self::Bgra(f) => (f.width, f.height), - #[cfg(target_os = "macos")] - Self::MacCvPixelBuffer(f) => (f.width, f.height), - #[cfg(target_os = "linux")] - Self::Dmabuf(f) => (f.desc.width, f.desc.height), - #[cfg(target_os = "windows")] - Self::SharedTexture(d) => (d.width, d.height), - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum EnqueueOutcome { - Accepted, - Coalesced, - Rejected, -} - -pub trait ScreenFrameSink: Send + Sync { - fn enqueue(&self, frame: ScreenFrame) -> EnqueueOutcome; -} - -pub const NATIVE_SCREEN_FRAME_SINK_HANDLE_MAGIC: u32 = u32::from_be_bytes(*b"FXSH"); -pub const NATIVE_SCREEN_FRAME_SINK_HANDLE_VERSION: u32 = 2; - -pub const NATIVE_SCREEN_FRAME_SINK_ACCEPTED: u32 = 1; -pub const NATIVE_SCREEN_FRAME_SINK_COALESCED: u32 = 2; -pub const NATIVE_SCREEN_FRAME_SINK_REJECTED: u32 = 3; - -pub type NativeScreenFrameSinkRetainFn = unsafe extern "C" fn(context: *const c_void); -pub type NativeScreenFrameSinkReleaseFn = unsafe extern "C" fn(context: *const c_void); -pub type NativeScreenFrameSinkEnqueueNv12Fn = unsafe extern "C" fn( - context: *const c_void, - data: *const u8, - data_len: usize, - width: u32, - height: u32, - stride_y: u32, - stride_uv: u32, - timestamp_us: i64, -) -> u32; -pub type NativeScreenFrameSinkEnqueueBgraFn = unsafe extern "C" fn( - context: *const c_void, - data: *const u8, - data_len: usize, - width: u32, - height: u32, - stride: u32, - timestamp_us: i64, -) -> u32; -pub type NativeScreenFrameSinkEnqueueMacCvPixelBufferFn = unsafe extern "C" fn( - context: *const c_void, - pixel_buffer: *mut c_void, - width: u32, - height: u32, - pixel_format: u32, - timestamp_us: i64, -) -> u32; -pub type NativeScreenFrameSinkEnqueueDmabufFn = unsafe extern "C" fn( - context: *const c_void, - desc: DmabufDesc, - fds: *const i32, - fd_count: usize, -) -> u32; -pub type NativeScreenFrameSinkEnqueueSharedTextureFn = - unsafe extern "C" fn(context: *const c_void, desc: SharedTextureDesc) -> u32; -pub type NativeScreenFrameSinkEnqueueScreenAudioFn = unsafe extern "C" fn( - context: *const c_void, - samples: *const f32, - num_frames: u32, - channels: u32, - sample_rate_hz: u32, - timestamp_us: i64, -) -> u32; - -#[repr(C)] -#[derive(Clone, Copy)] -pub struct NativeScreenFrameSinkHandle { - magic: u32, - version: u32, - context: *const c_void, - retain: NativeScreenFrameSinkRetainFn, - release: NativeScreenFrameSinkReleaseFn, - enqueue_nv12: Option, - enqueue_bgra: Option, - enqueue_mac_cv_pixel_buffer: Option, - enqueue_dmabuf: Option, - enqueue_shared_texture: Option, - enqueue_screen_audio: Option, -} - -unsafe impl Send for NativeScreenFrameSinkHandle {} -unsafe impl Sync for NativeScreenFrameSinkHandle {} - -impl NativeScreenFrameSinkHandle { - fn is_valid(&self) -> bool { - self.magic == NATIVE_SCREEN_FRAME_SINK_HANDLE_MAGIC - && self.version == NATIVE_SCREEN_FRAME_SINK_HANDLE_VERSION - && !self.context.is_null() - } - - pub fn retain_ref(&self) -> Option { - if !self.is_valid() { - return None; - } - unsafe { (self.retain)(self.context) }; - Some(NativeScreenFrameSinkHandleRef { handle: *self }) - } - - pub fn native_outcome(outcome: EnqueueOutcome) -> u32 { - match outcome { - EnqueueOutcome::Accepted => NATIVE_SCREEN_FRAME_SINK_ACCEPTED, - EnqueueOutcome::Coalesced => NATIVE_SCREEN_FRAME_SINK_COALESCED, - EnqueueOutcome::Rejected => NATIVE_SCREEN_FRAME_SINK_REJECTED, - } - } - - pub fn outcome_from_native(value: u32) -> EnqueueOutcome { - match value { - NATIVE_SCREEN_FRAME_SINK_ACCEPTED => EnqueueOutcome::Accepted, - NATIVE_SCREEN_FRAME_SINK_COALESCED => EnqueueOutcome::Coalesced, - _ => EnqueueOutcome::Rejected, - } - } -} - -pub struct NativeScreenFrameSinkHandleRef { - handle: NativeScreenFrameSinkHandle, -} - -unsafe impl Send for NativeScreenFrameSinkHandleRef {} -unsafe impl Sync for NativeScreenFrameSinkHandleRef {} - -impl NativeScreenFrameSinkHandleRef { - pub fn enqueue_nv12_copy( - &self, - data: &[u8], - width: u32, - height: u32, - stride_y: u32, - stride_uv: u32, - timestamp_us: i64, - ) -> EnqueueOutcome { - let Some(enqueue) = self.handle.enqueue_nv12 else { - return EnqueueOutcome::Rejected; - }; - NativeScreenFrameSinkHandle::outcome_from_native(unsafe { - enqueue( - self.handle.context, - data.as_ptr(), - data.len(), - width, - height, - stride_y, - stride_uv, - timestamp_us, - ) - }) - } - - pub fn enqueue_bgra_copy( - &self, - data: &[u8], - width: u32, - height: u32, - stride: u32, - timestamp_us: i64, - ) -> EnqueueOutcome { - let Some(enqueue) = self.handle.enqueue_bgra else { - return EnqueueOutcome::Rejected; - }; - NativeScreenFrameSinkHandle::outcome_from_native(unsafe { - enqueue( - self.handle.context, - data.as_ptr(), - data.len(), - width, - height, - stride, - timestamp_us, - ) - }) - } - - #[cfg(target_os = "macos")] - pub fn supports_mac_cv_pixel_buffer(&self) -> bool { - self.handle.enqueue_mac_cv_pixel_buffer.is_some() - } - - #[cfg(target_os = "macos")] - pub unsafe fn enqueue_mac_cv_pixel_buffer( - &self, - pixel_buffer: *mut c_void, - width: u32, - height: u32, - pixel_format: u32, - timestamp_us: i64, - ) -> EnqueueOutcome { - let Some(enqueue) = self.handle.enqueue_mac_cv_pixel_buffer else { - return EnqueueOutcome::Rejected; - }; - NativeScreenFrameSinkHandle::outcome_from_native(unsafe { - enqueue( - self.handle.context, - pixel_buffer, - width, - height, - pixel_format, - timestamp_us, - ) - }) - } - - #[cfg(target_os = "linux")] - pub fn supports_dmabuf(&self) -> bool { - self.handle.enqueue_dmabuf.is_some() - } - - #[cfg(target_os = "linux")] - pub fn enqueue_dmabuf_take_fds(&self, desc: DmabufDesc, fds: Vec) -> EnqueueOutcome { - let Some(enqueue) = self.handle.enqueue_dmabuf else { - return EnqueueOutcome::Rejected; - }; - let fd_count = fds.len(); - if fd_count == 0 || fd_count > 4 || usize::from(desc.plane_count) != fd_count { - return EnqueueOutcome::Rejected; - } - let mut raw_fds = [-1; 4]; - for (raw_fd, fd) in raw_fds.iter_mut().zip(fds) { - *raw_fd = fd.into_raw_fd(); - } - NativeScreenFrameSinkHandle::outcome_from_native(unsafe { - enqueue(self.handle.context, desc, raw_fds.as_ptr(), fd_count) - }) - } - - #[cfg(target_os = "windows")] - pub fn enqueue_shared_texture(&self, desc: SharedTextureDesc) -> EnqueueOutcome { - let Some(enqueue) = self.handle.enqueue_shared_texture else { - return EnqueueOutcome::Rejected; - }; - NativeScreenFrameSinkHandle::outcome_from_native(unsafe { - enqueue(self.handle.context, desc) - }) - } - - pub fn supports_screen_audio(&self) -> bool { - self.handle.enqueue_screen_audio.is_some() - } - - pub fn enqueue_screen_audio_f32( - &self, - samples: &[f32], - num_frames: u32, - channels: u32, - sample_rate_hz: u32, - timestamp_us: i64, - ) -> EnqueueOutcome { - let Some(enqueue) = self.handle.enqueue_screen_audio else { - return EnqueueOutcome::Rejected; - }; - if channels == 0 || num_frames == 0 { - return EnqueueOutcome::Rejected; - } - let expected_samples = (num_frames as usize).checked_mul(channels as usize); - if expected_samples != Some(samples.len()) { - return EnqueueOutcome::Rejected; - } - NativeScreenFrameSinkHandle::outcome_from_native(unsafe { - enqueue( - self.handle.context, - samples.as_ptr(), - num_frames, - channels, - sample_rate_hz, - timestamp_us, - ) - }) - } -} - -impl Drop for NativeScreenFrameSinkHandleRef { - fn drop(&mut self) { - if self.handle.is_valid() { - unsafe { (self.handle.release)(self.handle.context) }; - } - } -} - -pub const STAGING_PAIR_LEN: usize = 2; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum StagingSlotState { - Empty, - Submitted, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum StagingError { - WouldOverwrite, -} - -pub trait StagingBackend { - fn write(&mut self, fill: F); - fn read R>(&self, read: F) -> R; - fn is_ready(&self) -> bool; - fn is_idle(&self) -> bool; -} - -pub struct CpuStagingBackend { - buffer: Vec, -} - -impl CpuStagingBackend { - pub fn new(byte_len: usize) -> Self { - assert!(byte_len > 0, "staging backend length must be positive"); - assert!( - byte_len <= 1 << 30, - "staging backend length exceeds sanity cap" - ); - Self { - buffer: vec![0u8; byte_len], - } - } - - pub fn len(&self) -> usize { - self.buffer.len() - } - - pub fn is_empty(&self) -> bool { - self.buffer.is_empty() - } -} - -impl StagingBackend for CpuStagingBackend { - fn write(&mut self, fill: F) { - fill(&mut self.buffer); - } - - fn read R>(&self, read: F) -> R { - read(&self.buffer) - } - - fn is_ready(&self) -> bool { - true - } - - fn is_idle(&self) -> bool { - true - } -} - -pub struct StagingSurfacePair { - backends: [B; STAGING_PAIR_LEN], - slot_states: [StagingSlotState; STAGING_PAIR_LEN], - next_submit: u64, - next_map: u64, - skipped_count: u64, - overwrite_dropped_count: u64, -} - -impl StagingSurfacePair { - pub fn new(backends: [B; STAGING_PAIR_LEN]) -> Self { - assert_eq!( - STAGING_PAIR_LEN, 2, - "OBS staging pair is exactly two surfaces" - ); - assert!( - backends[0].is_idle(), - "backend zero must be idle at construction" - ); - assert!( - backends[1].is_idle(), - "backend one must be idle at construction" - ); - Self { - backends, - slot_states: [StagingSlotState::Empty; STAGING_PAIR_LEN], - next_submit: 0, - next_map: 0, - skipped_count: 0, - overwrite_dropped_count: 0, - } - } - - pub fn next_submit_sequence(&self) -> u64 { - self.next_submit - } - - pub fn next_map_sequence(&self) -> u64 { - self.next_map - } - - pub fn skipped_count(&self) -> u64 { - self.skipped_count - } - - pub fn overwrite_dropped_count(&self) -> u64 { - self.overwrite_dropped_count - } - - pub fn submit( - &mut self, - sequence: u64, - fill: F, - ) -> Result<(), StagingError> { - assert_eq!(sequence, self.next_submit, "submit must use next sequence"); - assert!( - self.next_map <= self.next_submit, - "map sequence must trail submit" - ); - let slot = (sequence & 1) as usize; - assert!(slot < STAGING_PAIR_LEN, "slot index within pair bounds"); - if self.slot_states[slot] != StagingSlotState::Empty { - self.overwrite_dropped_count = self.overwrite_dropped_count.wrapping_add(1); - return Err(StagingError::WouldOverwrite); - } - assert!( - self.backends[slot].is_idle(), - "backend reports busy on empty slot" - ); - self.backends[slot].write(fill); - self.slot_states[slot] = StagingSlotState::Submitted; - self.next_submit = self.next_submit.wrapping_add(1); - Ok(()) - } - - pub fn try_map R>(&mut self, sequence: u64, read: F) -> Option { - assert!( - self.next_map < self.next_submit, - "cannot map an unsubmitted sequence" - ); - assert_eq!( - sequence, self.next_map, - "try_map must use next pending sequence" - ); - let slot = (sequence & 1) as usize; - assert!(slot < STAGING_PAIR_LEN, "slot index within pair bounds"); - assert_eq!( - self.slot_states[slot], - StagingSlotState::Submitted, - "double map of unsubmitted slot" - ); - if !self.backends[slot].is_ready() { - self.skipped_count = self.skipped_count.wrapping_add(1); - self.slot_states[slot] = StagingSlotState::Empty; - self.next_map = self.next_map.wrapping_add(1); - return None; - } - let value = self.backends[slot].read(read); - self.slot_states[slot] = StagingSlotState::Empty; - self.next_map = self.next_map.wrapping_add(1); - Some(value) - } - - pub fn backend(&self, slot: usize) -> &B { - assert!(slot < STAGING_PAIR_LEN, "slot index within pair bounds"); - assert!( - slot < self.backends.len(), - "slot index within backend storage" - ); - &self.backends[slot] - } -} - -type SinkMap = HashMap>; -static REGISTRY: OnceLock> = OnceLock::new(); - -fn registry() -> &'static RwLock { - REGISTRY.get_or_init(|| RwLock::new(HashMap::new())) -} - -pub fn register_sink(capture_id: impl Into, sink: Arc) { - registry().write().insert(capture_id.into(), sink); -} - -pub fn unregister_sink(capture_id: &str) -> Option> { - registry().write().remove(capture_id) -} - -pub fn get_sink(capture_id: &str) -> Option> { - registry().read().get(capture_id).cloned() -} - -pub fn clear_all_sinks() { - registry().write().clear(); -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicU32, Ordering}; - - struct CountingSink { - accepted: AtomicU32, - } - - impl ScreenFrameSink for CountingSink { - fn enqueue(&self, _frame: ScreenFrame) -> EnqueueOutcome { - self.accepted.fetch_add(1, Ordering::Relaxed); - EnqueueOutcome::Accepted - } - } - - fn fresh_id(label: &str) -> String { - format!("test:{}:{}", label, std::process::id()) - } - - fn solid_nv12_frame(width: u32, height: u32) -> ScreenFrame { - let y = (width * height) as usize; - let uv = (width * (height / 2)) as usize; - let mut data = vec![0u8; y + uv]; - data[..y].fill(16); - data[y..].fill(128); - ScreenFrame::Nv12(Nv12Frame { - data: data.into(), - width, - height, - stride_y: width, - stride_uv: width, - timestamp_us: 0, - }) - } - - #[test] - fn sink_registration_round_trips_through_registry() { - let id = fresh_id("round-trip"); - let sink = Arc::new(CountingSink { - accepted: AtomicU32::new(0), - }); - register_sink(id.clone(), sink.clone()); - - let resolved = get_sink(&id).expect("sink must be resolvable"); - assert_eq!( - resolved.enqueue(solid_nv12_frame(64, 64)), - EnqueueOutcome::Accepted, - ); - assert_eq!(sink.accepted.load(Ordering::Relaxed), 1); - - let removed = unregister_sink(&id).expect("sink must be removable"); - assert_eq!( - removed.enqueue(solid_nv12_frame(64, 64)), - EnqueueOutcome::Accepted, - ); - assert!(get_sink(&id).is_none()); - assert!(unregister_sink(&id).is_none()); - } - - #[test] - fn screen_frame_exposes_timestamp_and_dimensions() { - let frame = solid_nv12_frame(640, 480); - assert_eq!(frame.dimensions(), (640, 480)); - assert_eq!(frame.timestamp_us(), 0); - } - - struct RejectingSink; - impl ScreenFrameSink for RejectingSink { - fn enqueue(&self, _frame: ScreenFrame) -> EnqueueOutcome { - EnqueueOutcome::Rejected - } - } - - #[test] - fn concurrent_producers_all_deliver_to_a_single_sink() { - let id = fresh_id("concurrent-producers"); - let sink = Arc::new(CountingSink { - accepted: AtomicU32::new(0), - }); - register_sink(id.clone(), sink.clone()); - - let producers = 8; - let frames_per_producer = 64; - let handles: Vec<_> = (0..producers) - .map(|p| { - let id = id.clone(); - std::thread::spawn(move || { - for f in 0..frames_per_producer { - let resolved = get_sink(&id).expect("sink missing during producer race"); - assert_eq!( - resolved.enqueue(solid_nv12_frame(32, 32)), - EnqueueOutcome::Accepted, - "producer {} frame {}", - p, - f - ); - } - }) - }) - .collect(); - for h in handles { - h.join().expect("producer panicked"); - } - unregister_sink(&id); - assert_eq!( - sink.accepted.load(Ordering::Relaxed), - producers * frames_per_producer - ); - } - - #[test] - fn sinks_for_distinct_ids_do_not_cross_route() { - let id_a = fresh_id("isolation-a"); - let id_b = fresh_id("isolation-b"); - let sink_a = Arc::new(CountingSink { - accepted: AtomicU32::new(0), - }); - let sink_b: Arc = Arc::new(RejectingSink); - register_sink(id_a.clone(), sink_a.clone()); - register_sink(id_b.clone(), sink_b.clone()); - - assert_eq!( - get_sink(&id_a).unwrap().enqueue(solid_nv12_frame(8, 8)), - EnqueueOutcome::Accepted - ); - assert_eq!( - get_sink(&id_b).unwrap().enqueue(solid_nv12_frame(8, 8)), - EnqueueOutcome::Rejected - ); - assert_eq!(sink_a.accepted.load(Ordering::Relaxed), 1); - - unregister_sink(&id_a); - unregister_sink(&id_b); - } - - #[test] - fn unregister_returns_the_active_arc_and_inflight_holders_keep_working() { - let id = fresh_id("unregister-race"); - let sink = Arc::new(CountingSink { - accepted: AtomicU32::new(0), - }); - register_sink(id.clone(), sink.clone()); - - let inflight = get_sink(&id).expect("sink available"); - let removed = unregister_sink(&id).expect("unregister returns sink"); - assert!(Arc::ptr_eq(&inflight, &removed)); - assert!(get_sink(&id).is_none()); - - assert_eq!( - inflight.enqueue(solid_nv12_frame(8, 8)), - EnqueueOutcome::Accepted - ); - assert_eq!(sink.accepted.load(Ordering::Relaxed), 1); - } - - #[test] - fn bgra_frame_exposes_timestamp_and_dimensions() { - let frame = ScreenFrame::Bgra(BgraFrame { - data: vec![0; 4 * 16 * 16], - width: 16, - height: 16, - stride: 64, - timestamp_us: 9_999, - }); - assert_eq!(frame.dimensions(), (16, 16)); - assert_eq!(frame.timestamp_us(), 9_999); - } - - struct StaticSharedBytes { - bytes: Box<[u8]>, - } - - impl SharedFrameBytes for StaticSharedBytes { - fn bytes(&self) -> &[u8] { - &self.bytes - } - } - - #[test] - fn frame_data_owned_round_trips_without_sharing() { - let data = FrameData::from(vec![1u8, 2, 3]); - assert!(!data.is_shared()); - assert!(!data.is_empty()); - assert_eq!(data.len(), 3); - assert_eq!(data.as_slice(), &[1, 2, 3]); - assert_eq!(data.into_vec(), vec![1, 2, 3]); - } - - #[test] - fn frame_data_shared_round_trips_prefix_of_source_bytes() { - let source = Arc::new(StaticSharedBytes { - bytes: vec![7u8; 16].into_boxed_slice(), - }); - let data = FrameData::from_shared(source.clone(), 12, None); - assert!(data.is_shared()); - assert_eq!(data.len(), 12); - assert_eq!(data.as_slice(), &[7u8; 12]); - assert_eq!(Arc::strong_count(&source), 2); - assert_eq!(data.into_vec(), vec![7u8; 12]); - assert_eq!(Arc::strong_count(&source), 1); - } - - #[test] - #[should_panic] - fn frame_data_shared_rejects_len_beyond_source_bytes() { - let source = Arc::new(StaticSharedBytes { - bytes: vec![0u8; 4].into_boxed_slice(), - }); - let _ = FrameData::from_shared(source, 5, None); - } - - #[test] - fn frame_data_shared_capacity_token_returns_slot_on_drop_with_counters_intact() { - let pool = frame_pool::CpuFrameBuilder::build_pool_with_capacity(2, 4).expect("pool init"); - let source = Arc::new(StaticSharedBytes { - bytes: vec![9u8; 8].into_boxed_slice(), - }); - let token = pool.try_acquire().expect("first slot"); - let data = FrameData::from_shared(source, 8, Some(token)); - assert_eq!(pool.currently_in_flight(), 1); - - let second = pool.try_acquire().expect("second slot"); - assert!(pool.try_acquire().is_none()); - assert_eq!(pool.skipped_total(), 1); - assert_eq!(pool.acquired_total(), 2); - - drop(data); - assert_eq!(pool.currently_in_flight(), 1); - let third = pool.try_acquire().expect("slot returned by frame drop"); - assert_eq!(pool.acquired_total(), 3); - assert_eq!(pool.skipped_total(), 1); - - drop(second); - drop(third); - assert_eq!(pool.currently_in_flight(), 0); - } - - #[test] - fn nv12_frame_with_shared_payload_exposes_timestamp_and_dimensions() { - let source = Arc::new(StaticSharedBytes { - bytes: vec![0u8; 64 * 96].into_boxed_slice(), - }); - let frame = ScreenFrame::Nv12(Nv12Frame { - data: FrameData::from_shared(source, 64 * 96, None), - width: 64, - height: 64, - stride_y: 64, - stride_uv: 64, - timestamp_us: 77, - }); - assert_eq!(frame.dimensions(), (64, 64)); - assert_eq!(frame.timestamp_us(), 77); - } - - #[cfg(target_os = "macos")] - #[test] - fn mac_cv_pixel_buffer_with_null_pointer_drops_without_calling_release() { - let frame = - unsafe { MacCvPixelBufferFrame::from_retained(std::ptr::null_mut(), 8, 8, 0, 0) }; - assert_eq!(frame.pixel_buffer_ptr(), std::ptr::null_mut()); - drop(frame); - } - - const STAGING_TEST_LEN: usize = 64; - - fn fresh_staging_pair() -> StagingSurfacePair { - let backends = [ - CpuStagingBackend::new(STAGING_TEST_LEN), - CpuStagingBackend::new(STAGING_TEST_LEN), - ]; - assert_eq!(backends[0].len(), STAGING_TEST_LEN); - assert_eq!(backends[1].len(), STAGING_TEST_LEN); - StagingSurfacePair::new(backends) - } - - #[test] - fn submit_then_map_returns_the_data_from_two_frames_ago() { - let mut pair = fresh_staging_pair(); - assert!(pair.submit(0, |buf| buf.fill(0xA1)).is_ok()); - assert!(pair.submit(1, |buf| buf.fill(0xB2)).is_ok()); - - let first = pair - .try_map(0, |buf| buf[0]) - .expect("first map ready under CPU backend"); - assert_eq!(first, 0xA1); - - assert!(pair.submit(2, |buf| buf.fill(0xC3)).is_ok()); - let second = pair - .try_map(1, |buf| buf[0]) - .expect("second map ready under CPU backend"); - assert_eq!(second, 0xB2); - - let third = pair - .try_map(2, |buf| buf[0]) - .expect("third map ready under CPU backend"); - assert_eq!(third, 0xC3); - } - - #[test] - fn two_submits_in_a_row_preserve_distinct_slots() { - let mut pair = fresh_staging_pair(); - assert!(pair.submit(0, |buf| buf.fill(0x11)).is_ok()); - assert!(pair.submit(1, |buf| buf.fill(0x22)).is_ok()); - assert_eq!(pair.next_submit_sequence(), 2); - - let first = pair - .try_map(0, |buf| buf[0]) - .expect("slot zero still holds first submission"); - assert_eq!(first, 0x11); - let second = pair - .try_map(1, |buf| buf[0]) - .expect("slot one still holds second submission"); - assert_eq!(second, 0x22); - } - - #[test] - fn third_unmapped_submit_returns_would_overwrite_and_drops_newest() { - let mut pair = fresh_staging_pair(); - assert!(pair.submit(0, |buf| buf.fill(0x33)).is_ok()); - assert!(pair.submit(1, |buf| buf.fill(0x44)).is_ok()); - assert_eq!(pair.overwrite_dropped_count(), 0); - - let outcome = pair.submit(2, |buf| buf.fill(0xFF)); - assert_eq!(outcome, Err(StagingError::WouldOverwrite)); - assert_eq!(pair.overwrite_dropped_count(), 1); - - let preserved = pair - .try_map(0, |buf| buf[0]) - .expect("slot zero kept its original submission"); - assert_eq!(preserved, 0x33); - } - - struct GatedBackend { - buffer: Vec, - ready: bool, - } - - impl GatedBackend { - fn new(len: usize) -> Self { - assert!(len > 0, "gated backend len positive"); - assert!(len <= 1 << 16, "gated backend len within sanity cap"); - Self { - buffer: vec![0u8; len], - ready: true, - } - } - } - - impl StagingBackend for GatedBackend { - fn write(&mut self, fill: F) { - fill(&mut self.buffer); - } - - fn read R>(&self, read: F) -> R { - read(&self.buffer) - } - - fn is_ready(&self) -> bool { - self.ready - } - - fn is_idle(&self) -> bool { - true - } - } - - #[test] - fn try_map_returning_none_advances_and_bumps_skipped_counter() { - let mut a = GatedBackend::new(STAGING_TEST_LEN); - let mut b = GatedBackend::new(STAGING_TEST_LEN); - a.ready = false; - b.ready = true; - let mut pair = StagingSurfacePair::new([a, b]); - - assert!(pair.submit(0, |buf| buf.fill(0x55)).is_ok()); - assert!(pair.submit(1, |buf| buf.fill(0x66)).is_ok()); - - let skipped = pair.try_map(0, |buf| buf[0]); - assert!(skipped.is_none()); - assert_eq!(pair.skipped_count(), 1); - assert_eq!(pair.next_map_sequence(), 1); - - let mapped = pair.try_map(1, |buf| buf[0]).expect("slot one ready"); - assert_eq!(mapped, 0x66); - assert_eq!(pair.next_map_sequence(), 2); - - assert!(pair.submit(2, |buf| buf.fill(0x77)).is_ok()); - assert!(pair.submit(3, |buf| buf.fill(0x88)).is_ok()); - let after_skip = pair.try_map(2, |buf| buf[0]); - assert!(after_skip.is_none()); - assert_eq!(pair.skipped_count(), 2); - } - - #[test] - #[should_panic(expected = "submit must use next sequence")] - fn submit_with_out_of_order_sequence_panics() { - let mut pair = fresh_staging_pair(); - let _ = pair.submit(7, |buf| buf.fill(0)); - } - - #[test] - #[should_panic(expected = "try_map must use next pending sequence")] - fn try_map_with_wrong_sequence_panics() { - let mut pair = fresh_staging_pair(); - assert!(pair.submit(0, |buf| buf.fill(0)).is_ok()); - let _ = pair.try_map(5, |buf| buf[0]); - } - - #[test] - #[should_panic(expected = "cannot map an unsubmitted sequence")] - fn try_map_without_any_submit_panics() { - let mut pair = fresh_staging_pair(); - let _ = pair.try_map(0, |buf| buf[0]); - } - - #[test] - #[should_panic(expected = "try_map must use next pending sequence")] - fn try_map_twice_on_the_same_sequence_panics() { - let mut pair = fresh_staging_pair(); - assert!(pair.submit(0, |buf| buf.fill(0xAB)).is_ok()); - assert!(pair.submit(1, |buf| buf.fill(0xCD)).is_ok()); - let first = pair.try_map(0, |buf| buf[0]).expect("first map"); - assert_eq!(first, 0xAB); - let _ = pair.try_map(0, |buf| buf[0]); - } - - static SCREEN_AUDIO_ENQUEUE_CALLS: AtomicU32 = AtomicU32::new(0); - - unsafe extern "C" fn screen_audio_test_retain(_context: *const c_void) {} - unsafe extern "C" fn screen_audio_test_release(_context: *const c_void) {} - unsafe extern "C" fn screen_audio_test_enqueue( - _context: *const c_void, - _samples: *const f32, - _num_frames: u32, - _channels: u32, - _sample_rate_hz: u32, - _timestamp_us: i64, - ) -> u32 { - SCREEN_AUDIO_ENQUEUE_CALLS.fetch_add(1, Ordering::Relaxed); - NATIVE_SCREEN_FRAME_SINK_ACCEPTED - } - - fn screen_audio_test_handle_ref() -> NativeScreenFrameSinkHandleRef { - let handle = NativeScreenFrameSinkHandle { - magic: NATIVE_SCREEN_FRAME_SINK_HANDLE_MAGIC, - version: NATIVE_SCREEN_FRAME_SINK_HANDLE_VERSION, - context: (&SCREEN_AUDIO_ENQUEUE_CALLS as *const AtomicU32).cast::(), - retain: screen_audio_test_retain, - release: screen_audio_test_release, - enqueue_nv12: None, - enqueue_bgra: None, - enqueue_mac_cv_pixel_buffer: None, - enqueue_dmabuf: None, - enqueue_shared_texture: None, - enqueue_screen_audio: Some(screen_audio_test_enqueue), - }; - NativeScreenFrameSinkHandleRef { handle } - } - - #[test] - fn enqueue_screen_audio_rejects_malformed_inputs_without_panicking() { - SCREEN_AUDIO_ENQUEUE_CALLS.store(0, Ordering::Relaxed); - let sink = screen_audio_test_handle_ref(); - - assert_eq!( - sink.enqueue_screen_audio_f32(&[0.0, 0.0], 1, 0, 48_000, 0), - EnqueueOutcome::Rejected - ); - assert_eq!( - sink.enqueue_screen_audio_f32(&[], 0, 2, 48_000, 0), - EnqueueOutcome::Rejected - ); - assert_eq!( - sink.enqueue_screen_audio_f32(&[0.0, 0.0, 0.0], 1, 2, 48_000, 0), - EnqueueOutcome::Rejected - ); - assert_eq!(SCREEN_AUDIO_ENQUEUE_CALLS.load(Ordering::Relaxed), 0); - } - - #[test] - fn enqueue_screen_audio_accepts_whole_stereo_frames() { - SCREEN_AUDIO_ENQUEUE_CALLS.store(0, Ordering::Relaxed); - let sink = screen_audio_test_handle_ref(); - - assert_eq!( - sink.enqueue_screen_audio_f32(&[0.1, 0.2, 0.3, 0.4], 2, 2, 48_000, 7), - EnqueueOutcome::Accepted - ); - assert_eq!(SCREEN_AUDIO_ENQUEUE_CALLS.load(Ordering::Relaxed), 1); - } -} diff --git a/fluxer_desktop/native/system-hunspell/Cargo.lock b/fluxer_desktop/native/system-hunspell/Cargo.lock index 63f3b8b9f..c6c3624bd 100644 --- a/fluxer_desktop/native/system-hunspell/Cargo.lock +++ b/fluxer_desktop/native/system-hunspell/Cargo.lock @@ -45,15 +45,6 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - [[package]] name = "cc" version = "1.4.6" @@ -90,12 +81,6 @@ dependencies = [ "libloading 0.8.9", ] -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "convert_case" version = "0.12.0" @@ -105,41 +90,12 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "cpufeatures" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - [[package]] name = "ctor" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", -] - [[package]] name = "either" version = "1.18.0" @@ -168,18 +124,10 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" -[[package]] -name = "fluxer_desktop_native" -version = "0.1.0" -dependencies = [ - "sha2", -] - [[package]] name = "fluxer_system_hunspell" version = "0.0.0" dependencies = [ - "fluxer_desktop_native", "hunspell-sys", "napi", "napi-build", @@ -312,15 +260,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "hybrid-array" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" -dependencies = [ - "typenum", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -584,17 +523,6 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "shlex" version = "1.3.0" @@ -659,12 +587,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - [[package]] name = "unicode-ident" version = "1.0.26" diff --git a/fluxer_desktop/native/system-hunspell/Cargo.toml b/fluxer_desktop/native/system-hunspell/Cargo.toml index e2aa2b53e..07362a98f 100644 --- a/fluxer_desktop/native/system-hunspell/Cargo.toml +++ b/fluxer_desktop/native/system-hunspell/Cargo.toml @@ -12,7 +12,6 @@ resolver = "2" crate-type = ["cdylib", "rlib"] [dependencies] -fluxer_desktop_native = {path = "../rust"} hunspell-sys = {version = "0.3.1", features = ["bundled"]} napi = {version = "3.12.6", default-features = false, features = ["dyn-symbols", "napi8"]} napi-derive = "3.6.7" diff --git a/fluxer_desktop/native/system-hunspell/index.d.ts b/fluxer_desktop/native/system-hunspell/index.d.ts index 7a2e6671d..a42722aa1 100644 --- a/fluxer_desktop/native/system-hunspell/index.d.ts +++ b/fluxer_desktop/native/system-hunspell/index.d.ts @@ -22,6 +22,4 @@ export declare class Hunspell { export declare function discoverSystemDictionaries(): Array; -export declare function hashFile(path: string): Promise; - export declare const loadError: Error | null; diff --git a/fluxer_desktop/native/system-hunspell/index.js b/fluxer_desktop/native/system-hunspell/index.js index 1c8fd06b8..c71297174 100644 --- a/fluxer_desktop/native/system-hunspell/index.js +++ b/fluxer_desktop/native/system-hunspell/index.js @@ -60,6 +60,5 @@ if (process.platform === 'linux') { module.exports = { Hunspell: binding ? binding.Hunspell : null, discoverSystemDictionaries: binding ? binding.discoverSystemDictionaries : null, - hashFile: binding ? binding.hashFile : null, loadError, }; diff --git a/fluxer_desktop/native/rust/src/system_hunspell/dictionaries.rs b/fluxer_desktop/native/system-hunspell/src/dictionaries.rs similarity index 53% rename from fluxer_desktop/native/rust/src/system_hunspell/dictionaries.rs rename to fluxer_desktop/native/system-hunspell/src/dictionaries.rs index a769797c1..fcbd5a26f 100644 --- a/fluxer_desktop/native/rust/src/system_hunspell/dictionaries.rs +++ b/fluxer_desktop/native/system-hunspell/src/dictionaries.rs @@ -96,71 +96,3 @@ fn scan_dir(dir: &Path, seen: &mut HashSet, out: &mut Vec bool { + let bytes = encoding.as_bytes(); + if bytes.is_empty() || bytes.len() > 16 { + return false; + } + bytes.eq_ignore_ascii_case(b"utf-8") || bytes.eq_ignore_ascii_case(b"utf8") +} diff --git a/fluxer_desktop/native/system-hunspell/src/lib.rs b/fluxer_desktop/native/system-hunspell/src/lib.rs index f557d50d1..6cb34cfc2 100644 --- a/fluxer_desktop/native/system-hunspell/src/lib.rs +++ b/fluxer_desktop/native/system-hunspell/src/lib.rs @@ -1,16 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +mod dictionaries; +mod encoding; + use std::ffi::{CStr, CString}; use std::fs::File; use std::path::Path; use std::ptr; use std::sync::{Mutex, MutexGuard}; -use fluxer_desktop_native::system_hunspell::dictionaries::{EnvSnapshot, discover_dictionaries}; -use fluxer_desktop_native::system_hunspell::encoding::is_utf8_encoding; -use fluxer_desktop_native::system_hunspell::hashing::hash_file_to_hex; -use napi::Task; -use napi::bindgen_prelude::{AsyncTask, Env, Error, Result, Status}; +use crate::dictionaries::{EnvSnapshot, discover_dictionaries}; +use crate::encoding::is_utf8_encoding; +use napi::bindgen_prelude::{Error, Result, Status}; use napi_derive::napi; static HUNSPELL_LOCK: Mutex<()> = Mutex::new(()); @@ -99,29 +100,6 @@ pub fn discover_system_dictionaries() -> Vec { .collect() } -pub struct HashFileTask { - path: String, -} - -#[napi(js_name = "hashFile")] -pub fn hash_file(path: String) -> Result> { - validate_path_arg(&path)?; - Ok(AsyncTask::new(HashFileTask { path })) -} - -impl Task for HashFileTask { - type Output = String; - type JsValue = String; - - fn compute(&mut self) -> Result { - hash_file_to_hex(&self.path).map_err(hash_error) - } - - fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { - Ok(output) - } -} - #[derive(Debug)] struct Dictionary { handle: *mut hunspell_sys::Hunhandle, @@ -297,81 +275,9 @@ fn hunspell_load_error(reason: &str) -> Error { ) } -fn hash_error(error: std::io::Error) -> Error { - let message = match error.kind() { - std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied => { - "could not open file for hashing" - } - _ => "read failed while hashing file", - }; - Error::new(Status::GenericFailure, message) -} - #[cfg(test)] mod tests { - use std::io::Write; - - use super::*; - - #[test] - fn suggestion_limit_defaults_and_bounds_match_legacy_contract() { - assert_eq!(8, normalize_suggestion_limit(None)); - assert_eq!(8, normalize_suggestion_limit(Some(0))); - assert_eq!(1, normalize_suggestion_limit(Some(1))); - assert_eq!(64, normalize_suggestion_limit(Some(64))); - assert_eq!(8, normalize_suggestion_limit(Some(65))); - } - - #[test] - fn path_validation_rejects_empty_and_nul_paths() { - assert_eq!( - "path must be non-empty", - validate_path_arg("") - .expect_err("empty path should fail") - .reason - ); - assert_eq!( - "path must not contain NUL bytes", - validate_path_arg("/tmp/a\0b") - .expect_err("NUL path should fail") - .reason - ); - } - - #[test] - fn text_validation_rejects_nul_words() { - assert_eq!( - "word must not contain NUL bytes", - validate_text_arg("a\0b", "word") - .expect_err("NUL word should fail") - .reason - ); - } - - #[test] - fn hash_error_maps_open_failures_to_existing_js_message() { - let err = hash_file_to_hex("/nonexistent/path/that/should/not/exist.bin") - .map_err(hash_error) - .expect_err("missing file should fail"); - assert_eq!(Status::GenericFailure, err.status); - assert_eq!("could not open file for hashing", err.reason); - } - - #[test] - fn hash_file_task_streams_file_to_expected_hex() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("payload"); - let mut file = File::create(&path).unwrap(); - file.write_all(b"abc").unwrap(); - - let mut task = HashFileTask { - path: path.display().to_string(), - }; - assert_eq!( - "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", - task.compute().unwrap() - ); - } + use super::Dictionary; #[test] fn dictionary_fixture_spells_and_accepts_runtime_words() { @@ -390,15 +296,6 @@ mod tests { drop(dir); } - #[test] - fn dictionary_rejects_non_utf8_dictionaries() { - let (_dir, aff_path, dic_path) = write_fixture_dictionary("ISO-8859-1"); - let err = Dictionary::load(&aff_path, &dic_path) - .expect_err("non-UTF-8 dictionary should be rejected"); - assert_eq!(Status::GenericFailure, err.status); - assert_eq!("Hunspell load failed: DictionaryNotUtf8", err.reason); - } - fn write_fixture_dictionary(encoding: &str) -> (tempfile::TempDir, String, String) { let dir = tempfile::tempdir().unwrap(); let aff_path = dir.path().join("fixture.aff"); diff --git a/fluxer_desktop/native/webauthn/src/common.rs b/fluxer_desktop/native/webauthn/src/common.rs index 07dc7b301..242e98f53 100644 --- a/fluxer_desktop/native/webauthn/src/common.rs +++ b/fluxer_desktop/native/webauthn/src/common.rs @@ -11,18 +11,10 @@ pub const CREATE_PREFIX: &str = "WebAuthn registration failed"; pub const GET_PREFIX: &str = "WebAuthn authentication failed"; pub const TRANSPORT_USB: u32 = 0x0000_0001; -#[allow(dead_code)] -pub const TRANSPORT_NFC: u32 = 0x0000_0002; -#[allow(dead_code)] -pub const TRANSPORT_BLE: u32 = 0x0000_0004; pub const TRANSPORT_INTERNAL: u32 = 0x0000_0010; #[allow(dead_code)] pub const ATTACHMENT_ANY: u32 = 0; -#[allow(dead_code)] -pub const ATTACHMENT_PLATFORM: u32 = 1; -#[allow(dead_code)] -pub const ATTACHMENT_CROSS_PLATFORM: u32 = 2; #[allow(dead_code)] pub const USER_VERIFICATION_REQUIRED: u32 = 1; @@ -189,14 +181,8 @@ mod tests { Some("cross-platform"), attachment_from_transport(TRANSPORT_USB) ); - assert_eq!( - Some("cross-platform"), - attachment_from_transport(TRANSPORT_NFC) - ); - assert_eq!( - Some("cross-platform"), - attachment_from_transport(TRANSPORT_BLE) - ); + assert_eq!(Some("cross-platform"), attachment_from_transport(0x2)); + assert_eq!(Some("cross-platform"), attachment_from_transport(0x4)); assert_eq!(None, attachment_from_transport(0)); } diff --git a/fluxer_desktop/native/win-game-capture/Cargo.lock b/fluxer_desktop/native/win-game-capture/Cargo.lock index 86df13eeb..970a45b17 100644 --- a/fluxer_desktop/native/win-game-capture/Cargo.lock +++ b/fluxer_desktop/native/win-game-capture/Cargo.lock @@ -29,42 +29,14 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" -[[package]] -name = "fluxer_encoder_ring" -version = "0.0.0" -dependencies = [ - "libloading", - "windows", -] - -[[package]] -name = "fluxer_gpu_rebuild" -version = "0.0.0" -dependencies = [ - "parking_lot", -] - -[[package]] -name = "fluxer_screen_frame_bus" -version = "0.0.0" -dependencies = [ - "fluxer_gpu_rebuild", - "parking_lot", -] - [[package]] name = "fluxer_win_game_capture" version = "0.0.0" dependencies = [ - "fluxer_encoder_ring", - "fluxer_screen_frame_bus", "napi", "napi-build", "napi-derive", - "parking_lot", "windows", - "windows-core", - "windows-sys", ] [[package]] @@ -171,15 +143,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "memchr" version = "2.8.3" @@ -250,29 +213,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -297,27 +237,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - [[package]] name = "rustc-hash" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "semver" version = "1.0.28" @@ -330,12 +255,6 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" -[[package]] -name = "smallvec" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" - [[package]] name = "syn" version = "2.0.119" @@ -471,15 +390,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-threading" version = "0.2.1" diff --git a/fluxer_desktop/native/win-game-capture/Cargo.toml b/fluxer_desktop/native/win-game-capture/Cargo.toml index 4a8255ea1..b9a83a37c 100644 --- a/fluxer_desktop/native/win-game-capture/Cargo.toml +++ b/fluxer_desktop/native/win-game-capture/Cargo.toml @@ -14,42 +14,12 @@ crate-type = ["cdylib", "rlib"] [dependencies] napi = {version = "3.12.6", default-features = false, features = ["dyn-symbols", "napi8"]} napi-derive = "3.6.7" -fluxer_screen_frame_bus = {path = "../screen-frame-bus"} -fluxer_encoder_ring = {path = "../encoder-ring"} -parking_lot = "0.12" [target.'cfg(target_os = "windows")'.dependencies] windows = {version = "0.62.2", features = [ - "Graphics_Capture", - "Graphics_DirectX_Direct3D11", "Win32_Foundation", - "Win32_Graphics_Direct3D", - "Win32_Graphics_Direct3D11", - "Win32_Graphics_Dxgi", - "Win32_Graphics_Dxgi_Common", "Win32_System_LibraryLoader", "Win32_System_Threading", - "Win32_System_WinRT", - "Win32_System_WinRT_Direct3D11", - "Win32_System_WinRT_Graphics_Capture", - "Win32_UI_WindowsAndMessaging", - "Win32_Graphics_Gdi", - "Win32_System_Performance", -]} -windows-core = "0.62.2" -windows-sys = {version = "0.61.2", features = [ - "Win32_Foundation", - "Win32_Graphics_Gdi", - "Win32_Security", - "Win32_System_Diagnostics_Debug", - "Win32_System_LibraryLoader", - "Win32_System_Memory", - "Win32_System_Performance", - "Win32_System_ProcessStatus", - "Win32_System_Registry", - "Win32_System_SystemInformation", - "Win32_System_Threading", - "Win32_UI_WindowsAndMessaging", ]} [build-dependencies] diff --git a/fluxer_desktop/native/win-game-capture/index.d.ts b/fluxer_desktop/native/win-game-capture/index.d.ts index 332ee7014..3efa35421 100644 --- a/fluxer_desktop/native/win-game-capture/index.d.ts +++ b/fluxer_desktop/native/win-game-capture/index.d.ts @@ -1,138 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {EventEmitter} from 'node:events'; - -export type CaptureStrategyName = 'wgc' | 'dxgi-duplication' | 'window-gdi'; - -export interface ScreenCaptureRect { - x: number; - y: number; - width: number; - height: number; -} - -export interface ScreenCaptureOptions { - sourceId: string; - sourceKind: 'screen' | 'window' | 'game'; - width?: number; - height?: number; - frameRate?: number; - captureId?: string; - colorRange?: 'full' | 'limited'; - colorSpace?: 'rec709' | 'srgb'; - showCursorClicks?: boolean; - captureRect?: ScreenCaptureRect; - frameSinkHandle?: unknown; - nativeFrameSinkRequired?: boolean; -} - -export interface ScreenCaptureStartResult { - width: number; - height: number; - frameRate: number; - pixelFormat: 'nv12' | 'bgra'; -} - -export interface ScreenCaptureSourceDescriptor { - kind: 'screen' | 'window' | 'game'; - id: string; - name: string; - width: number; - height: number; - targetPid?: number; -} - -export interface AvailabilityInfo { - available: boolean; - backend: string; - reason?: string; -} - -export interface CaptureDiagnostics { - state: number; - apiType: number; - transport: number; - fallbackReason: number; - captureFlags: number; - width: number; - height: number; - dxgiFormat: number; - frameCounter: number; - droppedFrameCounter: number; - lastPresentTimestampUs: number; - lastError: number; - activeStrategy: CaptureStrategyName; - lastFallbackReason: string; - startOptions: ScreenCaptureStartOptionsDiagnostics; - frameSinkAccepted: number; - frameSinkCoalesced: number; - frameSinkRejected: number; - mediaFramesDroppedWithoutSink: number; - cpuFallbackFramesDropped: number; -} - -export interface ScreenCaptureStartOptionsDiagnostics { - colorRange?: 'full' | 'limited'; - colorSpace?: 'rec709' | 'srgb'; - showCursorClicks?: boolean; - captureRect?: ScreenCaptureRect; - unsupportedOptions: Array<'showCursorClicks' | 'captureRect' | 'colorRange' | 'colorSpace'>; -} - -export interface EncoderAttachDiagnostics { - attached: boolean; - width: number; - height: number; - capacity: number; - framesSubmitted: number; - framesDropped: number; - ringFullEvents: number; - failedBlits: number; -} - -export interface FrameSinkDiagnostics { - accepted: number; - coalesced: number; - rejected: number; - mediaFramesDroppedWithoutSink: number; - cpuFallbackFramesDropped: number; -} - -export declare interface ScreenCapture { - on(event: 'error', listener: (err: Error) => void): this; - on(event: 'closed', listener: () => void): this; - on(event: 'stalled', listener: (message?: string) => void): this; - on(event: 'diagnostic', listener: (message?: string) => void): this; - on(event: string | symbol, listener: (...args: Array) => void): this; - - off(event: 'error', listener: (err: Error) => void): this; - off(event: 'closed', listener: () => void): this; - off(event: 'stalled', listener: (message?: string) => void): this; - off(event: 'diagnostic', listener: (message?: string) => void): this; - off(event: string | symbol, listener: (...args: Array) => void): this; - - emit(event: 'error', err: Error): boolean; - emit(event: 'closed'): boolean; - emit(event: 'stalled', message?: string): boolean; - emit(event: 'diagnostic', message?: string): boolean; -} - -export declare class ScreenCapture extends EventEmitter { - constructor(options?: ScreenCaptureOptions); - start(): Promise; - stop(): Promise; - getDiagnostics(): CaptureDiagnostics | null; - attachEncoder(width: number, height: number, frameRate?: number): void; - detachEncoder(): void; - isEncoderAttached(): boolean; - encoderRingFullCount(): number; - getEncoderAttachDiagnostics(): EncoderAttachDiagnostics | null; - getFrameSinkDiagnostics(): FrameSinkDiagnostics; -} - -export declare function isSupported(): boolean; -export declare function getAvailability(): AvailabilityInfo; -export declare function listSources(): Promise>; export declare function elevateGpuSchedulingPriority(processId?: number, priorityClass?: 'high' | 'realtime'): boolean; export declare function restoreGpuSchedulingPriority(processId?: number): boolean; export declare const loadError: Error | null; diff --git a/fluxer_desktop/native/win-game-capture/index.js b/fluxer_desktop/native/win-game-capture/index.js index e027e6e88..cf25831fe 100644 --- a/fluxer_desktop/native/win-game-capture/index.js +++ b/fluxer_desktop/native/win-game-capture/index.js @@ -1,6 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -const {EventEmitter} = require('node:events'); const {existsSync} = require('node:fs'); const {join, sep} = require('node:path'); const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs'); @@ -59,226 +58,6 @@ if (process.platform === 'win32') { }); } -class ScreenCapture extends EventEmitter { - constructor(options = {}) { - super(); - if (!binding) { - throw loadError || new Error(`${MODULE_NAME} binding unavailable`); - } - this.sourceId = options.sourceId; - this.sourceKind = options.sourceKind ?? 'window'; - this.width = options.width ?? 0; - this.height = options.height ?? 0; - this.frameRate = options.frameRate ?? 30; - this.captureId = typeof options.captureId === 'string' ? options.captureId : undefined; - this.colorRange = options.colorRange; - this.colorSpace = options.colorSpace; - this.showCursorClicks = options.showCursorClicks === true; - this.captureRect = options.captureRect; - this.frameSinkHandle = options.frameSinkHandle; - this.nativeFrameSinkRequired = options.nativeFrameSinkRequired === true; - this.started = false; - this.stopped = false; - this.closedEmitted = false; - - this.native = new binding.ScreenCapture(); - - this.native.setLifecycleCallback((...lifecycleArgs) => { - const [type, message] = - lifecycleArgs.length === 1 && Array.isArray(lifecycleArgs[0]) ? lifecycleArgs[0] : lifecycleArgs; - if (type === 'stalled') { - if (this.stopped) return; - this.emit('stalled', message); - return; - } - if (type === 'diagnostic') { - if (this.stopped) return; - this.emit('diagnostic', message); - return; - } - if (type === 'error') { - this.emit('error', new Error(message || 'DXGI capture error')); - return; - } - if (type === 'closed') { - if (this.stopped) { - this._emitClosedOnce(); - return; - } - this.stopped = true; - try { - this.native.stop(); - } catch {} - this._emitClosedOnce(); - } - }); - } - - async start() { - if (this.started || this.stopped) return undefined; - this.started = true; - try { - if (this.frameSinkHandle != null) { - if (typeof this.native.setFrameSinkHandle !== 'function') { - throw new Error(`${MODULE_NAME} native binding does not support native frame sink handles`); - } - this.native.setFrameSinkHandle(this.frameSinkHandle); - } else if (this.nativeFrameSinkRequired) { - throw new Error('Native frame sink handle is required for Windows screen capture'); - } - const result = this.native.start( - this.sourceId, - this.sourceKind, - this.width || undefined, - this.height || undefined, - this.frameRate || undefined, - this.captureId, - { - colorRange: this.colorRange, - colorSpace: this.colorSpace, - showCursorClicks: this.showCursorClicks, - captureRect: this.captureRect, - }, - ); - return result - ? { - width: result.width, - height: result.height, - frameRate: result.frameRate, - pixelFormat: result.pixelFormat, - } - : undefined; - } catch (error) { - this.stopped = true; - this.emit('error', error instanceof Error ? error : new Error(String(error))); - throw error; - } - } - - async stop() { - if (this.stopped) return; - this.stopped = true; - try { - this.native.stop(); - } finally { - this._emitClosedOnce(); - } - } - - getDiagnostics() { - if (!this.native || typeof this.native.getDiagnostics !== 'function') return null; - try { - return this.native.getDiagnostics() ?? null; - } catch (error) { - console.warn('[win-game-capture] getDiagnostics failed:', error?.message || error); - return null; - } - } - - attachEncoder(width, height) { - if (!this.native || typeof this.native.attachEncoder !== 'function') { - throw new Error(`${MODULE_NAME} native binding does not support encoder attachment`); - } - this.native.attachEncoder(width, height); - } - - detachEncoder() { - if (!this.native || typeof this.native.detachEncoder !== 'function') return; - this.native.detachEncoder(); - } - - isEncoderAttached() { - if (!this.native || typeof this.native.isEncoderAttached !== 'function') return false; - return Boolean(this.native.isEncoderAttached()); - } - - encoderRingFullCount() { - if (!this.native || typeof this.native.encoderRingFullCount !== 'function') return 0; - const count = this.native.encoderRingFullCount(); - return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0; - } - - getEncoderAttachDiagnostics() { - if (!this.native || typeof this.native.getEncoderAttachDiagnostics !== 'function') return null; - try { - return this.native.getEncoderAttachDiagnostics() ?? null; - } catch (error) { - console.warn('[win-game-capture] getEncoderAttachDiagnostics failed:', error?.message || error); - return null; - } - } - - getFrameSinkDiagnostics() { - if (!this.native || typeof this.native.getFrameSinkDiagnostics !== 'function') { - return { - accepted: 0, - coalesced: 0, - rejected: 0, - mediaFramesDroppedWithoutSink: 0, - cpuFallbackFramesDropped: 0, - }; - } - try { - return this.native.getFrameSinkDiagnostics(); - } catch (error) { - console.warn('[win-game-capture] getFrameSinkDiagnostics failed:', error?.message || error); - return { - accepted: 0, - coalesced: 0, - rejected: 0, - mediaFramesDroppedWithoutSink: 0, - cpuFallbackFramesDropped: 0, - }; - } - } - - _emitClosedOnce() { - if (this.closedEmitted) return; - this.closedEmitted = true; - queueMicrotask(() => this.emit('closed')); - } -} - -async function listSources() { - if (!binding || typeof binding.listSources !== 'function') return []; - try { - const sources = await binding.listSources(); - if (!Array.isArray(sources)) return []; - return sources - .filter((source) => { - return ( - source && - (source.kind === 'screen' || source.kind === 'window' || source.kind === 'game') && - typeof source.id === 'string' && - source.id.length > 0 - ); - }) - .map((source) => ({ - kind: source.kind, - id: source.id, - name: typeof source.name === 'string' && source.name.length > 0 ? source.name : source.id, - width: Number.isFinite(source.width) ? Math.max(0, Math.floor(source.width)) : 0, - height: Number.isFinite(source.height) ? Math.max(0, Math.floor(source.height)) : 0, - targetPid: Number.isFinite(source.targetPid) && source.targetPid > 0 ? Math.floor(source.targetPid) : undefined, - })); - } catch (error) { - console.warn('[win-game-capture] listSources failed:', error?.message || error); - return []; - } -} - -function isSupported() { - if (!binding) return false; - return binding.isSupported(); -} - -function getAvailability() { - if (!binding) { - return {available: false, backend: 'windows-game-capture', reason: 'load-failed'}; - } - return binding.getAvailability(); -} - function normalizeProcessId(processId) { if (processId === undefined || processId === null) return undefined; if (Number.isInteger(processId) && processId > 0) return processId; @@ -316,10 +95,6 @@ function restoreGpuSchedulingPriority(processId) { } module.exports = { - isSupported, - getAvailability, - listSources, - ScreenCapture, elevateGpuSchedulingPriority, restoreGpuSchedulingPriority, get loadError() { diff --git a/fluxer_desktop/native/win-game-capture/package.json b/fluxer_desktop/native/win-game-capture/package.json index 3eda9e4bf..8e7f50026 100644 --- a/fluxer_desktop/native/win-game-capture/package.json +++ b/fluxer_desktop/native/win-game-capture/package.json @@ -1,7 +1,7 @@ { "name": "@fluxer/win-game-capture", "version": "0.0.0", - "description": "Native Windows game capture fallback for fullscreen games that display capture cannot capture", + "description": "Native Windows GPU scheduling priority helpers", "private": true, "license": "AGPL-3.0-or-later", "os": [ diff --git a/fluxer_desktop/native/win-game-capture/src/capture_target.rs b/fluxer_desktop/native/win-game-capture/src/capture_target.rs deleted file mode 100644 index fd27e7021..000000000 --- a/fluxer_desktop/native/win-game-capture/src/capture_target.rs +++ /dev/null @@ -1,201 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::ptr::{null, null_mut}; - -use windows_sys::Win32::{ - Foundation::{HWND, LPARAM, RECT}, - Graphics::Gdi::{ - EnumDisplayMonitors, GetMonitorInfoW, HMONITOR, MONITOR_DEFAULTTONEAREST, MONITORINFO, - MonitorFromRect, - }, - System::Threading::GetCurrentProcessId, - UI::WindowsAndMessaging::{ - EnumWindows, GW_OWNER, GWL_STYLE, GetForegroundWindow, GetWindow, GetWindowLongPtrW, - GetWindowRect, GetWindowThreadProcessId, IsWindow, IsWindowVisible, WS_BORDER, WS_MAXIMIZE, - }, -}; - -const MONITOR_LIMIT: usize = 16; - -fn parse_hwnd_source_id(source_id: &str) -> Option { - let token = source_id.strip_prefix("window:")?.split(':').next()?; - let value = if let Some(hex) = token - .strip_prefix("0x") - .or_else(|| token.strip_prefix("0X")) - { - isize::from_str_radix(hex, 16).ok()? - } else { - token.parse::().ok()? - }; - let hwnd = value as HWND; - (unsafe { IsWindow(hwnd) } != 0).then_some(hwnd) -} - -fn parse_screen_ordinal(source_id: &str) -> Option { - let token = source_id.strip_prefix("screen:")?.split(':').next()?; - token.parse::().ok() -} - -pub(crate) fn resolve_game_capture_target( - source_id: &str, - source_kind: &str, -) -> Result { - if let Some(hwnd) = parse_hwnd_source_id(source_id) { - return Ok(hwnd); - } - if source_kind != "game" && source_kind != "screen" { - return Err(format!( - "invalid game capture source: {source_kind}:{source_id}" - )); - } - let monitor = monitor_for_screen_source(source_id)?; - find_fullscreen_window_on_monitor(monitor) - .or_else(find_foreground_fullscreen_window) - .or_else(find_fullscreen_window_on_any_monitor) - .ok_or_else(|| "no fullscreen game window found on selected display".to_string()) -} - -fn monitor_for_screen_source(source_id: &str) -> Result { - let ordinal = parse_screen_ordinal(source_id).unwrap_or(0); - let monitors = enumerate_monitors(); - monitors - .get(ordinal) - .copied() - .or_else(|| monitors.first().copied()) - .ok_or_else(|| "no monitors available for game capture".to_string()) -} - -fn enumerate_monitors() -> Vec { - unsafe extern "system" fn enum_monitor( - monitor: HMONITOR, - _hdc: windows_sys::Win32::Graphics::Gdi::HDC, - _rect: *mut RECT, - param: LPARAM, - ) -> i32 { - let monitors = &mut *(param as *mut Vec); - if monitors.len() >= MONITOR_LIMIT { - return 0; - } - monitors.push(monitor); - 1 - } - let mut monitors = Vec::with_capacity(MONITOR_LIMIT); - unsafe { - EnumDisplayMonitors( - null_mut(), - null(), - Some(enum_monitor), - &mut monitors as *mut _ as LPARAM, - ); - } - assert!(monitors.len() <= MONITOR_LIMIT, "monitor targets bounded"); - monitors -} - -pub(crate) fn monitor_rect(monitor: HMONITOR) -> Option { - let mut info = MONITORINFO { - cbSize: std::mem::size_of::() as u32, - rcMonitor: RECT::default(), - rcWork: RECT::default(), - dwFlags: 0, - }; - if unsafe { GetMonitorInfoW(monitor, &mut info) } == 0 { - return None; - } - Some(info.rcMonitor) -} - -fn rect_matches_monitor(window_rect: &RECT, monitor_rect: &RECT) -> bool { - const TOLERANCE: i32 = 2; - (window_rect.left - monitor_rect.left).abs() <= TOLERANCE - && (window_rect.top - monitor_rect.top).abs() <= TOLERANCE - && (window_rect.right - monitor_rect.right).abs() <= TOLERANCE - && (window_rect.bottom - monitor_rect.bottom).abs() <= TOLERANCE -} - -fn is_regular_maximized_window(hwnd: HWND) -> bool { - let style = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) } as u32; - (style & WS_MAXIMIZE) != 0 && (style & WS_BORDER) != 0 -} - -fn is_fullscreen_window_on_monitor(hwnd: HWND, monitor: HMONITOR, monitor_rect: &RECT) -> bool { - if unsafe { IsWindowVisible(hwnd) } == 0 { - return false; - } - if !unsafe { GetWindow(hwnd, GW_OWNER) }.is_null() { - return false; - } - let mut pid = 0u32; - unsafe { - GetWindowThreadProcessId(hwnd, &mut pid); - } - if pid == 0 || pid == unsafe { GetCurrentProcessId() } { - return false; - } - if is_regular_maximized_window(hwnd) { - return false; - } - let mut rect = RECT::default(); - if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 { - return false; - } - let window_monitor = unsafe { MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST) }; - window_monitor == monitor && rect_matches_monitor(&rect, monitor_rect) -} - -fn find_foreground_fullscreen_window() -> Option { - let hwnd = unsafe { GetForegroundWindow() }; - if hwnd.is_null() { - return None; - } - let mut rect = RECT::default(); - if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 { - return None; - } - let monitor = unsafe { MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST) }; - let monitor_rect = monitor_rect(monitor)?; - is_fullscreen_window_on_monitor(hwnd, monitor, &monitor_rect).then_some(hwnd) -} - -fn find_fullscreen_window_on_any_monitor() -> Option { - for monitor in enumerate_monitors() { - if let Some(hwnd) = find_fullscreen_window_on_monitor(monitor) { - return Some(hwnd); - } - } - None -} - -fn find_fullscreen_window_on_monitor(monitor: HMONITOR) -> Option { - struct Search { - monitor: HMONITOR, - monitor_rect: RECT, - result: HWND, - own_pid: u32, - } - unsafe extern "system" fn enum_window(hwnd: HWND, param: LPARAM) -> i32 { - let search = &mut *(param as *mut Search); - let mut pid = 0u32; - GetWindowThreadProcessId(hwnd, &mut pid); - if pid == 0 || pid == search.own_pid { - return 1; - } - if !is_fullscreen_window_on_monitor(hwnd, search.monitor, &search.monitor_rect) { - return 1; - } - search.result = hwnd; - 0 - } - - let monitor_rect = monitor_rect(monitor)?; - let mut search = Search { - monitor, - monitor_rect, - result: null_mut(), - own_pid: unsafe { GetCurrentProcessId() }, - }; - unsafe { - let _ = EnumWindows(Some(enum_window), &mut search as *mut _ as LPARAM); - } - (!search.result.is_null()).then_some(search.result) -} diff --git a/fluxer_desktop/native/win-game-capture/src/d3d11_device.rs b/fluxer_desktop/native/win-game-capture/src/d3d11_device.rs deleted file mode 100644 index 1bc423e9f..000000000 --- a/fluxer_desktop/native/win-game-capture/src/d3d11_device.rs +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use windows::Win32::{ - Foundation::HMODULE, - Graphics::{ - Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN}, - Direct3D11::{ - D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, D3D11CreateDevice, ID3D11Device, - ID3D11DeviceContext, - }, - Dxgi::{IDXGIAdapter, IDXGIDevice}, - }, -}; -use windows::core::Interface; - -pub(crate) fn create_shared_texture_device( - adapter: Option<&IDXGIAdapter>, -) -> Result<(ID3D11Device, ID3D11DeviceContext), String> { - let mut device = None; - let mut context = None; - let driver_type = if adapter.is_some() { - D3D_DRIVER_TYPE_UNKNOWN - } else { - D3D_DRIVER_TYPE_HARDWARE - }; - unsafe { - D3D11CreateDevice( - adapter, - driver_type, - HMODULE::default(), - D3D11_CREATE_DEVICE_BGRA_SUPPORT, - None, - D3D11_SDK_VERSION, - Some(&mut device), - None, - Some(&mut context), - ) - .map_err(|error| format!("D3D11CreateDevice for shared capture texture: {error}"))?; - } - let device = device.ok_or("D3D11 shared texture device was None")?; - let context = context.ok_or("D3D11 shared texture context was None")?; - if let Ok(dxgi_device) = device.cast::() { - let _ = unsafe { dxgi_device.SetGPUThreadPriority(7) }; - } - Ok((device, context)) -} diff --git a/fluxer_desktop/native/win-game-capture/src/dxgi_capture.rs b/fluxer_desktop/native/win-game-capture/src/dxgi_capture.rs deleted file mode 100644 index 6d1af9513..000000000 --- a/fluxer_desktop/native/win-game-capture/src/dxgi_capture.rs +++ /dev/null @@ -1,1096 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -#[cfg(target_os = "windows")] -use windows::{ - Win32::{ - Foundation::{HMODULE, HWND, RECT}, - Graphics::{ - Direct3D::D3D_DRIVER_TYPE_UNKNOWN, - Direct3D11::{ - D3D11_BOX, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_RESOURCE_MISC_SHARED, - D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, D3D11CreateDevice, - ID3D11Device, ID3D11DeviceContext, ID3D11Resource, ID3D11Texture2D, - }, - Dxgi::{ - Common::{ - DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_MODE_ROTATION, DXGI_MODE_ROTATION_IDENTITY, - DXGI_MODE_ROTATION_UNSPECIFIED, DXGI_SAMPLE_DESC, - }, - CreateDXGIFactory1, DXGI_ERROR_ACCESS_LOST, DXGI_ERROR_MORE_DATA, - DXGI_ERROR_WAIT_TIMEOUT, DXGI_OUTDUPL_FRAME_INFO, DXGI_OUTDUPL_POINTER_SHAPE_INFO, - DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR, - DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR, - DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME, IDXGIAdapter, IDXGIAdapter1, - IDXGIDevice, IDXGIFactory1, IDXGIOutput, IDXGIOutput1, IDXGIOutputDuplication, - IDXGIResource, - }, - Gdi::{HMONITOR, MONITOR_DEFAULTTONULL, MonitorFromWindow}, - }, - UI::WindowsAndMessaging::{GetWindowRect, IsWindow}, - }, - core::Interface, -}; - -#[cfg(target_os = "windows")] -use crate::{ - CaptureInner, emit_lifecycle, emit_shared_texture_frame, note_media_frame_without_sink, - resolve_frame_sink, -}; - -#[cfg(target_os = "windows")] -use std::sync::{Arc, atomic::Ordering}; - -#[cfg(target_os = "windows")] -pub fn parse_window_source_id(source_id: &str, source_kind: &str) -> Option { - if source_kind != "window" { - return None; - } - let hwnd_str = source_id.strip_prefix("window:")?; - let hwnd_token = hwnd_str.split(':').next()?; - let hwnd_num = parse_hwnd_token(hwnd_token)?; - let hwnd = HWND(hwnd_num as *mut _); - if unsafe { IsWindow(Some(hwnd)) }.as_bool() { - Some(hwnd) - } else { - None - } -} - -#[cfg(target_os = "windows")] -fn parse_hwnd_token(token: &str) -> Option { - if let Some(hex) = token - .strip_prefix("0x") - .or_else(|| token.strip_prefix("0X")) - { - return isize::from_str_radix(hex, 16).ok(); - } - token.parse().ok() -} - -#[cfg(target_os = "windows")] -fn validate_output_rotation(rotation: DXGI_MODE_ROTATION) -> Result<(), String> { - if rotation.0 == DXGI_MODE_ROTATION_UNSPECIFIED.0 || rotation.0 == DXGI_MODE_ROTATION_IDENTITY.0 - { - return Ok(()); - } - Err(format!( - "Rotated DXGI outputs are not supported by this fallback (rotation={})", - rotation.0 - )) -} - -#[cfg(target_os = "windows")] -pub struct DxgiCaptureSession { - device: ID3D11Device, - context: ID3D11DeviceContext, - hwnd: HWND, - capture_width: u32, - capture_height: u32, - requested_width: Option, - requested_height: Option, -} - -#[cfg(target_os = "windows")] -unsafe impl Send for DxgiCaptureSession {} - -#[cfg(target_os = "windows")] -#[derive(Default)] -struct PointerShapeCache { - buffer: Vec, - info: Option, -} - -#[cfg(target_os = "windows")] -enum PointerShapeUpdate { - Ok, - AccessLost, - Error(String), -} - -#[cfg(target_os = "windows")] -struct DuplicationState { - duplication: IDXGIOutputDuplication, - monitor: HMONITOR, - monitor_rect: RECT, - shared_output: Option, - cap_w: u32, - cap_h: u32, - out_w: u32, - out_h: u32, -} - -#[cfg(target_os = "windows")] -pub(crate) const SHARED_OUTPUT_SLOT_COUNT: usize = 3; - -#[cfg(target_os = "windows")] -pub(crate) struct SharedOutputSlot { - pub(crate) texture: ID3D11Texture2D, - pub(crate) handle: u64, -} - -#[cfg(target_os = "windows")] -pub(crate) struct SharedTextureOutput { - pub(crate) slots: [SharedOutputSlot; SHARED_OUTPUT_SLOT_COUNT], - pub(crate) slot_cursor: usize, - pub(crate) width: u32, - pub(crate) height: u32, - pub(crate) dxgi_format: u32, -} - -#[cfg(target_os = "windows")] -impl SharedTextureOutput { - pub(crate) fn next_slot_index(&mut self) -> usize { - assert!( - self.slot_cursor < SHARED_OUTPUT_SLOT_COUNT, - "slot cursor in range" - ); - assert!(self.width > 0, "shared output width positive"); - let slot_index = self.slot_cursor; - self.slot_cursor = (slot_index + 1) % SHARED_OUTPUT_SLOT_COUNT; - slot_index - } -} - -#[cfg(target_os = "windows")] -impl DxgiCaptureSession { - pub fn new( - hwnd: HWND, - requested_width: Option, - requested_height: Option, - ) -> Result { - let adapter = find_dxgi_adapter_for_window(hwnd)?; - let (device, context) = create_d3d11_device(&adapter)?; - - let mut window_rect = RECT::default(); - unsafe { - GetWindowRect(hwnd, &mut window_rect).map_err(|e| format!("GetWindowRect: {e}"))?; - } - let width = (window_rect.right - window_rect.left).max(1) as u32; - let height = (window_rect.bottom - window_rect.top).max(1) as u32; - let (capture_width, capture_height) = - resolve_output_size(width, height, requested_width, requested_height); - - Ok(Self { - device, - context, - hwnd, - capture_width, - capture_height, - requested_width, - requested_height, - }) - } - - pub fn capture_width(&self) -> u32 { - self.capture_width - } - - pub fn capture_height(&self) -> u32 { - self.capture_height - } -} - -#[cfg(target_os = "windows")] -fn find_dxgi_adapter_for_window(hwnd: HWND) -> Result { - let target_monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONULL) }; - if target_monitor.is_invalid() { - return Err("MonitorFromWindow returned null".into()); - } - let factory = unsafe { CreateDXGIFactory1::() } - .map_err(|e| format!("CreateDXGIFactory1: {e}"))?; - let mut adapter_index = 0u32; - loop { - let adapter = match unsafe { factory.EnumAdapters1(adapter_index) } { - Ok(adapter) => adapter, - Err(_) => break, - }; - adapter_index += 1; - let mut output_index = 0u32; - loop { - let output: IDXGIOutput = match unsafe { adapter.EnumOutputs(output_index) } { - Ok(output) => output, - Err(_) => break, - }; - output_index += 1; - let desc = unsafe { output.GetDesc().map_err(|e| format!("GetDesc: {e}"))? }; - if desc.Monitor == target_monitor { - validate_output_rotation(desc.Rotation)?; - return Ok(adapter); - } - } - } - Err("Could not find DXGI adapter for the target window's monitor".into()) -} - -#[cfg(target_os = "windows")] -fn create_d3d11_device( - adapter: &IDXGIAdapter1, -) -> Result<(ID3D11Device, ID3D11DeviceContext), String> { - let adapter: IDXGIAdapter = adapter - .cast() - .map_err(|e| format!("IDXGIAdapter cast: {e}"))?; - let mut device = None; - let mut context = None; - unsafe { - D3D11CreateDevice( - Some(&adapter), - D3D_DRIVER_TYPE_UNKNOWN, - HMODULE::default(), - D3D11_CREATE_DEVICE_BGRA_SUPPORT, - None, - D3D11_SDK_VERSION, - Some(&mut device), - None, - Some(&mut context), - ) - .map_err(|e| format!("D3D11CreateDevice: {e}"))?; - } - let device = device.ok_or("D3D11 device was None")?; - let context = context.ok_or("D3D11 context was None")?; - - if let Ok(dxgi_device) = device.cast::() { - let _ = unsafe { dxgi_device.SetGPUThreadPriority(7) }; - } - - Ok((device, context)) -} - -#[cfg(target_os = "windows")] -fn create_output_duplication( - device: &ID3D11Device, - hwnd: HWND, -) -> Result<(IDXGIOutputDuplication, RECT, HMONITOR), String> { - let dxgi_device: IDXGIDevice = device - .cast() - .map_err(|e| format!("IDXGIDevice cast: {e}"))?; - let adapter: IDXGIAdapter1 = unsafe { dxgi_device.GetAdapter() } - .map_err(|e| format!("GetAdapter: {e}"))? - .cast() - .map_err(|e| format!("IDXGIAdapter1 cast: {e}"))?; - - let target_monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONULL) }; - if target_monitor.is_invalid() { - return Err("MonitorFromWindow returned null".into()); - } - - let mut output_index = 0u32; - loop { - let output: IDXGIOutput = match unsafe { adapter.EnumOutputs(output_index) } { - Ok(o) => o, - Err(_) => break, - }; - output_index += 1; - - let desc = unsafe { output.GetDesc().map_err(|e| format!("GetDesc: {e}"))? }; - - if desc.Monitor == target_monitor { - validate_output_rotation(desc.Rotation)?; - let output1: IDXGIOutput1 = output - .cast() - .map_err(|e| format!("IDXGIOutput1 cast: {e}"))?; - let duplication = unsafe { - output1 - .DuplicateOutput(device) - .map_err(|e| format!("DuplicateOutput: {e}"))? - }; - return Ok((duplication, desc.DesktopCoordinates, target_monitor)); - } - } - - Err("Could not find DXGI output for the target window's monitor".into()) -} - -#[cfg(target_os = "windows")] -pub(crate) fn create_shared_output_texture( - device: &ID3D11Device, - width: u32, - height: u32, -) -> Result { - assert!(width > 0, "shared output width positive"); - assert!(height > 0, "shared output height positive"); - let mut slots = Vec::with_capacity(SHARED_OUTPUT_SLOT_COUNT); - for _ in 0..SHARED_OUTPUT_SLOT_COUNT { - slots.push(create_shared_output_slot(device, width, height)?); - } - assert_eq!( - slots.len(), - SHARED_OUTPUT_SLOT_COUNT, - "all shared output slots created" - ); - let slots = <[SharedOutputSlot; SHARED_OUTPUT_SLOT_COUNT]>::try_from(slots) - .map_err(|_| "shared output slot count mismatch".to_string())?; - Ok(SharedTextureOutput { - slots, - slot_cursor: 0, - width, - height, - dxgi_format: DXGI_FORMAT_B8G8R8A8_UNORM.0 as u32, - }) -} - -#[cfg(target_os = "windows")] -fn create_shared_output_slot( - device: &ID3D11Device, - width: u32, - height: u32, -) -> Result { - assert!(width > 0, "shared output slot width positive"); - assert!(height > 0, "shared output slot height positive"); - let desc = D3D11_TEXTURE2D_DESC { - Width: width, - Height: height, - MipLevels: 1, - ArraySize: 1, - Format: DXGI_FORMAT_B8G8R8A8_UNORM, - SampleDesc: DXGI_SAMPLE_DESC { - Count: 1, - Quality: 0, - }, - Usage: D3D11_USAGE_DEFAULT, - BindFlags: Default::default(), - CPUAccessFlags: 0, - MiscFlags: D3D11_RESOURCE_MISC_SHARED.0 as u32, - }; - let mut texture = None; - let texture = unsafe { - device - .CreateTexture2D(&desc, None, Some(&mut texture)) - .map_err(|e| format!("CreateTexture2D shared output: {e}"))?; - texture.ok_or("D3D11 shared output texture was None")? - }; - let resource: IDXGIResource = texture - .cast() - .map_err(|e| format!("IDXGIResource shared output cast: {e}"))?; - let handle = unsafe { resource.GetSharedHandle() } - .map_err(|e| format!("GetSharedHandle shared output: {e}"))?; - if handle.is_invalid() { - return Err("GetSharedHandle shared output returned an invalid handle".into()); - } - Ok(SharedOutputSlot { - texture, - handle: handle.0 as usize as u64, - }) -} - -#[cfg(target_os = "windows")] -pub(crate) fn resolve_output_size( - src_width: u32, - src_height: u32, - requested_width: Option, - requested_height: Option, -) -> (u32, u32) { - let max_width = requested_width.unwrap_or(src_width).max(1); - let max_height = requested_height.unwrap_or(src_height).max(1); - let scale = (max_width as f64 / src_width.max(1) as f64) - .min(max_height as f64 / src_height.max(1) as f64) - .min(1.0); - if scale >= 1.0 { - return (src_width.max(1), src_height.max(1)); - } - ( - ((src_width as f64 * scale).floor() as u32).max(1), - ((src_height as f64 * scale).floor() as u32).max(1), - ) -} - -#[cfg(target_os = "windows")] -pub(crate) fn capture_timestamp_us(capture_start: std::time::Instant) -> i64 { - let elapsed_us = capture_start.elapsed().as_micros(); - assert!(elapsed_us < i64::MAX as u128); - elapsed_us as i64 -} - -pub(crate) fn pacing_sleep_and_next_deadline( - now: std::time::Instant, - deadline: std::time::Instant, - frame_interval: std::time::Duration, -) -> (std::time::Duration, std::time::Instant) { - assert!(frame_interval > std::time::Duration::ZERO); - if now < deadline { - let sleep_duration = deadline - now; - assert!(sleep_duration <= frame_interval); - (sleep_duration, deadline + frame_interval) - } else { - (std::time::Duration::ZERO, now + frame_interval) - } -} - -#[cfg(target_os = "windows")] -pub fn capture_loop(inner: &Arc, frame_interval: std::time::Duration) { - let hwnd; - let device; - let context; - let requested_width; - let requested_height; - { - let guard = inner.session.lock(); - let session = match guard.as_ref() { - Some(s) => s, - None => { - emit_lifecycle(inner, "closed-clean", "no session"); - return; - } - }; - hwnd = session.hwnd; - device = session.device.clone(); - context = session.context.clone(); - requested_width = session.requested_width; - requested_height = session.requested_height; - } - - let capture_id = inner.capture_id.lock().clone(); - let mut duplication_state: Option = None; - let mut pointer_shape = PointerShapeCache::default(); - let mut recreate_backoff = std::time::Duration::from_millis(100); - let capture_start = std::time::Instant::now(); - let mut next_frame_deadline = capture_start + frame_interval; - - while inner.running.load(Ordering::Acquire) { - if !unsafe { IsWindow(Some(hwnd)) }.as_bool() { - emit_lifecycle(inner, "closed", "window closed"); - break; - } - - if duplication_state.is_none() { - match setup_duplication(&device, hwnd, requested_width, requested_height) { - Ok(state) => { - duplication_state = Some(state); - pointer_shape = PointerShapeCache::default(); - } - Err(e) => { - if !inner.running.load(Ordering::Acquire) { - break; - } - emit_lifecycle( - inner, - "error", - &format!("Failed to create DXGI output duplication: {e}"), - ); - std::thread::sleep(recreate_backoff); - recreate_backoff = - (recreate_backoff * 2).min(std::time::Duration::from_secs(2)); - continue; - } - } - } - - let Some(state) = duplication_state.as_mut() else { - continue; - }; - - match acquire_and_emit_frame( - inner, - &context, - state, - hwnd, - capture_id.as_deref(), - &mut pointer_shape, - capture_start, - ) { - FrameResult::Ok => { - recreate_backoff = std::time::Duration::from_millis(100); - } - FrameResult::Timeout => {} - FrameResult::Resized { width, height } => { - state.cap_w = width; - state.cap_h = height; - let (out_w, out_h) = - resolve_output_size(width, height, requested_width, requested_height); - state.out_w = out_w; - state.out_h = out_h; - state.shared_output = create_shared_output_texture(&device, width, height).ok(); - } - FrameResult::AccessLost => { - duplication_state = None; - pointer_shape = PointerShapeCache::default(); - std::thread::sleep(recreate_backoff); - recreate_backoff = (recreate_backoff * 2).min(std::time::Duration::from_secs(2)); - continue; - } - FrameResult::WindowGone => { - emit_lifecycle(inner, "closed", "window closed during capture"); - break; - } - FrameResult::Error(e) => { - emit_lifecycle(inner, "error", &e); - { - let mut guard = inner.fallback.lock(); - if let Some(tracker) = guard.as_mut() { - let _ = tracker.observe(crate::fallback::FailureSignature::DeviceLost); - } - } - duplication_state = None; - pointer_shape = PointerShapeCache::default(); - continue; - } - } - - let now = std::time::Instant::now(); - let (sleep_duration, deadline) = - pacing_sleep_and_next_deadline(now, next_frame_deadline, frame_interval); - next_frame_deadline = deadline; - if sleep_duration > std::time::Duration::ZERO { - std::thread::sleep(sleep_duration); - } - } - - inner.running.store(false, Ordering::Release); - emit_lifecycle(inner, "closed-clean", "capture stopped"); -} - -#[cfg(target_os = "windows")] -fn setup_duplication( - device: &ID3D11Device, - hwnd: HWND, - requested_width: Option, - requested_height: Option, -) -> Result { - let (duplication, monitor_rect, monitor) = create_output_duplication(device, hwnd)?; - - let mut window_rect = RECT::default(); - unsafe { - GetWindowRect(hwnd, &mut window_rect).map_err(|e| format!("GetWindowRect: {e}"))?; - } - - let crop_left = (window_rect.left - monitor_rect.left).max(0) as u32; - let crop_top = (window_rect.top - monitor_rect.top).max(0) as u32; - let monitor_width = (monitor_rect.right - monitor_rect.left) as u32; - let monitor_height = (monitor_rect.bottom - monitor_rect.top) as u32; - let crop_right = ((window_rect.right - monitor_rect.left) as u32).min(monitor_width); - let crop_bottom = ((window_rect.bottom - monitor_rect.top) as u32).min(monitor_height); - - let cap_w = crop_right.saturating_sub(crop_left).max(1); - let cap_h = crop_bottom.saturating_sub(crop_top).max(1); - let (out_w, out_h) = resolve_output_size(cap_w, cap_h, requested_width, requested_height); - - let shared_output = create_shared_output_texture(device, cap_w, cap_h).ok(); - - Ok(DuplicationState { - duplication, - monitor, - monitor_rect, - shared_output, - cap_w, - cap_h, - out_w, - out_h, - }) -} - -#[cfg(target_os = "windows")] -enum FrameResult { - Ok, - Timeout, - Resized { width: u32, height: u32 }, - AccessLost, - WindowGone, - Error(String), -} - -#[cfg(target_os = "windows")] -fn acquire_and_emit_frame( - inner: &Arc, - context: &ID3D11DeviceContext, - state: &mut DuplicationState, - hwnd: HWND, - capture_id: Option<&str>, - _pointer_shape: &mut PointerShapeCache, - capture_start: std::time::Instant, -) -> FrameResult { - let mut frame_info = DXGI_OUTDUPL_FRAME_INFO::default(); - let mut resource = None; - match unsafe { - state - .duplication - .AcquireNextFrame(50, &mut frame_info, &mut resource) - } { - Ok(()) => {} - Err(e) if e.code() == DXGI_ERROR_WAIT_TIMEOUT => return FrameResult::Timeout, - Err(e) if e.code() == DXGI_ERROR_ACCESS_LOST => return FrameResult::AccessLost, - Err(e) => return FrameResult::Error(format!("AcquireNextFrame: {e}")), - } - - let desktop_texture: ID3D11Texture2D = match resource - .as_ref() - .and_then(|r| r.cast::().ok()) - { - Some(t) => t, - None => { - let _ = unsafe { state.duplication.ReleaseFrame() }; - return FrameResult::Error("Failed to cast desktop resource to ID3D11Texture2D".into()); - } - }; - - if !unsafe { IsWindow(Some(hwnd)) }.as_bool() { - let _ = unsafe { state.duplication.ReleaseFrame() }; - return FrameResult::WindowGone; - } - - let mut window_rect = RECT::default(); - if unsafe { GetWindowRect(hwnd, &mut window_rect) }.is_err() { - let _ = unsafe { state.duplication.ReleaseFrame() }; - return FrameResult::WindowGone; - } - - let monitor_rect = &state.monitor_rect; - let crop_left = (window_rect.left - monitor_rect.left).max(0) as u32; - let crop_top = (window_rect.top - monitor_rect.top).max(0) as u32; - let monitor_width = (monitor_rect.right - monitor_rect.left) as u32; - let monitor_height = (monitor_rect.bottom - monitor_rect.top) as u32; - let crop_right = ((window_rect.right - monitor_rect.left) as u32).min(monitor_width); - let crop_bottom = ((window_rect.bottom - monitor_rect.top) as u32).min(monitor_height); - - let cur_w = crop_right.saturating_sub(crop_left).max(1); - let cur_h = crop_bottom.saturating_sub(crop_top).max(1); - - if cur_w != state.cap_w || cur_h != state.cap_h { - let current_monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONULL) }; - let _ = unsafe { state.duplication.ReleaseFrame() }; - if current_monitor != state.monitor { - return FrameResult::AccessLost; - } - return FrameResult::Resized { - width: cur_w, - height: cur_h, - }; - } - - let src_box = D3D11_BOX { - left: crop_left, - top: crop_top, - front: 0, - right: crop_left + state.cap_w, - bottom: crop_top + state.cap_h, - back: 1, - }; - - let desktop_resource: ID3D11Resource = match desktop_texture.cast() { - Ok(resource) => resource, - Err(e) => { - let _ = unsafe { state.duplication.ReleaseFrame() }; - return FrameResult::Error(format!("ID3D11Resource desktop cast: {e}")); - } - }; - - let result = emit_acquired_frame( - inner, - context, - state, - capture_id, - &desktop_resource, - &src_box, - capture_start, - ); - let _ = unsafe { state.duplication.ReleaseFrame() }; - result -} - -#[cfg(target_os = "windows")] -fn emit_acquired_frame( - inner: &Arc, - context: &ID3D11DeviceContext, - state: &mut DuplicationState, - capture_id: Option<&str>, - desktop_resource: &ID3D11Resource, - src_box: &D3D11_BOX, - capture_start: std::time::Instant, -) -> FrameResult { - assert!(src_box.right > src_box.left, "source box width positive"); - assert!(src_box.bottom > src_box.top, "source box height positive"); - let Some(frame_sink) = resolve_frame_sink(inner, capture_id) else { - note_media_frame_without_sink( - inner, - "DXGI frame dropped because no native frame sink is registered", - ); - return FrameResult::Ok; - }; - if state.out_w != state.cap_w || state.out_h != state.cap_h { - return FrameResult::Error( - "DXGI native bus scaling requires a GPU scaler; refusing CPU readback fallback".into(), - ); - } - let Some(shared_output) = state.shared_output.as_mut() else { - return FrameResult::Error("DXGI shared texture output unavailable".into()); - }; - let slot_index = shared_output.next_slot_index(); - let slot = &shared_output.slots[slot_index]; - let output_resource: ID3D11Resource = match slot.texture.cast() { - Ok(resource) => resource, - Err(e) => { - return FrameResult::Error(format!("ID3D11Resource shared output cast: {e}")); - } - }; - unsafe { - context.CopySubresourceRegion( - &output_resource, - 0, - 0, - 0, - 0, - desktop_resource, - 0, - Some(src_box), - ); - context.Flush(); - } - let _ = emit_shared_texture_frame( - inner, - &frame_sink, - slot.handle, - shared_output.width, - shared_output.height, - shared_output.dxgi_format, - capture_timestamp_us(capture_start), - ); - FrameResult::Ok -} - -#[cfg(target_os = "windows")] -fn update_pointer_shape( - duplication: &IDXGIOutputDuplication, - frame_info: &DXGI_OUTDUPL_FRAME_INFO, - cache: &mut PointerShapeCache, -) -> PointerShapeUpdate { - if frame_info.PointerShapeBufferSize == 0 { - return PointerShapeUpdate::Ok; - } - - let buffer_size = frame_info.PointerShapeBufferSize as usize; - if cache.buffer.len() < buffer_size { - cache.buffer.resize(buffer_size, 0); - } - - let mut required_size = 0u32; - let mut shape_info = DXGI_OUTDUPL_POINTER_SHAPE_INFO::default(); - let result = unsafe { - duplication.GetFramePointerShape( - cache.buffer.len() as u32, - cache.buffer.as_mut_ptr().cast(), - &mut required_size, - &mut shape_info, - ) - }; - - match result { - Ok(()) => { - cache.buffer.truncate(required_size as usize); - cache.info = Some(shape_info); - PointerShapeUpdate::Ok - } - Err(e) if e.code() == DXGI_ERROR_MORE_DATA && required_size > cache.buffer.len() as u32 => { - cache.buffer.resize(required_size as usize, 0); - let result = unsafe { - duplication.GetFramePointerShape( - cache.buffer.len() as u32, - cache.buffer.as_mut_ptr().cast(), - &mut required_size, - &mut shape_info, - ) - }; - match result { - Ok(()) => { - cache.buffer.truncate(required_size as usize); - cache.info = Some(shape_info); - PointerShapeUpdate::Ok - } - Err(e) if e.code() == DXGI_ERROR_ACCESS_LOST => PointerShapeUpdate::AccessLost, - Err(e) => PointerShapeUpdate::Error(format!("GetFramePointerShape: {e}")), - } - } - Err(e) if e.code() == DXGI_ERROR_ACCESS_LOST => PointerShapeUpdate::AccessLost, - Err(e) => PointerShapeUpdate::Error(format!("GetFramePointerShape: {e}")), - } -} - -#[cfg(target_os = "windows")] -#[allow(clippy::too_many_arguments)] -fn composite_pointer_shape( - frame: &mut [u8], - width: u32, - height: u32, - stride: u32, - frame_info: &DXGI_OUTDUPL_FRAME_INFO, - crop_left: u32, - crop_top: u32, - cache: &PointerShapeCache, -) { - if !frame_info.PointerPosition.Visible.as_bool() { - return; - } - let Some(shape_info) = cache.info else { - return; - }; - if shape_info.Width == 0 || shape_info.Height == 0 || shape_info.Pitch == 0 { - return; - } - - let pointer_x = frame_info.PointerPosition.Position.x - crop_left as i32; - let pointer_y = frame_info.PointerPosition.Position.y - crop_top as i32; - if pointer_x >= width as i32 || pointer_y >= height as i32 { - return; - } - - if shape_info.Type == DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR.0 as u32 { - composite_color_pointer( - frame, - width, - height, - stride, - pointer_x, - pointer_y, - &shape_info, - &cache.buffer, - ); - } else if shape_info.Type == DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR.0 as u32 { - composite_masked_color_pointer( - frame, - width, - height, - stride, - pointer_x, - pointer_y, - &shape_info, - &cache.buffer, - ); - } else if shape_info.Type == DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME.0 as u32 { - composite_monochrome_pointer( - frame, - width, - height, - stride, - pointer_x, - pointer_y, - &shape_info, - &cache.buffer, - ); - } -} - -#[cfg(target_os = "windows")] -#[allow(clippy::too_many_arguments)] -fn composite_color_pointer( - frame: &mut [u8], - width: u32, - height: u32, - stride: u32, - pointer_x: i32, - pointer_y: i32, - shape_info: &DXGI_OUTDUPL_POINTER_SHAPE_INFO, - shape: &[u8], -) { - let pitch = shape_info.Pitch as usize; - for y in 0..shape_info.Height as i32 { - let dst_y = pointer_y + y; - if dst_y < 0 || dst_y >= height as i32 { - continue; - } - for x in 0..shape_info.Width as i32 { - let dst_x = pointer_x + x; - if dst_x < 0 || dst_x >= width as i32 { - continue; - } - let src_offset = y as usize * pitch + x as usize * 4; - let dst_offset = dst_y as usize * stride as usize + dst_x as usize * 4; - if src_offset + 4 > shape.len() || dst_offset + 4 > frame.len() { - continue; - } - blend_bgra_pixel(frame, dst_offset, shape, src_offset); - } - } -} - -#[cfg(target_os = "windows")] -#[allow(clippy::too_many_arguments)] -fn composite_masked_color_pointer( - frame: &mut [u8], - width: u32, - height: u32, - stride: u32, - pointer_x: i32, - pointer_y: i32, - shape_info: &DXGI_OUTDUPL_POINTER_SHAPE_INFO, - shape: &[u8], -) { - let pitch = shape_info.Pitch as usize; - for y in 0..shape_info.Height as i32 { - let dst_y = pointer_y + y; - if dst_y < 0 || dst_y >= height as i32 { - continue; - } - for x in 0..shape_info.Width as i32 { - let dst_x = pointer_x + x; - if dst_x < 0 || dst_x >= width as i32 { - continue; - } - let src_offset = y as usize * pitch + x as usize * 4; - let dst_offset = dst_y as usize * stride as usize + dst_x as usize * 4; - if src_offset + 4 > shape.len() || dst_offset + 4 > frame.len() { - continue; - } - let mask = shape[src_offset + 3]; - if mask == 0 { - frame[dst_offset..dst_offset + 3] - .copy_from_slice(&shape[src_offset..src_offset + 3]); - frame[dst_offset + 3] = 255; - } else if mask == 0xff { - frame[dst_offset] ^= shape[src_offset]; - frame[dst_offset + 1] ^= shape[src_offset + 1]; - frame[dst_offset + 2] ^= shape[src_offset + 2]; - frame[dst_offset + 3] = 255; - } else { - blend_bgra_pixel(frame, dst_offset, shape, src_offset); - } - } - } -} - -#[cfg(target_os = "windows")] -#[allow(clippy::too_many_arguments)] -fn composite_monochrome_pointer( - frame: &mut [u8], - width: u32, - height: u32, - stride: u32, - pointer_x: i32, - pointer_y: i32, - shape_info: &DXGI_OUTDUPL_POINTER_SHAPE_INFO, - shape: &[u8], -) { - let pitch = shape_info.Pitch as usize; - let full_height = shape_info.Height as usize; - if pitch == 0 || full_height == 0 { - return; - } - - let visible_height = if shape.len() >= pitch * full_height * 2 { - full_height - } else { - full_height / 2 - }; - if visible_height == 0 || shape.len() < pitch * visible_height * 2 { - return; - } - - let xor_mask_offset = pitch * visible_height; - for y in 0..visible_height as i32 { - let dst_y = pointer_y + y; - if dst_y < 0 || dst_y >= height as i32 { - continue; - } - for x in 0..shape_info.Width as i32 { - let dst_x = pointer_x + x; - if dst_x < 0 || dst_x >= width as i32 { - continue; - } - let byte_index = y as usize * pitch + x as usize / 8; - let mask = 0x80 >> (x as usize % 8); - if byte_index >= xor_mask_offset || xor_mask_offset + byte_index >= shape.len() { - continue; - } - - let and_set = shape[byte_index] & mask != 0; - let xor_set = shape[xor_mask_offset + byte_index] & mask != 0; - let dst_offset = dst_y as usize * stride as usize + dst_x as usize * 4; - if dst_offset + 4 > frame.len() { - continue; - } - - match (and_set, xor_set) { - (true, false) => {} - (true, true) => { - frame[dst_offset] ^= 0xff; - frame[dst_offset + 1] ^= 0xff; - frame[dst_offset + 2] ^= 0xff; - frame[dst_offset + 3] = 255; - } - (false, false) => { - frame[dst_offset] = 0; - frame[dst_offset + 1] = 0; - frame[dst_offset + 2] = 0; - frame[dst_offset + 3] = 255; - } - (false, true) => { - frame[dst_offset] = 255; - frame[dst_offset + 1] = 255; - frame[dst_offset + 2] = 255; - frame[dst_offset + 3] = 255; - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::pacing_sleep_and_next_deadline; - use std::time::{Duration, Instant}; - - const TEST_FRAME_INTERVAL: Duration = Duration::from_millis(33); - - #[test] - fn pacing_sleeps_remaining_time_and_advances_deadline_by_interval() { - let start = Instant::now(); - let deadline = start + TEST_FRAME_INTERVAL; - let now = start + Duration::from_millis(10); - let (sleep_duration, next_deadline) = - pacing_sleep_and_next_deadline(now, deadline, TEST_FRAME_INTERVAL); - assert_eq!(sleep_duration, Duration::from_millis(23)); - assert_eq!(next_deadline, deadline + TEST_FRAME_INTERVAL); - } - - #[test] - fn pacing_does_not_drift_across_iterations_with_varying_work() { - let start = Instant::now(); - let mut deadline = start + TEST_FRAME_INTERVAL; - let mut now = start; - for iteration in 1..=100u32 { - now += Duration::from_millis(7); - let (sleep_duration, next_deadline) = - pacing_sleep_and_next_deadline(now, deadline, TEST_FRAME_INTERVAL); - assert!(sleep_duration <= TEST_FRAME_INTERVAL); - now += sleep_duration; - deadline = next_deadline; - assert_eq!(now, start + TEST_FRAME_INTERVAL * iteration); - } - } - - #[test] - fn pacing_resets_deadline_without_sleeping_when_behind() { - let start = Instant::now(); - let deadline = start + TEST_FRAME_INTERVAL; - let now = deadline + Duration::from_millis(50); - let (sleep_duration, next_deadline) = - pacing_sleep_and_next_deadline(now, deadline, TEST_FRAME_INTERVAL); - assert_eq!(sleep_duration, Duration::ZERO); - assert_eq!(next_deadline, now + TEST_FRAME_INTERVAL); - } - - #[test] - fn pacing_at_exact_deadline_resets_without_sleeping() { - let start = Instant::now(); - let deadline = start + TEST_FRAME_INTERVAL; - let (sleep_duration, next_deadline) = - pacing_sleep_and_next_deadline(deadline, deadline, TEST_FRAME_INTERVAL); - assert_eq!(sleep_duration, Duration::ZERO); - assert_eq!(next_deadline, deadline + TEST_FRAME_INTERVAL); - } -} - -#[cfg(target_os = "windows")] -fn blend_bgra_pixel(frame: &mut [u8], dst_offset: usize, shape: &[u8], src_offset: usize) { - let alpha = shape[src_offset + 3] as u32; - if alpha == 0 { - return; - } - if alpha == 255 { - frame[dst_offset..dst_offset + 4].copy_from_slice(&shape[src_offset..src_offset + 4]); - return; - } - let inv_alpha = 255 - alpha; - for channel in 0..3 { - let src = shape[src_offset + channel] as u32; - let dst = frame[dst_offset + channel] as u32; - frame[dst_offset + channel] = ((src * alpha + dst * inv_alpha + 127) / 255) as u8; - } - frame[dst_offset + 3] = 255; -} diff --git a/fluxer_desktop/native/win-game-capture/src/encoder_attach.rs b/fluxer_desktop/native/win-game-capture/src/encoder_attach.rs deleted file mode 100644 index a1d337eb3..000000000 --- a/fluxer_desktop/native/win-game-capture/src/encoder_attach.rs +++ /dev/null @@ -1,417 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::time::Duration; - -use parking_lot::{Condvar, Mutex}; - -use fluxer_encoder_ring::d3d11::D3D11SharedHandle; -use fluxer_encoder_ring::{ - D3D11KeyedMutexBackend, EncoderFrameRate, EncoderInputRing, EncoderReady, RING_SIZE, RingError, - TextureFormat, -}; - -#[cfg(target_os = "windows")] -use fluxer_encoder_ring::{EncoderDims, FillReservation, NvencD3D11Handoff}; - -#[cfg(target_os = "windows")] -use windows::Win32::Graphics::Direct3D11::{ID3D11DeviceContext, ID3D11Texture2D}; - -pub const READY_WAIT_TIMEOUT_CAP: Duration = Duration::from_millis(17); - -const EVICTIONS_PER_RESERVE_MAX: u64 = 2; - -#[cfg(target_os = "windows")] -const NVENC_BITRATE_BPS_DEFAULT: u32 = 8_000_000; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EncoderAttachError { - AlreadyAttached, - InvalidDimensions { width: u32, height: u32 }, - RingInitFailed, - NotAttached, - BlitFailed, - SlotUnavailable, - DeviceUnavailable, -} - -impl std::fmt::Display for EncoderAttachError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::AlreadyAttached => write!(f, "encoder ring already attached"), - Self::InvalidDimensions { width, height } => { - write!(f, "encoder ring invalid dimensions {width}x{height}") - } - Self::RingInitFailed => write!(f, "encoder ring initialise failed"), - Self::NotAttached => write!(f, "encoder ring not attached"), - Self::BlitFailed => write!(f, "blit into ring slot failed"), - Self::SlotUnavailable => write!(f, "no ring slot texture available"), - Self::DeviceUnavailable => write!(f, "ring backend device unavailable"), - } - } -} - -impl std::error::Error for EncoderAttachError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct EncoderAttachStats { - pub frames_submitted: u64, - pub frames_dropped: u64, - pub ring_full_events: u64, - pub failed_blits: u64, -} - -pub struct EncoderAttachment { - ring: Mutex>, - ready_condvar: Condvar, - attached: AtomicBool, - frames_submitted: AtomicU64, - frames_dropped: AtomicU64, - ring_full_events: AtomicU64, - failed_blits: AtomicU64, - width: u32, - height: u32, - frame_rate: EncoderFrameRate, - #[cfg(target_os = "windows")] - context: ID3D11DeviceContext, - #[cfg(target_os = "windows")] - nvenc: Mutex>, -} - -impl EncoderAttachment { - pub fn try_new(width: u32, height: u32) -> Result, EncoderAttachError> { - Self::try_new_with_frame_rate(width, height, EncoderFrameRate::default()) - } - - pub fn try_new_with_frame_rate( - width: u32, - height: u32, - frame_rate: EncoderFrameRate, - ) -> Result, EncoderAttachError> { - if width == 0 || height == 0 { - return Err(EncoderAttachError::InvalidDimensions { width, height }); - } - assert!(frame_rate.numerator > 0, "frame rate numerator positive"); - assert!( - frame_rate.denominator > 0, - "frame rate denominator positive" - ); - let backend = - D3D11KeyedMutexBackend::new().map_err(|_| EncoderAttachError::RingInitFailed)?; - let mut ring = EncoderInputRing::new(backend); - ring.initialise(width, height, TextureFormat::Nv12) - .map_err(|_| EncoderAttachError::RingInitFailed)?; - #[cfg(target_os = "windows")] - let context = ring - .backend_mut() - .context() - .ok_or(EncoderAttachError::DeviceUnavailable)?; - #[cfg(target_os = "windows")] - let nvenc = init_nvenc_pre_registered(&mut ring, width, height, frame_rate); - let attachment = Self { - ring: Mutex::new(ring), - ready_condvar: Condvar::new(), - attached: AtomicBool::new(true), - frames_submitted: AtomicU64::new(0), - frames_dropped: AtomicU64::new(0), - ring_full_events: AtomicU64::new(0), - failed_blits: AtomicU64::new(0), - width, - height, - frame_rate, - #[cfg(target_os = "windows")] - context, - #[cfg(target_os = "windows")] - nvenc: Mutex::new(nvenc), - }; - assert!( - attachment.attached.load(Ordering::Acquire), - "attachment is attached" - ); - assert!(attachment.width > 0, "attachment width positive"); - assert!( - attachment.frame_rate.numerator > 0, - "attachment fps positive" - ); - Ok(Arc::new(attachment)) - } - - pub fn width(&self) -> u32 { - let w = self.width; - assert!(w > 0, "attachment width positive"); - assert!(self.height > 0, "attachment height positive"); - w - } - - pub fn height(&self) -> u32 { - let h = self.height; - assert!(h > 0, "attachment height positive"); - assert!(self.width > 0, "attachment width positive"); - h - } - - pub fn frame_rate(&self) -> EncoderFrameRate { - let rate = self.frame_rate; - assert!(rate.numerator > 0, "attachment fps numerator positive"); - assert!(rate.denominator > 0, "attachment fps denominator positive"); - rate - } - - pub fn is_attached(&self) -> bool { - let a = self.attached.load(Ordering::Acquire); - assert!(self.width > 0, "width intact while reading attached"); - assert!(self.height > 0, "height intact while reading attached"); - a - } - - pub fn detach(&self) { - self.attached.store(false, Ordering::Release); - self.ready_condvar.notify_all(); - assert!( - !self.attached.load(Ordering::Acquire), - "detach flag cleared" - ); - assert!(self.width > 0, "attachment intact across detach"); - } - - pub fn capacity(&self) -> usize { - let cap = RING_SIZE; - assert!(cap > 0, "ring capacity positive"); - assert_eq!(cap, 8, "ring capacity matches RING_SIZE"); - cap - } - - pub fn note_ring_full(&self) { - self.ring_full_events.fetch_add(1, Ordering::Relaxed); - } - - #[cfg(target_os = "windows")] - pub fn nvenc_pre_registered(&self) -> bool { - let registered = self.nvenc.lock().is_some(); - assert!(self.width > 0, "width intact while reading nvenc state"); - assert!(self.height > 0, "height intact while reading nvenc state"); - registered - } - - pub fn stats(&self) -> EncoderAttachStats { - let stats = EncoderAttachStats { - frames_submitted: self.frames_submitted.load(Ordering::Relaxed), - frames_dropped: self.frames_dropped.load(Ordering::Relaxed), - ring_full_events: self.ring_full_events.load(Ordering::Relaxed), - failed_blits: self.failed_blits.load(Ordering::Relaxed), - }; - assert!( - stats.frames_submitted <= u64::MAX / 2, - "submitted plausible" - ); - assert!(stats.frames_dropped <= u64::MAX / 2, "dropped plausible"); - stats - } - - fn account_evictions(&self, evicted_delta: u64) { - assert!( - evicted_delta <= EVICTIONS_PER_RESERVE_MAX, - "evictions bounded per reserve" - ); - assert!(self.width > 0, "attachment intact while accounting"); - if evicted_delta == 0 { - return; - } - self.frames_dropped - .fetch_add(evicted_delta, Ordering::Relaxed); - self.ring_full_events - .fetch_add(evicted_delta, Ordering::Relaxed); - } - - pub fn submit_notify(&self) -> Result<(), EncoderAttachError> { - if !self.attached.load(Ordering::Acquire) { - return Err(EncoderAttachError::NotAttached); - } - let mut ring = self.ring.lock(); - let pre_dropped = ring.dropped_count(); - let result: Result<(), RingError> = ring.submit_skip_oldest(|_handle| {}); - let evicted_delta = ring.dropped_count().saturating_sub(pre_dropped); - drop(ring); - self.account_evictions(evicted_delta); - match result { - Ok(()) => { - self.frames_submitted.fetch_add(1, Ordering::Relaxed); - self.ready_condvar.notify_one(); - Ok(()) - } - Err(RingError::FullDropped { .. }) => Ok(()), - Err(_) => Err(EncoderAttachError::RingInitFailed), - } - } - - pub fn wait_next_ready(&self, timeout: Duration) -> Option> { - assert!(self.width > 0, "attachment width positive"); - assert!(self.height > 0, "attachment height positive"); - if !self.attached.load(Ordering::Acquire) { - return None; - } - let capped_timeout = timeout.min(READY_WAIT_TIMEOUT_CAP); - let mut ring = self.ring.lock(); - if let Some(ready) = ring.poll_next_ready() { - return Some(ready); - } - let _ = self.ready_condvar.wait_for(&mut ring, capped_timeout); - if !self.attached.load(Ordering::Acquire) { - return None; - } - ring.poll_next_ready() - } - - #[cfg(target_os = "windows")] - fn cancel_reservation(&self, reservation: FillReservation) { - assert!( - (reservation.slot_index() as usize) < RING_SIZE, - "cancelled slot within ring" - ); - assert!(self.width > 0, "attachment intact while cancelling"); - let mut ring = self.ring.lock(); - let _ = ring.cancel(reservation); - } - - #[cfg(target_os = "windows")] - pub fn submit_capture_frame_with_blit( - &self, - capture_texture: &ID3D11Texture2D, - capture_width: u32, - capture_height: u32, - ) -> Result<(), EncoderAttachError> { - assert!(capture_width > 0, "capture width positive"); - assert!(capture_height > 0, "capture height positive"); - assert!(self.width > 0, "attachment width positive"); - assert!(self.height > 0, "attachment height positive"); - if !self.attached.load(Ordering::Acquire) { - return Err(EncoderAttachError::NotAttached); - } - let mut ring = self.ring.lock(); - let pre_dropped = ring.dropped_count(); - let reserved = ring.reserve_skip_oldest(); - let evicted_delta = ring.dropped_count().saturating_sub(pre_dropped); - let dest_texture = match &reserved { - Ok(reservation) => ring - .backend_mut() - .texture_for_slot(reservation.slot_index()), - Err(_) => None, - }; - drop(ring); - self.account_evictions(evicted_delta); - let reservation = match reserved { - Ok(reservation) => reservation, - Err(RingError::FullDropped { .. }) => return Ok(()), - Err(_) => return Err(EncoderAttachError::RingInitFailed), - }; - let Some(dest_texture) = dest_texture else { - self.cancel_reservation(reservation); - self.failed_blits.fetch_add(1, Ordering::Relaxed); - return Err(EncoderAttachError::BlitFailed); - }; - unsafe { - self.context.CopyResource(&dest_texture, capture_texture); - } - let mut ring = self.ring.lock(); - match ring.commit(reservation) { - Ok(sequence) => { - drop(ring); - assert!(sequence > 0, "committed sequence positive"); - self.frames_submitted.fetch_add(1, Ordering::Relaxed); - self.ready_condvar.notify_one(); - Ok(()) - } - Err(_) => Err(EncoderAttachError::RingInitFailed), - } - } -} - -#[cfg(target_os = "windows")] -fn collect_ring_slot_handles( - ring: &mut EncoderInputRing, -) -> Option> { - assert_eq!( - ring.free_count(), - RING_SIZE, - "collect requires a fresh ring" - ); - let mut reservations: Vec> = Vec::with_capacity(RING_SIZE); - let mut handles: Vec = Vec::with_capacity(RING_SIZE); - for _ in 0..RING_SIZE { - let Ok(reservation) = ring.reserve() else { - break; - }; - handles.push(reservation.handle.clone()); - reservations.push(reservation); - } - let mut all_cancelled = true; - for reservation in reservations.drain(..) { - if ring.cancel(reservation).is_err() { - all_cancelled = false; - } - } - if !all_cancelled { - return None; - } - assert_eq!(ring.free_count(), RING_SIZE, "all reservations returned"); - assert!(handles.len() <= RING_SIZE, "collected handles bounded"); - if handles.len() == RING_SIZE { - Some(handles) - } else { - None - } -} - -#[cfg(target_os = "windows")] -fn init_nvenc_pre_registered( - ring: &mut EncoderInputRing, - width: u32, - height: u32, - frame_rate: EncoderFrameRate, -) -> Option { - assert!(width > 0, "nvenc init width positive"); - assert!(height > 0, "nvenc init height positive"); - assert!(frame_rate.numerator > 0, "nvenc frame rate positive"); - let device = ring.backend_mut().device()?; - let handles = collect_ring_slot_handles(ring)?; - assert_eq!(handles.len(), RING_SIZE, "pre-register covers whole ring"); - let dims = EncoderDims::new(width, height); - let mut handoff = - NvencD3D11Handoff::new_with_frame_rate(device, dims, NVENC_BITRATE_BPS_DEFAULT, frame_rate) - .ok()?; - handoff.pre_register_slots(&handles, dims).ok()?; - Some(handoff) -} - -impl Drop for EncoderAttachment { - fn drop(&mut self) { - self.attached.store(false, Ordering::Release); - self.ready_condvar.notify_all(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rejects_zero_dimensions() { - let err = EncoderAttachment::try_new(0, 720).err(); - assert!(matches!( - err, - Some(EncoderAttachError::InvalidDimensions { .. }) - )); - let err2 = EncoderAttachment::try_new(640, 0).err(); - assert!(matches!( - err2, - Some(EncoderAttachError::InvalidDimensions { .. }) - )); - } - - #[test] - fn ready_wait_timeout_cap_is_one_frame_interval() { - assert!(READY_WAIT_TIMEOUT_CAP >= Duration::from_millis(1)); - assert!(READY_WAIT_TIMEOUT_CAP <= Duration::from_millis(33)); - } -} diff --git a/fluxer_desktop/native/win-game-capture/src/fallback.rs b/fluxer_desktop/native/win-game-capture/src/fallback.rs deleted file mode 100644 index 68647bed4..000000000 --- a/fluxer_desktop/native/win-game-capture/src/fallback.rs +++ /dev/null @@ -1,710 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -#![allow(dead_code)] - -use crate::game_capture_abi::{ - GAME_CAPTURE_FALLBACK_DEVICE_LOST, GAME_CAPTURE_FALLBACK_EXTERNAL_MEMORY_UNSUPPORTED, - GAME_CAPTURE_FALLBACK_FORCED_CPU, GAME_CAPTURE_FALLBACK_FORMAT_UNSUPPORTED, - GAME_CAPTURE_FALLBACK_MULTISAMPLED, GAME_CAPTURE_FALLBACK_NONE, - GAME_CAPTURE_FALLBACK_SHARED_TEXTURE_UNSUPPORTED, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum CaptureStrategy { - GameHook, - Wgc, - DxgiDuplication, - WindowGdi, -} - -impl CaptureStrategy { - pub const RANKED: [CaptureStrategy; 4] = [ - CaptureStrategy::GameHook, - CaptureStrategy::Wgc, - CaptureStrategy::DxgiDuplication, - CaptureStrategy::WindowGdi, - ]; - - pub fn rank(self) -> u8 { - match self { - CaptureStrategy::GameHook => 0, - CaptureStrategy::Wgc => 1, - CaptureStrategy::DxgiDuplication => 2, - CaptureStrategy::WindowGdi => 3, - } - } - - pub fn next_worse(self) -> Option { - match self { - CaptureStrategy::GameHook => Some(CaptureStrategy::Wgc), - CaptureStrategy::Wgc => Some(CaptureStrategy::DxgiDuplication), - CaptureStrategy::DxgiDuplication => Some(CaptureStrategy::WindowGdi), - CaptureStrategy::WindowGdi => None, - } - } - - pub fn as_str(self) -> &'static str { - match self { - CaptureStrategy::GameHook => "game-hook", - CaptureStrategy::Wgc => "wgc", - CaptureStrategy::DxgiDuplication => "dxgi-duplication", - CaptureStrategy::WindowGdi => "window-gdi", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum FailureSignature { - InjectionFailed, - AntiCheatDenied, - NoFramesWithinTimeout, - DeviceLost, - UnsupportedTransport, - UnsupportedFormat, - WindowGone, - FramesRecovered, -} - -impl FailureSignature { - pub fn from_fallback_reason(reason: u32) -> Option { - match reason { - GAME_CAPTURE_FALLBACK_NONE => None, - GAME_CAPTURE_FALLBACK_SHARED_TEXTURE_UNSUPPORTED - | GAME_CAPTURE_FALLBACK_EXTERNAL_MEMORY_UNSUPPORTED => { - Some(FailureSignature::UnsupportedTransport) - } - GAME_CAPTURE_FALLBACK_FORMAT_UNSUPPORTED | GAME_CAPTURE_FALLBACK_MULTISAMPLED => { - Some(FailureSignature::UnsupportedFormat) - } - GAME_CAPTURE_FALLBACK_DEVICE_LOST => Some(FailureSignature::DeviceLost), - GAME_CAPTURE_FALLBACK_FORCED_CPU => None, - _ => None, - } - } - - fn is_terminal_for_all(self) -> bool { - matches!(self, FailureSignature::WindowGone) - } - - fn permanently_disqualifies_current(self) -> bool { - matches!( - self, - FailureSignature::AntiCheatDenied - | FailureSignature::InjectionFailed - | FailureSignature::UnsupportedTransport - | FailureSignature::UnsupportedFormat - ) - } -} - -pub const MAX_DEVICE_LOST_RETRIES: u32 = 3; - -pub const STABLE_FRAMES_BEFORE_UPGRADE: u32 = 2; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum FallbackDecision { - Stay { - retry_in_place: bool, - }, - Transition { - from: CaptureStrategy, - to: CaptureStrategy, - reason: String, - }, - Upgrade { - from: CaptureStrategy, - to: CaptureStrategy, - reason: String, - }, - GiveUp { - reason: String, - }, -} - -#[derive(Debug, Clone)] -pub struct FallbackRanker { - active: CaptureStrategy, - disqualified: Vec, - device_lost_streak: u32, - stable_frame_streak: u32, - last_fallback_reason: Option, -} - -impl FallbackRanker { - pub fn new(initial: CaptureStrategy) -> Self { - Self { - active: initial, - disqualified: Vec::new(), - device_lost_streak: 0, - stable_frame_streak: 0, - last_fallback_reason: None, - } - } - - pub fn active(&self) -> CaptureStrategy { - self.active - } - - pub fn last_fallback_reason(&self) -> Option<&str> { - self.last_fallback_reason.as_deref() - } - - pub fn is_disqualified(&self, strategy: CaptureStrategy) -> bool { - self.disqualified.contains(&strategy) - } - - fn best_available_above(&self) -> Option { - CaptureStrategy::RANKED.iter().copied().find(|candidate| { - candidate.rank() < self.active.rank() && !self.is_disqualified(*candidate) - }) - } - - fn next_available_below(&self) -> Option { - let mut candidate = self.active.next_worse(); - while let Some(strategy) = candidate { - if !self.is_disqualified(strategy) { - return Some(strategy); - } - candidate = strategy.next_worse(); - } - None - } - - fn disqualify(&mut self, strategy: CaptureStrategy) { - if !self.disqualified.contains(&strategy) { - self.disqualified.push(strategy); - } - } - - pub fn observe(&mut self, signature: FailureSignature) -> FallbackDecision { - if signature.is_terminal_for_all() { - return FallbackDecision::GiveUp { - reason: format!( - "the capture target is gone; no capture strategy can continue (was using \ - {})", - self.active.as_str() - ), - }; - } - - if signature == FailureSignature::FramesRecovered { - self.device_lost_streak = 0; - self.stable_frame_streak = self.stable_frame_streak.saturating_add(1); - if self.stable_frame_streak >= STABLE_FRAMES_BEFORE_UPGRADE - && let Some(target) = self.best_available_above() - { - let from = self.active; - let reason = format!( - "{} has been stable for {} frames; attempting to upgrade back to the \ - preferred {} strategy", - from.as_str(), - self.stable_frame_streak, - target.as_str() - ); - self.active = target; - self.stable_frame_streak = 0; - self.last_fallback_reason = Some(reason.clone()); - return FallbackDecision::Upgrade { - from, - to: target, - reason, - }; - } - return FallbackDecision::Stay { - retry_in_place: false, - }; - } - - self.stable_frame_streak = 0; - - if signature == FailureSignature::DeviceLost { - self.device_lost_streak = self.device_lost_streak.saturating_add(1); - if self.device_lost_streak <= MAX_DEVICE_LOST_RETRIES { - return FallbackDecision::Stay { - retry_in_place: true, - }; - } - return self.fall_back( - signature, - &format!( - "{} kept losing its capture device ({} consecutive recoveries failed); falling \ - back", - self.active.as_str(), - self.device_lost_streak - ), - ); - } - - self.device_lost_streak = 0; - - if signature.permanently_disqualifies_current() { - self.disqualify(self.active); - } - - let reason = describe_failure(self.active, signature); - self.fall_back(signature, &reason) - } - - fn fall_back(&mut self, signature: FailureSignature, reason: &str) -> FallbackDecision { - self.device_lost_streak = 0; - match self.next_available_below() { - Some(target) => { - let from = self.active; - let full = format!("{reason}; switching to {} capture", target.as_str()); - self.active = target; - self.last_fallback_reason = Some(full.clone()); - FallbackDecision::Transition { - from, - to: target, - reason: full, - } - } - None => { - let give_up = format!( - "{reason}, and no lower-ranked capture strategy is available ({} was the last \ - resort, signature={:?})", - self.active.as_str(), - signature - ); - self.last_fallback_reason = Some(give_up.clone()); - FallbackDecision::GiveUp { reason: give_up } - } - } - } -} - -#[derive(Debug, Clone, Default)] -pub struct FallbackSnapshot { - pub active_strategy: String, - pub last_fallback_reason: String, -} - -#[derive(Debug)] -pub struct FallbackTracker { - ranker: FallbackRanker, -} - -impl FallbackTracker { - pub fn new(initial: CaptureStrategy) -> Self { - Self { - ranker: FallbackRanker::new(initial), - } - } - - pub fn observe(&mut self, signature: FailureSignature) -> FallbackDecision { - self.ranker.observe(signature) - } - - pub fn active(&self) -> CaptureStrategy { - self.ranker.active() - } - - pub fn snapshot(&self) -> FallbackSnapshot { - FallbackSnapshot { - active_strategy: self.ranker.active().as_str().to_string(), - last_fallback_reason: self.ranker.last_fallback_reason().unwrap_or("").to_string(), - } - } -} - -pub fn decision_lifecycle(decision: &FallbackDecision) -> (&'static str, String) { - match decision { - FallbackDecision::Stay { retry_in_place } => ( - "diagnostic", - if *retry_in_place { - "capture hit a transient device loss; retrying the current strategy in place" - .to_string() - } else { - "capture is healthy on the current strategy".to_string() - }, - ), - FallbackDecision::Transition { from, to, reason } => ( - "error", - format!( - "fallback: {} -> {} ({reason}) [next-strategy={}]", - from.as_str(), - to.as_str(), - to.as_str() - ), - ), - FallbackDecision::Upgrade { from, to, reason } => ( - "diagnostic", - format!( - "upgrade: {} -> {} ({reason}) [next-strategy={}]", - from.as_str(), - to.as_str(), - to.as_str() - ), - ), - FallbackDecision::GiveUp { reason } => ( - "error", - format!("fallback exhausted: {reason} [next-strategy=none]"), - ), - } -} - -fn describe_failure(strategy: CaptureStrategy, signature: FailureSignature) -> String { - let what = match signature { - FailureSignature::InjectionFailed => "could not inject its capture hook", - FailureSignature::AntiCheatDenied => { - "is protected by anti-cheat, so the capture hook must not be injected" - } - FailureSignature::NoFramesWithinTimeout => "produced no frames within the start timeout", - FailureSignature::DeviceLost => "lost its capture device", - FailureSignature::UnsupportedTransport => "used a frame transport this build cannot read", - FailureSignature::UnsupportedFormat => "used a swapchain format the fast path cannot read", - FailureSignature::WindowGone => "lost its capture target", - FailureSignature::FramesRecovered => "recovered", - }; - format!("{} capture {what}", strategy.as_str()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::game_capture_abi::{ - GAME_CAPTURE_FALLBACK_DEVICE_LOST, GAME_CAPTURE_FALLBACK_EXTERNAL_MEMORY_UNSUPPORTED, - GAME_CAPTURE_FALLBACK_FORCED_CPU, GAME_CAPTURE_FALLBACK_FORMAT_UNSUPPORTED, - GAME_CAPTURE_FALLBACK_MULTISAMPLED, GAME_CAPTURE_FALLBACK_NONE, - GAME_CAPTURE_FALLBACK_SHARED_TEXTURE_UNSUPPORTED, - }; - - #[test] - fn ranking_order_is_best_to_worst() { - assert!(CaptureStrategy::GameHook.rank() < CaptureStrategy::Wgc.rank()); - assert!(CaptureStrategy::Wgc.rank() < CaptureStrategy::DxgiDuplication.rank()); - assert!(CaptureStrategy::DxgiDuplication.rank() < CaptureStrategy::WindowGdi.rank()); - assert_eq!( - CaptureStrategy::GameHook.next_worse(), - Some(CaptureStrategy::Wgc) - ); - assert_eq!( - CaptureStrategy::Wgc.next_worse(), - Some(CaptureStrategy::DxgiDuplication) - ); - assert_eq!( - CaptureStrategy::DxgiDuplication.next_worse(), - Some(CaptureStrategy::WindowGdi) - ); - assert_eq!(CaptureStrategy::WindowGdi.next_worse(), None); - } - - #[test] - fn injection_failure_falls_back_to_wgc() { - let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook); - match ranker.observe(FailureSignature::InjectionFailed) { - FallbackDecision::Transition { from, to, .. } => { - assert_eq!(from, CaptureStrategy::GameHook); - assert_eq!(to, CaptureStrategy::Wgc); - } - other => panic!("expected Transition, got {other:?}"), - } - assert_eq!(ranker.active(), CaptureStrategy::Wgc); - assert!(ranker.is_disqualified(CaptureStrategy::GameHook)); - assert!(ranker.last_fallback_reason().is_some()); - } - - #[test] - fn no_frames_timeout_falls_back_but_does_not_disqualify() { - let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook); - match ranker.observe(FailureSignature::NoFramesWithinTimeout) { - FallbackDecision::Transition { to, .. } => { - assert_eq!(to, CaptureStrategy::Wgc) - } - other => panic!("expected Transition, got {other:?}"), - } - assert!(!ranker.is_disqualified(CaptureStrategy::GameHook)); - } - - #[test] - fn wgc_failure_falls_back_to_dxgi() { - let mut ranker = FallbackRanker::new(CaptureStrategy::Wgc); - match ranker.observe(FailureSignature::NoFramesWithinTimeout) { - FallbackDecision::Transition { from, to, .. } => { - assert_eq!(from, CaptureStrategy::Wgc); - assert_eq!(to, CaptureStrategy::DxgiDuplication); - } - other => panic!("expected Transition, got {other:?}"), - } - assert!(!ranker.is_disqualified(CaptureStrategy::Wgc)); - } - - #[test] - fn anti_cheat_denied_disqualifies_game_hook() { - let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook); - let decision = ranker.observe(FailureSignature::AntiCheatDenied); - assert!(matches!( - decision, - FallbackDecision::Transition { - to: CaptureStrategy::Wgc, - .. - } - )); - assert!(ranker.is_disqualified(CaptureStrategy::GameHook)); - } - - #[test] - fn device_lost_retries_in_place_then_falls_back() { - let mut ranker = FallbackRanker::new(CaptureStrategy::DxgiDuplication); - for _ in 0..MAX_DEVICE_LOST_RETRIES { - assert_eq!( - ranker.observe(FailureSignature::DeviceLost), - FallbackDecision::Stay { - retry_in_place: true - } - ); - assert_eq!(ranker.active(), CaptureStrategy::DxgiDuplication); - } - match ranker.observe(FailureSignature::DeviceLost) { - FallbackDecision::Transition { to, .. } => assert_eq!(to, CaptureStrategy::WindowGdi), - other => panic!("expected Transition after retry budget, got {other:?}"), - } - assert!(!ranker.is_disqualified(CaptureStrategy::DxgiDuplication)); - } - - #[test] - fn frames_recovered_resets_device_lost_streak() { - let mut ranker = FallbackRanker::new(CaptureStrategy::DxgiDuplication); - ranker.observe(FailureSignature::DeviceLost); - ranker.observe(FailureSignature::DeviceLost); - assert_eq!( - ranker.observe(FailureSignature::FramesRecovered), - FallbackDecision::Stay { - retry_in_place: false - } - ); - for _ in 0..MAX_DEVICE_LOST_RETRIES { - assert_eq!( - ranker.observe(FailureSignature::DeviceLost), - FallbackDecision::Stay { - retry_in_place: true - } - ); - } - } - - #[test] - fn window_gone_gives_up_from_any_strategy() { - for start in CaptureStrategy::RANKED { - let mut ranker = FallbackRanker::new(start); - match ranker.observe(FailureSignature::WindowGone) { - FallbackDecision::GiveUp { .. } => {} - other => panic!("expected GiveUp from {start:?}, got {other:?}"), - } - } - } - - #[test] - fn last_resort_failure_gives_up() { - let mut ranker = FallbackRanker::new(CaptureStrategy::WindowGdi); - match ranker.observe(FailureSignature::NoFramesWithinTimeout) { - FallbackDecision::GiveUp { reason } => assert!(reason.contains("window-gdi")), - other => panic!("expected GiveUp at last resort, got {other:?}"), - } - } - - #[test] - fn full_descent_game_to_wgc_to_dxgi_to_window_then_give_up() { - let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook); - assert!(matches!( - ranker.observe(FailureSignature::InjectionFailed), - FallbackDecision::Transition { - to: CaptureStrategy::Wgc, - .. - } - )); - assert!(matches!( - ranker.observe(FailureSignature::UnsupportedTransport), - FallbackDecision::Transition { - to: CaptureStrategy::DxgiDuplication, - .. - } - )); - assert!(matches!( - ranker.observe(FailureSignature::UnsupportedTransport), - FallbackDecision::Transition { - to: CaptureStrategy::WindowGdi, - .. - } - )); - assert!(matches!( - ranker.observe(FailureSignature::UnsupportedFormat), - FallbackDecision::GiveUp { .. } - )); - } - - #[test] - fn stable_recovery_climbs_back_up_skipping_disqualified() { - let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook); - ranker.observe(FailureSignature::InjectionFailed); - for _ in 0..=MAX_DEVICE_LOST_RETRIES { - ranker.observe(FailureSignature::DeviceLost); - } - assert_eq!(ranker.active(), CaptureStrategy::DxgiDuplication); - - assert_eq!( - ranker.observe(FailureSignature::FramesRecovered), - FallbackDecision::Stay { - retry_in_place: false - } - ); - match ranker.observe(FailureSignature::FramesRecovered) { - FallbackDecision::Upgrade { from, to, .. } => { - assert_eq!(from, CaptureStrategy::DxgiDuplication); - assert_eq!(to, CaptureStrategy::Wgc); - } - other => panic!("expected Upgrade, got {other:?}"), - } - assert_eq!(ranker.active(), CaptureStrategy::Wgc); - } - - #[test] - fn no_upgrade_when_already_best() { - let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook); - for _ in 0..(STABLE_FRAMES_BEFORE_UPGRADE + 2) { - assert_eq!( - ranker.observe(FailureSignature::FramesRecovered), - FallbackDecision::Stay { - retry_in_place: false - } - ); - } - assert_eq!(ranker.active(), CaptureStrategy::GameHook); - } - - #[test] - fn upgrade_does_not_revive_disqualified_better_strategy() { - let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook); - ranker.observe(FailureSignature::AntiCheatDenied); - assert_eq!(ranker.active(), CaptureStrategy::Wgc); - for _ in 0..(STABLE_FRAMES_BEFORE_UPGRADE + 2) { - assert_eq!( - ranker.observe(FailureSignature::FramesRecovered), - FallbackDecision::Stay { - retry_in_place: false - } - ); - } - assert_eq!(ranker.active(), CaptureStrategy::Wgc); - } - - #[test] - fn fallback_reason_maps_from_abi_reason() { - assert_eq!( - FailureSignature::from_fallback_reason(GAME_CAPTURE_FALLBACK_NONE), - None - ); - assert_eq!( - FailureSignature::from_fallback_reason( - GAME_CAPTURE_FALLBACK_SHARED_TEXTURE_UNSUPPORTED - ), - Some(FailureSignature::UnsupportedTransport) - ); - assert_eq!( - FailureSignature::from_fallback_reason( - GAME_CAPTURE_FALLBACK_EXTERNAL_MEMORY_UNSUPPORTED - ), - Some(FailureSignature::UnsupportedTransport) - ); - assert_eq!( - FailureSignature::from_fallback_reason(GAME_CAPTURE_FALLBACK_FORMAT_UNSUPPORTED), - Some(FailureSignature::UnsupportedFormat) - ); - assert_eq!( - FailureSignature::from_fallback_reason(GAME_CAPTURE_FALLBACK_MULTISAMPLED), - Some(FailureSignature::UnsupportedFormat) - ); - assert_eq!( - FailureSignature::from_fallback_reason(GAME_CAPTURE_FALLBACK_DEVICE_LOST), - Some(FailureSignature::DeviceLost) - ); - assert_eq!( - FailureSignature::from_fallback_reason(GAME_CAPTURE_FALLBACK_FORCED_CPU), - None - ); - assert_eq!(FailureSignature::from_fallback_reason(u32::MAX), None); - } - - #[test] - fn unsupported_transport_disqualifies_current_strategy() { - let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook); - match ranker.observe(FailureSignature::UnsupportedTransport) { - FallbackDecision::Transition { from, to, reason } => { - assert_eq!(from, CaptureStrategy::GameHook); - assert_eq!(to, CaptureStrategy::Wgc); - assert!(reason.contains("transport")); - } - other => panic!("expected Transition, got {other:?}"), - } - assert!(ranker.is_disqualified(CaptureStrategy::GameHook)); - } - - #[test] - fn forced_cpu_reason_is_not_a_failure_signature() { - let mut ranker = FallbackRanker::new(CaptureStrategy::GameHook); - if let Some(signature) = - FailureSignature::from_fallback_reason(GAME_CAPTURE_FALLBACK_FORCED_CPU) - { - ranker.observe(signature); - } - assert_eq!(ranker.active(), CaptureStrategy::GameHook); - assert!(!ranker.is_disqualified(CaptureStrategy::GameHook)); - } - - #[test] - fn strategy_names_are_stable_for_the_js_seam() { - assert_eq!(CaptureStrategy::GameHook.as_str(), "game-hook"); - assert_eq!(CaptureStrategy::Wgc.as_str(), "wgc"); - assert_eq!( - CaptureStrategy::DxgiDuplication.as_str(), - "dxgi-duplication" - ); - assert_eq!(CaptureStrategy::WindowGdi.as_str(), "window-gdi"); - } - - #[test] - fn tracker_snapshot_tracks_active_and_reason() { - let mut tracker = FallbackTracker::new(CaptureStrategy::GameHook); - let snap = tracker.snapshot(); - assert_eq!(snap.active_strategy, "game-hook"); - assert_eq!(snap.last_fallback_reason, "", "no reason before a fallback"); - - tracker.observe(FailureSignature::InjectionFailed); - let snap = tracker.snapshot(); - assert_eq!(snap.active_strategy, "wgc"); - assert!(snap.last_fallback_reason.contains("wgc")); - } - - #[test] - fn decision_lifecycle_maps_to_event_pairs() { - let transition = FallbackDecision::Transition { - from: CaptureStrategy::GameHook, - to: CaptureStrategy::DxgiDuplication, - reason: "game-hook capture could not inject its capture hook".to_string(), - }; - let (kind, msg) = decision_lifecycle(&transition); - assert_eq!(kind, "error"); - assert!(msg.contains("next-strategy=dxgi-duplication")); - - let upgrade = FallbackDecision::Upgrade { - from: CaptureStrategy::WindowGdi, - to: CaptureStrategy::DxgiDuplication, - reason: "stable".to_string(), - }; - let (kind, msg) = decision_lifecycle(&upgrade); - assert_eq!(kind, "diagnostic"); - assert!(msg.contains("next-strategy=dxgi-duplication")); - - let give_up = FallbackDecision::GiveUp { - reason: "window-gdi was the last resort".to_string(), - }; - let (kind, msg) = decision_lifecycle(&give_up); - assert_eq!(kind, "error"); - assert!(msg.contains("next-strategy=none")); - - let (kind, _) = decision_lifecycle(&FallbackDecision::Stay { - retry_in_place: true, - }); - assert_eq!(kind, "diagnostic"); - } -} diff --git a/fluxer_desktop/native/win-game-capture/src/game_capture_abi.rs b/fluxer_desktop/native/win-game-capture/src/game_capture_abi.rs deleted file mode 100644 index 595fdd2ce..000000000 --- a/fluxer_desktop/native/win-game-capture/src/game_capture_abi.rs +++ /dev/null @@ -1,307 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -#![allow(dead_code)] - -pub const GAME_CAPTURE_MAGIC: u32 = 0x4658_4743; -pub const GAME_CAPTURE_ABI_VERSION: u32 = 4; -pub const GAME_CAPTURE_ABI_VERSION_PRESENT_CLOCK: u32 = 4; - -pub const GAME_CAPTURE_PRESENT_CLOCK_WALL: u32 = 0; -pub const GAME_CAPTURE_PRESENT_CLOCK_QPC: u32 = 1; - -pub const GAME_CAPTURE_STATE_INIT: u32 = 0; -pub const GAME_CAPTURE_STATE_ACTIVE: u32 = 1; -pub const GAME_CAPTURE_STATE_STOPPED: u32 = 2; -pub const GAME_CAPTURE_STATE_ERROR: u32 = 3; -pub const GAME_CAPTURE_STATE_RESIZE_REQUIRED: u32 = 4; - -pub const GAME_CAPTURE_BUFFER_COUNT: usize = 2; -pub const GAME_CAPTURE_BYTES_PER_PIXEL: u32 = 4; -pub const GAME_CAPTURE_TRANSPORT_MEMORY: u32 = 0; -pub const GAME_CAPTURE_TRANSPORT_SHARED_TEXTURE: u32 = 1; - -pub const GAME_CAPTURE_API_UNKNOWN: u32 = 0; -pub const GAME_CAPTURE_API_OPENGL: u32 = 1; -pub const GAME_CAPTURE_API_D3D8: u32 = 2; -pub const GAME_CAPTURE_API_D3D9: u32 = 3; -pub const GAME_CAPTURE_API_D3D10: u32 = 4; -pub const GAME_CAPTURE_API_D3D11: u32 = 5; -pub const GAME_CAPTURE_API_D3D12: u32 = 6; -pub const GAME_CAPTURE_API_VULKAN: u32 = 7; - -pub const GAME_CAPTURE_FALLBACK_NONE: u32 = 0; -pub const GAME_CAPTURE_FALLBACK_SHARED_TEXTURE_UNSUPPORTED: u32 = 1; -pub const GAME_CAPTURE_FALLBACK_FORMAT_UNSUPPORTED: u32 = 2; -pub const GAME_CAPTURE_FALLBACK_FORCED_CPU: u32 = 3; -pub const GAME_CAPTURE_FALLBACK_EXTERNAL_MEMORY_UNSUPPORTED: u32 = 4; -pub const GAME_CAPTURE_FALLBACK_MULTISAMPLED: u32 = 5; -pub const GAME_CAPTURE_FALLBACK_DEVICE_LOST: u32 = 6; - -pub const GAME_CAPTURE_FLAG_HDR: u32 = 1 << 0; -pub const GAME_CAPTURE_FLAG_MULTISAMPLED: u32 = 1 << 1; -pub const GAME_CAPTURE_FLAG_FLIP_VERTICAL: u32 = 1 << 2; -pub const GAME_CAPTURE_FLAG_PROTECTED: u32 = 1 << 3; -pub const GAME_CAPTURE_FLAG_TEN_BIT: u32 = 1 << 4; - -pub const ENV_DISABLE_HOOK: &str = "FLUXER_GAME_CAPTURE_DISABLE_HOOK"; -pub const ENV_DISABLE_D3D12: &str = "FLUXER_GAME_CAPTURE_DISABLE_D3D12"; -pub const ENV_DISABLE_VULKAN: &str = "DISABLE_FLUXER_VULKAN_CAPTURE"; -pub const ENV_FORCE_CPU: &str = "FLUXER_GAME_CAPTURE_FORCE_CPU"; -pub const ENV_FORCE_SHARED_TEXTURE: &str = "FLUXER_GAME_CAPTURE_FORCE_SHARED_TEXTURE"; -pub const ENV_ENABLE_OPENGL_SHARED_TEXTURE: &str = - "FLUXER_GAME_CAPTURE_ENABLE_OPENGL_SHARED_TEXTURE"; -pub const ENV_DISABLE_OPENGL_SHARED_TEXTURE: &str = - "FLUXER_GAME_CAPTURE_DISABLE_OPENGL_SHARED_TEXTURE"; -pub const ENV_VERBOSE: &str = "FLUXER_GAME_CAPTURE_VERBOSE"; -pub const ENV_INJECT_METHOD: &str = "FLUXER_GAME_CAPTURE_INJECT_METHOD"; - -pub const GAME_CAPTURE_CONTROL_DISABLE_SHARED_TEXTURE: u32 = 1 << 0; - -pub fn env_flag_enabled(name: &str) -> bool { - std::env::var_os(name) - .map(|value| !value.is_empty()) - .unwrap_or(false) -} - -pub const GAME_CAPTURE_INFO_PREFIX: &str = "FluxerGameCapture_Info_"; -pub const GAME_CAPTURE_FRAME_PREFIX: &str = "FluxerGameCapture_Frame_"; -pub const GAME_CAPTURE_READY_PREFIX: &str = "FluxerGameCapture_Ready_"; -pub const GAME_CAPTURE_STOP_PREFIX: &str = "FluxerGameCapture_Stop_"; -pub const GAME_CAPTURE_KEEPALIVE_PREFIX: &str = "FluxerGameCapture_KeepAlive_"; -pub const GAME_CAPTURE_MUTEX_PREFIX: &str = "FluxerGameCapture_FrameMutex_"; - -#[repr(C)] -#[derive(Clone, Copy)] -pub struct GameCaptureSharedInfo { - pub magic: u32, - pub version: u32, - pub state: u32, - pub last_error: u32, - pub hwnd: u64, - pub max_width: u32, - pub max_height: u32, - pub width: u32, - pub height: u32, - pub pitch: u32, - pub frame_index: u32, - pub frame_counter: u64, - pub timestamp_us: i64, - pub target_frame_interval_ns: u64, - pub transport: u32, - pub dxgi_format: u32, - pub texture_handle: u64, - pub api_type: u32, - pub fallback_reason: u32, - pub capture_flags: u32, - pub dropped_frame_counter: u64, - pub last_present_timestamp_us: i64, - pub control: u32, - pub present_clock: u32, - pub reserved: [u32; 23], -} - -const _: () = assert!(std::mem::size_of::() == 224); - -impl GameCaptureSharedInfo { - pub fn new(hwnd: u64, max_width: u32, max_height: u32, frame_rate: u32) -> Self { - Self { - magic: GAME_CAPTURE_MAGIC, - version: GAME_CAPTURE_ABI_VERSION, - state: GAME_CAPTURE_STATE_INIT, - last_error: 0, - hwnd, - max_width, - max_height, - width: 0, - height: 0, - pitch: max_width.saturating_mul(GAME_CAPTURE_BYTES_PER_PIXEL), - frame_index: 0, - frame_counter: 0, - timestamp_us: 0, - target_frame_interval_ns: frame_interval_ns(frame_rate), - transport: GAME_CAPTURE_TRANSPORT_MEMORY, - dxgi_format: 0, - texture_handle: 0, - api_type: GAME_CAPTURE_API_UNKNOWN, - fallback_reason: GAME_CAPTURE_FALLBACK_NONE, - capture_flags: 0, - dropped_frame_counter: 0, - last_present_timestamp_us: 0, - control: 0, - present_clock: GAME_CAPTURE_PRESENT_CLOCK_WALL, - reserved: [0; 23], - } - } -} - -pub fn host_supports_present_clock(version: u32) -> bool { - version >= GAME_CAPTURE_ABI_VERSION_PRESENT_CLOCK -} - -pub fn qpc_ticks_to_us(ticks: i64, frequency: i64) -> i64 { - if frequency <= 0 { - return 0; - } - if ticks <= 0 { - return 0; - } - let whole_second_us = (ticks / frequency).saturating_mul(1_000_000); - let fractional_us = (ticks % frequency).saturating_mul(1_000_000) / frequency; - whole_second_us.saturating_add(fractional_us) -} - -pub fn presented_recently( - present_clock: u32, - last_present_us: i64, - wall_now_us: i64, - qpc_now_us: i64, - window_us: i64, -) -> bool { - assert!(window_us > 0); - if last_present_us <= 0 { - return false; - } - let now_us = if present_clock == GAME_CAPTURE_PRESENT_CLOCK_QPC { - qpc_now_us - } else { - wall_now_us - }; - now_us.saturating_sub(last_present_us) < window_us -} - -#[cfg(target_os = "windows")] -pub fn qpc_now_us() -> i64 { - use windows_sys::Win32::System::Performance::{ - QueryPerformanceCounter, QueryPerformanceFrequency, - }; - let mut ticks = 0i64; - let mut frequency = 0i64; - let counter_ok = unsafe { QueryPerformanceCounter(&mut ticks) }; - let frequency_ok = unsafe { QueryPerformanceFrequency(&mut frequency) }; - if counter_ok == 0 || frequency_ok == 0 { - return 0; - } - qpc_ticks_to_us(ticks, frequency) -} - -pub fn frame_interval_ns(frame_rate: u32) -> u64 { - let rate = frame_rate.clamp(1, 144) as u64; - 1_000_000_000 / rate -} - -pub fn frame_buffer_stride(width: u32) -> u32 { - width.saturating_mul(GAME_CAPTURE_BYTES_PER_PIXEL) -} - -pub fn frame_buffer_size(width: u32, height: u32) -> Option { - frame_buffer_stride(width) - .checked_mul(height)? - .try_into() - .ok() -} - -pub fn shared_frame_mapping_size(width: u32, height: u32) -> Option { - frame_buffer_size(width, height)?.checked_mul(GAME_CAPTURE_BUFFER_COUNT) -} - -pub fn object_name(prefix: &str, pid: u32) -> String { - format!("{prefix}{pid}") -} - -pub fn mutex_name(pid: u32, index: usize) -> String { - format!("{GAME_CAPTURE_MUTEX_PREFIX}{index}_{pid}") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn qpc_ticks_to_us_converts_whole_and_fractional_seconds() { - assert_eq!(qpc_ticks_to_us(10_000_000, 10_000_000), 1_000_000); - assert_eq!(qpc_ticks_to_us(15_000_000, 10_000_000), 1_500_000); - assert_eq!(qpc_ticks_to_us(1, 10_000_000), 0); - assert_eq!(qpc_ticks_to_us(10, 10_000_000), 1); - } - - #[test] - fn qpc_ticks_to_us_does_not_overflow_for_long_uptimes() { - let frequency = 3_800_000_000i64; - let uptime_seconds = 400 * 24 * 60 * 60i64; - let ticks = frequency * uptime_seconds; - assert_eq!( - qpc_ticks_to_us(ticks, frequency), - uptime_seconds * 1_000_000 - ); - } - - #[test] - fn qpc_ticks_to_us_rejects_invalid_inputs() { - assert_eq!(qpc_ticks_to_us(123, 0), 0); - assert_eq!(qpc_ticks_to_us(123, -1), 0); - assert_eq!(qpc_ticks_to_us(-123, 10_000_000), 0); - assert_eq!(qpc_ticks_to_us(0, 10_000_000), 0); - } - - #[test] - fn host_supports_present_clock_gates_on_abi_version() { - assert!(!host_supports_present_clock(0)); - assert!(!host_supports_present_clock(3)); - assert!(host_supports_present_clock(4)); - assert!(host_supports_present_clock(5)); - } - - #[test] - fn presented_recently_uses_wall_clock_for_legacy_hooks() { - assert!(presented_recently( - GAME_CAPTURE_PRESENT_CLOCK_WALL, - 1_000_000, - 1_500_000, - i64::MAX, - 1_000_000 - )); - assert!(!presented_recently( - GAME_CAPTURE_PRESENT_CLOCK_WALL, - 1_000_000, - 2_000_000, - 0, - 1_000_000 - )); - } - - #[test] - fn presented_recently_uses_qpc_for_present_clock_hooks() { - assert!(presented_recently( - GAME_CAPTURE_PRESENT_CLOCK_QPC, - 1_000_000, - i64::MAX, - 1_500_000, - 1_000_000 - )); - assert!(!presented_recently( - GAME_CAPTURE_PRESENT_CLOCK_QPC, - 1_000_000, - 0, - 2_000_000, - 1_000_000 - )); - } - - #[test] - fn presented_recently_requires_an_observed_present() { - assert!(!presented_recently( - GAME_CAPTURE_PRESENT_CLOCK_WALL, - 0, - 1, - 1, - 1_000_000 - )); - assert!(!presented_recently( - GAME_CAPTURE_PRESENT_CLOCK_QPC, - -1, - 1, - 1, - 1_000_000 - )); - } -} diff --git a/fluxer_desktop/native/win-game-capture/src/hdr.rs b/fluxer_desktop/native/win-game-capture/src/hdr.rs deleted file mode 100644 index 171c96bb2..000000000 --- a/fluxer_desktop/native/win-game-capture/src/hdr.rs +++ /dev/null @@ -1,465 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -#![allow(dead_code)] - -pub const DXGI_FORMAT_R16G16B16A16_FLOAT: u32 = 10; -pub const DXGI_FORMAT_R16G16B16A16_UNORM: u32 = 11; -pub const DXGI_FORMAT_R10G10B10A2_UNORM: u32 = 24; -pub const DXGI_FORMAT_R8G8B8A8_UNORM: u32 = 28; -pub const DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: u32 = 29; -pub const DXGI_FORMAT_B8G8R8A8_UNORM: u32 = 87; -pub const DXGI_FORMAT_B8G8R8X8_UNORM: u32 = 88; -pub const DXGI_FORMAT_R10G10B10_XR_BIAS_A2_UNORM: u32 = 89; -pub const DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: u32 = 91; -pub const DXGI_FORMAT_B8G8R8X8_UNORM_SRGB: u32 = 93; -pub const DXGI_FORMAT_P010: u32 = 104; -pub const DXGI_FORMAT_P016: u32 = 105; -pub const DXGI_FORMAT_420_OPAQUE: u32 = 106; -pub const DXGI_FORMAT_YUY2: u32 = 107; -pub const DXGI_FORMAT_R32G32B32A32_FLOAT: u32 = 2; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SourceFormat { - Bgra8, - Rgba8, - R10G10B10A2 { hdr: bool }, - Rgba16Float { hdr: bool }, -} - -impl SourceFormat { - pub fn classify(dxgi_format: u32, capture_flags: u32) -> Option { - let hdr = capture_flags & crate::game_capture_abi::GAME_CAPTURE_FLAG_HDR != 0; - match dxgi_format { - DXGI_FORMAT_B8G8R8A8_UNORM - | DXGI_FORMAT_B8G8R8A8_UNORM_SRGB - | DXGI_FORMAT_B8G8R8X8_UNORM - | DXGI_FORMAT_B8G8R8X8_UNORM_SRGB => Some(SourceFormat::Bgra8), - DXGI_FORMAT_R8G8B8A8_UNORM | DXGI_FORMAT_R8G8B8A8_UNORM_SRGB => { - Some(SourceFormat::Rgba8) - } - DXGI_FORMAT_R10G10B10A2_UNORM => Some(SourceFormat::R10G10B10A2 { hdr }), - DXGI_FORMAT_R16G16B16A16_FLOAT => Some(SourceFormat::Rgba16Float { hdr }), - _ => None, - } - } - - pub fn is_8bit(self) -> bool { - matches!(self, SourceFormat::Bgra8 | SourceFormat::Rgba8) - } - - pub fn is_hdr(self) -> bool { - matches!( - self, - SourceFormat::R10G10B10A2 { hdr: true } | SourceFormat::Rgba16Float { hdr: true } - ) - } - - pub fn bytes_per_pixel(self) -> usize { - match self { - SourceFormat::Bgra8 | SourceFormat::Rgba8 | SourceFormat::R10G10B10A2 { .. } => 4, - SourceFormat::Rgba16Float { .. } => 8, - } - } -} - -const SDR_WHITE_NITS: f32 = 80.0; - -pub fn r10g10b10a2_row_to_bgra(src_row: &[u8], width: usize, dst_row: &mut [u8], hdr: bool) { - for x in 0..width { - let so = x * 4; - let dofs = x * 4; - if so + 4 > src_row.len() || dofs + 4 > dst_row.len() { - break; - } - let packed = u32::from_le_bytes([ - src_row[so], - src_row[so + 1], - src_row[so + 2], - src_row[so + 3], - ]); - let r10 = (packed & 0x3FF) as u16; - let g10 = ((packed >> 10) & 0x3FF) as u16; - let b10 = ((packed >> 20) & 0x3FF) as u16; - let a2 = ((packed >> 30) & 0x3) as u8; - let (r, g, b) = if hdr { - tonemap_rec2020_pq_to_srgb8(r10, g10, b10) - } else { - (scale10_to_8(r10), scale10_to_8(g10), scale10_to_8(b10)) - }; - dst_row[dofs] = b; - dst_row[dofs + 1] = g; - dst_row[dofs + 2] = r; - dst_row[dofs + 3] = (a2 as u16 * 255 / 3) as u8; - } -} - -pub fn rgba16f_row_to_bgra(src_row: &[u8], width: usize, dst_row: &mut [u8], hdr: bool) { - for x in 0..width { - let so = x * 8; - let dofs = x * 4; - if so + 8 > src_row.len() || dofs + 4 > dst_row.len() { - break; - } - let r = f16_to_f32(u16::from_le_bytes([src_row[so], src_row[so + 1]])); - let g = f16_to_f32(u16::from_le_bytes([src_row[so + 2], src_row[so + 3]])); - let b = f16_to_f32(u16::from_le_bytes([src_row[so + 4], src_row[so + 5]])); - let a = f16_to_f32(u16::from_le_bytes([src_row[so + 6], src_row[so + 7]])); - let (lr, lg, lb) = if hdr { - ( - reinhard(r.max(0.0)), - reinhard(g.max(0.0)), - reinhard(b.max(0.0)), - ) - } else { - (r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)) - }; - dst_row[dofs] = linear_to_srgb8(lb); - dst_row[dofs + 1] = linear_to_srgb8(lg); - dst_row[dofs + 2] = linear_to_srgb8(lr); - dst_row[dofs + 3] = (a.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; - } -} - -fn scale10_to_8(v10: u16) -> u8 { - ((v10 as u32 * 255 + 511) / 1023) as u8 -} - -fn reinhard(linear: f32) -> f32 { - let v = linear.max(0.0); - (v / (1.0 + v)).clamp(0.0, 1.0) -} - -fn tonemap_rec2020_pq_to_srgb8(r10: u16, g10: u16, b10: u16) -> (u8, u8, u8) { - let lr = pq_eotf(r10 as f32 / 1023.0); - let lg = pq_eotf(g10 as f32 / 1023.0); - let lb = pq_eotf(b10 as f32 / 1023.0); - let scale = 10000.0 / SDR_WHITE_NITS; - let map = |v: f32| reinhard((v * scale).max(0.0)); - ( - linear_to_srgb8(map(lr)), - linear_to_srgb8(map(lg)), - linear_to_srgb8(map(lb)), - ) -} - -fn pq_eotf(e: f32) -> f32 { - const M1: f64 = 0.1593017578125; - const M2: f64 = 78.84375; - const C1: f64 = 0.8359375; - const C2: f64 = 18.8515625; - const C3: f64 = 18.6875; - let e = (e.clamp(0.0, 1.0)) as f64; - let ep = e.powf(1.0 / M2); - let num = (ep - C1).max(0.0); - let den = C2 - C3 * ep; - if den <= 0.0 { - return 0.0; - } - (num / den).powf(1.0 / M1) as f32 -} - -fn linear_to_srgb8(linear: f32) -> u8 { - let l = linear.clamp(0.0, 1.0); - let srgb = if l <= 0.0031308 { - l * 12.92 - } else { - 1.055 * l.powf(1.0 / 2.4) - 0.055 - }; - (srgb.clamp(0.0, 1.0) * 255.0 + 0.5) as u8 -} - -pub fn f16_to_f32(h: u16) -> f32 { - let sign = (h >> 15) & 0x1; - let exp = (h >> 10) & 0x1F; - let mant = h & 0x3FF; - let sign_f = if sign == 1 { -1.0f32 } else { 1.0f32 }; - if exp == 0 { - sign_f * (mant as f32) * 2f32.powi(-24) - } else if exp == 0x1F { - if mant == 0 { sign_f * 65504.0 } else { 0.0 } - } else { - sign_f * (1.0 + (mant as f32) / 1024.0) * 2f32.powi(exp as i32 - 15) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::game_capture_abi::{GAME_CAPTURE_FLAG_HDR, GAME_CAPTURE_FLAG_TEN_BIT}; - - #[test] - fn classifies_8bit_formats() { - assert_eq!( - SourceFormat::classify(DXGI_FORMAT_B8G8R8A8_UNORM, 0), - Some(SourceFormat::Bgra8) - ); - assert_eq!( - SourceFormat::classify(DXGI_FORMAT_R8G8B8A8_UNORM, 0), - Some(SourceFormat::Rgba8) - ); - assert!( - SourceFormat::classify(DXGI_FORMAT_B8G8R8A8_UNORM, 0) - .unwrap() - .is_8bit() - ); - } - - #[test] - fn classifies_obs_8bit_alias_set() { - for format in [ - DXGI_FORMAT_B8G8R8A8_UNORM, - DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, - DXGI_FORMAT_B8G8R8X8_UNORM, - DXGI_FORMAT_B8G8R8X8_UNORM_SRGB, - ] { - assert_eq!(SourceFormat::classify(format, 0), Some(SourceFormat::Bgra8)); - assert_eq!( - SourceFormat::classify(format, GAME_CAPTURE_FLAG_HDR), - Some(SourceFormat::Bgra8), - "8-bit format {format} must not become HDR just because the flag is set" - ); - } - - for format in [DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_FORMAT_R8G8B8A8_UNORM_SRGB] { - assert_eq!(SourceFormat::classify(format, 0), Some(SourceFormat::Rgba8)); - assert_eq!( - SourceFormat::classify(format, GAME_CAPTURE_FLAG_HDR), - Some(SourceFormat::Rgba8), - "8-bit format {format} must not become HDR just because the flag is set" - ); - } - } - - #[test] - fn classifies_10bit_and_hdr_flag() { - assert_eq!( - SourceFormat::classify(DXGI_FORMAT_R10G10B10A2_UNORM, GAME_CAPTURE_FLAG_TEN_BIT), - Some(SourceFormat::R10G10B10A2 { hdr: false }) - ); - assert_eq!( - SourceFormat::classify( - DXGI_FORMAT_R10G10B10A2_UNORM, - GAME_CAPTURE_FLAG_TEN_BIT | GAME_CAPTURE_FLAG_HDR - ), - Some(SourceFormat::R10G10B10A2 { hdr: true }) - ); - assert!( - SourceFormat::classify(DXGI_FORMAT_R10G10B10A2_UNORM, GAME_CAPTURE_FLAG_HDR) - .unwrap() - .is_hdr() - ); - } - - #[test] - fn high_precision_formats_have_expected_byte_widths() { - assert_eq!(SourceFormat::Bgra8.bytes_per_pixel(), 4); - assert_eq!(SourceFormat::Rgba8.bytes_per_pixel(), 4); - assert_eq!( - SourceFormat::R10G10B10A2 { hdr: false }.bytes_per_pixel(), - 4 - ); - assert_eq!(SourceFormat::R10G10B10A2 { hdr: true }.bytes_per_pixel(), 4); - assert_eq!( - SourceFormat::Rgba16Float { hdr: false }.bytes_per_pixel(), - 8 - ); - assert_eq!(SourceFormat::Rgba16Float { hdr: true }.bytes_per_pixel(), 8); - } - - #[test] - fn classifies_fp16_scrgb() { - assert_eq!( - SourceFormat::classify(DXGI_FORMAT_R16G16B16A16_FLOAT, GAME_CAPTURE_FLAG_HDR), - Some(SourceFormat::Rgba16Float { hdr: true }) - ); - assert_eq!( - SourceFormat::classify(DXGI_FORMAT_R16G16B16A16_FLOAT, 0), - Some(SourceFormat::Rgba16Float { hdr: false }) - ); - assert_eq!( - SourceFormat::Rgba16Float { hdr: false }.bytes_per_pixel(), - 8 - ); - } - - #[test] - fn unsupported_format_is_none() { - assert_eq!(SourceFormat::classify(104, 0), None); - assert_eq!(SourceFormat::classify(0, 0), None); - } - - #[test] - fn video_and_opaque_formats_stay_unsupported_even_with_hdr_flags() { - for format in [ - DXGI_FORMAT_P010, - DXGI_FORMAT_P016, - DXGI_FORMAT_420_OPAQUE, - DXGI_FORMAT_YUY2, - ] { - assert_eq!( - SourceFormat::classify(format, GAME_CAPTURE_FLAG_TEN_BIT | GAME_CAPTURE_FLAG_HDR), - None, - "DXGI video/opaque format {format} is not a directly readable game backbuffer" - ); - } - } - - #[test] - fn obs_high_precision_formats_without_reader_conversion_stay_unsupported() { - assert_eq!( - SourceFormat::classify(DXGI_FORMAT_R16G16B16A16_UNORM, GAME_CAPTURE_FLAG_HDR), - None, - "OBS maps RGBA16 UNORM, but Fluxer needs a row converter before claiming support" - ); - assert_eq!( - SourceFormat::classify(DXGI_FORMAT_R32G32B32A32_FLOAT, GAME_CAPTURE_FLAG_HDR), - None, - "OBS maps RGBA32F, but Fluxer has no reader-side conversion for it yet" - ); - assert_eq!( - SourceFormat::classify( - DXGI_FORMAT_R10G10B10_XR_BIAS_A2_UNORM, - GAME_CAPTURE_FLAG_TEN_BIT | GAME_CAPTURE_FLAG_HDR - ), - None, - "XR-bias 10A2 is not equivalent to regular R10G10B10A2 UNORM" - ); - } - - #[test] - fn f16_round_trips_known_values() { - assert_eq!(f16_to_f32(0x0000), 0.0); - assert_eq!(f16_to_f32(0x3C00), 1.0); - assert_eq!(f16_to_f32(0x4000), 2.0); - assert_eq!(f16_to_f32(0x3800), 0.5); - assert!((f16_to_f32(0xC000) + 2.0).abs() < 1e-6); - } - - #[test] - fn f16_special_values_are_bounded_for_sdr_fallback() { - assert_eq!(f16_to_f32(0x7C00), 65504.0); - assert_eq!(f16_to_f32(0xFC00), -65504.0); - assert_eq!(f16_to_f32(0x7E00), 0.0); - } - - #[test] - fn r10_sdr_unpack_scales_extremes() { - let packed: u32 = 0x3FF | (0x3FF << 10) | (0x3FF << 20) | (0x3 << 30); - let src = packed.to_le_bytes(); - let mut dst = [0u8; 4]; - r10g10b10a2_row_to_bgra(&src, 1, &mut dst, false); - assert_eq!(dst, [255, 255, 255, 255]); - - let src0 = 0u32.to_le_bytes(); - let mut dst0 = [0u8; 4]; - r10g10b10a2_row_to_bgra(&src0, 1, &mut dst0, false); - assert_eq!(dst0, [0, 0, 0, 0]); - } - - #[test] - fn r10_sdr_unpack_scales_midpoints_and_alpha() { - let packed: u32 = 0x200 | (0x100 << 10) | (0x080 << 20) | (0x2 << 30); - let src = packed.to_le_bytes(); - let mut dst = [0u8; 4]; - r10g10b10a2_row_to_bgra(&src, 1, &mut dst, false); - assert!((31..=33).contains(&dst[0]), "B was {}", dst[0]); - assert!((63..=65).contains(&dst[1]), "G was {}", dst[1]); - assert!((127..=129).contains(&dst[2]), "R was {}", dst[2]); - assert_eq!(dst[3], 170); - } - - #[test] - fn r10_unpack_respects_short_rows() { - let src = [0xFF, 0xFF, 0xFF]; - let mut dst = [9u8; 8]; - r10g10b10a2_row_to_bgra(&src, 2, &mut dst, false); - assert_eq!(dst, [9u8; 8]); - } - - #[test] - fn r10_unpack_channel_order_is_bgra() { - let packed: u32 = 0x3FF; - let src = packed.to_le_bytes(); - let mut dst = [0u8; 4]; - r10g10b10a2_row_to_bgra(&src, 1, &mut dst, false); - assert_eq!(dst[0], 0, "B should be 0"); - assert_eq!(dst[1], 0, "G should be 0"); - assert_eq!(dst[2], 255, "R should be 255"); - } - - #[test] - fn r10_hdr_unpack_is_bounded_and_nonzero() { - let packed: u32 = 0x3FF | (0x200 << 10) | (0x100 << 20) | (0x3 << 30); - let src = packed.to_le_bytes(); - let mut dst = [0u8; 4]; - r10g10b10a2_row_to_bgra(&src, 1, &mut dst, true); - assert!(dst[2] > 0, "bright red PQ should map to a visible value"); - } - - #[test] - fn r10_hdr_unpack_is_monotonic_after_tonemap() { - let pack = |v: u32| -> [u8; 4] { (v | (v << 10) | (v << 20) | (0x3 << 30)).to_le_bytes() }; - let mut low = [0u8; 4]; - let mut mid = [0u8; 4]; - let mut high = [0u8; 4]; - r10g10b10a2_row_to_bgra(&pack(0x100), 1, &mut low, true); - r10g10b10a2_row_to_bgra(&pack(0x200), 1, &mut mid, true); - r10g10b10a2_row_to_bgra(&pack(0x3FF), 1, &mut high, true); - assert!(low[2] <= mid[2] && mid[2] <= high[2]); - assert_eq!(high[3], 255); - } - - #[test] - fn fp16_sdr_clamps_and_encodes_srgb() { - let mut src = [0u8; 8]; - src[0..2].copy_from_slice(&0x3C00u16.to_le_bytes()); - src[6..8].copy_from_slice(&0x3C00u16.to_le_bytes()); - let mut dst = [0u8; 4]; - rgba16f_row_to_bgra(&src, 1, &mut dst, false); - assert_eq!(dst[0], 0, "B"); - assert_eq!(dst[1], 0, "G"); - assert_eq!(dst[2], 255, "R linear 1.0 -> sRGB 255"); - assert_eq!(dst[3], 255, "A"); - } - - #[test] - fn fp16_sdr_clamps_negative_and_above_one_channels() { - let mut src = [0u8; 8]; - src[0..2].copy_from_slice(&0xBC00u16.to_le_bytes()); - src[2..4].copy_from_slice(&0x4000u16.to_le_bytes()); - src[4..6].copy_from_slice(&0x3800u16.to_le_bytes()); - src[6..8].copy_from_slice(&0x4000u16.to_le_bytes()); - let mut dst = [0u8; 4]; - rgba16f_row_to_bgra(&src, 1, &mut dst, false); - assert!((180..=196).contains(&dst[0]), "B linear 0.5 was {}", dst[0]); - assert_eq!(dst[1], 255, "G linear 2.0 clamps to white"); - assert_eq!(dst[2], 0, "R negative clamps to black"); - assert_eq!(dst[3], 255, "A above 1.0 clamps opaque"); - } - - #[test] - fn fp16_unpack_respects_short_rows() { - let src = [0x00, 0x3C, 0x00, 0x3C]; - let mut dst = [7u8; 8]; - rgba16f_row_to_bgra(&src, 2, &mut dst, false); - assert_eq!(dst, [7u8; 8]); - } - - #[test] - fn fp16_hdr_above_one_is_tonemapped_not_clipped_to_garbage() { - let mut src = [0u8; 8]; - src[0..2].copy_from_slice(&0x4400u16.to_le_bytes()); - src[6..8].copy_from_slice(&0x3C00u16.to_le_bytes()); - let mut dst = [0u8; 4]; - rgba16f_row_to_bgra(&src, 1, &mut dst, true); - assert!(dst[2] > 100, "HDR highlight should be bright after tonemap"); - } - - #[test] - fn srgb_encode_endpoints() { - assert_eq!(linear_to_srgb8(0.0), 0); - assert_eq!(linear_to_srgb8(1.0), 255); - let mid = linear_to_srgb8(0.5); - assert!((180..=196).contains(&mid), "sRGB(0.5) was {mid}"); - } -} diff --git a/fluxer_desktop/native/win-game-capture/src/lib.rs b/fluxer_desktop/native/win-game-capture/src/lib.rs index 444b99a7b..89a035bd0 100644 --- a/fluxer_desktop/native/win-game-capture/src/lib.rs +++ b/fluxer_desktop/native/win-game-capture/src/lib.rs @@ -1,1082 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later #![deny(clippy::all)] -#![allow(unsafe_op_in_unsafe_fn)] -#[cfg(target_os = "windows")] -mod capture_target; -#[cfg(target_os = "windows")] -mod d3d11_device; -#[cfg(any(target_os = "windows", test))] -mod dxgi_capture; -pub mod encoder_attach; -mod fallback; -mod game_capture_abi; mod gpu_priority; -mod hdr; -#[cfg(target_os = "windows")] -mod nv12_gpu; -mod sources; -#[cfg(any(target_os = "windows", test))] -mod stall; -#[cfg(target_os = "windows")] -mod wgc_capture; - -pub use encoder_attach::{EncoderAttachError, EncoderAttachStats, EncoderAttachment}; use napi::bindgen_prelude::*; -use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; -use napi::{JsValue, Status, ValueType}; use napi_derive::napi; -use parking_lot::{Mutex, RwLock}; -use std::ffi::c_void; -use std::sync::Arc; - -#[cfg(target_os = "windows")] -use dxgi_capture::DxgiCaptureSession; -use fluxer_encoder_ring::EncoderFrameRate; -#[cfg(target_os = "windows")] -use fluxer_screen_frame_bus::EnqueueOutcome; -#[cfg(target_os = "windows")] -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -#[cfg(target_os = "windows")] -use wgc_capture::WgcCaptureSession; - -const LIFECYCLE_QUEUE_LIMIT: usize = 8; -const START_OPTION_UNSUPPORTED_LIMIT: usize = 4; - -type LifecycleTsfn = Arc< - ThreadsafeFunction< - (String, String), - (), - (String, String), - napi::Status, - false, - true, - LIFECYCLE_QUEUE_LIMIT, - >, ->; - -#[napi(object)] -#[derive(Clone, Debug)] -pub struct ScreenCaptureRect { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, -} - -#[napi(object)] -#[derive(Clone, Debug, Default)] -pub struct ScreenCaptureStartOptions { - #[napi(js_name = "showCursorClicks")] - pub show_cursor_clicks: Option, - #[napi(js_name = "captureRect")] - pub capture_rect: Option, - #[napi(js_name = "colorRange")] - pub color_range: Option, - #[napi(js_name = "colorSpace")] - pub color_space: Option, -} - -#[napi(object)] -#[derive(Clone, Debug, Default)] -pub struct CaptureStartOptionsDiagnostics { - #[napi(js_name = "showCursorClicks")] - pub show_cursor_clicks: Option, - #[napi(js_name = "captureRect")] - pub capture_rect: Option, - #[napi(js_name = "colorRange")] - pub color_range: Option, - #[napi(js_name = "colorSpace")] - pub color_space: Option, - #[napi(js_name = "unsupportedOptions")] - pub unsupported_options: Vec, -} - -#[napi(object)] -pub struct CaptureStartResult { - pub width: u32, - pub height: u32, - #[napi(js_name = "frameRate")] - pub frame_rate: u32, - #[napi(js_name = "pixelFormat")] - pub pixel_format: String, -} - -#[napi(object)] -pub struct ScreenCaptureSourceDescriptor { - pub kind: String, - pub id: String, - pub name: String, - pub width: u32, - pub height: u32, - #[napi(js_name = "targetPid")] - pub target_pid: Option, -} - -#[napi(object)] -pub struct AvailabilityInfo { - pub available: bool, - pub backend: String, - pub reason: Option, -} - -#[napi(object)] -pub struct CaptureDiagnostics { - pub state: u32, - #[napi(js_name = "apiType")] - pub api_type: u32, - pub transport: u32, - #[napi(js_name = "fallbackReason")] - pub fallback_reason: u32, - #[napi(js_name = "captureFlags")] - pub capture_flags: u32, - pub width: u32, - pub height: u32, - #[napi(js_name = "dxgiFormat")] - pub dxgi_format: u32, - #[napi(js_name = "frameCounter")] - pub frame_counter: f64, - #[napi(js_name = "droppedFrameCounter")] - pub dropped_frame_counter: f64, - #[napi(js_name = "lastPresentTimestampUs")] - pub last_present_timestamp_us: f64, - #[napi(js_name = "lastError")] - pub last_error: u32, - #[napi(js_name = "activeStrategy")] - pub active_strategy: String, - #[napi(js_name = "lastFallbackReason")] - pub last_fallback_reason: String, - #[napi(js_name = "startOptions")] - pub start_options: CaptureStartOptionsDiagnostics, - #[napi(js_name = "frameSinkAccepted")] - pub frame_sink_accepted: f64, - #[napi(js_name = "frameSinkCoalesced")] - pub frame_sink_coalesced: f64, - #[napi(js_name = "frameSinkRejected")] - pub frame_sink_rejected: f64, - #[napi(js_name = "mediaFramesDroppedWithoutSink")] - pub media_frames_dropped_without_sink: f64, - #[napi(js_name = "cpuFallbackFramesDropped")] - pub cpu_fallback_frames_dropped: f64, -} - -#[napi(object)] -pub struct EncoderAttachDiagnostics { - pub attached: bool, - pub width: u32, - pub height: u32, - pub capacity: u32, - #[napi(js_name = "framesSubmitted")] - pub frames_submitted: f64, - #[napi(js_name = "framesDropped")] - pub frames_dropped: f64, - #[napi(js_name = "ringFullEvents")] - pub ring_full_events: f64, - #[napi(js_name = "failedBlits")] - pub failed_blits: f64, -} - -#[napi(object)] -pub struct FrameSinkDiagnostics { - pub accepted: f64, - pub coalesced: f64, - pub rejected: f64, - #[napi(js_name = "mediaFramesDroppedWithoutSink")] - pub media_frames_dropped_without_sink: f64, - #[napi(js_name = "cpuFallbackFramesDropped")] - pub cpu_fallback_frames_dropped: f64, -} - -pub struct CaptureInner { - pub lifecycle_tsfn: Mutex>, - #[cfg(target_os = "windows")] - pub session: Mutex>, - #[cfg(target_os = "windows")] - pub(crate) wgc_session: Mutex>, - pub running: std::sync::atomic::AtomicBool, - pub fallback: Mutex>, - pub capture_id: Mutex>, - pub start_options: Mutex, - pub encoder_attachment: RwLock>>, - pub native_frame_sink: - Mutex>>, - #[cfg(target_os = "windows")] - pub frame_sink_accepted: AtomicU64, - #[cfg(target_os = "windows")] - pub frame_sink_coalesced: AtomicU64, - #[cfg(target_os = "windows")] - pub frame_sink_rejected: AtomicU64, - #[cfg(target_os = "windows")] - pub media_frames_dropped_without_sink: AtomicU64, - #[cfg(target_os = "windows")] - pub cpu_fallback_frames_dropped: AtomicU64, - #[cfg(target_os = "windows")] - pub frame_sink_backpressure_emitted: AtomicBool, - #[cfg(target_os = "windows")] - pub frame_sink_missing_emitted: AtomicBool, - #[cfg(target_os = "windows")] - pub cpu_fallback_emitted: AtomicBool, -} - -pub fn emit_lifecycle(inner: &CaptureInner, event_type: &str, message: &str) { - let guard = inner.lifecycle_tsfn.lock(); - if let Some(tsfn) = guard.as_ref() { - let _ = tsfn.call( - (event_type.to_string(), message.to_string()), - ThreadsafeFunctionCallMode::NonBlocking, - ); - } -} - -#[cfg(target_os = "windows")] -struct BusSharedTexture { - handle: u64, - width: u32, - height: u32, - dxgi_format: u32, - timestamp_us: i64, -} - -#[cfg(target_os = "windows")] -impl BusSharedTexture { - fn into_bus_desc(self) -> fluxer_screen_frame_bus::SharedTextureDesc { - fluxer_screen_frame_bus::SharedTextureDesc { - handle: self.handle, - width: self.width, - height: self.height, - dxgi_format: self.dxgi_format, - timestamp_us: self.timestamp_us, - } - } -} - -#[derive(Clone, Copy)] -struct FrameSinkCounterSnapshot { - accepted: u64, - coalesced: u64, - rejected: u64, - dropped_without_sink: u64, - cpu_fallback_dropped: u64, -} - -#[cfg(target_os = "windows")] -fn frame_sink_counter_snapshot(inner: &CaptureInner) -> FrameSinkCounterSnapshot { - FrameSinkCounterSnapshot { - accepted: inner.frame_sink_accepted.load(Ordering::Acquire), - coalesced: inner.frame_sink_coalesced.load(Ordering::Acquire), - rejected: inner.frame_sink_rejected.load(Ordering::Acquire), - dropped_without_sink: inner - .media_frames_dropped_without_sink - .load(Ordering::Acquire), - cpu_fallback_dropped: inner.cpu_fallback_frames_dropped.load(Ordering::Acquire), - } -} - -#[cfg(target_os = "windows")] -fn frame_sink_diagnostics_from(snapshot: FrameSinkCounterSnapshot) -> FrameSinkDiagnostics { - FrameSinkDiagnostics { - accepted: snapshot.accepted as f64, - coalesced: snapshot.coalesced as f64, - rejected: snapshot.rejected as f64, - media_frames_dropped_without_sink: snapshot.dropped_without_sink as f64, - cpu_fallback_frames_dropped: snapshot.cpu_fallback_dropped as f64, - } -} - -#[cfg(target_os = "windows")] -pub(crate) enum FrameSinkRef { - Native(Arc), - Bus(Arc), -} - -#[cfg(target_os = "windows")] -pub(crate) fn resolve_frame_sink( - inner: &CaptureInner, - capture_id: Option<&str>, -) -> Option { - if let Some(sink) = native_frame_sink_for(inner) { - return Some(FrameSinkRef::Native(sink)); - } - let capture_id = capture_id?; - fluxer_screen_frame_bus::get_sink(capture_id).map(FrameSinkRef::Bus) -} - -#[cfg(target_os = "windows")] -pub(crate) fn emit_shared_texture_frame( - inner: &CaptureInner, - sink: &FrameSinkRef, - handle: u64, - width: u32, - height: u32, - dxgi_format: u32, - timestamp_us: i64, -) -> bool { - assert!(handle != 0, "shared texture handle is non-zero"); - assert!(width > 0, "shared texture width is positive"); - assert!(height > 0, "shared texture height is positive"); - let desc = BusSharedTexture { - handle, - width, - height, - dxgi_format, - timestamp_us, - } - .into_bus_desc(); - let outcome = match sink { - FrameSinkRef::Native(sink) => sink.enqueue_shared_texture(desc), - FrameSinkRef::Bus(sink) => { - sink.enqueue(fluxer_screen_frame_bus::ScreenFrame::SharedTexture(desc)) - } - }; - record_frame_sink_outcome(inner, outcome); - frame_sink_outcome_delivered(outcome) -} - -#[cfg(target_os = "windows")] -fn frame_sink_outcome_delivered(outcome: EnqueueOutcome) -> bool { - !matches!(outcome, EnqueueOutcome::Rejected) -} - -#[cfg(target_os = "windows")] -fn record_frame_sink_outcome(inner: &CaptureInner, outcome: EnqueueOutcome) { - match outcome { - EnqueueOutcome::Accepted => { - inner.frame_sink_accepted.fetch_add(1, Ordering::AcqRel); - } - EnqueueOutcome::Coalesced => { - inner.frame_sink_coalesced.fetch_add(1, Ordering::AcqRel); - emit_frame_sink_backpressure_once( - inner, - "Windows shared texture frame coalesced by native frame sink", - ); - } - EnqueueOutcome::Rejected => { - inner.frame_sink_rejected.fetch_add(1, Ordering::AcqRel); - emit_frame_sink_backpressure_once( - inner, - "Windows shared texture frame rejected by native frame sink", - ); - } - } -} - -#[cfg(target_os = "windows")] -fn emit_frame_sink_backpressure_once(inner: &CaptureInner, message: &'static str) { - if inner - .frame_sink_backpressure_emitted - .swap(true, Ordering::AcqRel) - { - return; - } - emit_lifecycle(inner, "diagnostic", message); -} - -#[cfg(target_os = "windows")] -pub(crate) fn note_media_frame_without_sink(inner: &CaptureInner, message: &'static str) { - inner - .media_frames_dropped_without_sink - .fetch_add(1, Ordering::AcqRel); - if inner - .frame_sink_missing_emitted - .swap(true, Ordering::AcqRel) - { - return; - } - emit_lifecycle(inner, "diagnostic", message); -} - -#[cfg(target_os = "windows")] -fn native_frame_sink_for( - inner: &CaptureInner, -) -> Option> { - inner.native_frame_sink.lock().as_ref().cloned() -} - -pub fn observe_fallback( - inner: &CaptureInner, - signature: fallback::FailureSignature, -) -> Option { - let decision = { - let mut guard = inner.fallback.lock(); - guard.as_mut().map(|tracker| tracker.observe(signature)) - }; - if let Some(decision) = decision.as_ref() { - let (kind, message) = fallback::decision_lifecycle(decision); - emit_lifecycle(inner, kind, &message); - } - decision -} - -#[napi] -pub struct ScreenCapture { - inner: Arc, -} - -#[napi] -impl ScreenCapture { - #[allow(clippy::new_without_default)] - #[napi(constructor)] - pub fn new() -> Self { - Self { - inner: Arc::new(CaptureInner { - lifecycle_tsfn: Mutex::new(None), - #[cfg(target_os = "windows")] - session: Mutex::new(None), - #[cfg(target_os = "windows")] - wgc_session: Mutex::new(None), - running: std::sync::atomic::AtomicBool::new(false), - fallback: Mutex::new(None), - capture_id: Mutex::new(None), - start_options: Mutex::new(CaptureStartOptionsDiagnostics::default()), - encoder_attachment: RwLock::new(None), - native_frame_sink: Mutex::new(None), - #[cfg(target_os = "windows")] - frame_sink_accepted: AtomicU64::new(0), - #[cfg(target_os = "windows")] - frame_sink_coalesced: AtomicU64::new(0), - #[cfg(target_os = "windows")] - frame_sink_rejected: AtomicU64::new(0), - #[cfg(target_os = "windows")] - media_frames_dropped_without_sink: AtomicU64::new(0), - #[cfg(target_os = "windows")] - cpu_fallback_frames_dropped: AtomicU64::new(0), - #[cfg(target_os = "windows")] - frame_sink_backpressure_emitted: AtomicBool::new(false), - #[cfg(target_os = "windows")] - frame_sink_missing_emitted: AtomicBool::new(false), - #[cfg(target_os = "windows")] - cpu_fallback_emitted: AtomicBool::new(false), - }), - } - } - - #[napi(js_name = "setLifecycleCallback")] - pub fn set_lifecycle_callback(&self, callback: Function<(String, String), ()>) -> Result<()> { - let tsfn: LifecycleTsfn = callback - .build_threadsafe_function::<(String, String)>() - .weak::() - .callee_handled::() - .max_queue_size::() - .build() - .map(Arc::new)?; - let mut guard = self.inner.lifecycle_tsfn.lock(); - *guard = Some(tsfn); - Ok(()) - } - - #[napi(js_name = "setFrameSinkHandle")] - pub fn set_frame_sink_handle(&self, frame_sink_handle: Unknown<'_>) -> Result<()> { - let sink = retain_native_frame_sink_handle(frame_sink_handle)?; - let mut guard = self.inner.native_frame_sink.lock(); - *guard = Some(sink); - Ok(()) - } - - #[napi] - #[allow(clippy::too_many_arguments)] - pub fn start( - &self, - source_id: String, - source_kind: String, - width: Option, - height: Option, - frame_rate: Option, - capture_id: Option, - start_options: Option, - ) -> Result { - let start_options = record_start_options(&self.inner, start_options)?; - let normalized_capture_id = capture_id - .map(|raw| raw.trim().to_string()) - .filter(|trimmed| !trimmed.is_empty()); - { - let mut guard = self.inner.capture_id.lock(); - *guard = normalized_capture_id; - } - #[cfg(target_os = "windows")] - { - self.start_windows( - source_id, - source_kind, - width, - height, - frame_rate, - start_options, - ) - } - #[cfg(not(target_os = "windows"))] - { - let _ = ( - source_id, - source_kind, - width, - height, - frame_rate, - start_options, - ); - Err(napi::Error::from_reason( - "native game capture only supported on Windows", - )) - } - } - - #[napi(js_name = "getDiagnostics")] - pub fn get_diagnostics(&self) -> Option { - let snapshot = { - let guard = self.inner.fallback.lock(); - guard.as_ref().map(|tracker| tracker.snapshot()) - }?; - - #[cfg(target_os = "windows")] - { - let frame_sink = frame_sink_counter_snapshot(&self.inner); - Some(strategy_only_diagnostics( - &snapshot, - current_start_options(&self.inner), - frame_sink, - )) - } - - #[cfg(not(target_os = "windows"))] - Some(strategy_only_diagnostics( - &snapshot, - current_start_options(&self.inner), - FrameSinkCounterSnapshot { - accepted: 0, - coalesced: 0, - rejected: 0, - dropped_without_sink: 0, - cpu_fallback_dropped: 0, - }, - )) - } - - #[napi(js_name = "getFrameSinkDiagnostics")] - pub fn get_frame_sink_diagnostics(&self) -> FrameSinkDiagnostics { - #[cfg(target_os = "windows")] - { - frame_sink_diagnostics_from(frame_sink_counter_snapshot(&self.inner)) - } - #[cfg(not(target_os = "windows"))] - { - FrameSinkDiagnostics { - accepted: 0.0, - coalesced: 0.0, - rejected: 0.0, - media_frames_dropped_without_sink: 0.0, - cpu_fallback_frames_dropped: 0.0, - } - } - } - - #[napi] - pub fn stop(&self) -> Result<()> { - self.inner - .running - .store(false, std::sync::atomic::Ordering::Release); - self.inner.capture_id.lock().take(); - self.inner.native_frame_sink.lock().take(); - if let Some(attachment) = self.inner.encoder_attachment.write().take() { - attachment.detach(); - } - #[cfg(target_os = "windows")] - { - let mut guard = self.inner.session.lock(); - *guard = None; - let mut wgc_guard = self.inner.wgc_session.lock(); - *wgc_guard = None; - } - { - let mut fallback_guard = self.inner.fallback.lock(); - *fallback_guard = None; - } - Ok(()) - } - - #[napi(js_name = "attachEncoder")] - pub fn attach_encoder(&self, width: u32, height: u32, frame_rate: Option) -> Result<()> { - if width == 0 || height == 0 { - return Err(napi::Error::new( - Status::InvalidArg, - "ScreenCapture.attachEncoder requires positive dimensions", - )); - } - let frame_rate = EncoderFrameRate::from_fps(frame_rate.unwrap_or(30)); - let attachment = EncoderAttachment::try_new_with_frame_rate(width, height, frame_rate) - .map_err(|e| { - napi::Error::new(Status::GenericFailure, format!("attachEncoder failed: {e}")) - })?; - *self.inner.encoder_attachment.write() = Some(attachment); - emit_lifecycle( - &self.inner, - "diagnostic", - &format!( - "encoder ring attached: {width}x{height}@{}fps, capacity=8", - frame_rate.numerator - ), - ); - Ok(()) - } - - #[napi(js_name = "detachEncoder")] - pub fn detach_encoder(&self) -> Result<()> { - if let Some(attachment) = self.inner.encoder_attachment.write().take() { - attachment.detach(); - } - emit_lifecycle(&self.inner, "diagnostic", "encoder ring detached"); - Ok(()) - } - - #[napi(js_name = "isEncoderAttached")] - pub fn is_encoder_attached(&self) -> bool { - self.inner - .encoder_attachment - .read() - .as_ref() - .map(|attachment| attachment.is_attached()) - .unwrap_or(false) - } - - #[napi(js_name = "encoderRingFullCount")] - pub fn encoder_ring_full_count(&self) -> u32 { - self.inner - .encoder_attachment - .read() - .as_ref() - .map(|attachment| attachment.stats().ring_full_events.min(u32::MAX as u64) as u32) - .unwrap_or(0) - } - - #[napi(js_name = "getEncoderAttachDiagnostics")] - pub fn get_encoder_attach_diagnostics(&self) -> Option { - let guard = self.inner.encoder_attachment.read(); - let attachment = guard.as_ref()?; - let stats = attachment.stats(); - Some(EncoderAttachDiagnostics { - attached: attachment.is_attached(), - width: attachment.width(), - height: attachment.height(), - capacity: attachment.capacity().min(u32::MAX as usize) as u32, - frames_submitted: stats.frames_submitted as f64, - frames_dropped: stats.frames_dropped as f64, - ring_full_events: stats.ring_full_events as f64, - failed_blits: stats.failed_blits as f64, - }) - } -} - -fn record_start_options( - inner: &CaptureInner, - options: Option, -) -> Result { - let state = build_start_option_diagnostics(options.unwrap_or_default())?; - if !state.unsupported_options.is_empty() { - emit_lifecycle( - inner, - "diagnostic", - &format!( - "Windows capture start options currently unsupported: {}", - state.unsupported_options.join(", ") - ), - ); - } - let mut guard = inner.start_options.lock(); - *guard = state.clone(); - Ok(state) -} - -fn current_start_options(inner: &CaptureInner) -> CaptureStartOptionsDiagnostics { - inner.start_options.lock().clone() -} - -fn build_start_option_diagnostics( - options: ScreenCaptureStartOptions, -) -> Result { - validate_capture_rect(options.capture_rect.as_ref())?; - validate_enum_option( - options.color_range.as_deref(), - "colorRange", - &["full", "limited"], - )?; - validate_enum_option( - options.color_space.as_deref(), - "colorSpace", - &["rec709", "srgb"], - )?; - - let mut unsupported_options = Vec::with_capacity(START_OPTION_UNSUPPORTED_LIMIT); - if options.show_cursor_clicks.is_some() { - unsupported_options.push("showCursorClicks".to_string()); - } - if options.capture_rect.is_some() { - unsupported_options.push("captureRect".to_string()); - } - if options.color_range.is_some() { - unsupported_options.push("colorRange".to_string()); - } - if options.color_space.is_some() { - unsupported_options.push("colorSpace".to_string()); - } - assert!( - unsupported_options.len() <= START_OPTION_UNSUPPORTED_LIMIT, - "unsupported start-option list bounded" - ); - - Ok(CaptureStartOptionsDiagnostics { - show_cursor_clicks: options.show_cursor_clicks, - capture_rect: options.capture_rect, - color_range: options.color_range, - color_space: options.color_space, - unsupported_options, - }) -} - -fn validate_capture_rect(rect: Option<&ScreenCaptureRect>) -> Result<()> { - let Some(rect) = rect else { - return Ok(()); - }; - if !rect.x.is_finite() || !rect.y.is_finite() { - return Err(napi::Error::new( - Status::InvalidArg, - "captureRect x/y must be finite numbers", - )); - } - if !rect.width.is_finite() || !rect.height.is_finite() { - return Err(napi::Error::new( - Status::InvalidArg, - "captureRect width/height must be finite numbers", - )); - } - if rect.width <= 0.0 || rect.height <= 0.0 { - return Err(napi::Error::new( - Status::InvalidArg, - "captureRect requires positive width and height", - )); - } - Ok(()) -} - -fn validate_enum_option(value: Option<&str>, name: &str, allowed: &[&str]) -> Result<()> { - let Some(value) = value else { - return Ok(()); - }; - if allowed.contains(&value) { - return Ok(()); - } - Err(napi::Error::new( - Status::InvalidArg, - format!("invalid {name}: {value}"), - )) -} - -fn retain_native_frame_sink_handle( - value: Unknown<'_>, -) -> Result> { - if value.get_type()? != ValueType::External { - return Err(napi::Error::new( - Status::InvalidArg, - "ScreenCapture.setFrameSinkHandle expects a native external frame sink handle", - )); - } - - let raw_value = value.value(); - let mut data: *mut c_void = std::ptr::null_mut(); - let status = - unsafe { napi::sys::napi_get_value_external(raw_value.env, raw_value.value, &mut data) }; - if status != napi::sys::Status::napi_ok || data.is_null() { - return Err(napi::Error::new( - Status::InvalidArg, - "ScreenCapture.setFrameSinkHandle received an empty native external frame sink handle", - )); - } - - let handle = unsafe { - data.cast::() - .as_ref() - } - .and_then(fluxer_screen_frame_bus::NativeScreenFrameSinkHandle::retain_ref) - .ok_or_else(|| { - napi::Error::new( - Status::InvalidArg, - "ScreenCapture.setFrameSinkHandle received an invalid native frame sink handle", - ) - })?; - - Ok(Arc::new(handle)) -} - -impl Drop for ScreenCapture { - fn drop(&mut self) { - self.inner.native_frame_sink.lock().take(); - } -} - -#[cfg(target_os = "windows")] -impl ScreenCapture { - #[allow(clippy::too_many_arguments)] - fn start_windows( - &self, - source_id: String, - source_kind: String, - width: Option, - height: Option, - frame_rate: Option, - _start_options: CaptureStartOptionsDiagnostics, - ) -> Result { - use std::sync::atomic::Ordering; - - if self.inner.running.load(Ordering::Acquire) { - return Err(napi::Error::from_reason("Capture already running")); - } - - let target_frame_rate = frame_rate.unwrap_or(30).clamp(1, 144); - - let frame_interval = - std::time::Duration::from_nanos(1_000_000_000 / target_frame_rate as u64); - - if source_kind == "screen" { - let monitor = wgc_capture::parse_monitor_source_id(&source_id, &source_kind) - .ok_or_else(|| { - napi::Error::from_reason(format!("Invalid source: {source_kind}:{source_id}")) - })?; - if !wgc_capture::wgc_capture_supported() { - return Err(napi::Error::from_reason( - "Windows Graphics Capture is unavailable for screen capture", - )); - } - let session = WgcCaptureSession::new_monitor(monitor, width, height).map_err(|e| { - napi::Error::from_reason(format!("Failed to create WGC screen capture: {e}")) - })?; - return self.start_windows_wgc_session(session, target_frame_rate); - } - - let hwnd = if source_kind == "game" { - let target = capture_target::resolve_game_capture_target(&source_id, &source_kind) - .map_err(|e| { - napi::Error::from_reason(format!("Failed to resolve game capture target: {e}")) - })?; - windows::Win32::Foundation::HWND(target as *mut _) - } else { - dxgi_capture::parse_window_source_id(&source_id, &source_kind).ok_or_else(|| { - napi::Error::from_reason(format!("Invalid source: {source_kind}:{source_id}")) - })? - }; - - if let Some(result) = self.try_start_windows_wgc(hwnd, width, height, target_frame_rate)? { - return Ok(result); - } - - let session = DxgiCaptureSession::new(hwnd, width, height) - .map_err(|e| napi::Error::from_reason(format!("Failed to create DXGI capture: {e}")))?; - - let capture_width = session.capture_width(); - let capture_height = session.capture_height(); - - { - let mut guard = self.inner.session.lock(); - *guard = Some(session); - } - { - let mut guard = self.inner.fallback.lock(); - *guard = Some(fallback::FallbackTracker::new( - fallback::CaptureStrategy::DxgiDuplication, - )); - } - - self.inner.running.store(true, Ordering::Release); - - let inner = Arc::clone(&self.inner); - - std::thread::Builder::new() - .name("dxgi-capture".into()) - .spawn(move || { - dxgi_capture::capture_loop(&inner, frame_interval); - }) - .map_err(|e| { - napi::Error::from_reason(format!("Failed to spawn capture thread: {e}")) - })?; - - Ok(CaptureStartResult { - width: capture_width, - height: capture_height, - frame_rate: target_frame_rate, - pixel_format: "bgra".to_string(), - }) - } - - fn try_start_windows_wgc( - &self, - hwnd: windows::Win32::Foundation::HWND, - width: Option, - height: Option, - target_frame_rate: u32, - ) -> Result> { - assert!(target_frame_rate >= 1, "frame rate at least 1"); - assert!(target_frame_rate <= 144, "frame rate at most 144"); - if !wgc_capture::wgc_capture_supported() { - return Ok(None); - } - let session = match WgcCaptureSession::new(hwnd, width, height) { - Ok(session) => session, - Err(e) => { - emit_lifecycle( - &self.inner, - "diagnostic", - &format!("WGC window capture unavailable; using DXGI duplication: {e}"), - ); - return Ok(None); - } - }; - self.start_windows_wgc_session(session, target_frame_rate) - .map(Some) - } - - fn start_windows_wgc_session( - &self, - session: WgcCaptureSession, - target_frame_rate: u32, - ) -> Result { - assert!(target_frame_rate >= 1, "frame rate at least 1"); - assert!(target_frame_rate <= 144, "frame rate at most 144"); - let capture_width = session.capture_width(); - let capture_height = session.capture_height(); - - { - let mut guard = self.inner.wgc_session.lock(); - *guard = Some(session); - } - { - let mut guard = self.inner.fallback.lock(); - *guard = Some(fallback::FallbackTracker::new( - fallback::CaptureStrategy::Wgc, - )); - } - - self.inner.running.store(true, Ordering::Release); - - let inner = Arc::clone(&self.inner); - let frame_interval = - std::time::Duration::from_nanos(1_000_000_000 / target_frame_rate as u64); - - std::thread::Builder::new() - .name("wgc-capture".into()) - .spawn(move || { - wgc_capture::capture_loop(&inner, frame_interval); - }) - .map_err(|e| { - napi::Error::from_reason(format!("Failed to spawn WGC capture thread: {e}")) - })?; - - Ok(CaptureStartResult { - width: capture_width, - height: capture_height, - frame_rate: target_frame_rate, - pixel_format: "bgra".to_string(), - }) - } -} - -fn strategy_only_diagnostics( - snapshot: &fallback::FallbackSnapshot, - start_options: CaptureStartOptionsDiagnostics, - frame_sink: FrameSinkCounterSnapshot, -) -> CaptureDiagnostics { - CaptureDiagnostics { - state: 0, - api_type: 0, - transport: 0, - fallback_reason: 0, - capture_flags: 0, - width: 0, - height: 0, - dxgi_format: 0, - frame_counter: 0.0, - dropped_frame_counter: 0.0, - last_present_timestamp_us: 0.0, - last_error: 0, - active_strategy: snapshot.active_strategy.clone(), - last_fallback_reason: snapshot.last_fallback_reason.clone(), - start_options, - frame_sink_accepted: frame_sink.accepted as f64, - frame_sink_coalesced: frame_sink.coalesced as f64, - frame_sink_rejected: frame_sink.rejected as f64, - media_frames_dropped_without_sink: frame_sink.dropped_without_sink as f64, - cpu_fallback_frames_dropped: frame_sink.cpu_fallback_dropped as f64, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn start_options_are_kept_as_explicit_unsupported_state() { - let state = build_start_option_diagnostics(ScreenCaptureStartOptions { - show_cursor_clicks: Some(true), - capture_rect: Some(ScreenCaptureRect { - x: 10.0, - y: 20.0, - width: 300.0, - height: 200.0, - }), - color_range: Some("full".to_string()), - color_space: Some("rec709".to_string()), - }) - .expect("valid options"); - - assert_eq!(state.show_cursor_clicks, Some(true)); - assert_eq!(state.color_range.as_deref(), Some("full")); - assert_eq!(state.color_space.as_deref(), Some("rec709")); - assert_eq!( - state.unsupported_options, - vec![ - "showCursorClicks".to_string(), - "captureRect".to_string(), - "colorRange".to_string(), - "colorSpace".to_string(), - ] - ); - } - - #[test] - fn capture_rect_requires_positive_dimensions() { - let err = build_start_option_diagnostics(ScreenCaptureStartOptions { - capture_rect: Some(ScreenCaptureRect { - x: 0.0, - y: 0.0, - width: 0.0, - height: 10.0, - }), - ..ScreenCaptureStartOptions::default() - }) - .err(); - assert!(err.is_some(), "invalid captureRect is rejected"); - } -} - -#[napi(js_name = "isSupported")] -pub fn is_supported() -> bool { - cfg!(target_os = "windows") -} - -#[napi(js_name = "getAvailability")] -pub fn get_availability() -> AvailabilityInfo { - AvailabilityInfo { - available: cfg!(target_os = "windows"), - backend: "windows-game-capture".to_string(), - reason: if cfg!(target_os = "windows") { - None - } else { - Some("unsupported-platform".to_string()) - }, - } -} - -#[napi(js_name = "listSources")] -pub fn list_sources() -> Result> { - Ok(sources::list_sources()) -} #[napi(js_name = "elevateGpuSchedulingPriority")] pub fn elevate_gpu_scheduling_priority( diff --git a/fluxer_desktop/native/win-game-capture/src/nv12_gpu.rs b/fluxer_desktop/native/win-game-capture/src/nv12_gpu.rs deleted file mode 100644 index bfebe1b56..000000000 --- a/fluxer_desktop/native/win-game-capture/src/nv12_gpu.rs +++ /dev/null @@ -1,365 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use windows::Win32::Graphics::Direct3D11::{ - D3D11_BIND_RENDER_TARGET, D3D11_RESOURCE_MISC_SHARED, D3D11_TEXTURE2D_DESC, - D3D11_USAGE_DEFAULT, D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE, D3D11_VIDEO_PROCESSOR_CONTENT_DESC, - D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC, D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC_0, - D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC, D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC_0, - D3D11_VIDEO_PROCESSOR_STREAM, D3D11_VIDEO_USAGE_PLAYBACK_NORMAL, - D3D11_VPIV_DIMENSION_TEXTURE2D, D3D11_VPOV_DIMENSION_TEXTURE2D, ID3D11Device, - ID3D11DeviceContext, ID3D11Texture2D, ID3D11VideoContext, ID3D11VideoContext1, - ID3D11VideoDevice, ID3D11VideoProcessor, ID3D11VideoProcessorEnumerator, - ID3D11VideoProcessorInputView, ID3D11VideoProcessorOutputView, -}; -use windows::Win32::Graphics::Dxgi::Common::{ - DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709, DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, - DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020, DXGI_COLOR_SPACE_TYPE, - DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, DXGI_FORMAT_NV12, DXGI_RATIONAL, DXGI_SAMPLE_DESC, -}; -use windows::Win32::Graphics::Dxgi::IDXGIResource; -use windows::core::Interface; - -use crate::hdr; - -fn vlog(msg: &str) { - if crate::game_capture_abi::env_flag_enabled(crate::game_capture_abi::ENV_VERBOSE) { - use std::io::Write; - let _ = writeln!(std::io::stderr(), "[fluxer-nv12] {msg}"); - } -} - -pub const NV12_OUTPUT_SLOT_COUNT: usize = 3; - -struct Nv12OutputSlot { - _texture: ID3D11Texture2D, - view: ID3D11VideoProcessorOutputView, - handle: u64, -} - -pub struct Nv12GpuConverter { - _video_device: ID3D11VideoDevice, - video_context: ID3D11VideoContext, - processor: ID3D11VideoProcessor, - _enumerator: ID3D11VideoProcessorEnumerator, - input_view: ID3D11VideoProcessorInputView, - output_slots: [Nv12OutputSlot; NV12_OUTPUT_SLOT_COUNT], - slot_cursor: usize, - context: ID3D11DeviceContext, - out_width: u32, - out_height: u32, -} - -pub struct Nv12SharedTextureFrame { - pub handle: u64, - pub width: u32, - pub height: u32, - pub dxgi_format: u32, -} - -impl Nv12GpuConverter { - #[allow(clippy::too_many_arguments)] - pub fn new( - device: &ID3D11Device, - context: &ID3D11DeviceContext, - input: &ID3D11Texture2D, - in_width: u32, - in_height: u32, - out_width: u32, - out_height: u32, - source_format: hdr::SourceFormat, - ) -> Option { - let out_width = (out_width & !1).max(2); - let out_height = (out_height & !1).max(2); - let video_device = device - .cast::() - .inspect_err(|e| vlog(&format!("cast ID3D11VideoDevice failed: {e:?}"))) - .ok()?; - let video_context = context - .cast::() - .inspect_err(|e| vlog(&format!("cast ID3D11VideoContext failed: {e:?}"))) - .ok()?; - - let content_desc = D3D11_VIDEO_PROCESSOR_CONTENT_DESC { - InputFrameFormat: D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE, - InputFrameRate: DXGI_RATIONAL { - Numerator: 60, - Denominator: 1, - }, - InputWidth: in_width, - InputHeight: in_height, - OutputFrameRate: DXGI_RATIONAL { - Numerator: 60, - Denominator: 1, - }, - OutputWidth: out_width, - OutputHeight: out_height, - Usage: D3D11_VIDEO_USAGE_PLAYBACK_NORMAL, - }; - let enumerator = unsafe { video_device.CreateVideoProcessorEnumerator(&content_desc) } - .inspect_err(|e| vlog(&format!("CreateVideoProcessorEnumerator: {e:?}"))) - .ok()?; - let processor = unsafe { video_device.CreateVideoProcessor(&enumerator, 0) } - .inspect_err(|e| vlog(&format!("CreateVideoProcessor: {e:?}"))) - .ok()?; - - if let Ok(vctx1) = video_context.cast::() { - let input_cs = input_colour_space(source_format); - unsafe { - vctx1.VideoProcessorSetStreamColorSpace1(&processor, 0, input_cs); - vctx1.VideoProcessorSetOutputColorSpace1( - &processor, - DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, - ); - } - vlog(&format!( - "video processor colour space set: input={} -> output=YCbCr studio Rec.709", - input_cs.0 - )); - } else { - vlog("ID3D11VideoContext1 unavailable; using default SDR Rec.709 colour space"); - } - - let output_desc = D3D11_TEXTURE2D_DESC { - Width: out_width, - Height: out_height, - MipLevels: 1, - ArraySize: 1, - Format: DXGI_FORMAT_NV12, - SampleDesc: DXGI_SAMPLE_DESC { - Count: 1, - Quality: 0, - }, - Usage: D3D11_USAGE_DEFAULT, - BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32, - CPUAccessFlags: 0, - MiscFlags: D3D11_RESOURCE_MISC_SHARED.0 as u32, - }; - let mut output_slots = Vec::with_capacity(NV12_OUTPUT_SLOT_COUNT); - for _ in 0..NV12_OUTPUT_SLOT_COUNT { - output_slots.push(create_output_slot( - device, - &video_device, - &enumerator, - &output_desc, - )?); - } - assert_eq!( - output_slots.len(), - NV12_OUTPUT_SLOT_COUNT, - "all NV12 output slots created" - ); - let Ok(output_slots) = <[Nv12OutputSlot; NV12_OUTPUT_SLOT_COUNT]>::try_from(output_slots) - else { - vlog("NV12 output slot count mismatch"); - return None; - }; - - let input_view_desc = D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC { - FourCC: 0, - ViewDimension: D3D11_VPIV_DIMENSION_TEXTURE2D, - Anonymous: D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC_0 { - Texture2D: windows::Win32::Graphics::Direct3D11::D3D11_TEX2D_VPIV { - MipSlice: 0, - ArraySlice: 0, - }, - }, - }; - let mut input_view = None; - unsafe { - video_device.CreateVideoProcessorInputView( - input, - &enumerator, - &input_view_desc, - Some(&mut input_view), - ) - } - .inspect_err(|e| vlog(&format!("CreateVideoProcessorInputView: {e:?}"))) - .ok()?; - let input_view = input_view?; - vlog(&format!( - "NV12 converter built OK ({in_width}x{in_height} -> {out_width}x{out_height})" - )); - - Some(Self { - _video_device: video_device, - video_context, - processor, - _enumerator: enumerator, - input_view, - output_slots, - slot_cursor: 0, - context: context.clone(), - out_width, - out_height, - }) - } - - pub fn dxgi_format(&self) -> u32 { - DXGI_FORMAT_NV12.0 as u32 - } - - pub fn convert_shared_texture(&mut self) -> Result { - assert!( - self.slot_cursor < NV12_OUTPUT_SLOT_COUNT, - "slot cursor in range" - ); - assert!(self.out_width >= 2, "output width at least 2"); - let slot_index = self.slot_cursor; - self.slot_cursor = (slot_index + 1) % NV12_OUTPUT_SLOT_COUNT; - self.run_video_processor(slot_index)?; - unsafe { - self.context.Flush(); - } - Ok(Nv12SharedTextureFrame { - handle: self.output_slots[slot_index].handle, - width: self.out_width, - height: self.out_height, - dxgi_format: self.dxgi_format(), - }) - } - - fn run_video_processor(&self, slot_index: usize) -> Result<(), String> { - assert!(slot_index < NV12_OUTPUT_SLOT_COUNT, "slot index in range"); - let mut stream = D3D11_VIDEO_PROCESSOR_STREAM { - Enable: windows::core::BOOL(1), - OutputIndex: 0, - InputFrameOrField: 0, - PastFrames: 0, - FutureFrames: 0, - ppPastSurfaces: std::ptr::null_mut(), - pInputSurface: std::mem::ManuallyDrop::new(Some(self.input_view.clone())), - ppFutureSurfaces: std::ptr::null_mut(), - ppPastSurfacesRight: std::ptr::null_mut(), - pInputSurfaceRight: std::mem::ManuallyDrop::new(None), - ppFutureSurfacesRight: std::ptr::null_mut(), - }; - let blt = unsafe { - self.video_context.VideoProcessorBlt( - &self.processor, - &self.output_slots[slot_index].view, - 0, - std::slice::from_ref(&stream), - ) - }; - unsafe { - std::mem::ManuallyDrop::drop(&mut stream.pInputSurface); - } - blt.inspect_err(|e| vlog(&format!("VideoProcessorBlt RGB->NV12: {e:?}"))) - .map_err(|e| format!("VideoProcessorBlt RGB->NV12: {e}")) - } -} - -fn create_output_slot( - device: &ID3D11Device, - video_device: &ID3D11VideoDevice, - enumerator: &ID3D11VideoProcessorEnumerator, - output_desc: &D3D11_TEXTURE2D_DESC, -) -> Option { - assert!(output_desc.Width >= 2, "output width at least 2"); - assert!(output_desc.Height >= 2, "output height at least 2"); - let mut output_texture = None; - unsafe { device.CreateTexture2D(output_desc, None, Some(&mut output_texture)) } - .inspect_err(|e| vlog(&format!("CreateTexture2D NV12 output: {e:?}"))) - .ok()?; - let output_texture = output_texture?; - let resource: IDXGIResource = output_texture - .cast() - .inspect_err(|e| { - vlog(&format!( - "QueryInterface IDXGIResource for NV12 output: {e:?}" - )) - }) - .ok()?; - let shared_handle = unsafe { resource.GetSharedHandle() } - .inspect_err(|e| vlog(&format!("GetSharedHandle NV12 output: {e:?}"))) - .ok()?; - if shared_handle.is_invalid() { - vlog("GetSharedHandle NV12 output returned an invalid handle"); - return None; - } - let shared_handle = shared_handle.0 as usize as u64; - - let output_view_desc = D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC { - ViewDimension: D3D11_VPOV_DIMENSION_TEXTURE2D, - Anonymous: D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC_0 { - Texture2D: windows::Win32::Graphics::Direct3D11::D3D11_TEX2D_VPOV { MipSlice: 0 }, - }, - }; - let mut output_view = None; - unsafe { - video_device.CreateVideoProcessorOutputView( - &output_texture, - enumerator, - &output_view_desc, - Some(&mut output_view), - ) - } - .inspect_err(|e| vlog(&format!("CreateVideoProcessorOutputView: {e:?}"))) - .ok()?; - let output_view = output_view?; - - Some(Nv12OutputSlot { - _texture: output_texture, - view: output_view, - handle: shared_handle, - }) -} - -fn input_colour_space(source_format: hdr::SourceFormat) -> DXGI_COLOR_SPACE_TYPE { - match source_format { - hdr::SourceFormat::R10G10B10A2 { hdr: true } => DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020, - hdr::SourceFormat::Rgba16Float { hdr: true } => DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709, - _ => DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, - } -} - -unsafe impl Send for Nv12GpuConverter {} - -#[cfg(test)] -mod tests { - use super::*; - - fn cs_value(source_format: hdr::SourceFormat) -> i32 { - input_colour_space(source_format).0 - } - - #[test] - fn eight_bit_sources_use_sdr_rec709_colour_space() { - assert_eq!( - cs_value(hdr::SourceFormat::Bgra8), - DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709.0 - ); - assert_eq!( - cs_value(hdr::SourceFormat::Rgba8), - DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709.0 - ); - } - - #[test] - fn unflagged_high_precision_sources_stay_sdr_rec709() { - assert_eq!( - cs_value(hdr::SourceFormat::R10G10B10A2 { hdr: false }), - DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709.0 - ); - assert_eq!( - cs_value(hdr::SourceFormat::Rgba16Float { hdr: false }), - DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709.0 - ); - } - - #[test] - fn ten_bit_hdr_uses_pq_rec2020_input_space() { - assert_eq!( - cs_value(hdr::SourceFormat::R10G10B10A2 { hdr: true }), - DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020.0 - ); - } - - #[test] - fn fp16_hdr_uses_linear_extended_rec709_input_space() { - assert_eq!( - cs_value(hdr::SourceFormat::Rgba16Float { hdr: true }), - DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709.0 - ); - } -} diff --git a/fluxer_desktop/native/win-game-capture/src/sources.rs b/fluxer_desktop/native/win-game-capture/src/sources.rs deleted file mode 100644 index 69f881698..000000000 --- a/fluxer_desktop/native/win-game-capture/src/sources.rs +++ /dev/null @@ -1,168 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use crate::ScreenCaptureSourceDescriptor; - -#[cfg(target_os = "windows")] -use std::ptr::null_mut; - -#[cfg(target_os = "windows")] -use windows_sys::Win32::{ - Foundation::{HWND, LPARAM, RECT}, - Graphics::Gdi::{EnumDisplayMonitors, HMONITOR}, - System::Threading::GetCurrentProcessId, - UI::WindowsAndMessaging::{ - EnumWindows, GW_OWNER, GWL_STYLE, GetWindow, GetWindowLongPtrW, GetWindowRect, - GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, IsWindowVisible, WS_CHILD, - }, -}; - -#[cfg(target_os = "windows")] -const MONITOR_SOURCE_LIMIT: usize = 16; - -pub fn list_sources() -> Vec { - #[cfg(target_os = "windows")] - { - let mut sources = enumerate_monitor_sources(); - sources.extend(enumerate_window_sources()); - sources - } - - #[cfg(not(target_os = "windows"))] - { - Vec::new() - } -} - -#[cfg(target_os = "windows")] -fn enumerate_monitor_sources() -> Vec { - unsafe extern "system" fn enum_monitor( - _monitor: HMONITOR, - _hdc: windows_sys::Win32::Graphics::Gdi::HDC, - rect: *mut RECT, - param: LPARAM, - ) -> i32 { - let monitors = &mut *(param as *mut Vec); - if monitors.len() >= MONITOR_SOURCE_LIMIT { - return 0; - } - if !rect.is_null() { - monitors.push(*rect); - } - 1 - } - - let mut monitors: Vec = Vec::new(); - unsafe { - EnumDisplayMonitors( - null_mut(), - null_mut(), - Some(enum_monitor), - &mut monitors as *mut _ as LPARAM, - ); - } - assert!( - monitors.len() <= MONITOR_SOURCE_LIMIT, - "monitor source enumeration bounded" - ); - - monitors - .into_iter() - .enumerate() - .map(|(index, rect)| { - let width = (rect.right - rect.left).max(0) as u32; - let height = (rect.bottom - rect.top).max(0) as u32; - ScreenCaptureSourceDescriptor { - kind: "screen".to_string(), - id: format!("screen:{index}:0"), - name: format!("Display {}", index + 1), - width, - height, - target_pid: None, - } - }) - .collect() -} - -#[cfg(target_os = "windows")] -fn enumerate_window_sources() -> Vec { - struct EnumState { - own_pid: u32, - sources: Vec, - } - - unsafe extern "system" fn enum_window(hwnd: HWND, param: LPARAM) -> i32 { - let state = &mut *(param as *mut EnumState); - if let Some(source) = describe_window_source(hwnd, state.own_pid) { - state.sources.push(source); - } - 1 - } - - let mut state = EnumState { - own_pid: unsafe { GetCurrentProcessId() }, - sources: Vec::new(), - }; - unsafe { - EnumWindows(Some(enum_window), &mut state as *mut _ as LPARAM); - } - state.sources -} - -#[cfg(target_os = "windows")] -fn describe_window_source(hwnd: HWND, own_pid: u32) -> Option { - if hwnd.is_null() || unsafe { IsWindowVisible(hwnd) } == 0 { - return None; - } - if !unsafe { GetWindow(hwnd, GW_OWNER) }.is_null() { - return None; - } - let style = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) } as u32; - if style & WS_CHILD != 0 { - return None; - } - - let mut pid = 0u32; - unsafe { - GetWindowThreadProcessId(hwnd, &mut pid); - } - if pid == 0 || pid == own_pid { - return None; - } - - let mut rect = RECT::default(); - if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 { - return None; - } - let width = (rect.right - rect.left).max(0) as u32; - let height = (rect.bottom - rect.top).max(0) as u32; - if width == 0 || height == 0 { - return None; - } - - let title = window_title(hwnd).unwrap_or_else(|| format!("Window {pid}")); - Some(ScreenCaptureSourceDescriptor { - kind: "window".to_string(), - id: format!("window:{}:0", hwnd as usize), - name: title, - width, - height, - target_pid: Some(pid), - }) -} - -#[cfg(target_os = "windows")] -fn window_title(hwnd: HWND) -> Option { - let len = unsafe { GetWindowTextLengthW(hwnd) }; - if len <= 0 { - return None; - } - let mut buf = vec![0u16; (len + 1) as usize]; - let copied = unsafe { GetWindowTextW(hwnd, buf.as_mut_ptr(), buf.len() as i32) }; - if copied <= 0 { - return None; - } - let title = String::from_utf16_lossy(&buf[..copied as usize]) - .trim() - .to_string(); - if title.is_empty() { None } else { Some(title) } -} diff --git a/fluxer_desktop/native/win-game-capture/src/stall.rs b/fluxer_desktop/native/win-game-capture/src/stall.rs deleted file mode 100644 index d5803ed11..000000000 --- a/fluxer_desktop/native/win-game-capture/src/stall.rs +++ /dev/null @@ -1,183 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::time::{Duration, Instant}; - -pub const MIN_STALL_THRESHOLD: Duration = Duration::from_millis(250); -pub const MAX_STALL_THRESHOLD: Duration = Duration::from_secs(30); - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StallSignal { - Quiet, - Stalled { frames_seen: u64, elapsed_ms: u64 }, - Resumed { stalled_for_ms: u64 }, -} - -pub struct NoFrameStallTracker { - threshold: Duration, - last_frame_at: Instant, - frames_seen: u64, - reported: bool, -} - -impl NoFrameStallTracker { - pub fn new(threshold: Duration, started_at: Instant) -> Self { - assert!( - threshold >= MIN_STALL_THRESHOLD, - "stall threshold at least the minimum" - ); - assert!( - threshold <= MAX_STALL_THRESHOLD, - "stall threshold at most the maximum" - ); - Self { - threshold, - last_frame_at: started_at, - frames_seen: 0, - reported: false, - } - } - - pub fn observe( - &mut self, - now: Instant, - produced_frame: bool, - target_expects_frames: bool, - ) -> StallSignal { - let idle = now.saturating_duration_since(self.last_frame_at); - if produced_frame { - self.frames_seen = self.frames_seen.saturating_add(1); - self.last_frame_at = now; - if !self.reported { - return StallSignal::Quiet; - } - self.reported = false; - return StallSignal::Resumed { - stalled_for_ms: duration_ms(idle), - }; - } - if !target_expects_frames { - self.last_frame_at = now; - return StallSignal::Quiet; - } - if self.reported || idle < self.threshold { - return StallSignal::Quiet; - } - self.reported = true; - StallSignal::Stalled { - frames_seen: self.frames_seen, - elapsed_ms: duration_ms(idle), - } - } -} - -fn duration_ms(duration: Duration) -> u64 { - u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) -} - -#[cfg(test)] -mod tests { - use super::*; - - const THRESHOLD: Duration = Duration::from_millis(3000); - - fn tracker(start: Instant) -> NoFrameStallTracker { - NoFrameStallTracker::new(THRESHOLD, start) - } - - #[test] - fn stays_quiet_before_the_threshold_elapses() { - let start = Instant::now(); - let mut stall = tracker(start); - assert_eq!( - stall.observe(start + Duration::from_millis(2999), false, true), - StallSignal::Quiet - ); - } - - #[test] - fn reports_a_stall_once_when_no_frame_ever_arrives() { - let start = Instant::now(); - let mut stall = tracker(start); - assert_eq!( - stall.observe(start + THRESHOLD, false, true), - StallSignal::Stalled { - frames_seen: 0, - elapsed_ms: 3000, - } - ); - assert_eq!( - stall.observe(start + Duration::from_millis(9000), false, true), - StallSignal::Quiet, - "a stall episode is reported at most once" - ); - } - - #[test] - fn counts_delivered_frames_before_a_later_stall() { - let start = Instant::now(); - let mut stall = tracker(start); - assert_eq!( - stall.observe(start + Duration::from_millis(16), true, true), - StallSignal::Quiet - ); - assert_eq!( - stall.observe(start + Duration::from_millis(32), true, true), - StallSignal::Quiet - ); - assert_eq!( - stall.observe(start + Duration::from_millis(3032), false, true), - StallSignal::Stalled { - frames_seen: 2, - elapsed_ms: 3000, - } - ); - } - - #[test] - fn resuming_frames_clears_the_episode_and_arms_the_next_one() { - let start = Instant::now(); - let mut stall = tracker(start); - assert_eq!( - stall.observe(start + THRESHOLD, false, true), - StallSignal::Stalled { - frames_seen: 0, - elapsed_ms: 3000, - } - ); - assert_eq!( - stall.observe(start + Duration::from_millis(4000), true, true), - StallSignal::Resumed { - stalled_for_ms: 4000, - } - ); - assert_eq!( - stall.observe(start + Duration::from_millis(7000), false, true), - StallSignal::Stalled { - frames_seen: 1, - elapsed_ms: 3000, - } - ); - } - - #[test] - fn a_target_that_is_not_expected_to_produce_frames_never_stalls() { - let start = Instant::now(); - let mut stall = tracker(start); - assert_eq!( - stall.observe(start + Duration::from_millis(60_000), false, false), - StallSignal::Quiet - ); - assert_eq!( - stall.observe(start + Duration::from_millis(62_000), false, true), - StallSignal::Quiet, - "the idle window restarts once the target can produce frames again" - ); - assert_eq!( - stall.observe(start + Duration::from_millis(63_000), false, true), - StallSignal::Stalled { - frames_seen: 0, - elapsed_ms: 3000, - } - ); - } -} diff --git a/fluxer_desktop/native/win-game-capture/src/wgc_capture.rs b/fluxer_desktop/native/win-game-capture/src/wgc_capture.rs deleted file mode 100644 index abaac249a..000000000 --- a/fluxer_desktop/native/win-game-capture/src/wgc_capture.rs +++ /dev/null @@ -1,948 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::sync::{Arc, atomic::Ordering}; - -use windows::Graphics::Capture::{ - Direct3D11CaptureFrame, Direct3D11CaptureFramePool, GraphicsCaptureItem, GraphicsCaptureSession, -}; -use windows::Graphics::DirectX::Direct3D11::IDirect3DDevice; -use windows::Graphics::DirectX::DirectXPixelFormat; -use windows::Graphics::SizeInt32; -use windows::Win32::Foundation::{HWND, LPARAM, RECT, RPC_E_CHANGED_MODE}; -use windows::Win32::Graphics::Direct3D11::{ - D3D11_BIND_SHADER_RESOURCE, D3D11_BOX, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, ID3D11Device, - ID3D11DeviceContext, ID3D11Resource, ID3D11Texture2D, -}; -use windows::Win32::Graphics::Dxgi::Common::{ - DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC, -}; -use windows::Win32::Graphics::Dxgi::IDXGIDevice; -use windows::Win32::Graphics::Gdi::{EnumDisplayMonitors, HDC, HMONITOR}; -use windows::Win32::System::WinRT::Direct3D11::{ - CreateDirect3D11DeviceFromDXGIDevice, IDirect3DDxgiInterfaceAccess, -}; -use windows::Win32::System::WinRT::Graphics::Capture::IGraphicsCaptureItemInterop; -use windows::Win32::System::WinRT::{RO_INIT_MULTITHREADED, RoInitialize}; -use windows::Win32::UI::WindowsAndMessaging::{IsWindow, IsWindowVisible}; -use windows::core::{BOOL, Interface}; - -use crate::dxgi_capture::{ - SharedTextureOutput, capture_timestamp_us, create_shared_output_texture, - pacing_sleep_and_next_deadline, resolve_output_size, -}; -use crate::nv12_gpu::Nv12GpuConverter; -use crate::stall::{NoFrameStallTracker, StallSignal}; -use crate::{ - CaptureInner, emit_lifecycle, emit_shared_texture_frame, note_media_frame_without_sink, - resolve_frame_sink, -}; - -const WGC_FRAME_POOL_BUFFERS: i32 = 2; -const WGC_FRAME_DRAIN_LIMIT: u32 = 4; -const WGC_MONITOR_ENUM_LIMIT: usize = 16; -const WGC_STALL_THRESHOLD: std::time::Duration = std::time::Duration::from_millis(3000); - -fn ensure_winrt_initialized() { - let result = unsafe { RoInitialize(RO_INIT_MULTITHREADED) }; - if let Err(error) = result { - assert!( - error.code() == RPC_E_CHANGED_MODE, - "RoInitialize failed unexpectedly: {error:?}" - ); - } -} - -pub fn wgc_capture_supported() -> bool { - ensure_winrt_initialized(); - GraphicsCaptureSession::IsSupported().unwrap_or(false) -} - -#[derive(Clone, Copy)] -pub enum WgcCaptureTarget { - Window(HWND), - Monitor(HMONITOR), -} - -impl WgcCaptureTarget { - fn create_item( - self, - interop: &IGraphicsCaptureItemInterop, - ) -> Result { - match self { - Self::Window(hwnd) => unsafe { interop.CreateForWindow(hwnd) } - .map_err(|e| format!("IGraphicsCaptureItemInterop::CreateForWindow: {e}")), - Self::Monitor(monitor) => unsafe { interop.CreateForMonitor(monitor) } - .map_err(|e| format!("IGraphicsCaptureItemInterop::CreateForMonitor: {e}")), - } - } - - fn is_alive(self) -> bool { - match self { - Self::Window(hwnd) => unsafe { IsWindow(Some(hwnd)) }.as_bool(), - Self::Monitor(monitor) => !monitor.is_invalid(), - } - } - - fn expects_frames(self) -> bool { - match self { - Self::Window(hwnd) => unsafe { IsWindowVisible(hwnd) }.as_bool(), - Self::Monitor(monitor) => !monitor.is_invalid(), - } - } - - fn label(self) -> &'static str { - match self { - Self::Window(_) => "window", - Self::Monitor(_) => "monitor", - } - } - - fn closed_message(self) -> &'static str { - match self { - Self::Window(_) => "window closed", - Self::Monitor(_) => "monitor capture target unavailable", - } - } -} - -pub fn parse_monitor_source_id(source_id: &str, source_kind: &str) -> Option { - if source_kind != "screen" { - return None; - } - let mut parts = source_id.split(':'); - if parts.next()? != "screen" { - return None; - } - let index = parts.next()?.parse::().ok()?; - let sub_id = parts.next()?; - if sub_id != "0" && sub_id != "1" { - return None; - } - if parts.next().is_some() || index >= WGC_MONITOR_ENUM_LIMIT { - return None; - } - enumerate_monitors().get(index).copied() -} - -fn enumerate_monitors() -> Vec { - struct MonitorEnumState { - monitors: Vec, - } - - unsafe extern "system" fn enum_monitor( - monitor: HMONITOR, - _hdc: HDC, - _rect: *mut RECT, - param: LPARAM, - ) -> BOOL { - let state = unsafe { &mut *(param.0 as *mut MonitorEnumState) }; - if state.monitors.len() >= WGC_MONITOR_ENUM_LIMIT { - return BOOL(0); - } - if !monitor.is_invalid() { - state.monitors.push(monitor); - } - BOOL(1) - } - - let mut state = MonitorEnumState { - monitors: Vec::with_capacity(WGC_MONITOR_ENUM_LIMIT), - }; - unsafe { - let _ = EnumDisplayMonitors( - None, - None, - Some(enum_monitor), - LPARAM((&mut state as *mut MonitorEnumState) as isize), - ); - } - assert!( - state.monitors.len() <= WGC_MONITOR_ENUM_LIMIT, - "monitor enumeration bounded" - ); - state.monitors -} - -pub struct WgcCaptureSession { - device: ID3D11Device, - context: ID3D11DeviceContext, - d3d_device: IDirect3DDevice, - item: GraphicsCaptureItem, - target: WgcCaptureTarget, - output_width: u32, - output_height: u32, - requested_width: Option, - requested_height: Option, -} - -unsafe impl Send for WgcCaptureSession {} - -impl WgcCaptureSession { - pub fn new( - hwnd: HWND, - requested_width: Option, - requested_height: Option, - ) -> Result { - Self::new_for_target( - WgcCaptureTarget::Window(hwnd), - requested_width, - requested_height, - ) - } - - pub fn new_monitor( - monitor: HMONITOR, - requested_width: Option, - requested_height: Option, - ) -> Result { - Self::new_for_target( - WgcCaptureTarget::Monitor(monitor), - requested_width, - requested_height, - ) - } - - fn new_for_target( - target: WgcCaptureTarget, - requested_width: Option, - requested_height: Option, - ) -> Result { - ensure_winrt_initialized(); - let (device, context) = crate::d3d11_device::create_shared_texture_device(None)?; - let dxgi_device: IDXGIDevice = device - .cast() - .map_err(|e| format!("IDXGIDevice cast: {e}"))?; - let inspectable = unsafe { CreateDirect3D11DeviceFromDXGIDevice(&dxgi_device) } - .map_err(|e| format!("CreateDirect3D11DeviceFromDXGIDevice: {e}"))?; - let d3d_device: IDirect3DDevice = inspectable - .cast() - .map_err(|e| format!("IDirect3DDevice cast: {e}"))?; - let interop = windows::core::factory::() - .map_err(|e| format!("GraphicsCaptureItem interop factory: {e}"))?; - let item = target.create_item(&interop)?; - let size = item - .Size() - .map_err(|e| format!("GraphicsCaptureItem.Size: {e}"))?; - let content_width = size.Width.max(1) as u32; - let content_height = size.Height.max(1) as u32; - let (output_width, output_height) = resolve_output_size( - content_width, - content_height, - requested_width, - requested_height, - ); - assert!(output_width > 0, "WGC output width positive"); - assert!(output_height > 0, "WGC output height positive"); - Ok(Self { - device, - context, - d3d_device, - item, - target, - output_width, - output_height, - requested_width, - requested_height, - }) - } - - pub fn capture_width(&self) -> u32 { - assert!(self.output_width > 0, "WGC output width positive"); - assert!(self.output_height > 0, "WGC output height positive"); - self.output_width - } - - pub fn capture_height(&self) -> u32 { - assert!(self.output_height > 0, "WGC output height positive"); - assert!(self.output_width > 0, "WGC output width positive"); - self.output_height - } -} - -struct WgcState { - frame_pool: Direct3D11CaptureFramePool, - session: GraphicsCaptureSession, - output_pipeline: Option, - pixel_format: DirectXPixelFormat, - content_width: u32, - content_height: u32, - out_w: u32, - out_h: u32, -} - -impl WgcState { - fn close(self) { - let _ = self.session.Close(); - let _ = self.frame_pool.Close(); - } -} - -struct WgcNv12Pipeline { - input_resource: ID3D11Resource, - converter: Nv12GpuConverter, -} - -enum WgcOutputPipeline { - Bgra(SharedTextureOutput), - Nv12(WgcNv12Pipeline), -} - -enum WgcFrameResult { - Ok, - NoFrame, - Resized { width: u32, height: u32 }, - Error(String), -} - -enum LoopStep { - Paced, - Restart, -} - -struct WgcLoopContext { - device: ID3D11Device, - context: ID3D11DeviceContext, - d3d_device: IDirect3DDevice, - item: GraphicsCaptureItem, - target: WgcCaptureTarget, - requested_width: Option, - requested_height: Option, -} - -fn wgc_loop_context(inner: &Arc) -> Option { - let guard = inner.wgc_session.lock(); - let session = guard.as_ref()?; - Some(WgcLoopContext { - device: session.device.clone(), - context: session.context.clone(), - d3d_device: session.d3d_device.clone(), - item: session.item.clone(), - target: session.target, - requested_width: session.requested_width, - requested_height: session.requested_height, - }) -} - -fn teardown_wgc_state(wgc_state: &mut Option) { - if let Some(state) = wgc_state.take() { - state.close(); - } -} - -fn sleep_with_backoff(backoff: &mut std::time::Duration) { - assert!( - *backoff >= std::time::Duration::from_millis(100), - "backoff at least base interval" - ); - assert!( - *backoff <= std::time::Duration::from_secs(2), - "backoff bounded" - ); - std::thread::sleep(*backoff); - *backoff = (*backoff * 2).min(std::time::Duration::from_secs(2)); -} - -pub fn capture_loop(inner: &Arc, frame_interval: std::time::Duration) { - ensure_winrt_initialized(); - let Some(ctx) = wgc_loop_context(inner) else { - emit_lifecycle(inner, "closed-clean", "no WGC session"); - return; - }; - - let capture_id = inner.capture_id.lock().clone(); - let mut wgc_state: Option = None; - let mut recreate_backoff = std::time::Duration::from_millis(100); - let capture_start = std::time::Instant::now(); - let mut next_frame_deadline = capture_start + frame_interval; - let mut frames_dropped_coalesced: u64 = 0; - let mut stall_tracker = NoFrameStallTracker::new(WGC_STALL_THRESHOLD, capture_start); - - while inner.running.load(Ordering::Acquire) { - if !ctx.target.is_alive() { - emit_lifecycle(inner, "closed", ctx.target.closed_message()); - break; - } - - if wgc_state.is_none() { - match setup_wgc_state(&ctx) { - Ok(state) => { - wgc_state = Some(state); - } - Err(e) => { - if !inner.running.load(Ordering::Acquire) { - break; - } - emit_lifecycle( - inner, - "error", - &format!("Failed to create WGC capture session: {e}"), - ); - sleep_with_backoff(&mut recreate_backoff); - continue; - } - } - } - - let Some(state) = wgc_state.as_mut() else { - continue; - }; - let result = poll_and_emit_frame( - inner, - &ctx.context, - state, - capture_id.as_deref(), - capture_start, - &mut frames_dropped_coalesced, - ); - let signal = stall_tracker.observe( - std::time::Instant::now(), - matches!(result, WgcFrameResult::Ok), - ctx.target.expects_frames(), - ); - emit_stall_signal(inner, ctx.target, signal); - match handle_frame_result(inner, &ctx, &mut wgc_state, result, &mut recreate_backoff) { - LoopStep::Paced => {} - LoopStep::Restart => continue, - } - - let now = std::time::Instant::now(); - let (sleep_duration, deadline) = - pacing_sleep_and_next_deadline(now, next_frame_deadline, frame_interval); - next_frame_deadline = deadline; - if sleep_duration > std::time::Duration::ZERO { - std::thread::sleep(sleep_duration); - } - } - - teardown_wgc_state(&mut wgc_state); - inner.running.store(false, Ordering::Release); - emit_lifecycle(inner, "closed-clean", "capture stopped"); -} - -fn emit_stall_signal(inner: &Arc, target: WgcCaptureTarget, signal: StallSignal) { - match signal { - StallSignal::Quiet => {} - StallSignal::Stalled { - frames_seen, - elapsed_ms, - } => { - emit_lifecycle( - inner, - "stalled", - &stall_detail(target, frames_seen, elapsed_ms), - ); - } - StallSignal::Resumed { stalled_for_ms } => { - emit_lifecycle( - inner, - "diagnostic", - &format!( - "WGC {} capture frames resumed after {stalled_for_ms}ms without a frame", - target.label() - ), - ); - } - } -} - -fn stall_detail(target: WgcCaptureTarget, frames_seen: u64, elapsed_ms: u64) -> String { - let label = target.label(); - if frames_seen == 0 { - return format!( - "WGC {label} capture stalled: the target is still alive but Windows Graphics Capture \ - has delivered no frame in the first {elapsed_ms}ms; the stream is blank" - ); - } - format!( - "WGC {label} capture stalled: no new frame for {elapsed_ms}ms after {frames_seen} frames; \ - the target may be paused, occluded, or no longer rendering" - ) -} - -fn handle_frame_result( - inner: &Arc, - ctx: &WgcLoopContext, - wgc_state: &mut Option, - result: WgcFrameResult, - recreate_backoff: &mut std::time::Duration, -) -> LoopStep { - match result { - WgcFrameResult::Ok => { - *recreate_backoff = std::time::Duration::from_millis(100); - LoopStep::Paced - } - WgcFrameResult::NoFrame => LoopStep::Paced, - WgcFrameResult::Resized { width, height } => { - let Some(state) = wgc_state.as_mut() else { - return LoopStep::Restart; - }; - match resize_wgc_state(ctx, state, width, height) { - Ok(()) => LoopStep::Paced, - Err(e) => { - emit_lifecycle( - inner, - "error", - &format!("WGC frame pool resize failed: {e}"), - ); - teardown_wgc_state(wgc_state); - sleep_with_backoff(recreate_backoff); - LoopStep::Restart - } - } - } - WgcFrameResult::Error(e) => { - emit_lifecycle(inner, "error", &e); - { - let mut guard = inner.fallback.lock(); - if let Some(tracker) = guard.as_mut() { - let _ = tracker.observe(crate::fallback::FailureSignature::DeviceLost); - } - } - teardown_wgc_state(wgc_state); - sleep_with_backoff(recreate_backoff); - LoopStep::Restart - } - } -} - -fn setup_wgc_state(ctx: &WgcLoopContext) -> Result { - let size = ctx - .item - .Size() - .map_err(|e| format!("GraphicsCaptureItem.Size: {e}"))?; - let content_width = size.Width.max(1) as u32; - let content_height = size.Height.max(1) as u32; - let (out_w, out_h) = resolve_output_size( - content_width, - content_height, - ctx.requested_width, - ctx.requested_height, - ); - create_wgc_state_for_format( - ctx, - DirectXPixelFormat::R16G16B16A16Float, - content_width, - content_height, - out_w, - out_h, - ) - .or_else(|_| { - create_wgc_state_for_format( - ctx, - DirectXPixelFormat::B8G8R8A8UIntNormalized, - content_width, - content_height, - out_w, - out_h, - ) - }) -} - -fn create_wgc_state_for_format( - ctx: &WgcLoopContext, - pixel_format: DirectXPixelFormat, - content_width: u32, - content_height: u32, - out_w: u32, - out_h: u32, -) -> Result { - let output_pipeline = create_wgc_output_pipeline( - ctx, - pixel_format, - content_width, - content_height, - out_w, - out_h, - )?; - let frame_pool = Direct3D11CaptureFramePool::CreateFreeThreaded( - &ctx.d3d_device, - pixel_format, - WGC_FRAME_POOL_BUFFERS, - SizeInt32 { - Width: content_width as i32, - Height: content_height as i32, - }, - ) - .map_err(|e| format!("Direct3D11CaptureFramePool::CreateFreeThreaded: {e}"))?; - let session = frame_pool - .CreateCaptureSession(&ctx.item) - .map_err(|e| format!("Direct3D11CaptureFramePool.CreateCaptureSession: {e}"))?; - session - .StartCapture() - .map_err(|e| format!("GraphicsCaptureSession.StartCapture: {e}"))?; - Ok(WgcState { - frame_pool, - session, - output_pipeline, - pixel_format, - content_width, - content_height, - out_w, - out_h, - }) -} - -fn resize_wgc_state( - ctx: &WgcLoopContext, - state: &mut WgcState, - width: u32, - height: u32, -) -> Result<(), String> { - assert!(width > 0, "WGC resize width positive"); - assert!(height > 0, "WGC resize height positive"); - let (out_w, out_h) = - resolve_output_size(width, height, ctx.requested_width, ctx.requested_height); - match resize_wgc_state_for_format(ctx, state, state.pixel_format, width, height, out_w, out_h) { - Ok(()) => Ok(()), - Err(first_error) if state.pixel_format == DirectXPixelFormat::R16G16B16A16Float => { - resize_wgc_state_for_format( - ctx, - state, - DirectXPixelFormat::B8G8R8A8UIntNormalized, - width, - height, - out_w, - out_h, - ) - .map_err(|fallback_error| { - format!( - "HDR resize failed ({first_error}); BGRA fallback failed ({fallback_error})" - ) - }) - } - Err(first_error) => Err(first_error), - } -} - -fn resize_wgc_state_for_format( - ctx: &WgcLoopContext, - state: &mut WgcState, - pixel_format: DirectXPixelFormat, - width: u32, - height: u32, - out_w: u32, - out_h: u32, -) -> Result<(), String> { - let output_pipeline = - create_wgc_output_pipeline(ctx, pixel_format, width, height, out_w, out_h)?; - state - .frame_pool - .Recreate( - &ctx.d3d_device, - pixel_format, - WGC_FRAME_POOL_BUFFERS, - SizeInt32 { - Width: width as i32, - Height: height as i32, - }, - ) - .map_err(|e| format!("Direct3D11CaptureFramePool.Recreate: {e}"))?; - state.content_width = width; - state.content_height = height; - state.out_w = out_w; - state.out_h = out_h; - state.pixel_format = pixel_format; - state.output_pipeline = output_pipeline; - Ok(()) -} - -fn create_wgc_output_pipeline( - ctx: &WgcLoopContext, - pixel_format: DirectXPixelFormat, - content_width: u32, - content_height: u32, - out_w: u32, - out_h: u32, -) -> Result, String> { - if pixel_format == DirectXPixelFormat::R16G16B16A16Float { - return create_wgc_nv12_pipeline( - ctx, - DXGI_FORMAT_R16G16B16A16_FLOAT, - content_width, - content_height, - out_w, - out_h, - crate::hdr::SourceFormat::Rgba16Float { hdr: true }, - ) - .map(WgcOutputPipeline::Nv12) - .map(Some); - } - if out_w != content_width || out_h != content_height { - return create_wgc_nv12_pipeline( - ctx, - DXGI_FORMAT_B8G8R8A8_UNORM, - content_width, - content_height, - out_w, - out_h, - crate::hdr::SourceFormat::Bgra8, - ) - .map(WgcOutputPipeline::Nv12) - .map(Some); - } - Ok( - create_shared_output_texture(&ctx.device, content_width, content_height) - .ok() - .map(WgcOutputPipeline::Bgra), - ) -} - -fn create_wgc_nv12_pipeline( - ctx: &WgcLoopContext, - input_format: DXGI_FORMAT, - content_width: u32, - content_height: u32, - out_w: u32, - out_h: u32, - source_format: crate::hdr::SourceFormat, -) -> Result { - assert!(content_width > 0, "WGC NV12 input width positive"); - assert!(content_height > 0, "WGC NV12 input height positive"); - let input_desc = D3D11_TEXTURE2D_DESC { - Width: content_width, - Height: content_height, - MipLevels: 1, - ArraySize: 1, - Format: input_format, - SampleDesc: DXGI_SAMPLE_DESC { - Count: 1, - Quality: 0, - }, - Usage: D3D11_USAGE_DEFAULT, - BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32, - CPUAccessFlags: 0, - MiscFlags: 0, - }; - let mut input_texture = None; - unsafe { - ctx.device - .CreateTexture2D(&input_desc, None, Some(&mut input_texture)) - } - .map_err(|e| format!("CreateTexture2D WGC NV12 input: {e}"))?; - let input_texture = - input_texture.ok_or_else(|| "CreateTexture2D WGC NV12 input returned null".to_string())?; - let input_resource: ID3D11Resource = input_texture - .cast() - .map_err(|e| format!("ID3D11Resource WGC NV12 input cast: {e}"))?; - let converter = Nv12GpuConverter::new( - &ctx.device, - &ctx.context, - &input_texture, - content_width, - content_height, - out_w, - out_h, - source_format, - ) - .ok_or_else(|| "WGC NV12 converter unavailable".to_string())?; - Ok(WgcNv12Pipeline { - input_resource, - converter, - }) -} - -fn poll_and_emit_frame( - inner: &Arc, - context: &ID3D11DeviceContext, - state: &mut WgcState, - capture_id: Option<&str>, - capture_start: std::time::Instant, - frames_dropped_coalesced: &mut u64, -) -> WgcFrameResult { - let mut newest: Option = None; - let mut drained: u32 = 0; - while drained < WGC_FRAME_DRAIN_LIMIT { - let Ok(frame) = state.frame_pool.TryGetNextFrame() else { - break; - }; - if let Some(previous) = newest.replace(frame) { - *frames_dropped_coalesced += 1; - let _ = previous.Close(); - } - drained += 1; - } - assert!(drained <= WGC_FRAME_DRAIN_LIMIT, "frame drain bounded"); - let Some(frame) = newest else { - return WgcFrameResult::NoFrame; - }; - let result = emit_wgc_frame(inner, context, state, capture_id, &frame, capture_start); - let _ = frame.Close(); - result -} - -fn wgc_frame_source_resource(frame: &Direct3D11CaptureFrame) -> Result { - let surface = frame - .Surface() - .map_err(|e| format!("Direct3D11CaptureFrame.Surface: {e}"))?; - let access: IDirect3DDxgiInterfaceAccess = surface - .cast() - .map_err(|e| format!("IDirect3DDxgiInterfaceAccess cast: {e}"))?; - let source_texture: ID3D11Texture2D = - unsafe { access.GetInterface() }.map_err(|e| format!("WGC surface GetInterface: {e}"))?; - source_texture - .cast() - .map_err(|e| format!("ID3D11Resource WGC surface cast: {e}")) -} - -fn emit_wgc_frame( - inner: &Arc, - context: &ID3D11DeviceContext, - state: &mut WgcState, - capture_id: Option<&str>, - frame: &Direct3D11CaptureFrame, - capture_start: std::time::Instant, -) -> WgcFrameResult { - let content = match frame.ContentSize() { - Ok(size) => size, - Err(e) => { - return WgcFrameResult::Error(format!("Direct3D11CaptureFrame.ContentSize: {e}")); - } - }; - let content_width = content.Width.max(1) as u32; - let content_height = content.Height.max(1) as u32; - if content_width != state.content_width || content_height != state.content_height { - return WgcFrameResult::Resized { - width: content_width, - height: content_height, - }; - } - let Some(frame_sink) = resolve_frame_sink(inner, capture_id) else { - note_media_frame_without_sink( - inner, - "WGC frame dropped because no native frame sink is registered", - ); - return WgcFrameResult::Ok; - }; - let source_resource = match wgc_frame_source_resource(frame) { - Ok(resource) => resource, - Err(e) => return WgcFrameResult::Error(e), - }; - let timestamp_us = capture_timestamp_us(capture_start); - let Some(output_pipeline) = state.output_pipeline.as_mut() else { - return WgcFrameResult::Error("WGC shared texture output unavailable".into()); - }; - match output_pipeline { - WgcOutputPipeline::Bgra(shared_output) => emit_wgc_bgra_frame( - inner, - context, - &frame_sink, - shared_output, - &source_resource, - content_width, - content_height, - timestamp_us, - ), - WgcOutputPipeline::Nv12(pipeline) => emit_wgc_nv12_frame( - inner, - context, - &frame_sink, - pipeline, - &source_resource, - content_width, - content_height, - timestamp_us, - ), - } -} - -#[allow(clippy::too_many_arguments)] -fn emit_wgc_bgra_frame( - inner: &Arc, - context: &ID3D11DeviceContext, - frame_sink: &crate::FrameSinkRef, - shared_output: &mut SharedTextureOutput, - source_resource: &ID3D11Resource, - content_width: u32, - content_height: u32, - timestamp_us: i64, -) -> WgcFrameResult { - if shared_output.width != content_width || shared_output.height != content_height { - return WgcFrameResult::Error( - "WGC BGRA shared output size does not match content size".into(), - ); - } - let slot_index = shared_output.next_slot_index(); - let slot = &shared_output.slots[slot_index]; - let output_resource: ID3D11Resource = match slot.texture.cast() { - Ok(resource) => resource, - Err(e) => return WgcFrameResult::Error(format!("ID3D11Resource shared output cast: {e}")), - }; - let src_box = D3D11_BOX { - left: 0, - top: 0, - front: 0, - right: content_width, - bottom: content_height, - back: 1, - }; - unsafe { - context.CopySubresourceRegion( - &output_resource, - 0, - 0, - 0, - 0, - source_resource, - 0, - Some(&src_box), - ); - context.Flush(); - } - let _ = emit_shared_texture_frame( - inner, - frame_sink, - slot.handle, - shared_output.width, - shared_output.height, - shared_output.dxgi_format, - timestamp_us, - ); - WgcFrameResult::Ok -} - -#[allow(clippy::too_many_arguments)] -fn emit_wgc_nv12_frame( - inner: &Arc, - context: &ID3D11DeviceContext, - frame_sink: &crate::FrameSinkRef, - pipeline: &mut WgcNv12Pipeline, - source_resource: &ID3D11Resource, - content_width: u32, - content_height: u32, - timestamp_us: i64, -) -> WgcFrameResult { - let src_box = D3D11_BOX { - left: 0, - top: 0, - front: 0, - right: content_width, - bottom: content_height, - back: 1, - }; - unsafe { - context.CopySubresourceRegion( - &pipeline.input_resource, - 0, - 0, - 0, - 0, - source_resource, - 0, - Some(&src_box), - ); - } - let frame = match pipeline.converter.convert_shared_texture() { - Ok(frame) => frame, - Err(error) => return WgcFrameResult::Error(error), - }; - let _ = emit_shared_texture_frame( - inner, - frame_sink, - frame.handle, - frame.width, - frame.height, - frame.dxgi_format, - timestamp_us, - ); - WgcFrameResult::Ok -} diff --git a/fluxer_desktop/native/win-process-loopback/Cargo.lock b/fluxer_desktop/native/win-process-loopback/Cargo.lock index 676d103fc..07554770f 100644 --- a/fluxer_desktop/native/win-process-loopback/Cargo.lock +++ b/fluxer_desktop/native/win-process-loopback/Cargo.lock @@ -8,27 +8,12 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "convert_case" version = "0.12.0" @@ -38,55 +23,12 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "cpufeatures" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - [[package]] name = "ctor" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", -] - -[[package]] -name = "fluxer_desktop_native" -version = "0.1.0" -dependencies = [ - "sha2", -] - -[[package]] -name = "fluxer_gpu_rebuild" -version = "0.0.0" -dependencies = [ - "parking_lot", -] - [[package]] name = "fluxer_rt_thread" version = "0.0.0" @@ -96,20 +38,15 @@ dependencies = [ ] [[package]] -name = "fluxer_screen_frame_bus" +name = "fluxer_sample_pool" version = "0.0.0" -dependencies = [ - "fluxer_gpu_rebuild", - "parking_lot", -] [[package]] name = "fluxer_win_process_loopback" version = "0.0.0" dependencies = [ - "fluxer_desktop_native", "fluxer_rt_thread", - "fluxer_screen_frame_bus", + "fluxer_sample_pool", "napi", "napi-build", "napi-derive", @@ -205,15 +142,6 @@ dependencies = [ "slab", ] -[[package]] -name = "hybrid-array" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" -dependencies = [ - "typenum", -] - [[package]] name = "libc" version = "0.2.189" @@ -230,15 +158,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "memchr" version = "2.8.3" @@ -309,29 +228,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -356,56 +252,24 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - [[package]] name = "rustc-hash" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" -[[package]] -name = "smallvec" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" - [[package]] name = "syn" version = "2.0.119" @@ -428,12 +292,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - [[package]] name = "unicode-ident" version = "1.0.26" diff --git a/fluxer_desktop/native/win-process-loopback/Cargo.toml b/fluxer_desktop/native/win-process-loopback/Cargo.toml index 0da38132a..12db97904 100644 --- a/fluxer_desktop/native/win-process-loopback/Cargo.toml +++ b/fluxer_desktop/native/win-process-loopback/Cargo.toml @@ -12,9 +12,8 @@ resolver = "2" crate-type = ["cdylib", "rlib"] [dependencies] -fluxer_desktop_native = {path = "../rust"} +fluxer_sample_pool = {path = "../sample-pool"} fluxer_rt_thread = {path = "../rt-thread"} -fluxer_screen_frame_bus = {path = "../screen-frame-bus"} napi = {version = "3.12.6", default-features = false, features = ["dyn-symbols", "napi8"]} napi-derive = "3.6.7" diff --git a/fluxer_desktop/native/win-process-loopback/index.js b/fluxer_desktop/native/win-process-loopback/index.js index fdf9f0c6e..39009afcf 100644 --- a/fluxer_desktop/native/win-process-loopback/index.js +++ b/fluxer_desktop/native/win-process-loopback/index.js @@ -187,21 +187,6 @@ class ProcessLoopback extends EventEmitter { this._rejectStart = null; } - setScreenAudioSink(handle) { - if (typeof this._native.setScreenAudioSink !== 'function') return false; - try { - return this._native.setScreenAudioSink(handle) !== false; - } catch { - return false; - } - } - - clearScreenAudioSink() { - if (typeof this._native.clearScreenAudioSink === 'function') { - this._native.clearScreenAudioSink(); - } - } - start() { if (this._stopped) return Promise.resolve(); if (this._started) return Promise.resolve(); @@ -222,7 +207,6 @@ class ProcessLoopback extends EventEmitter { stop() { if (this._stopped) return; this._stopped = true; - this.clearScreenAudioSink(); if (!this._started && this._rejectStart) { this._rejectStart(new Error('ProcessLoopback stopped before audio capture started')); this._clearStartWaiters(); diff --git a/fluxer_desktop/native/rust/src/win_process_loopback/audio_contract.rs b/fluxer_desktop/native/win-process-loopback/src/audio_contract.rs similarity index 72% rename from fluxer_desktop/native/rust/src/win_process_loopback/audio_contract.rs rename to fluxer_desktop/native/win-process-loopback/src/audio_contract.rs index 2b659e59c..382524474 100644 --- a/fluxer_desktop/native/rust/src/win_process_loopback/audio_contract.rs +++ b/fluxer_desktop/native/win-process-loopback/src/audio_contract.rs @@ -2,11 +2,6 @@ pub const TARGET_SAMPLE_RATE: u32 = 48_000; pub const TARGET_CHANNELS: u16 = 2; -pub const BITS_PER_SAMPLE: u16 = 32; -pub const BYTES_PER_SAMPLE: u16 = BITS_PER_SAMPLE / 8; -pub const FRAME_BLOCK_ALIGN: u16 = TARGET_CHANNELS * BYTES_PER_SAMPLE; -pub const AVG_BYTES_PER_SECOND: u32 = - TARGET_SAMPLE_RATE * TARGET_CHANNELS as u32 * BYTES_PER_SAMPLE as u32; pub fn validate_sample_rate(value: u32) -> bool { value == TARGET_SAMPLE_RATE @@ -16,14 +11,17 @@ pub fn validate_channels(value: u32) -> bool { value == TARGET_CHANNELS as u32 } +#[cfg(any(target_os = "windows", test))] pub fn sample_count_for_frames(frames: u32) -> Option { (frames as usize).checked_mul(TARGET_CHANNELS as usize) } +#[cfg(any(target_os = "windows", test))] pub fn qpc_100ns_to_timestamp_us(qpc_100ns: u64) -> i64 { (qpc_100ns / 10).min(i64::MAX as u64) as i64 } +#[cfg(any(target_os = "windows", test))] pub fn pcm16_to_float32(sample: i16) -> f32 { sample as f32 / 32768.0 } @@ -32,15 +30,6 @@ pub fn pcm16_to_float32(sample: i16) -> f32 { mod tests { use super::*; - #[test] - fn format_constants_describe_48khz_stereo_float32_frames() { - assert_eq!(48_000, TARGET_SAMPLE_RATE); - assert_eq!(2, TARGET_CHANNELS); - assert_eq!(32, BITS_PER_SAMPLE); - assert_eq!(8, FRAME_BLOCK_ALIGN); - assert_eq!(384_000, AVG_BYTES_PER_SECOND); - } - #[test] fn option_validation_rejects_unsupported_public_shapes() { assert!(validate_sample_rate(48_000)); diff --git a/fluxer_desktop/native/win-process-loopback/src/lib.rs b/fluxer_desktop/native/win-process-loopback/src/lib.rs index a68b77439..f01f8b7a6 100644 --- a/fluxer_desktop/native/win-process-loopback/src/lib.rs +++ b/fluxer_desktop/native/win-process-loopback/src/lib.rs @@ -1,15 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use std::ptr; +mod audio_contract; +#[cfg(any(target_os = "windows", test))] +mod process_tree; +#[cfg(any(target_os = "windows", test))] +mod session_mixer; +mod windows_version; + use std::sync::Arc; -use fluxer_desktop_native::win_process_loopback::audio_contract; -use fluxer_screen_frame_bus::{NativeScreenFrameSinkHandle, NativeScreenFrameSinkHandleRef}; use napi::{ - Env, JsValue, - bindgen_prelude::{ - Error, Float32Array, Function, Result, Status, ToNapiValue, Unknown, ValueType, - }, + Env, + bindgen_prelude::{Error, Float32Array, Function, Result, Status, ToNapiValue, Unknown}, sys, threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode, UnknownReturnValue}, }; @@ -19,35 +21,6 @@ pub mod pcm_pool; use crate::pcm_pool::{PCM_SLOT_SAMPLES_MAX, PooledPcmFrame}; -fn retain_screen_audio_sink_handle( - value: Unknown<'_>, -) -> Result> { - if value.get_type()? != ValueType::External { - return Err(Error::new( - Status::InvalidArg, - "ProcessLoopback.setScreenAudioSink expects a native external sink handle", - )); - } - let raw_value = value.value(); - let mut data: *mut std::ffi::c_void = ptr::null_mut(); - let status = unsafe { sys::napi_get_value_external(raw_value.env, raw_value.value, &mut data) }; - if status != sys::Status::napi_ok || data.is_null() { - return Err(Error::new( - Status::InvalidArg, - "ProcessLoopback.setScreenAudioSink received an empty native external sink handle", - )); - } - let handle = unsafe { data.cast::().as_ref() } - .and_then(NativeScreenFrameSinkHandle::retain_ref) - .ok_or_else(|| { - Error::new( - Status::InvalidArg, - "ProcessLoopback.setScreenAudioSink received an invalid native sink handle", - ) - })?; - Ok(Arc::new(handle)) -} - pub struct ErrorMessage(pub String); impl ToNapiValue for ErrorMessage { @@ -311,53 +284,11 @@ struct Callbacks { on_error: ErrorTsfn, on_closed: VoidTsfn, on_started: Option, - screen_audio_sink: std::sync::RwLock>>, } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] impl Callbacks { - fn set_screen_audio_sink(&self, sink: Arc) { - if let Ok(mut guard) = self.screen_audio_sink.write() { - *guard = Some(sink); - } - } - - fn clear_screen_audio_sink(&self) { - if let Ok(mut guard) = self.screen_audio_sink.write() { - *guard = None; - } - } - - fn try_emit_frame_to_sink(&self, frame: &FramePayload) -> bool { - let Ok(guard) = self.screen_audio_sink.read() else { - return false; - }; - let Some(sink) = guard.as_ref() else { - return false; - }; - let samples: &[f32] = match &frame.samples { - FrameSamples::Pooled(slot) => slot.data_slice(), - FrameSamples::Owned(owned) => owned.as_slice(), - }; - let channels = u32::from(audio_contract::TARGET_CHANNELS); - if channels == 0 || samples.is_empty() || !samples.len().is_multiple_of(channels as usize) { - return true; - } - let frames = samples.len() as u32 / channels; - sink.enqueue_screen_audio_f32( - samples, - frames, - channels, - audio_contract::TARGET_SAMPLE_RATE, - frame.timestamp_us, - ); - true - } - fn emit_frame(&self, frame: FramePayload) { - if self.try_emit_frame_to_sink(&frame) { - return; - } let _ = self .on_frame .call(frame, ThreadsafeFunctionCallMode::NonBlocking); @@ -454,31 +385,12 @@ impl ProcessLoopback { on_error: build_error_tsfn(&env, on_error)?, on_closed: build_void_tsfn(&env, on_closed)?, on_started: on_started.map(|cb| build_void_tsfn(&env, cb)).transpose()?, - screen_audio_sink: std::sync::RwLock::new(None), }; Ok(Self { inner: platform::Inner::new(pid, options, callbacks)?, }) } - #[napi(js_name = "setScreenAudioSink")] - pub fn set_screen_audio_sink(&self, sink_handle: Unknown<'_>) -> Result<()> { - let sink = retain_screen_audio_sink_handle(sink_handle)?; - if !sink.supports_screen_audio() { - return Err(Error::new( - Status::InvalidArg, - "ProcessLoopback.setScreenAudioSink handle does not support screen audio", - )); - } - self.inner.set_screen_audio_sink(sink); - Ok(()) - } - - #[napi(js_name = "clearScreenAudioSink")] - pub fn clear_screen_audio_sink(&self) { - self.inner.clear_screen_audio_sink(); - } - #[napi] pub fn start(&self) -> Result<()> { self.inner.start() @@ -652,8 +564,7 @@ fn classify_windows_capabilities( pub fn get_backend_info() -> WinProcessLoopbackBackendInfo { let detected = platform::detected_build(); let runtime_probe = platform::process_loopback_probe(); - let min_build = - fluxer_desktop_native::win_process_loopback::windows_version::PROCESS_LOOPBACK_MIN_BUILD; + let min_build = crate::windows_version::PROCESS_LOOPBACK_MIN_BUILD; let ( supported, process_supported, @@ -975,9 +886,7 @@ mod platform { ProcessLoopbackRuntimeProbe, }; use crate::pcm_pool::{PCM_SLOT_SAMPLES_MAX, PcmFramePool, PcmPoolError, PooledPcmFrame}; - use fluxer_desktop_native::win_process_loopback::{ - audio_contract, process_tree, session_mixer, - }; + use crate::{audio_contract, process_tree, session_mixer}; use fluxer_rt_thread::{PriorityProfile, RealtimePriorityGuard}; use napi::bindgen_prelude::{Error, Result, Status}; use std::{ @@ -992,7 +901,7 @@ mod platform { time::{Duration, Instant}, }; use windows::Win32::Foundation::{ - CloseHandle, GetLastError, HANDLE, HWND, INVALID_HANDLE_VALUE, WAIT_TIMEOUT, + CloseHandle, HANDLE, HWND, INVALID_HANDLE_VALUE, WAIT_TIMEOUT, }; use windows::Win32::Media::Audio::{ AUDCLNT_BUFFERFLAGS_SILENT, AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR, AUDCLNT_SHAREMODE_SHARED, @@ -1170,17 +1079,6 @@ mod platform { }) } - pub(crate) fn set_screen_audio_sink( - &self, - sink: std::sync::Arc, - ) { - self.callbacks.set_screen_audio_sink(sink); - } - - pub(crate) fn clear_screen_audio_sink(&self) { - self.callbacks.clear_screen_audio_sink(); - } - pub(crate) fn start(&self) -> Result<()> { if self.disposed.load(Ordering::Acquire) { return Err(Error::new( @@ -1499,7 +1397,7 @@ mod platform { let client = match activate_loopback_client(pid, include_tree, capture_scope, stop_event) { Ok(client) => client, - Err(err) if stop_signal.load(Ordering::SeqCst) => return Ok(()), + Err(_) if stop_signal.load(Ordering::SeqCst) => return Ok(()), Err(err) => return Err(err.reason.to_string()), }; @@ -2411,11 +2309,6 @@ mod platform { .unwrap_or(false) } - #[allow(dead_code)] - fn _touch_lasterror() -> u32 { - unsafe { GetLastError().0 } - } - #[cfg(test)] mod blob_propvariant_tests { use super::*; @@ -2470,14 +2363,6 @@ mod platform { )) } - pub(crate) fn set_screen_audio_sink( - &self, - _sink: std::sync::Arc, - ) { - } - - pub(crate) fn clear_screen_audio_sink(&self) {} - pub(crate) fn start(&self) -> Result<()> { Err(Error::new( Status::GenericFailure, diff --git a/fluxer_desktop/native/win-process-loopback/src/pcm_pool.rs b/fluxer_desktop/native/win-process-loopback/src/pcm_pool.rs index b46b7bcb6..d0020716f 100644 --- a/fluxer_desktop/native/win-process-loopback/src/pcm_pool.rs +++ b/fluxer_desktop/native/win-process-loopback/src/pcm_pool.rs @@ -1,511 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use std::cell::UnsafeCell; -use std::fmt; -use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard}; - -pub const PCM_SLOT_SAMPLES_MAX: usize = 16_384; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PcmPoolError { - ZeroCapacity, - ZeroSamplesPerSlot, - SamplesPerSlotTooLarge(usize), - PayloadTooLarge { offered: usize, capacity: usize }, -} - -impl fmt::Display for PcmPoolError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ZeroCapacity => write!(f, "PcmFramePool capacity must be > 0"), - Self::ZeroSamplesPerSlot => write!(f, "PcmFramePool samples_per_slot must be > 0"), - Self::SamplesPerSlotTooLarge(n) => write!( - f, - "PcmFramePool samples_per_slot {n} exceeds PCM_SLOT_SAMPLES_MAX={PCM_SLOT_SAMPLES_MAX}" - ), - Self::PayloadTooLarge { offered, capacity } => write!( - f, - "PcmFramePool payload {offered} samples exceeds slot capacity {capacity}" - ), - } - } -} - -impl std::error::Error for PcmPoolError {} - -struct PcmSlotCell { - inner: UnsafeCell>, -} - -unsafe impl Send for PcmSlotCell {} -unsafe impl Sync for PcmSlotCell {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PcmPoolStats { - pub acquired: u64, - pub released: u64, - pub dropped: u64, - pub in_flight: u32, -} - -pub(crate) struct PcmFramePoolInner { - slots: Vec, - free: Mutex>, - capacity: u32, - samples_per_slot: u32, - acquired_total: AtomicU64, - released_total: AtomicU64, - dropped_total: AtomicU64, - in_flight: AtomicU32, -} - -fn lock_free_list(free: &Mutex>) -> MutexGuard<'_, Vec> { - free.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) -} - -pub struct PcmFramePool { - inner: Arc, -} - -impl fmt::Debug for PcmFramePool { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let stats = self.stats(); - f.debug_struct("PcmFramePool") - .field("capacity", &self.inner.capacity) - .field("samples_per_slot", &self.inner.samples_per_slot) - .field("stats", &stats) - .finish() - } -} - -impl PcmFramePool { - pub fn new(capacity: usize, samples_per_slot: usize) -> Result { - if capacity == 0 { - return Err(PcmPoolError::ZeroCapacity); - } - if samples_per_slot == 0 { - return Err(PcmPoolError::ZeroSamplesPerSlot); - } - if samples_per_slot > PCM_SLOT_SAMPLES_MAX { - return Err(PcmPoolError::SamplesPerSlotTooLarge(samples_per_slot)); - } - assert!(capacity > 0); - assert!(samples_per_slot > 0); - assert!(samples_per_slot <= PCM_SLOT_SAMPLES_MAX); - - let mut slots: Vec = Vec::with_capacity(capacity); - for _ in 0..capacity { - let buf: Box<[f32]> = vec![0.0_f32; samples_per_slot].into_boxed_slice(); - assert_eq!(buf.len(), samples_per_slot); - slots.push(PcmSlotCell { - inner: UnsafeCell::new(buf), - }); - } - assert_eq!(slots.len(), capacity); - - let mut free: Vec = Vec::with_capacity(capacity); - for index in 0..capacity { - free.push(index); - } - assert_eq!(free.len(), capacity); - - let cap_u32 = u32::try_from(capacity).map_err(|_| PcmPoolError::ZeroCapacity)?; - let sps_u32 = - u32::try_from(samples_per_slot).map_err(|_| PcmPoolError::ZeroSamplesPerSlot)?; - let inner = PcmFramePoolInner { - slots, - free: Mutex::new(free), - capacity: cap_u32, - samples_per_slot: sps_u32, - acquired_total: AtomicU64::new(0), - released_total: AtomicU64::new(0), - dropped_total: AtomicU64::new(0), - in_flight: AtomicU32::new(0), - }; - Ok(Self { - inner: Arc::new(inner), - }) - } - - pub fn try_acquire(&self) -> Option { - assert!(self.inner.capacity > 0); - assert!(self.inner.samples_per_slot > 0); - - let mut free = lock_free_list(&self.inner.free); - assert!(free.len() <= self.inner.capacity as usize); - let index = match free.pop() { - Some(idx) => idx, - None => { - drop(free); - self.inner.dropped_total.fetch_add(1, Ordering::Relaxed); - return None; - } - }; - assert!(index < self.inner.capacity as usize); - self.inner.acquired_total.fetch_add(1, Ordering::Relaxed); - let after = self.inner.in_flight.fetch_add(1, Ordering::AcqRel) + 1; - assert!(after <= self.inner.capacity); - drop(free); - - Some(PooledPcmFrame { - slot_index: index, - filled_len: 0, - pool: Arc::clone(&self.inner), - }) - } - - pub fn capacity(&self) -> u32 { - let cap = self.inner.capacity; - assert!(cap > 0); - assert!(cap as usize == self.inner.slots.len()); - cap - } - - pub fn samples_per_slot(&self) -> u32 { - let sps = self.inner.samples_per_slot; - assert!(sps > 0); - assert!(sps as usize <= PCM_SLOT_SAMPLES_MAX); - sps - } - - pub fn stats(&self) -> PcmPoolStats { - assert!(self.inner.capacity > 0); - let in_flight = self.inner.in_flight.load(Ordering::Acquire); - assert!(in_flight <= self.inner.capacity); - let acquired = self.inner.acquired_total.load(Ordering::Relaxed); - let released = self.inner.released_total.load(Ordering::Relaxed); - let dropped = self.inner.dropped_total.load(Ordering::Relaxed); - assert!(released <= acquired); - PcmPoolStats { - acquired, - released, - dropped, - in_flight, - } - } -} - -impl Clone for PcmFramePool { - fn clone(&self) -> Self { - Self { - inner: Arc::clone(&self.inner), - } - } -} - -pub struct PooledPcmFrame { - slot_index: usize, - filled_len: usize, - pool: Arc, -} - -impl PooledPcmFrame { - pub fn write(&mut self, samples: &[f32]) -> Result<(), PcmPoolError> { - debug_assert!(self.slot_index < self.pool.capacity as usize); - let cap = self.pool.samples_per_slot as usize; - if samples.len() > cap { - return Err(PcmPoolError::PayloadTooLarge { - offered: samples.len(), - capacity: cap, - }); - } - debug_assert!(samples.len() <= cap); - - let cell = &self.pool.slots[self.slot_index]; - let buf: &mut [f32] = unsafe { &mut *cell.inner.get() }; - debug_assert_eq!(buf.len(), cap); - if !samples.is_empty() { - buf[..samples.len()].copy_from_slice(samples); - } - self.filled_len = samples.len(); - debug_assert!(self.filled_len <= cap); - Ok(()) - } - - pub fn write_with(&mut self, sample_count: usize, fill: F) -> Result<(), PcmPoolError> - where - F: FnOnce(&mut [f32]), - { - debug_assert!(self.slot_index < self.pool.capacity as usize); - let cap = self.pool.samples_per_slot as usize; - if sample_count > cap { - return Err(PcmPoolError::PayloadTooLarge { - offered: sample_count, - capacity: cap, - }); - } - debug_assert!(sample_count <= cap); - - let cell = &self.pool.slots[self.slot_index]; - let buf: &mut [f32] = unsafe { &mut *cell.inner.get() }; - debug_assert_eq!(buf.len(), cap); - fill(&mut buf[..sample_count]); - self.filled_len = sample_count; - debug_assert!(self.filled_len <= cap); - Ok(()) - } - - pub fn data_slice(&self) -> &[f32] { - debug_assert!(self.slot_index < self.pool.capacity as usize); - debug_assert!(self.filled_len <= self.pool.samples_per_slot as usize); - let cell = &self.pool.slots[self.slot_index]; - let buf: &[f32] = unsafe { &*cell.inner.get() }; - &buf[..self.filled_len] - } - - pub fn filled_len(&self) -> usize { - debug_assert!(self.slot_index < self.pool.capacity as usize); - debug_assert!(self.filled_len <= self.pool.samples_per_slot as usize); - self.filled_len - } - - pub fn capacity(&self) -> usize { - let cap = self.pool.samples_per_slot as usize; - debug_assert!(cap > 0); - debug_assert!(cap <= PCM_SLOT_SAMPLES_MAX); - cap - } - - pub fn as_mut_ptr(&mut self) -> *mut f32 { - debug_assert!(self.slot_index < self.pool.capacity as usize); - debug_assert!(self.filled_len <= self.pool.samples_per_slot as usize); - let cell = &self.pool.slots[self.slot_index]; - let buf: &mut [f32] = unsafe { &mut *cell.inner.get() }; - debug_assert_eq!(buf.len(), self.pool.samples_per_slot as usize); - buf.as_mut_ptr() - } - - pub fn into_external_parts(mut self) -> (*mut f32, usize, Self) { - debug_assert!(self.slot_index < self.pool.capacity as usize); - debug_assert!(self.filled_len <= self.pool.samples_per_slot as usize); - let len = self.filled_len; - let ptr = self.as_mut_ptr(); - debug_assert!(!ptr.is_null()); - (ptr, len, self) - } -} - -impl Drop for PooledPcmFrame { - fn drop(&mut self) { - debug_assert!(self.slot_index < self.pool.capacity as usize); - if self.slot_index >= self.pool.capacity as usize { - return; - } - let mut free = lock_free_list(&self.pool.free); - debug_assert!(free.len() < self.pool.capacity as usize); - free.push(self.slot_index); - let before = self.pool.in_flight.fetch_sub(1, Ordering::AcqRel); - debug_assert!(before >= 1); - self.pool.released_total.fetch_add(1, Ordering::Relaxed); - drop(free); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::thread; - - const POOL_CAP: usize = 16; - const SLOT_SAMPLES: usize = 2_048; - - fn default_pool() -> PcmFramePool { - PcmFramePool::new(POOL_CAP, SLOT_SAMPLES).expect("default pool builds") - } - - #[test] - fn new_rejects_zero_capacity() { - let err = PcmFramePool::new(0, SLOT_SAMPLES).unwrap_err(); - assert_eq!(err, PcmPoolError::ZeroCapacity); - } - - #[test] - fn new_rejects_zero_samples_per_slot() { - let err = PcmFramePool::new(4, 0).unwrap_err(); - assert_eq!(err, PcmPoolError::ZeroSamplesPerSlot); - } - - #[test] - fn new_rejects_samples_per_slot_above_max() { - let err = PcmFramePool::new(4, PCM_SLOT_SAMPLES_MAX + 1).unwrap_err(); - assert_eq!( - err, - PcmPoolError::SamplesPerSlotTooLarge(PCM_SLOT_SAMPLES_MAX + 1) - ); - } - - #[test] - fn acquire_release_cycle_increments_counters() { - let pool = PcmFramePool::new(4, SLOT_SAMPLES).expect("pool"); - { - let _slot = pool.try_acquire().expect("slot"); - let stats_held = pool.stats(); - assert_eq!(stats_held.acquired, 1); - assert_eq!(stats_held.in_flight, 1); - } - let stats_after = pool.stats(); - assert_eq!(stats_after.acquired, 1); - assert_eq!(stats_after.released, 1); - assert_eq!(stats_after.in_flight, 0); - } - - #[test] - fn pool_exhausts_at_cap_and_counts_drop() { - let pool = default_pool(); - let mut held = Vec::with_capacity(POOL_CAP); - for _ in 0..POOL_CAP { - held.push(pool.try_acquire().expect("slot in capacity")); - } - assert!(pool.try_acquire().is_none()); - let stats = pool.stats(); - assert_eq!(stats.dropped, 1); - assert_eq!(stats.acquired as usize, POOL_CAP); - assert_eq!(stats.in_flight as usize, POOL_CAP); - } - - #[test] - fn write_then_data_slice_matches_payload() { - let pool = PcmFramePool::new(2, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - let payload = [0.25_f32; 32]; - slot.write(&payload).expect("payload fits"); - assert_eq!(slot.data_slice(), &payload[..]); - assert_eq!(slot.filled_len(), 32); - } - - #[test] - fn write_rejects_payload_larger_than_slot() { - let pool = PcmFramePool::new(2, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - let big = [0.0_f32; 128]; - let err = slot.write(&big).unwrap_err(); - assert!(matches!(err, PcmPoolError::PayloadTooLarge { .. })); - } - - #[test] - fn write_with_fills_exactly_sample_count_samples() { - let pool = PcmFramePool::new(2, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - slot.write_with(8, |out| { - assert_eq!(out.len(), 8); - for (index, sample) in out.iter_mut().enumerate() { - *sample = index as f32; - } - }) - .expect("payload fits"); - assert_eq!(slot.filled_len(), 8); - assert_eq!(slot.data_slice(), &[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]); - } - - #[test] - fn write_with_rejects_sample_count_larger_than_slot() { - let pool = PcmFramePool::new(2, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - let err = slot.write_with(128, |_| {}).unwrap_err(); - assert!(matches!(err, PcmPoolError::PayloadTooLarge { .. })); - assert_eq!(slot.filled_len(), 0); - } - - #[test] - fn into_external_parts_exposes_filled_pointer_and_length() { - let pool = PcmFramePool::new(2, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - let payload = [1.5_f32; 16]; - slot.write(&payload).expect("write fits"); - let (ptr, len, owned) = slot.into_external_parts(); - assert!(!ptr.is_null()); - assert_eq!(len, 16); - let observed = unsafe { core::slice::from_raw_parts(ptr, len) }; - assert_eq!(observed, &payload[..]); - assert_eq!(pool.stats().in_flight, 1); - drop(owned); - assert_eq!(pool.stats().in_flight, 0); - } - - #[test] - fn into_external_parts_drop_returns_slot_to_pool() { - let pool = PcmFramePool::new(1, 32).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - slot.write(&[0.75_f32; 8]).expect("write"); - let (_ptr, _len, owned) = slot.into_external_parts(); - assert!(pool.try_acquire().is_none()); - drop(owned); - let revived = pool.try_acquire().expect("revived"); - assert_eq!(revived.filled_len(), 0); - drop(revived); - assert_eq!(pool.stats().in_flight, 0); - } - - #[test] - fn pooled_frame_survives_send_across_threads() { - let pool = PcmFramePool::new(2, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - slot.write(&[0.5_f32; 32]).expect("write"); - let handle = thread::spawn(move || { - assert_eq!(slot.filled_len(), 32); - assert_eq!(slot.data_slice()[0], 0.5); - drop(slot); - }); - handle.join().expect("worker"); - assert_eq!(pool.stats().in_flight, 0); - assert_eq!(pool.stats().released, 1); - } - - #[test] - fn capacity_one_pool_round_trips() { - let pool = PcmFramePool::new(1, 32).expect("pool"); - for _ in 0..5 { - let mut slot = pool.try_acquire().expect("slot"); - slot.write(&[1.0, 2.0, 3.0]).expect("write"); - assert_eq!(slot.data_slice(), &[1.0, 2.0, 3.0]); - drop(slot); - } - let stats = pool.stats(); - assert_eq!(stats.acquired, 5); - assert_eq!(stats.released, 5); - assert_eq!(stats.in_flight, 0); - } - - #[test] - fn default_pool_dimensions_match_constants() { - let pool = default_pool(); - assert_eq!(pool.capacity() as usize, POOL_CAP); - assert_eq!(pool.samples_per_slot() as usize, SLOT_SAMPLES); - } - - #[test] - fn full_acquire_drop_cycle_keeps_accounting_consistent() { - let pool = default_pool(); - for _ in 0..4 { - let mut held = Vec::with_capacity(POOL_CAP); - for _ in 0..POOL_CAP { - held.push(pool.try_acquire().expect("slot in capacity")); - } - assert!(pool.try_acquire().is_none()); - drop(held); - let stats = pool.stats(); - assert_eq!(stats.in_flight, 0); - assert_eq!(stats.released, stats.acquired); - } - let stats = pool.stats(); - assert_eq!(stats.acquired as usize, POOL_CAP * 4); - assert_eq!(stats.released as usize, POOL_CAP * 4); - assert_eq!(stats.in_flight, 0); - } - - #[test] - fn external_parts_release_does_not_abort_accounting() { - let pool = PcmFramePool::new(2, 64).expect("pool"); - let mut slot = pool.try_acquire().expect("slot"); - slot.write(&[0.25_f32; 16]).expect("write"); - let (ptr, len, owned) = slot.into_external_parts(); - assert!(!ptr.is_null()); - assert_eq!(len, 16); - drop(owned); - let stats = pool.stats(); - assert_eq!(stats.in_flight, 0); - assert_eq!(stats.released, 1); - assert_eq!(stats.acquired, 1); - } -} +pub use fluxer_sample_pool::PCM_SLOT_SAMPLES_MAX; +pub type PcmFramePool = fluxer_sample_pool::SamplePool; +pub type PooledPcmFrame = fluxer_sample_pool::PooledSamples; +pub use fluxer_sample_pool::PcmPoolError; diff --git a/fluxer_desktop/native/rust/src/win_process_loopback/process_tree.rs b/fluxer_desktop/native/win-process-loopback/src/process_tree.rs similarity index 100% rename from fluxer_desktop/native/rust/src/win_process_loopback/process_tree.rs rename to fluxer_desktop/native/win-process-loopback/src/process_tree.rs diff --git a/fluxer_desktop/native/rust/src/win_process_loopback/session_mixer.rs b/fluxer_desktop/native/win-process-loopback/src/session_mixer.rs similarity index 100% rename from fluxer_desktop/native/rust/src/win_process_loopback/session_mixer.rs rename to fluxer_desktop/native/win-process-loopback/src/session_mixer.rs diff --git a/fluxer_desktop/native/win-process-loopback/src/windows_version.rs b/fluxer_desktop/native/win-process-loopback/src/windows_version.rs new file mode 100644 index 000000000..71e06bf85 --- /dev/null +++ b/fluxer_desktop/native/win-process-loopback/src/windows_version.rs @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub const PROCESS_LOOPBACK_MIN_BUILD: u32 = 20_348; diff --git a/fluxer_desktop/native/windows-input-hook/Cargo.lock b/fluxer_desktop/native/windows-input-hook/Cargo.lock index 1166d0a82..640a7970e 100644 --- a/fluxer_desktop/native/windows-input-hook/Cargo.lock +++ b/fluxer_desktop/native/windows-input-hook/Cargo.lock @@ -8,27 +8,12 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "convert_case" version = "0.12.0" @@ -38,53 +23,16 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "cpufeatures" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - [[package]] name = "ctor" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", -] - -[[package]] -name = "fluxer_desktop_native" -version = "0.1.0" -dependencies = [ - "sha2", -] - [[package]] name = "fluxer_windows_input_hook" version = "0.0.0" dependencies = [ - "fluxer_desktop_native", "napi", "napi-build", "napi-derive", @@ -179,15 +127,6 @@ dependencies = [ "slab", ] -[[package]] -name = "hybrid-array" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" -dependencies = [ - "typenum", -] - [[package]] name = "libc" version = "0.2.189" @@ -310,17 +249,6 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "slab" version = "0.4.12" @@ -349,12 +277,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - [[package]] name = "unicode-ident" version = "1.0.26" diff --git a/fluxer_desktop/native/windows-input-hook/Cargo.toml b/fluxer_desktop/native/windows-input-hook/Cargo.toml index d85cbc557..1a07ffbbe 100644 --- a/fluxer_desktop/native/windows-input-hook/Cargo.toml +++ b/fluxer_desktop/native/windows-input-hook/Cargo.toml @@ -12,7 +12,6 @@ resolver = "2" crate-type = ["cdylib", "rlib"] [dependencies] -fluxer_desktop_native = {path = "../rust"} napi = {version = "3.12.6", default-features = false, features = ["dyn-symbols", "napi8"]} napi-derive = "3.6.7" diff --git a/fluxer_desktop/native/rust/src/input/keymap.rs b/fluxer_desktop/native/windows-input-hook/src/keymap.rs similarity index 85% rename from fluxer_desktop/native/rust/src/input/keymap.rs rename to fluxer_desktop/native/windows-input-hook/src/keymap.rs index f9acf5902..1c3534bf7 100644 --- a/fluxer_desktop/native/rust/src/input/keymap.rs +++ b/fluxer_desktop/native/windows-input-hook/src/keymap.rs @@ -583,57 +583,3 @@ pub mod windows { pub fn fallback_name(prefix_value: impl std::fmt::Display) -> String { format!("Key{prefix_value}") } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn windows_ascii_letters_use_vk_mapping() { - assert_eq!(Some("A"), windows::vk_to_name(0x41)); - assert_eq!(Some("Z"), windows::vk_to_name(0x5a)); - assert_eq!(Some("M"), windows::vk_to_name(0x4d)); - } - - #[test] - fn windows_modifiers_map_to_side_distinguished_names() { - assert_eq!(Some("ShiftLeft"), windows::vk_to_name(0xa0)); - assert_eq!(Some("ShiftRight"), windows::vk_to_name(0xa1)); - assert_eq!(Some("ControlLeft"), windows::vk_to_name(0xa2)); - assert_eq!(Some("MetaLeft"), windows::vk_to_name(0x5b)); - assert_eq!(Some("AltLeft"), windows::vk_to_name(0xa4)); - } - - #[test] - fn windows_function_keys_cover_f1_to_f12() { - assert_eq!(Some("F1"), windows::vk_to_name(0x70)); - assert_eq!(Some("F12"), windows::vk_to_name(0x7b)); - assert_eq!(Some("F13"), windows::vk_to_name(0x7c)); - assert_eq!(Some("Pause"), windows::vk_to_name(0x13)); - } - - #[test] - fn windows_special_numpad_and_media_keys_map() { - assert_eq!(Some("PrintScreen"), windows::vk_to_name(0x2c)); - assert_eq!(Some("Numpad0"), windows::vk_to_name(0x60)); - assert_eq!(Some("NumpadDivide"), windows::vk_to_name(0x6f)); - assert_eq!(Some("NumpadEqual"), windows::vk_to_name(0x92)); - assert_eq!(Some("AudioVolumeMute"), windows::vk_to_name(0xad)); - assert_eq!(Some("BrowserBack"), windows::vk_to_name(0xa6)); - assert_eq!(Some("LaunchMail"), windows::vk_to_name(0xb4)); - assert_eq!(Some("KanaMode"), windows::vk_to_name(0x15)); - } - - #[test] - fn windows_arrows_and_editing_keys_map() { - assert_eq!(Some("ArrowLeft"), windows::vk_to_name(0x25)); - assert_eq!(Some("PageDown"), windows::vk_to_name(0x22)); - assert_eq!(Some("Delete"), windows::vk_to_name(0x2e)); - } - - #[test] - fn windows_unknown_vk_falls_back_to_key_number() { - assert_eq!(None, windows::vk_to_name(0x0fff)); - assert_eq!("Key291", fallback_name(0x123_u16)); - } -} diff --git a/fluxer_desktop/native/windows-input-hook/src/lib.rs b/fluxer_desktop/native/windows-input-hook/src/lib.rs index f9de8f8b0..d76f3585d 100644 --- a/fluxer_desktop/native/windows-input-hook/src/lib.rs +++ b/fluxer_desktop/native/windows-input-hook/src/lib.rs @@ -2,6 +2,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +mod keymap; +#[cfg(target_os = "windows")] +mod modifiers; +#[cfg(target_os = "windows")] +mod windows_mouse; + use napi::{ Env, bindgen_prelude::{Function, Object, Result, ToNapiValue}, @@ -139,7 +145,7 @@ fn scan_aware_key_name(vk: u16, flags: u32) -> Option<&'static str> { } pub fn write_key_name(buf: &mut [u8; KEY_NAME_BUF], len_out: &mut u8, vk: u16) { - use fluxer_desktop_native::input::keymap::{fallback_name, windows::vk_to_name}; + use crate::keymap::{fallback_name, windows::vk_to_name}; if let Some(name) = vk_to_name(vk) { write_ascii_key_name(buf, len_out, name); } else { @@ -208,7 +214,7 @@ mod platform { EventKind, EventThreadsafeFunction, HeldInputs, QueuedEvent, VK_PACKET, write_key_name_from_hook, write_packet_key_name, }; - use fluxer_desktop_native::input::modifiers::{ + use crate::modifiers::{ Modifiers, windows::{from_sampled, with_pressed_key}, }; @@ -395,7 +401,7 @@ mod platform { } unsafe extern "system" fn mouse_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { - use fluxer_desktop_native::input::windows_mouse::{Action, classify}; + use crate::windows_mouse::{Action, classify}; if code >= 0 && let Some(handle) = current_handle() { @@ -771,23 +777,6 @@ pub fn is_available() -> bool { #[cfg(test)] mod tests { use super::*; - use fluxer_desktop_native::input::ring::Ring; - - #[test] - fn write_key_name_known_vk() { - let mut buf = [0u8; KEY_NAME_BUF]; - let mut len = 0u8; - write_key_name(&mut buf, &mut len, 0x41); - assert_eq!(&buf[..len as usize], b"A"); - } - - #[test] - fn write_key_name_function_keys() { - let mut buf = [0u8; KEY_NAME_BUF]; - let mut len = 0u8; - write_key_name(&mut buf, &mut len, 0x7b); - assert_eq!(&buf[..len as usize], b"F12"); - } #[test] fn write_key_name_from_hook_distinguishes_keypad_navigation() { @@ -808,14 +797,6 @@ mod tests { assert_eq!(&buf[..len as usize], b"NumpadEnter"); } - #[test] - fn write_key_name_fallback() { - let mut buf = [0u8; KEY_NAME_BUF]; - let mut len = 0u8; - write_key_name(&mut buf, &mut len, 0x0fff); - assert_eq!(&buf[..len as usize], b"Key4095"); - } - #[test] fn write_packet_key_name_uses_the_injected_character() { let name = |unit: u32| { @@ -887,21 +868,4 @@ mod tests { ); assert!(held.take_releases().is_empty()); } - - #[test] - fn ring_holds_queued_events() { - let ring: Ring = Ring::new(); - let idx = ring.claim().expect("claim slot") as usize; - - unsafe { - let slots = ring.slots.as_ptr() as *mut QueuedEvent; - (*slots.add(idx)).kind = EventKind::MouseUp; - (*slots.add(idx)).button = 4; - } - let pop_idx = ring.pop().expect("pop slot") as usize; - assert_eq!(ring.slots[pop_idx].kind, EventKind::MouseUp); - assert_eq!(ring.slots[pop_idx].button, 4); - ring.release(); - assert!(ring.pop().is_none()); - } } diff --git a/fluxer_desktop/native/windows-input-hook/src/modifiers.rs b/fluxer_desktop/native/windows-input-hook/src/modifiers.rs new file mode 100644 index 000000000..005fa4b9b --- /dev/null +++ b/fluxer_desktop/native/windows-input-hook/src/modifiers.rs @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Modifiers { + pub ctrl: bool, + pub alt: bool, + pub shift: bool, + pub meta: bool, +} + +pub mod windows { + use super::Modifiers; + + pub const HIGH_BIT: u16 = 0x8000; + + pub fn from_sampled( + shift_state: u16, + ctrl_state: u16, + alt_state: u16, + lwin_state: u16, + rwin_state: u16, + ) -> Modifiers { + Modifiers { + shift: (shift_state & HIGH_BIT) != 0, + ctrl: (ctrl_state & HIGH_BIT) != 0, + alt: (alt_state & HIGH_BIT) != 0, + meta: ((lwin_state | rwin_state) & HIGH_BIT) != 0, + } + } + + pub fn with_pressed_key(modifiers: Modifiers, vk: u16) -> Modifiers { + match vk { + 0x10 | 0xa0 | 0xa1 => Modifiers { + shift: true, + ..modifiers + }, + 0x11 | 0xa2 | 0xa3 => Modifiers { + ctrl: true, + ..modifiers + }, + 0x12 | 0xa4 | 0xa5 => Modifiers { + alt: true, + ..modifiers + }, + 0x5b | 0x5c => Modifiers { + meta: true, + ..modifiers + }, + _ => modifiers, + } + } +} diff --git a/fluxer_desktop/native/rust/src/input/windows_mouse.rs b/fluxer_desktop/native/windows-input-hook/src/windows_mouse.rs similarity index 58% rename from fluxer_desktop/native/rust/src/input/windows_mouse.rs rename to fluxer_desktop/native/windows-input-hook/src/windows_mouse.rs index 85d98cd8d..8638090dc 100644 --- a/fluxer_desktop/native/rust/src/input/windows_mouse.rs +++ b/fluxer_desktop/native/windows-input-hook/src/windows_mouse.rs @@ -71,60 +71,3 @@ pub fn classify(msg: u32, xbutton: u16) -> Action { _ => Action::Ignored, } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn primary_buttons_up_down_resolve_to_012() { - assert_eq!( - Action::Button { - down: true, - button: 0 - }, - classify(WM_LBUTTONDOWN, 0) - ); - assert_eq!( - Action::Button { - down: false, - button: 2 - }, - classify(WM_RBUTTONUP, 0) - ); - assert_eq!( - Action::Button { - down: true, - button: 1 - }, - classify(WM_MBUTTONDOWN, 0) - ); - } - - #[test] - fn xbutton_1_2_map_to_back_forward() { - assert_eq!( - Action::Button { - down: true, - button: 3 - }, - classify(WM_XBUTTONDOWN, 1) - ); - assert_eq!( - Action::Button { - down: false, - button: 4 - }, - classify(WM_XBUTTONUP, 2) - ); - assert_eq!(Action::Ignored, classify(WM_XBUTTONDOWN, 7)); - } - - #[test] - fn move_and_wheel_are_activity_and_unknown_messages_are_ignored() { - assert_eq!(Action::Activity, classify(WM_MOUSEMOVE, 0)); - assert_eq!(Action::Activity, classify(WM_MOUSEWHEEL, 0)); - assert_eq!(Action::Activity, classify(WM_MOUSEHWHEEL, 0)); - assert_eq!(Action::Ignored, classify(0xdead, 0)); - } -} diff --git a/fluxer_desktop/package.json b/fluxer_desktop/package.json index e1b4f22e0..f67c3ff0f 100644 --- a/fluxer_desktop/package.json +++ b/fluxer_desktop/package.json @@ -11,9 +11,9 @@ "scripts": { "build": "node scripts/build.mjs", "set-channel": "../tools/ci/run.sh build-desktop --step set_build_channel", - "test": "pnpm test:main && cargo test --manifest-path native/rust/Cargo.toml", + "test": "pnpm test:main", "test:main": "pnpm set-channel && node --test 'src/main/*.test.mjs' 'src/preload/*.test.mjs' 'src/common/*.test.mjs'", - "test:native": "cargo test --manifest-path native/rust/Cargo.toml && cargo test --manifest-path native/app-store/Cargo.toml && cargo test --manifest-path native/gateway-socket/Cargo.toml", + "test:native": "cargo test --manifest-path native/app-store/Cargo.toml && cargo test --manifest-path native/gateway-socket/Cargo.toml", "typecheck": "pnpm set-channel && tsc --noEmit" }, "dependencies": { @@ -21,6 +21,7 @@ "@fluxer/desktop_ipc": "workspace:*", "@fluxer/instance_bootstrap": "workspace:*", "@fluxer/ip_utils": "workspace:*", + "@fluxer/record_utils": "workspace:*", "@fluxer/voice_engine_v2": "workspace:*", "@simplewebauthn/browser": "14.0.0", "franc-min": "6.2.0", diff --git a/fluxer_desktop/scripts/build.mjs b/fluxer_desktop/scripts/build.mjs index 9440156a9..a6f5b246b 100644 --- a/fluxer_desktop/scripts/build.mjs +++ b/fluxer_desktop/scripts/build.mjs @@ -322,12 +322,12 @@ function expectedNativeRuntimeArtifactsForArch(platform, arch) { artifacts.push({ label: '@fluxer/app-store', relativePath: `app-store.${tag}.node`, - runtimeFiles: ['index.js', 'loader-diagnostics.cjs', 'pure.cjs'], + runtimeFiles: ['index.js', 'loader-diagnostics.cjs'], }); artifacts.push({ label: '@fluxer/gateway-socket', relativePath: `gateway-socket.${tag}.node`, - runtimeFiles: ['index.js', 'loader-diagnostics.cjs', 'pure.cjs'], + runtimeFiles: ['index.js', 'loader-diagnostics.cjs'], }); if (platform === 'darwin') { artifacts.push({ @@ -357,7 +357,7 @@ function expectedNativeRuntimeArtifactsForArch(platform, arch) { artifacts.push({ label: '@fluxer/platform-info', relativePath: `platform-info.${tag}.node`, - runtimeFiles: ['index.js', 'loader-diagnostics.cjs', 'pure.cjs'], + runtimeFiles: ['index.js', 'loader-diagnostics.cjs'], }); } else if (platform === 'win32') { artifacts.push({ @@ -380,7 +380,7 @@ function expectedNativeRuntimeArtifactsForArch(platform, arch) { artifacts.push({ label: '@fluxer/platform-info', relativePath: `platform-info.${tag}.node`, - runtimeFiles: ['index.js', 'loader-diagnostics.cjs', 'pure.cjs'], + runtimeFiles: ['index.js', 'loader-diagnostics.cjs'], }); } else if (platform === 'linux') { artifacts.push({ @@ -410,7 +410,7 @@ function expectedNativeRuntimeArtifactsForArch(platform, arch) { artifacts.push({ label: '@fluxer/platform-info', relativePath: `platform-info.${tag}.node`, - runtimeFiles: ['index.js', 'loader-diagnostics.cjs', 'pure.cjs'], + runtimeFiles: ['index.js', 'loader-diagnostics.cjs'], }); } return artifacts; diff --git a/fluxer_desktop/src/common/ChannelIsolation.test.mjs b/fluxer_desktop/src/common/ChannelIsolation.test.mjs index 5a4074ec2..f39951e03 100644 --- a/fluxer_desktop/src/common/ChannelIsolation.test.mjs +++ b/fluxer_desktop/src/common/ChannelIsolation.test.mjs @@ -72,11 +72,4 @@ describe('running channels stay apart', () => { const directories = CHANNELS.map((channel) => new RegExp(`${channel}: '([^']+)'`, 'u').exec(map)?.[1]); assert.deepEqual(directories, ['fluxer', 'fluxercanary', 'fluxerdevelopment']); }); - - test('only a packaged app claims its deep link scheme, so a dev Electron run never takes it from an installed app', () => { - const source = fs.readFileSync(path.join(DESKTOP_DIR, 'src/main/DeepLinks.ts'), 'utf8'); - assert.doesNotMatch(source, /setAsDefaultProtocolClient\(['"]/u); - assert.doesNotMatch(source, /process\.defaultApp/u); - assert.match(source, /if \(app\.isPackaged\) \{\n\t\tapp\.setAsDefaultProtocolClient\(APP_PROTOCOL\);/u); - }); }); diff --git a/fluxer_desktop/src/common/DesktopConfig.ts b/fluxer_desktop/src/common/DesktopConfig.ts index 785f01250..4bb16a6b1 100644 --- a/fluxer_desktop/src/common/DesktopConfig.ts +++ b/fluxer_desktop/src/common/DesktopConfig.ts @@ -1,3 +1,4 @@ +import {isObjectLike as isRecord} from '@fluxer/record_utils/src/RecordGuards'; // SPDX-License-Identifier: AGPL-3.0-or-later import fs from 'node:fs'; @@ -101,10 +102,6 @@ function getDefaultDesktopWindowBehaviorSettings(): DesktopWindowBehaviorSetting }; } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object'; -} - function sanitizePersistedDesktopWindowBehaviorSettings( value: unknown, ): PersistedDesktopWindowBehaviorSettings | undefined { diff --git a/fluxer_desktop/src/common/ErrorMessage.ts b/fluxer_desktop/src/common/ErrorMessage.ts new file mode 100644 index 000000000..f30c0b543 --- /dev/null +++ b/fluxer_desktop/src/common/ErrorMessage.ts @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/fluxer_desktop/src/common/ExponentialBackoff.ts b/fluxer_desktop/src/common/ExponentialBackoff.ts new file mode 100644 index 000000000..b0f893b25 --- /dev/null +++ b/fluxer_desktop/src/common/ExponentialBackoff.ts @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export function jitteredExponentialDelay(base: number, cap: number, attempt: number, random: () => number): number { + return Math.round(Math.min(base * 2 ** attempt, cap) * (0.5 + random() * 0.5)); +} diff --git a/fluxer_desktop/src/common/ModuleSystem.test.mjs b/fluxer_desktop/src/common/ModuleSystem.test.mjs index 62fe90564..2796eef6d 100644 --- a/fluxer_desktop/src/common/ModuleSystem.test.mjs +++ b/fluxer_desktop/src/common/ModuleSystem.test.mjs @@ -8,7 +8,6 @@ import {after, describe, test} from 'node:test'; import {pathToFileURL} from 'node:url'; const { - decideModuleSystemDisableRequest, getOfflineRendererRoot, hasOfflineRenderer, readModuleSystemDisableRequest, @@ -78,21 +77,6 @@ describe('the launch decision on a build with the module system flag off', () => }); }); -describe('decideModuleSystemDisableRequest, a pure decision no shipped build can reach', () => { - test('is honoured when an offline renderer can take over', () => { - assert.deepEqual(decideModuleSystemDisableRequest(true, true), {kind: 'honoured'}); - }); - - test('is ignored when there is no offline renderer to take over', () => { - assert.deepEqual(decideModuleSystemDisableRequest(true, false), {kind: 'ignored-without-offline-renderer'}); - }); - - test('that was never made is neither honoured nor ignored', () => { - assert.deepEqual(decideModuleSystemDisableRequest(false, false), {kind: 'not-requested'}); - assert.deepEqual(decideModuleSystemDisableRequest(false, true), {kind: 'not-requested'}); - }); -}); - describe('the launch decision on a modules build, given an offline renderer no shipped modules build has', () => { test('the build define carries the module system on its own', () => { assert.equal(modulesBuild.MODULE_SYSTEM_BUILD_ENABLED, true); diff --git a/fluxer_desktop/src/preload/PreloadNativeGatewayEvent.test.mjs b/fluxer_desktop/src/common/NativeGatewayTransportEvent.test.mjs similarity index 63% rename from fluxer_desktop/src/preload/PreloadNativeGatewayEvent.test.mjs rename to fluxer_desktop/src/common/NativeGatewayTransportEvent.test.mjs index 847a70ae1..226f8327c 100644 --- a/fluxer_desktop/src/preload/PreloadNativeGatewayEvent.test.mjs +++ b/fluxer_desktop/src/common/NativeGatewayTransportEvent.test.mjs @@ -7,7 +7,7 @@ import '../main/LocalAppTestSupport.test.mjs'; const {NATIVE_GATEWAY_TRANSPORT_CLOSE_REASON_MAX_BYTES, NativeGatewayTransportEventKind} = await import( '../../../packages/desktop_ipc/src/GatewayTransportContract.ts' ); -const {reconstructNativeGatewayTransportEvent} = await import('./PreloadNativeGatewayEvent.ts'); +const {parseNativeGatewayTransportEvent} = await import('./NativeGatewayTransportEvent.ts'); const CONNECTION_ID = 'gateway-renderer-3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d:9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d'; @@ -31,15 +31,15 @@ function closeFrame(reason) { function rejects(value, fragment) { assert.throws( - () => reconstructNativeGatewayTransportEvent(value), - (error) => error.name === 'InvalidPreloadNativeGatewayEventError' && error.message.includes(fragment), + () => parseNativeGatewayTransportEvent(value), + (error) => error.name === 'InvalidNativeGatewayTransportEventError' && error.message.includes(fragment), `the preload must refuse this event with ${fragment}`, ); } describe('reconstructing a native gateway transport event in the preload', () => { test('each event kind keeps its own payload and nulls the rest', () => { - const opened = reconstructNativeGatewayTransportEvent(frame({})); + const opened = parseNativeGatewayTransportEvent(frame({})); assert.deepEqual(opened, { connectionId: CONNECTION_ID, kind: 'open', @@ -51,18 +51,18 @@ describe('reconstructing a native gateway transport event in the preload', () => message: null, }); - const message = reconstructNativeGatewayTransportEvent( + const message = parseNativeGatewayTransportEvent( frame({kind: NativeGatewayTransportEventKind.MESSAGE, data: '{"op":11}'}), ); assert.equal(message.data, '{"op":11}'); assert.equal(message.binary, null); - const closed = reconstructNativeGatewayTransportEvent(closeFrame('going away')); + const closed = parseNativeGatewayTransportEvent(closeFrame('going away')); assert.equal(closed.code, 1000); assert.equal(closed.reason, 'going away'); assert.equal(closed.wasClean, true); - const errored = reconstructNativeGatewayTransportEvent( + const errored = parseNativeGatewayTransportEvent( frame({kind: NativeGatewayTransportEventKind.ERROR, message: 'socket died'}), ); assert.equal(errored.message, 'socket died'); @@ -71,9 +71,7 @@ describe('reconstructing a native gateway transport event in the preload', () => test('a binary frame is copied out of a view so the sender cannot mutate it afterwards', () => { const source = new Uint8Array([9, 1, 2, 3, 4, 9]); const view = source.subarray(1, 5); - const event = reconstructNativeGatewayTransportEvent( - frame({kind: NativeGatewayTransportEventKind.BINARY, binary: view}), - ); + const event = parseNativeGatewayTransportEvent(frame({kind: NativeGatewayTransportEventKind.BINARY, binary: view})); view[0] = 99; assert.ok(event.binary instanceof ArrayBuffer); assert.deepEqual(Array.from(new Uint8Array(event.binary)), [1, 2, 3, 4]); @@ -121,17 +119,66 @@ describe('reconstructing a native gateway transport event in the preload', () => assert.equal(cap, 123); const asciiAtCap = 'a'.repeat(cap); - assert.equal(reconstructNativeGatewayTransportEvent(closeFrame(asciiAtCap)).reason, asciiAtCap); + assert.equal(parseNativeGatewayTransportEvent(closeFrame(asciiAtCap)).reason, asciiAtCap); rejects(closeFrame('a'.repeat(cap + 1)), 'close reason is too large'); const threeByteAtCap = '€'.repeat(cap / 3); - assert.equal(reconstructNativeGatewayTransportEvent(closeFrame(threeByteAtCap)).reason, threeByteAtCap); + assert.equal(parseNativeGatewayTransportEvent(closeFrame(threeByteAtCap)).reason, threeByteAtCap); rejects(closeFrame('€'.repeat(cap / 3 + 1)), 'close reason is too large'); const astral = '\u{1f600}'.repeat(30); assert.equal(new TextEncoder().encode(astral).length, 120); assert.equal(astral.length, 60); - assert.equal(reconstructNativeGatewayTransportEvent(closeFrame(astral)).reason, astral); + assert.equal(parseNativeGatewayTransportEvent(closeFrame(astral)).reason, astral); rejects(closeFrame('\u{1f600}'.repeat(31)), 'close reason is too large'); }); }); + +function nativeEvent(overrides) { + return { + kind: 'open', + data: null, + binary: null, + code: null, + reason: null, + wasClean: null, + message: null, + ...overrides, + }; +} + +describe('NativeGatewayTransportEventParser', () => { + test('accepts the addon exact seven field shape and normalises binary to ArrayBuffer', () => { + const parsed = parseNativeGatewayTransportEvent( + nativeEvent({kind: 'binary', binary: Buffer.from([1, 2, 3])}), + 'id', + ); + assert.equal(parsed.kind, 'binary'); + assert.deepEqual([...new Uint8Array(parsed.binary)], [1, 2, 3]); + assert.equal(parsed.data, null); + }); + + test('rejects an inapplicable field that is not null', () => { + assert.throws( + () => parseNativeGatewayTransportEvent(nativeEvent({kind: 'open', message: ''}), 'id'), + /message must be null/u, + ); + assert.throws( + () => parseNativeGatewayTransportEvent(nativeEvent({kind: 'open', data: undefined}), 'id'), + /must be a plain object with exactly the declared keys|data must be null/u, + ); + }); + + test('rejects a close event with an out of range code', () => { + assert.throws( + () => + parseNativeGatewayTransportEvent(nativeEvent({kind: 'close', code: 70_000, reason: '', wasClean: true}), 'id'), + /unsigned 16-bit integer/u, + ); + }); + + test('rejects a prototype-polluted record', () => { + const hostile = Object.create({kind: 'open'}); + assert.throws(() => parseNativeGatewayTransportEvent(hostile, 'id'), /plain object/u); + }); +}); diff --git a/fluxer_desktop/src/preload/PreloadNativeGatewayEvent.ts b/fluxer_desktop/src/common/NativeGatewayTransportEvent.ts similarity index 65% rename from fluxer_desktop/src/preload/PreloadNativeGatewayEvent.ts rename to fluxer_desktop/src/common/NativeGatewayTransportEvent.ts index a841dc81a..69f39a22d 100644 --- a/fluxer_desktop/src/preload/PreloadNativeGatewayEvent.ts +++ b/fluxer_desktop/src/common/NativeGatewayTransportEvent.ts @@ -16,22 +16,22 @@ const CLOSE_CODE_MAX = 65_535; const MAX_UTF8_BYTES_PER_UTF16_UNIT = 3; const TEXT_ENCODER = new TextEncoder(); -class InvalidPreloadNativeGatewayEventError extends TypeError { +class InvalidNativeGatewayTransportEventError extends TypeError { public constructor(reason: string) { - super(`Native gateway transport event rejected by the preload: ${reason}`); - this.name = 'InvalidPreloadNativeGatewayEventError'; + super(`Native gateway transport event is invalid: ${reason}`); + this.name = 'InvalidNativeGatewayTransportEventError'; } } function requireString(value: unknown, maxBytes: number, name: string): string { if (typeof value !== 'string') { - throw new InvalidPreloadNativeGatewayEventError(`${name} must be a string`); + throw new InvalidNativeGatewayTransportEventError(`${name} must be a string`); } if (value.length > maxBytes) { - throw new InvalidPreloadNativeGatewayEventError(`${name} is too large`); + throw new InvalidNativeGatewayTransportEventError(`${name} is too large`); } if (value.length * MAX_UTF8_BYTES_PER_UTF16_UNIT > maxBytes && TEXT_ENCODER.encode(value).length > maxBytes) { - throw new InvalidPreloadNativeGatewayEventError(`${name} is too large`); + throw new InvalidNativeGatewayTransportEventError(`${name} is too large`); } return value; } @@ -39,7 +39,7 @@ function requireString(value: unknown, maxBytes: number, name: string): string { function requireNull(record: Readonly>, fields: ReadonlyArray): void { for (const field of fields) { if (record[field] !== null) { - throw new InvalidPreloadNativeGatewayEventError(`${field} must be null for this event kind`); + throw new InvalidNativeGatewayTransportEventError(`${field} must be null for this event kind`); } } } @@ -47,33 +47,38 @@ function requireNull(record: Readonly>, fields: Readonly function requireArrayBuffer(value: unknown): ArrayBuffer { if (value instanceof ArrayBuffer) { if (value.byteLength > NATIVE_GATEWAY_TRANSPORT_FRAME_MAX_BYTES) { - throw new InvalidPreloadNativeGatewayEventError('binary frame data is too large'); + throw new InvalidNativeGatewayTransportEventError('binary frame data is too large'); } return value; } if (ArrayBuffer.isView(value)) { if (value.byteLength > NATIVE_GATEWAY_TRANSPORT_FRAME_MAX_BYTES) { - throw new InvalidPreloadNativeGatewayEventError('binary frame data is too large'); + throw new InvalidNativeGatewayTransportEventError('binary frame data is too large'); } const copy = new Uint8Array(value.byteLength); copy.set(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)); return copy.buffer; } - throw new InvalidPreloadNativeGatewayEventError('binary frame data must be a byte buffer'); + throw new InvalidNativeGatewayTransportEventError('binary frame data must be a byte buffer'); } -export function reconstructNativeGatewayTransportEvent(value: unknown): NativeGatewayTransportEvent { - const record = readExactPlainRecord({value, expectedKeys: EVENT_KEYS}); +export function parseNativeGatewayTransportEvent(value: unknown, connectionId?: string): NativeGatewayTransportEvent { + const record = readExactPlainRecord({ + value, + expectedKeys: connectionId === undefined ? EVENT_KEYS : EVENT_KEYS.slice(1), + }); if (record == null) { - throw new InvalidPreloadNativeGatewayEventError('payload must be a plain object with exactly the declared keys'); + throw new InvalidNativeGatewayTransportEventError('payload must be a plain object with exactly the declared keys'); } - const connectionId = requireString( - record.connectionId, - NATIVE_GATEWAY_TRANSPORT_CONNECTION_ID_MAX_BYTES, - 'connection id', - ); - if (!isNativeGatewayTransportConnectionId(connectionId)) { - throw new InvalidPreloadNativeGatewayEventError('connection id has an invalid format'); + if (connectionId === undefined) { + connectionId = requireString( + record.connectionId, + NATIVE_GATEWAY_TRANSPORT_CONNECTION_ID_MAX_BYTES, + 'connection id', + ); + if (!isNativeGatewayTransportConnectionId(connectionId)) { + throw new InvalidNativeGatewayTransportEventError('connection id has an invalid format'); + } } const base = {connectionId, data: null, binary: null, code: null, reason: null, wasClean: null, message: null}; switch (record.kind) { @@ -93,11 +98,11 @@ export function reconstructNativeGatewayTransportEvent(value: unknown): NativeGa case NativeGatewayTransportEventKind.CLOSE: { const {code} = record; if (typeof code !== 'number' || !Number.isInteger(code) || code < 0 || code > CLOSE_CODE_MAX) { - throw new InvalidPreloadNativeGatewayEventError('close code must be an unsigned 16-bit integer'); + throw new InvalidNativeGatewayTransportEventError('close code must be an unsigned 16-bit integer'); } const reason = requireString(record.reason, NATIVE_GATEWAY_TRANSPORT_CLOSE_REASON_MAX_BYTES, 'close reason'); if (typeof record.wasClean !== 'boolean') { - throw new InvalidPreloadNativeGatewayEventError('close disposition must be a boolean'); + throw new InvalidNativeGatewayTransportEventError('close disposition must be a boolean'); } requireNull(record, ['data', 'binary', 'message']); return {...base, kind: NativeGatewayTransportEventKind.CLOSE, code, reason, wasClean: record.wasClean}; @@ -108,6 +113,6 @@ export function reconstructNativeGatewayTransportEvent(value: unknown): NativeGa return {...base, kind: NativeGatewayTransportEventKind.ERROR, message}; } default: - throw new InvalidPreloadNativeGatewayEventError('event kind is unknown'); + throw new InvalidNativeGatewayTransportEventError('event kind is unknown'); } } diff --git a/fluxer_desktop/src/common/Types.ts b/fluxer_desktop/src/common/Types.ts index 3fcd13799..a0f0b9eab 100644 --- a/fluxer_desktop/src/common/Types.ts +++ b/fluxer_desktop/src/common/Types.ts @@ -1,5 +1,57 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import type { + AppMetricsSnapshot, + ChromiumRuntimeInfo, + ClipboardWriteFileOptions, + ClipboardWriteFileResult, + DesktopTroubleshootingSettings, + DesktopVoiceDebugEventSinkEntry, + DisplayMediaPortalSurfacePreference, + GlobalKeybindTriggeredEvent, + GlobalKeyEvent, + GlobalKeyHookRegisterOptions, + GlobalMouseEvent, + GlobalShortcutCaptureEvent, + GlobalShortcutEvent, + GlobalShortcutsBackend, + GlobalShortcutsPortalShortcut, + GlobalShortcutsPortalState, + GlobalShortcutsSyncPayload, + GpuInfo, + InputMonitoringPermissionStatus, + LinuxAppearanceSnapshot, + MediaAccessStatus, + MediaAccessType, + NativeAudioApplication, + NativeAudioAvailability, + NativeAudioEndMessage, + NativeAudioFrameMessage, + NativeAudioStartOptions, + NativeAudioStartResult, + NativeScreenCaptureLifecycleMessage, + SetDesktopTroubleshootingDisableHardwareAccelerationOptions, + SpellcheckBundledDictionary, + SpellcheckEngine, + SpellcheckResolvedEngineInfo, + StreamerModeCaptureAppStatus, + StreamingPriorityDiagnostics, + ThemeDirectoryCssFile, + ThemeLinkedFileChange, + ThemeLocalFileReadResult, + ThemeLocalFileReference, + TrayPresenceStatus, + TrayRuntimeStatePayload, + UpdaterDownloadOption, + VirtmicBackend, + VirtmicNode, + VoiceBackgroundMediaCacheRequest, + VoiceBackgroundMediaCacheResult, + VoiceBackgroundMediaReadResult, +} from '@fluxer/desktop_ipc/src/DesktopBridgeTypes'; + +export type * from '@fluxer/desktop_ipc/src/DesktopBridgeTypes'; + import type {BuildChannel} from '@electron/common/BuildChannel'; import type {DesktopAccountStorageAPI} from '@fluxer/desktop_ipc/src/AccountContract'; import type {DesktopHandoffAPI} from '@fluxer/desktop_ipc/src/BrowserHandoffContract'; @@ -16,12 +68,6 @@ import type { RegistrationResponseJSON, } from '@simplewebauthn/browser'; -export interface LinuxAppearanceSnapshot { - colorScheme: 'no-preference' | 'prefer-dark' | 'prefer-light'; - contrast: 'no-preference' | 'high'; - accent: {r: number; g: number; b: number} | null; -} - export interface DesktopInfo { version: string; channel: BuildChannel; @@ -41,72 +87,6 @@ export interface DesktopInfo { chromiumRuntime: ChromiumRuntimeInfo; } -export interface ChromiumRuntimeInfo { - enableFeatures: Array; - disableFeatures: Array; - switches: Array; -} - -export interface GpuDeviceInfo { - active: boolean; - vendorId: number; - deviceId: number; - vendorName?: string; - deviceString?: string; - driverVendor?: string; - driverVersion?: string; - dedicatedVideoMemory?: number; - sharedSystemMemory?: number; - subsystemVendorId?: number; - subsystemDeviceId?: number; - registryId?: string; - adapterLuid?: string; - pciPath?: string; - integrated?: boolean; - removable?: boolean; - headless?: boolean; - source?: 'metal' | 'dxgi' | 'linux-sysfs' | 'electron'; -} - -export interface GpuInfo { - devices: ReadonlyArray; - glRenderer?: string; - glVendor?: string; - machineModelName?: string; - machineModelVersion?: string; - nativeSource?: 'metal' | 'dxgi' | 'linux-sysfs'; -} - -export type StreamingPriorityDiagnostics = Record; - -export interface CpuInfo { - model: string; - speed: number; - cores: number; - physicalCores: number; -} - -export interface ProcessMetrics { - cpu: { - percentCPUUsage: number; - }; - memory: { - workingSetSize: number; - peakWorkingSetSize: number; - privateBytes?: number; - }; - pid: number; - type: string; - name?: string; -} - -export interface AppMetricsSnapshot { - cpuInfo: CpuInfo; - processes: Array; - totalMemoryMB: number; - freeMemoryMB: number; -} - export interface DesktopWindowBehaviorSettings { showTrayIcon: boolean; minimizeToTray: boolean; @@ -123,91 +103,8 @@ export interface DesktopWindowBehaviorSettings { activeMiddleClickAutoscroll: boolean; } -export interface ThemeLocalFileReference { - id: string; - name: string; - path: string; - mimeType: string; - size: number; -} - -export interface ThemeLocalFileReadResult { - path: string; - dataUrl?: string; - error?: string; -} - -export interface ThemeDirectoryCssFile { - fileName: string; - path: string; - css: string; -} - -export type ThemeLinkedFileError = 'not_allowed' | 'missing' | 'not_file' | 'too_large' | 'too_many' | 'read_failed'; - -export interface ThemeLinkedFileChange { - path: string; - css?: string; - error?: ThemeLinkedFileError; -} - -export type VoiceBackgroundMediaKind = 'static' | 'animated' | 'video'; - -export interface VoiceBackgroundMediaCacheRequest { - id: string; - mimeType: string; - fileName?: string; - data: ArrayBuffer; -} - -export interface VoiceBackgroundMediaCacheResult { - path: string; - mediaKind: VoiceBackgroundMediaKind; -} - -export interface VoiceBackgroundMediaReadResult { - path: string; - mediaKind: VoiceBackgroundMediaKind; - dataUrl: string; -} - -export interface DesktopTroubleshootingSettings { - disableHardwareAcceleration: boolean; -} - -export interface SetDesktopTroubleshootingDisableHardwareAccelerationOptions { - disable: boolean; - restart?: boolean; -} - -export interface DesktopVoiceDebugEventSinkEntry { - sequence: number; - line: string; -} - -export type ClipboardWriteFileMediaType = 'image' | 'gif' | 'video' | 'audio'; - -export interface ClipboardWriteFileOptions { - url: string; - suggestedName?: string; - mediaType: ClipboardWriteFileMediaType; -} - -export interface ClipboardWriteFileResult { - success: boolean; - path?: string; - error?: string; -} - export type UpdaterContext = 'user' | 'background' | 'focus'; -export type UpdaterDownloadFormat = 'setup' | 'dmg' | 'zip' | 'appimage' | 'deb' | 'rpm' | 'tar_gz'; -export interface UpdaterDownloadOption { - format: UpdaterDownloadFormat; - label: string; - url: string; - suggestedName?: string; - sha256?: string | null; -} + export type UpdaterEvent = | { type: 'checking'; @@ -244,10 +141,6 @@ export interface DownloadFileResult { error?: string; } -export type MediaAccessType = 'microphone' | 'camera' | 'screen' | 'audio-capture'; -export type MediaAccessStatus = 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown'; -export type InputMonitoringPermissionStatus = 'granted' | 'denied' | 'not-determined' | 'unsupported'; - export interface DesktopSource { id: string; name: string; @@ -269,7 +162,6 @@ export type VirtmicUnavailableReason = | 'load-failed' | 'no-pipewire' | 'disabled-by-launch'; -export type VirtmicBackend = 'pipewire'; export interface VirtmicAvailability { available: boolean; @@ -277,15 +169,6 @@ export interface VirtmicAvailability { backend?: VirtmicBackend; } -export type VirtmicNode = Record; - -export interface VirtmicLinkOptions { - ignoreDevices?: boolean; - ignoreInputMedia?: boolean; - ignoreVirtual?: boolean; - workaround?: boolean; -} - export interface VirtmicApi { getAvailability: () => Promise; listTargets: (options?: {granular?: boolean}) => Promise<{ @@ -296,74 +179,6 @@ export interface VirtmicApi { stop: () => Promise; } -export type NativeAudioBackend = 'macos-sck' | 'macos-coreaudio' | 'windows-wasapi-loopback' | 'linux-pipewire'; - -export interface NativeAudioAvailability { - available: boolean; - backend?: NativeAudioBackend; - capabilities?: { - process: boolean; - system: boolean; - systemExcludesSelf?: boolean; - processInclude?: boolean; - processExclude?: boolean; - sessionMixer?: boolean; - systemLoopbackMode?: 'process-exclude' | 'session-mixer' | 'unavailable'; - }; - reason?: - | 'unsupported-platform' - | 'addon-not-installed' - | 'load-failed' - | 'os-version-too-old' - | 'permission-denied' - | 'no-pipewire' - | 'disabled-by-launch'; - detail?: string; -} - -export interface NativeAudioApplication { - pid: number; - identifier: string; - name: string; - audible?: boolean; -} - -export interface NativeAudioStartOptions { - targetPid?: number; - includeProcessTree?: boolean; - macBackend?: 'sck' | 'coreaudio' | 'auto'; - macCaptureScope?: 'process' | 'system'; - winCaptureScope?: 'process' | 'system' | 'session-mixer'; - linuxRule?: VirtmicLinkOptions & { - include?: Array; - exclude?: Array; - onlySpeakers?: boolean; - onlyDefaultSpeakers?: boolean; - }; -} - -export interface NativeAudioStartResult { - captureId: string; - sampleRate: number; - channels: number; -} - -export interface NativeAudioFrameMessage { - captureId: string; - sampleRate: number; - channels: number; - timestampUs: number; - samples: ArrayBuffer; -} - -export type NativeAudioEndReason = 'stopped' | 'target-exited' | 'addon-error'; - -export interface NativeAudioEndMessage { - captureId: string; - reason: NativeAudioEndReason; - detail?: string; -} - export interface NativeAudioApi { getAvailability: () => Promise; listAudibleApplications: () => Promise>; @@ -375,17 +190,6 @@ export interface NativeAudioApi { onEnd: (callback: (message: NativeAudioEndMessage) => void) => () => void; } -export type NativeScreenCaptureLifecycleEventKind = 'error' | 'closed' | 'closed-clean' | 'stalled' | 'diagnostic'; - -export type NativeScreenCaptureLifecycleSource = 'delegate' | 'programmatic'; - -export interface NativeScreenCaptureLifecycleMessage { - captureId: string; - kind: NativeScreenCaptureLifecycleEventKind; - message: string; - source?: NativeScreenCaptureLifecycleSource; -} - export interface NativeScreenCaptureApi { onLifecycleEvent: (callback: (message: NativeScreenCaptureLifecycleMessage) => void) => () => void; } @@ -396,8 +200,6 @@ export interface DisplayMediaRequestInfo { supportsLoopbackAudio: boolean; } -export type DisplayMediaPortalSurfacePreference = 'window' | 'monitor'; - export interface NotificationOptions { id?: string; title: string; @@ -411,27 +213,6 @@ export interface NotificationResult { id: string; } -export interface StreamerModeCaptureProcess { - name: string; - pid?: number; -} - -export interface StreamerModeCaptureAppStatus { - detected: boolean; - processes: Array; -} - -export type TrayPresenceStatus = 'online' | 'idle' | 'dnd' | 'invisible'; - -export interface TrayRuntimeStatePayload { - voiceConnected: boolean; - voiceChannelLabel: string | null; - selfMute: boolean; - selfDeaf: boolean; - presenceStatus: TrayPresenceStatus | null; - buildInfo: string | null; -} - export type TrayActionPayload = | { action: 'set-status'; @@ -484,7 +265,7 @@ export interface ElectronAPI { windowClose: () => void; windowIsMaximized: () => Promise; focusThemeStudioPopout: () => Promise; - closeThemeStudioPopout: () => Promise; + popoutSetAlwaysOnTop: (key: string, flag: boolean) => Promise; popoutFocus: (key: string) => Promise; openVoiceDebugEventSinkPopout: (entries: Array) => Promise; @@ -555,11 +336,7 @@ export interface ElectronAPI { readableEventDevices: number; inInputGroup: boolean; }>; - linuxEvdevGrantAccess: () => Promise<{ - success: boolean; - needsRelogin: boolean; - error?: string; - }>; + onGlobalKeyEvent: (callback: (event: GlobalKeyEvent) => void) => () => void; onGlobalMouseEvent: (callback: (event: GlobalMouseEvent) => void) => () => void; onGlobalKeybindTriggered: (callback: (event: GlobalKeybindTriggeredEvent) => void) => () => void; @@ -608,23 +385,6 @@ export interface ElectronAPI { nativeGatewayTransport: NativeGatewayTransportAPI; } -export type GlobalShortcutsBackend = 'portal' | 'x11' | 'evdev' | 'windows' | 'macos' | 'none'; - -export type GlobalShortcutsPortalState = - | 'unknown' - | 'probing' - | 'unsupported' - | 'not-set-up' - | 'binding' - | 'bound' - | 'declined' - | 'error'; - -export interface GlobalShortcutsPortalShortcut { - action: string; - triggerDescription: string | null; -} - export interface GlobalShortcutsPortalStatus { state: GlobalShortcutsPortalState; version: number | null; @@ -658,52 +418,6 @@ export interface GlobalShortcutsStatus { supportsModifierOnly: boolean; } -export interface GlobalShortcutCombo { - code?: string; - key: string; - ctrl: boolean; - alt: boolean; - shift: boolean; - meta: boolean; - mouseButton?: number; - modifierOnly?: boolean; - bothSides?: boolean; -} - -export interface GlobalShortcutBinding { - sourceId: string; - action: string; - combo: GlobalShortcutCombo; -} - -export interface GlobalShortcutActionDefinition { - action: string; - description: string; - preferredCombo: GlobalShortcutCombo | null; -} - -export interface GlobalShortcutsSyncPayload { - bindings: Array; - actions: Array; -} - -export interface GlobalShortcutEvent { - action: string; - sourceId: string; - phase: 'press' | 'release'; -} - -export interface GlobalShortcutCaptureEvent { - type: 'keydown' | 'keyup' | 'mousedown' | 'mouseup'; - code: string | null; - key: string | null; - button: number | null; - ctrl: boolean; - alt: boolean; - shift: boolean; - meta: boolean; -} - export interface GlobalShortcutsApi { sync: (payload: GlobalShortcutsSyncPayload) => Promise; setPaused: (paused: boolean) => Promise; @@ -719,46 +433,6 @@ export interface GlobalShortcutsApi { onCapture: (callback: (event: GlobalShortcutCaptureEvent) => void) => () => void; } -export interface GlobalKeyHookRegisterOptions { - id: string; - description?: string; - keycode?: number; - keyName?: string; - physicalKeyName?: string; - mouseButton?: number; - ctrl?: boolean; - alt?: boolean; - shift?: boolean; - meta?: boolean; -} - -export interface GlobalKeyEvent { - type: 'keydown' | 'keyup'; - keycode: number; - keyName: string; - backend?: 'evdev' | 'native' | null; - altKey: boolean; - ctrlKey: boolean; - shiftKey: boolean; - metaKey: boolean; -} - -export interface GlobalMouseEvent { - type: 'mousedown' | 'mouseup'; - button: number; - altKey: boolean; - ctrlKey: boolean; - shiftKey: boolean; - metaKey: boolean; -} - -export interface GlobalKeybindTriggeredEvent { - id: string; - type: 'keydown' | 'keyup'; -} - -export type SpellcheckEngine = 'auto' | 'hunspell' | 'system'; - export interface SpellcheckState { enabled: boolean; engine: SpellcheckEngine; @@ -767,19 +441,6 @@ export interface SpellcheckState { personalDictionary: Array; } -export interface SpellcheckBundledDictionary { - tag: string; - package: string; - displayName: string; - nativeName: string; -} - -export interface SpellcheckResolvedEngineInfo { - mode: 'hunspell' | 'system' | 'off'; - hunspellLangs: Array; - systemLangs: Array; -} - export interface TextareaContextMenuParams { misspelledWord?: string; suggestions?: Array; diff --git a/fluxer_desktop/src/main/AtomicFile.ts b/fluxer_desktop/src/main/AtomicFile.ts new file mode 100644 index 000000000..5f6099bd8 --- /dev/null +++ b/fluxer_desktop/src/main/AtomicFile.ts @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {randomBytes} from 'node:crypto'; +import fs, {type FileHandle} from 'node:fs/promises'; +import path from 'node:path'; + +export const WINDOWS_TRANSIENT_FS_ERROR_CODES: ReadonlySet = new Set(['EPERM', 'EACCES', 'EBUSY']); + +export const WINDOWS_TRANSIENT_FS_RETRY_DELAYS_MS: ReadonlyArray = [25, 50, 100, 200, 400, 800]; + +export class ModuleStoreError extends Error { + public constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ModuleStoreError'; + } +} + +export class ModuleStoreIOError extends ModuleStoreError { + public constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ModuleStoreIOError'; + } +} + +export function isWindowsTransientFsError(error: unknown): boolean { + return ( + process.platform === 'win32' && + typeof error === 'object' && + error !== null && + 'code' in error && + typeof error.code === 'string' && + WINDOWS_TRANSIENT_FS_ERROR_CODES.has(error.code) + ); +} + +export async function renameWithRetry(source: string, target: string): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + await fs.rename(source, target); + return; + } catch (error) { + const delay = WINDOWS_TRANSIENT_FS_RETRY_DELAYS_MS[attempt]; + if (delay === undefined || !isWindowsTransientFsError(error)) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } +} + +export async function fsyncDirectory(directory: string): Promise { + if (process.platform === 'win32') { + return; + } + let handle: FileHandle; + try { + handle = await fs.open(directory, 'r'); + } catch (error) { + throw new ModuleStoreIOError(`failed to open ${directory} for fsync`, {cause: error}); + } + try { + await handle.sync(); + } catch (error) { + throw new ModuleStoreIOError(`failed to fsync ${directory}`, {cause: error}); + } finally { + await handle.close(); + } +} + +export async function writeFileAtomically(target: string, contents: Uint8Array | string, mode: number): Promise { + const directory = path.dirname(target); + const temporary = path.join(directory, `${path.basename(target)}.${randomBytes(8).toString('hex')}.tmp`); + let handle: FileHandle; + try { + handle = await fs.open(temporary, 'wx', mode); + } catch (error) { + throw new ModuleStoreIOError(`failed to create ${temporary}`, {cause: error}); + } + try { + await handle.writeFile(contents, 'utf8'); + await handle.sync(); + } catch (error) { + await handle.close(); + await fs.rm(temporary, {force: true}); + throw new ModuleStoreIOError(`failed to write ${temporary}`, {cause: error}); + } + await handle.close(); + try { + await renameWithRetry(temporary, target); + } catch (error) { + await fs.rm(temporary, {force: true}); + throw new ModuleStoreIOError(`failed to publish ${target}`, {cause: error}); + } + await fsyncDirectory(directory); +} diff --git a/fluxer_desktop/src/main/Autostart.ts b/fluxer_desktop/src/main/Autostart.ts index a30b575b8..ccda69749 100644 --- a/fluxer_desktop/src/main/Autostart.ts +++ b/fluxer_desktop/src/main/Autostart.ts @@ -14,12 +14,18 @@ import { WINDOWS_LEGACY_APP_USER_MODEL_IDS, } from '@electron/common/DesktopIdentity'; import {isPortableMode} from '@electron/common/UserDataPath'; +import {writeFileAtomically} from '@electron/main/AtomicFile'; import {AUTOSTART_LAUNCH_ARG} from '@electron/main/AutostartLaunch'; import {getLinuxPortalsMode} from '@electron/main/LaunchOptions'; -import {GENERATED_MARKER} from '@electron/main/LinuxDesktopEntry'; +import { + escapeDesktopValue as escapeDesktopEntry, + GENERATED_MARKER, + quoteExecArg as quoteDesktopExecArg, +} from '@electron/main/LinuxDesktopEntry'; import {getStableLinuxLaunchPath} from '@electron/main/LinuxLaunchPath'; import {isFlatpakRuntime} from '@electron/main/LinuxSandbox'; import {t} from '@electron/main/MainI18n'; +import {loadOptionalNativeAddon} from '@electron/main/NativeAddon'; import {app, ipcMain} from 'electron'; import log from 'electron-log'; @@ -108,39 +114,6 @@ interface LinuxPortalsBinding { loadError: Error | null; } -let cachedWinShellBinding: WinShellBinding | null | undefined; -let cachedLinuxPortalsBinding: LinuxPortalsBinding | null | undefined; - -function loadWinShell(): WinShellBinding | null { - if (!isWindows) return null; - if (cachedWinShellBinding !== undefined) return cachedWinShellBinding; - try { - cachedWinShellBinding = requireModule('@fluxer/win-shell') as WinShellBinding; - } catch (error) { - log.warn('[Autostart] Failed to load @fluxer/win-shell:', error); - cachedWinShellBinding = null; - } - if (cachedWinShellBinding?.loadError) { - log.warn('[Autostart] @fluxer/win-shell load error:', cachedWinShellBinding.loadError); - } - return cachedWinShellBinding; -} - -function loadLinuxPortals(): LinuxPortalsBinding | null { - if (!isLinux || getLinuxPortalsMode(process.argv) === 'off') return null; - if (cachedLinuxPortalsBinding !== undefined) return cachedLinuxPortalsBinding; - try { - cachedLinuxPortalsBinding = requireModule('@fluxer/linux-portals') as LinuxPortalsBinding; - } catch (error) { - log.warn('[Autostart] Failed to load @fluxer/linux-portals:', error); - cachedLinuxPortalsBinding = null; - } - if (cachedLinuxPortalsBinding?.loadError) { - log.warn('[Autostart] @fluxer/linux-portals load error:', cachedLinuxPortalsBinding.loadError); - } - return cachedLinuxPortalsBinding; -} - function getWindowsLoginItemPath(): string { if (!isWindows) { return process.execPath; @@ -296,14 +269,6 @@ function getLinuxDesktopFilePath(): string { return path.join(getLinuxAutostartDir(), LINUX_DESKTOP_FILE_BASENAME); } -function escapeDesktopEntry(value: string): string { - return value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/\t/g, '\\t').replace(/\r/g, '\\r'); -} - -function quoteDesktopExecArg(value: string): string { - return `"${value.replace(/(["\\$`])/g, '\\$1')}"`; -} - function tryParseDesktopEntry(contents: string): Map { const entry = new Map(); let inDesktopEntry = false; @@ -414,14 +379,11 @@ async function enableLinuxAutostart(): Promise { async function writeLinuxAutostartFile(contents: string): Promise { const dir = getLinuxAutostartDir(); const filePath = getLinuxDesktopFilePath(); - const tempPath = `${filePath}.${process.pid}.tmp`; try { await fs.promises.mkdir(dir, {recursive: true, mode: 0o700}); - await fs.promises.writeFile(tempPath, contents, {encoding: 'utf8', mode: 0o644}); - await fs.promises.rename(tempPath, filePath); + await writeFileAtomically(filePath, contents, 0o644); log.info('[Autostart] Wrote Linux autostart entry', {filePath}); } catch (error) { - await fs.promises.rm(tempPath, {force: true}).catch(() => {}); log.error('[Autostart] Failed to write Linux autostart entry:', error); throw error; } @@ -703,3 +665,20 @@ export function registerAutostartHandlers(): void { markInitialized(); }); } + +const loadWinShell = loadOptionalNativeAddon({ + platform: 'win32', + load: () => requireModule('@fluxer/win-shell'), + logger: log, + level: 'warn', + unavailableMessage: '[Autostart] Failed to load @fluxer/win-shell:', +}); + +const loadLinuxPortals = loadOptionalNativeAddon({ + platform: 'linux', + load: () => requireModule('@fluxer/linux-portals'), + logger: log, + level: 'warn', + unavailableMessage: '[Autostart] Failed to load @fluxer/linux-portals:', + disabled: () => getLinuxPortalsMode(process.argv) === 'off', +}); diff --git a/fluxer_desktop/src/main/Bootstrap.ts b/fluxer_desktop/src/main/Bootstrap.ts index 0378c0605..b73db639e 100644 --- a/fluxer_desktop/src/main/Bootstrap.ts +++ b/fluxer_desktop/src/main/Bootstrap.ts @@ -7,9 +7,7 @@ import {BUILD_CHANNEL} from '@electron/common/BuildChannel'; import {WINDOWS_APP_USER_MODEL_ID, WINDOWS_TOAST_ACTIVATOR_CLSID} from '@electron/common/DesktopIdentity'; import {type ModulePollPlan, nextModulePollDelay, resolveModulePollPlan} from '@electron/common/ModulePollPlan'; import { - decideModuleSystemDisableRequest, hasOfflineRenderer, - ModuleSystemDisableDecision, ModuleSystemLaunchDecision, readBundledRendererVersion, resolveModuleSystemLaunch, @@ -219,7 +217,7 @@ async function runModuleBootstrap(): Promise { setSecondInstanceSink, } = await import('@electron/main/ModuleBootHandoff'); const {armDesktopUpdate, publishDesktopUpdateCheck} = await import('@electron/main/DesktopUpdateGate'); - const {instanceTurnedModulesOff} = await import('@electron/main/InstanceModulePreference'); + const {createOnDemandModuleInstaller} = await import('@electron/main/ModuleOnDemand'); const {getModuleStoreRoot, ModuleStore} = await import('@electron/main/ModuleStore'); const {ModuleUpdater} = await import('@electron/main/ModuleUpdater'); @@ -267,26 +265,6 @@ async function runModuleBootstrap(): Promise { } armSecondInstanceForwarding(); - const instancePreference = decideModuleSystemDisableRequest( - instanceTurnedModulesOff(userDataConfig.base), - CAN_TURN_MODULE_SYSTEM_OFF, - ); - switch (instancePreference.kind) { - case ModuleSystemDisableDecision.NOT_REQUESTED: - break; - case ModuleSystemDisableDecision.IGNORED_WITHOUT_OFFLINE_RENDERER: - logger.warn( - 'Ignoring the instance desktop module preference, only a development build with a bundled renderer may turn the module system off', - ); - break; - case ModuleSystemDisableDecision.HONOURED: - logger.warn( - 'The instance turned the desktop module system off on an earlier boot, launching the offline renderer instead', - ); - await import(MAIN_PROCESS_ENTRY_URL); - return; - } - loadDesktopConfig(userDataConfig.base); applyPreReadyChromiumConfiguration(userDataConfig.channel, process.argv); diff --git a/fluxer_desktop/src/main/BootstrapEntry.test.mjs b/fluxer_desktop/src/main/BootstrapEntry.test.mjs index 495cc347c..6e6d1aa3c 100644 --- a/fluxer_desktop/src/main/BootstrapEntry.test.mjs +++ b/fluxer_desktop/src/main/BootstrapEntry.test.mjs @@ -40,46 +40,16 @@ describe('Bootstrap entry point', () => { const sites = source.split('\n').filter((line) => line.includes('import(MAIN_PROCESS_ENTRY_URL)')); assert.equal( sites.length, - 3, - 'a new MainApp import site must be justified here before it is added. The three are: the launch permit, the flag off branch, and the instance module preference, which only fires once decideModuleSystemDisableRequest has confirmed an offline renderer can take over.', + 2, + 'a new MainApp import site must be justified here before it is added. The two are: the launch permit and the flag off branch.', ); }); - test('the instance module preference can only be honoured through the offline renderer guard', () => { - assert.match( - source, - /const instancePreference = decideModuleSystemDisableRequest\(\n\t\tinstanceTurnedModulesOff\(userDataConfig\.base\),\n\t\tCAN_TURN_MODULE_SYSTEM_OFF,\n\t\);/, - 'A server side switch that could turn the module system off on a build with no renderer would brick the install, so it goes through the same guard the launch flag does.', - ); - assert.match(source, /case ModuleSystemDisableDecision\.HONOURED:\n\t\t\tlogger\.warn\(/); - assert.match( - source, - /const CAN_TURN_MODULE_SYSTEM_OFF = HAS_OFFLINE_RENDERER && BUILD_CHANNEL === 'development';/, - 'Every release shell bundles a renderer now, so the switch and the launch flag would otherwise skip updates, floors and on-demand assets on stable and canary.', - ); - }); - - test('the instance module preference is read from the module store root, never from the app store database', () => { - assert.doesNotMatch(source, /getDesktopAppStorage/); - assert.match(source, /instanceTurnedModulesOff\(userDataConfig\.base\)/); - }); - - test('the instance module preference is read once during the boot, so flipping it mid session only reaches the next boot', () => { - assert.equal( - source.split('instanceTurnedModulesOff(').length - 1, - 1, - 'instanceTurnedModulesOff re-reads the file off disk on every call, so a second read from the module poll or from any later re-check would tear the module system out from under a running session. The switch is a boot decision.', - ); - const bootstrap = source.slice(source.indexOf('async function runModuleBootstrap()')); - assert.ok(bootstrap.indexOf('instanceTurnedModulesOff(') < bootstrap.indexOf('await app.whenReady()')); - }); - test('an ignored disable override is logged rather than silently swallowed', () => { assert.match( source, /if \(MODULE_SYSTEM_LAUNCH\.kind === ModuleSystemLaunchDecision\.ENABLED_WITH_IGNORED_DISABLE_REQUEST\) \{/, ); - assert.match(source, /case ModuleSystemDisableDecision\.IGNORED_WITHOUT_OFFLINE_RENDERER:\n\t\t\tlogger\.warn\(/); }); test('the deliberate floor bypass on the rollback path reaches the log', () => { diff --git a/fluxer_desktop/src/main/BrowserHandoff.ts b/fluxer_desktop/src/main/BrowserHandoff.ts index 9f874c346..bffa09db8 100644 --- a/fluxer_desktop/src/main/BrowserHandoff.ts +++ b/fluxer_desktop/src/main/BrowserHandoff.ts @@ -1,3 +1,4 @@ +import {isRecord} from '@fluxer/record_utils/src/RecordGuards'; // SPDX-License-Identifier: AGPL-3.0-or-later import type {Buffer} from 'node:buffer'; @@ -103,10 +104,6 @@ class DesktopHandoffResponseSchemaError extends Error { } } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function boundedString(value: unknown, maxLength: number): string | null { if (typeof value !== 'string' || value.length === 0 || value.length > maxLength) { return null; diff --git a/fluxer_desktop/src/main/ChromiumRuntime.test.mjs b/fluxer_desktop/src/main/ChromiumRuntime.test.mjs deleted file mode 100644 index 7286cab58..000000000 --- a/fluxer_desktop/src/main/ChromiumRuntime.test.mjs +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import assert from 'node:assert/strict'; -import {readFileSync} from 'node:fs'; -import {createRequire} from 'node:module'; -import {describe, test} from 'node:test'; -import {fileURLToPath} from 'node:url'; -import vm from 'node:vm'; - -const require = createRequire(import.meta.url); -const esbuild = require('esbuild'); - -const sourcePath = fileURLToPath(new URL('./ChromiumRuntime.ts', import.meta.url)); -const source = readFileSync(sourcePath, 'utf8'); -const transformedSource = esbuild.transformSync(source, { - loader: 'ts', - format: 'cjs', - platform: 'node', - target: 'node20', -}).code; - -function loadChromiumRuntime(platform = 'win32') { - const appendedSwitches = []; - const app = { - getVersion: () => '0.0.0-test', - getGPUInfo: async () => ({}), - commandLine: { - appendSwitch(name, value) { - appendedSwitches.push({name, value}); - }, - getSwitchValue: () => '', - }, - }; - const log = {debug() {}, info() {}, warn() {}}; - - function requireStub(specifier) { - if (specifier === 'electron') return {app}; - if (specifier === 'electron-log') return log; - return require(specifier); - } - - const module = {exports: {}}; - const context = vm.createContext({ - require: requireStub, - module, - exports: module.exports, - process: { - ...process, - platform, - execPath: '/tmp/fluxer-test', - resourcesPath: '/tmp/fluxer-resources', - versions: {...process.versions}, - }, - console, - }); - vm.runInContext(transformedSource, context, {filename: sourcePath}); - return {appendedSwitches, module: module.exports}; -} - -describe('ChromiumRuntime configured switch allowlist', () => { - test('rejects settings-supplied switches that would disable or shrink the HTTP cache', () => { - const {appendedSwitches, module} = loadChromiumRuntime('win32'); - - module.appendConfiguredChromiumSwitches([ - 'disable-http-cache', - 'disk-cache-size', - 'disk-cache-dir', - 'disable-gpu-shader-disk-cache', - 'disable_metal', - ]); - - assert.deepEqual(appendedSwitches, [{name: 'disable_metal', value: undefined}]); - }); -}); diff --git a/fluxer_desktop/src/main/ChromiumRuntime.ts b/fluxer_desktop/src/main/ChromiumRuntime.ts index c0054a938..7bdd7ea56 100644 --- a/fluxer_desktop/src/main/ChromiumRuntime.ts +++ b/fluxer_desktop/src/main/ChromiumRuntime.ts @@ -1,3 +1,4 @@ +import {isObjectLike as isRecord} from '@fluxer/record_utils/src/RecordGuards'; // SPDX-License-Identifier: AGPL-3.0-or-later import fs from 'node:fs'; @@ -147,10 +148,6 @@ function resolveUserDataChild(userDataPath: string, relativePath: string): strin return null; } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object'; -} - function splitChromiumFeatureList(value: string): Array { if (!value) return []; return value diff --git a/fluxer_desktop/src/main/DeepLinks.ts b/fluxer_desktop/src/main/DeepLinks.ts index f3a9906f6..fa9016bd1 100644 --- a/fluxer_desktop/src/main/DeepLinks.ts +++ b/fluxer_desktop/src/main/DeepLinks.ts @@ -4,7 +4,6 @@ import {APP_PROTOCOL} from '@electron/common/Constants'; import {updateFromCommandLine} from '@electron/main/DesktopUpdatePrompt'; import {parseJumpListTaskFromArgv} from '@electron/main/JumpList'; import {isDesktopUpdateRequested} from '@electron/main/LaunchOptions'; -import {recordRecentDeepLink} from '@electron/main/RecentDocuments'; import {getMainWindow, isMainWindowTakenOver, onMainWindowTakeoverEnded, showWindow} from '@electron/main/Window'; import {app, ipcMain} from 'electron'; import log from 'electron-log'; @@ -240,3 +239,12 @@ export function handleSecondInstance(argv: Array): void { export function consumeInitialJumpListTask(): 'open-settings' | 'new-dm' | null { return parseJumpListTaskFromArgv(process.argv); } + +function recordRecentDeepLink(url: string): void { + if (process.platform !== 'win32' && process.platform !== 'darwin') return; + try { + app.addRecentDocument(url); + } catch (error) { + log.debug('addRecentDocument failed', {error}); + } +} diff --git a/fluxer_desktop/src/main/DesktopAppStorage.ts b/fluxer_desktop/src/main/DesktopAppStorage.ts index b05016fcf..466e7aea1 100644 --- a/fluxer_desktop/src/main/DesktopAppStorage.ts +++ b/fluxer_desktop/src/main/DesktopAppStorage.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; +import {errorMessage as describeError} from '@electron/common/ErrorMessage'; import type {AppStoreBoundary, AppStoreBoundaryFactory} from '@electron/main/AppStoreNativeBoundary'; import {desktopAppStoreStateFile} from '@electron/main/DesktopAppStoreHealth'; import type { @@ -102,10 +103,6 @@ function byteLength(value: string): number { return Buffer.byteLength(value, 'utf8'); } -function describeError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function requireString(value: unknown, context: string): string { if (typeof value !== 'string') { throw new DesktopAppStoreRequestError(context, 'must be a string'); diff --git a/fluxer_desktop/src/main/DesktopDebugInfo.test.mjs b/fluxer_desktop/src/main/DesktopDebugInfo.test.mjs index 228d007a9..798281f5c 100644 --- a/fluxer_desktop/src/main/DesktopDebugInfo.test.mjs +++ b/fluxer_desktop/src/main/DesktopDebugInfo.test.mjs @@ -6,6 +6,7 @@ import {createRequire} from 'node:module'; import {describe, test} from 'node:test'; import {fileURLToPath} from 'node:url'; import vm from 'node:vm'; +import {loadTsModule} from './fixtures/TsModuleLoader.mjs'; const require = createRequire(import.meta.url); const esbuild = require('esbuild'); @@ -53,6 +54,9 @@ function loadDesktopDebugInfo(options = {}) { if (specifier === '@electron/main/PlatformInfo') { return {getDesktopInfo: async () => ({})}; } + if (specifier === '@electron/main/LaunchOptions' || specifier === '@fluxer/desktop_ipc/src/DesktopLabels') { + return loadTsModule(specifier); + } return require(specifier); } diff --git a/fluxer_desktop/src/main/DesktopDebugInfo.ts b/fluxer_desktop/src/main/DesktopDebugInfo.ts index ee94f39fa..c090982fa 100644 --- a/fluxer_desktop/src/main/DesktopDebugInfo.ts +++ b/fluxer_desktop/src/main/DesktopDebugInfo.ts @@ -12,7 +12,13 @@ import { readDesktopAccountStoreTelemetry, } from '@electron/main/DesktopAccountStoreTelemetry'; import {type DesktopAppStoreState, readDesktopAppStoreState} from '@electron/main/DesktopAppStoreHealth'; +import {getArgValue, hasFlag, isSafeModeLaunch} from '@electron/main/LaunchOptions'; import {getDesktopInfo} from '@electron/main/PlatformInfo'; +import { + formatDesktopOsName, + formatReleaseChannelLabel, + normalizeArchitectureValue, +} from '@fluxer/desktop_ipc/src/DesktopLabels'; import {app} from 'electron'; import log from 'electron-log'; @@ -20,7 +26,6 @@ const DEBUG_INFO_ARGS = new Set(['--fluxer-debug-info', '--fluxer-client-info', const DISABLE_HARDWARE_ACCELERATION_ARGS = new Set(['--fluxer-disable-gpu', '--fluxer-disable-hardware-acceleration']); const OPEN_DEVTOOLS_ARGS = new Set(['--fluxer-devtools', '--fluxer-open-devtools']); const RESET_WINDOW_STATE_ARGS = new Set(['--fluxer-reset-window-state']); -const SAFE_MODE_ARGS = new Set(['--fluxer-safe-mode']); const RENDERER_CONSOLE_LOG_ARGS = new Set(['--fluxer-log-renderer-console']); const NET_LOG_ARGS = new Set(['--fluxer-net-log']); @@ -111,30 +116,6 @@ export function shouldForwardRendererConsoleToMainLog(argv: ReadonlyArray, flags: ReadonlySet): boolean { - return argv.some((arg) => flags.has(arg) || [...flags].some((flag) => arg.startsWith(`${flag}=`))); -} - -function isSafeModeLaunch(argv: ReadonlyArray): boolean { - return hasFlag(argv, SAFE_MODE_ARGS); -} - -function getArgValue(argv: ReadonlyArray, names: ReadonlySet): string | null { - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - for (const name of names) { - if (arg === name) { - const next = argv[index + 1]; - return next && !next.startsWith('--') ? next : ''; - } - if (arg.startsWith(`${name}=`)) { - return arg.slice(name.length + 1); - } - } - } - return null; -} - export function getLaunchNetLogPath(userDataPath: string, argv: ReadonlyArray): string | null { const value = getArgValue(argv, NET_LOG_ARGS); if (value === null) { @@ -178,16 +159,6 @@ function sanitizeLaunchArgs(argv: ReadonlyArray): Array { return argv.map(sanitizeLaunchArg); } -function normalizeArchitectureValue(value: string | null | undefined): string | undefined { - if (!value) return undefined; - const trimmed = value.trim(); - if (/\barm64\b|\baarch64\b|\barmv8\b|\barm64e\b/i.test(trimmed)) return 'arm64'; - if (/\barm\b|\barmv7\b|\barmv6\b/i.test(trimmed)) return 'arm'; - if (/\bx86_64\b|\bx64\b|\bamd64\b|\bwin64\b|\bwow64\b/i.test(trimmed)) return 'x64'; - if (/\bx86\b|\bi[3-6]86\b/i.test(trimmed)) return 'x86'; - return trimmed || undefined; -} - function formatSystemArchitecture(desktopInfo: DesktopInfo): string { return ( normalizeArchitectureValue(desktopInfo.hardwareArch || desktopInfo.arch) ?? @@ -196,37 +167,6 @@ function formatSystemArchitecture(desktopInfo: DesktopInfo): string { ); } -function getWindowsVersionName(osVersion: string): string { - const parts = osVersion.split('.'); - const majorVersion = parseInt(parts[0], 10); - const buildNumber = parseInt(parts[2], 10); - if (majorVersion === 10) { - if (buildNumber >= 22000) { - return 'Windows 11'; - } - return 'Windows 10'; - } - return 'Windows'; -} - -function formatOsName(desktopInfo: DesktopInfo): string { - switch (desktopInfo.os) { - case 'darwin': - return 'macOS'; - case 'win32': - return desktopInfo.osVersion ? getWindowsVersionName(desktopInfo.osVersion) : 'Windows'; - case 'linux': - return 'Linux'; - default: - return desktopInfo.os; - } -} - -function formatReleaseChannelLabel(value: string): string { - const normalized = value.trim().toLowerCase(); - return normalized.charAt(0).toUpperCase() + normalized.slice(1); -} - function getBuildString(desktopInfo: DesktopInfo): string { const buildVersion = process.env.PUBLIC_BUILD_VERSION || process.env.BUILD_VERSION || desktopInfo.version || 'dev'; const releaseChannel = formatReleaseChannelLabel( @@ -246,7 +186,7 @@ function safeGetLocale(): string { function formatDesktopClientInfo(desktopInfo: DesktopInfo, options: DesktopClientInfoOptions = {}): string { const osVersion = desktopInfo.systemVersion || desktopInfo.osVersion; - const osDescription = `${[formatOsName(desktopInfo), osVersion].filter(Boolean).join(' ')} (${formatSystemArchitecture( + const osDescription = `${[formatDesktopOsName(desktopInfo), osVersion].filter(Boolean).join(' ')} (${formatSystemArchitecture( desktopInfo, )})`; const parts = [ diff --git a/fluxer_desktop/src/main/DesktopOutboundHTTP.ts b/fluxer_desktop/src/main/DesktopOutboundHTTP.ts index 245de0424..dded05400 100644 --- a/fluxer_desktop/src/main/DesktopOutboundHTTP.ts +++ b/fluxer_desktop/src/main/DesktopOutboundHTTP.ts @@ -57,8 +57,6 @@ const DESKTOP_OUTBOUND_HTTP_CAPACITY_MESSAGE = 'Too many downloads are already i const DesktopOutboundBlockReason = Object.freeze({ INSECURE_TRANSPORT: 'insecure-transport', - INVALID_TARGET: 'invalid-target', - LOOKUP_HOSTNAME_MISMATCH: 'lookup-hostname-mismatch', NON_PUBLIC_ADDRESS: 'non-public-address', NO_USABLE_ADDRESS: 'no-usable-address', RESOLUTION_FAILED: 'resolution-failed', diff --git a/fluxer_desktop/src/main/DesktopRuntimeTransactionRegistry.test.mjs b/fluxer_desktop/src/main/DesktopRuntimeTransactionRegistry.test.mjs index 95bf7728f..68f11f536 100644 --- a/fluxer_desktop/src/main/DesktopRuntimeTransactionRegistry.test.mjs +++ b/fluxer_desktop/src/main/DesktopRuntimeTransactionRegistry.test.mjs @@ -59,7 +59,6 @@ function createPlan(instanceKey) { giftEndpoint: null, }, selfHosted: true, - desktopModulesEnabled: null, }; } diff --git a/fluxer_desktop/src/main/DesktopUpdatePrompt.ts b/fluxer_desktop/src/main/DesktopUpdatePrompt.ts index dd0f3db6b..499d6349e 100644 --- a/fluxer_desktop/src/main/DesktopUpdatePrompt.ts +++ b/fluxer_desktop/src/main/DesktopUpdatePrompt.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {DESKTOP_APP_NAME} from '@electron/common/DesktopIdentity'; +import {errorMessage as describeError} from '@electron/common/ErrorMessage'; import {createChildLogger} from '@electron/common/Logger'; import { checkDesktopUpdateNow, @@ -36,10 +37,6 @@ async function ask(options: MessageBoxOptions): Promise { return result.response; } -function describeError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - async function runUpdateCheck(): Promise { for (;;) { let manualVersion: string | null = null; diff --git a/fluxer_desktop/src/main/DesktopUpdateRun.ts b/fluxer_desktop/src/main/DesktopUpdateRun.ts index 75fccffd5..4b1b3109a 100644 --- a/fluxer_desktop/src/main/DesktopUpdateRun.ts +++ b/fluxer_desktop/src/main/DesktopUpdateRun.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {errorMessage as describeFailure} from '@electron/common/ErrorMessage'; + import type {DesktopUpdateCheck} from '@electron/main/DesktopUpdateGate'; import type {ModuleLaunchAttempt} from '@electron/main/ModuleStore'; @@ -46,10 +48,6 @@ interface FilteredProbe { readonly shellLatestVersion: string; } -function describeFailure(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - export class DesktopUpdateRun { private readonly options: DesktopUpdateRunOptions; private readonly now: () => number; diff --git a/fluxer_desktop/src/main/GlobalShortcutKeys.ts b/fluxer_desktop/src/main/GlobalShortcutKeys.ts index fcdd6f1d1..67f5d617a 100644 --- a/fluxer_desktop/src/main/GlobalShortcutKeys.ts +++ b/fluxer_desktop/src/main/GlobalShortcutKeys.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {PUNCTUATION_KEY_CODES} from '@fluxer/desktop_ipc/src/GlobalShortcutKeyNames'; + const EVDEV_KEY_CODES: ReadonlyMap = new Map([ [0x001, 'Escape'], [0x002, 'Digit1'], @@ -557,32 +559,6 @@ const MACOS_KEY_CODES: ReadonlyMap = new Map([ const X11_KEYCODE_OFFSET = 8; const WINDOWS_EXTENDED_SCAN_PREFIX = 0xe000; -const PUNCTUATION_KEY_CODES: Readonly> = { - ' ': 'Space', - '-': 'Minus', - _: 'Minus', - '=': 'Equal', - '+': 'Equal', - ',': 'Comma', - '<': 'Comma', - '.': 'Period', - '>': 'Period', - ';': 'Semicolon', - ':': 'Semicolon', - "'": 'Quote', - '"': 'Quote', - '/': 'Slash', - '?': 'Slash', - '\\': 'Backslash', - '|': 'Backslash', - '[': 'BracketLeft', - '{': 'BracketLeft', - ']': 'BracketRight', - '}': 'BracketRight', - '`': 'Backquote', - '~': 'Backquote', -}; - const NAMED_KEY_CODES: Readonly> = { Spacebar: 'Space', Esc: 'Escape', @@ -643,25 +619,6 @@ export function macosKeycodeToDomCode(keycode: number): string | null { return MACOS_KEY_CODES.get(keycode) ?? null; } -function isPrintableShortcutKey(key: string | null | undefined): key is string { - if (!key) return false; - if (key === 'Dead' || key === 'Unidentified' || key === 'Process') return false; - return key.length === 1; -} - -export function shouldPreferLayoutKey(combo: {key: string; code?: string}): boolean { - if (!isPrintableShortcutKey(combo.key)) return false; - return !(combo.code && /^Numpad/.test(combo.code)); -} - -export function hookKeyNameForLayoutKey(key: string): string | null { - if (key.length !== 1) return null; - const punctuation = PUNCTUATION_KEY_CODES[key]; - if (punctuation) return punctuation; - const upper = key.toUpperCase(); - return /^[A-Z0-9]$/.test(upper) ? upper : null; -} - export function hookKeyNameToDomCode(name: string): string | null { if (/^[A-Z]$/.test(name)) return `Key${name}`; if (/^[0-9]$/.test(name)) return `Digit${name}`; diff --git a/fluxer_desktop/src/main/GlobalShortcutMatcher.test.mjs b/fluxer_desktop/src/main/GlobalShortcutMatcher.test.mjs index 3fe7084b5..b9c80b5af 100644 --- a/fluxer_desktop/src/main/GlobalShortcutMatcher.test.mjs +++ b/fluxer_desktop/src/main/GlobalShortcutMatcher.test.mjs @@ -35,120 +35,6 @@ function phases(transitions) { return transitions.map((transition) => `${transition.phase}:${transition.sourceId}`); } -describe('GlobalShortcutMatcher key identity', () => { - test('a physical backend never confuses a keysym with a binding code', () => { - const matcher = matcherWith([ - 'default:voice_toggle_mute', - 'voice_toggle_mute', - {key: 'm', code: 'KeyM', ctrl: true, shift: true}, - ]); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'Digit2', {raw: 3, ctrl: true, shift: true}), true)), []); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'KeyM', {raw: 50, ctrl: true, shift: true}), true)), [ - 'press:default:voice_toggle_mute', - ]); - }); - - test('a layout-aware backend matches printable keys by layout name', () => { - const matcher = matcherWith([ - 'default:voice_toggle_mute', - 'voice_toggle_mute', - {key: 'm', code: 'Semicolon', ctrl: true}, - ]); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'KeyM', {key: 'Comma', raw: 58, ctrl: true}), true)), []); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'KeyL', {key: 'L', raw: 59, ctrl: true}), true)), []); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'Semicolon', {key: 'M', raw: 47, ctrl: true}), true)), [ - 'press:default:voice_toggle_mute', - ]); - }); - - test('a letter from another layout group falls back to the physical code', () => { - const matcher = matcherWith(['custom:a', 'voice_toggle_mute', {key: 'q', code: 'KeyQ', ctrl: true}]); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'KeyQ', {key: null, raw: 24, ctrl: true}), true)), [ - 'press:custom:a', - ]); - }); - - test('a letter whose hook name is not a single character falls back to the physical code', () => { - const matcher = matcherWith(['custom:a', 'voice_toggle_mute', {key: 'q', code: 'KeyQ', ctrl: true}]); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'KeyQ', {key: 'Oem7', raw: 24, ctrl: true}), true)), [ - 'press:custom:a', - ]); - }); - - test('punctuation matches by physical code whatever the hook calls the key', () => { - const matcher = matcherWith( - ['custom:less', 'voice_toggle_mute', {key: '<', code: 'IntlBackslash'}], - ['custom:semicolon', 'voice_toggle_deafen', {key: ';', code: 'Comma', shift: true}], - ); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'Comma', {key: 'Comma', raw: 0x33}), true)), []); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'IntlBackslash', {key: 'Comma', raw: 0x56}), true)), [ - 'press:custom:less', - ]); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'Comma', {key: 'Comma', raw: 0x33, shift: true}), true)), [ - 'press:custom:semicolon', - ]); - }); - - test('a letter binding without a code ignores the physical key that types punctuation', () => { - const matcher = matcherWith([ - 'default:voice_toggle_mute', - 'voice_toggle_mute', - {key: 'm', ctrl: true, shift: true}, - ]); - assert.deepEqual( - phases(matcher.handleKey(key('keydown', 'KeyM', {key: 'Comma', raw: 50, ctrl: true, shift: true}), true)), - [], - ); - assert.deepEqual( - phases(matcher.handleKey(key('keydown', 'Semicolon', {key: 'M', raw: 39, ctrl: true, shift: true}), true)), - ['press:default:voice_toggle_mute'], - ); - }); - - test('a key-only binding falls back to the US position on physical backends', () => { - const matcher = matcherWith([ - 'default:voice_toggle_deafen', - 'voice_toggle_deafen', - {key: 'd', ctrl: true, shift: true}, - ]); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'KeyD', {raw: 32, ctrl: true, shift: true}), true)), [ - 'press:default:voice_toggle_deafen', - ]); - }); - - test('a named key binding that uses its hook name as the layout key still matches', () => { - const matcher = new GlobalShortcutMatcher(); - matcher.setBindings([ - { - sourceId: 'key:voice_push_to_talk::F8:F8', - action: 'key:voice_push_to_talk::F8:F8', - trigger: {kind: 'key', layoutKey: 'F8', code: 'F8'}, - modifiers: {ctrl: false, alt: false, shift: false, meta: false}, - }, - ]); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'F8', {key: 'F8', raw: 74}), true)), [ - 'press:key:voice_push_to_talk::F8:F8', - ]); - assert.deepEqual(phases(matcher.handleKey(key('keyup', 'F8', {key: 'F8', raw: 74}), true)), [ - 'release:key:voice_push_to_talk::F8:F8', - ]); - }); - - test('non-printable and numpad keys match by code even on layout-aware backends', () => { - const matcher = matcherWith( - ['custom:a', 'voice_push_to_talk', {key: 'F13', code: 'F13'}], - ['custom:b', 'voice_disconnect', {key: '1', code: 'Numpad1'}], - ); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'F13', {key: 'F13', raw: 191}), true)), [ - 'press:custom:a', - ]); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'Digit1', {key: '1', raw: 10}), true)), []); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'Numpad1', {key: 'Numpad1', raw: 87}), true)), [ - 'press:custom:b', - ]); - }); -}); - describe('GlobalShortcutMatcher modifiers', () => { test('bindings with modifiers need an exact modifier match', () => { const matcher = matcherWith(['custom:a', 'voice_toggle_mute', {key: 'F5', code: 'F5', ctrl: true}]); @@ -295,34 +181,6 @@ describe('GlobalShortcutMatcher modifier-only combos', () => { }); }); -describe('GlobalShortcutMatcher layout modifiers', () => { - test('an X11 key the layout turns into Ctrl holds a Ctrl binding', () => { - const matcher = matcherWith([ - 'custom:a', - 'voice_push_to_talk', - {key: 'Control', code: 'ControlLeft', ctrl: true, modifierOnly: true}, - ]); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'CapsLock', {key: 'ControlLeft', raw: 66}), true)), [ - 'press:custom:a', - ]); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'KeyC', {key: 'C', raw: 54, ctrl: true}), true)), []); - assert.deepEqual( - phases(matcher.handleKey(key('keyup', 'CapsLock', {key: 'ControlLeft', raw: 66, ctrl: true}), true)), - ['release:custom:a'], - ); - }); - - test('a physical Ctrl key the layout turns into Caps Lock does not hold a Ctrl binding', () => { - const matcher = matcherWith([ - 'custom:a', - 'voice_push_to_talk', - {key: 'Control', code: 'ControlLeft', ctrl: true, modifierOnly: true}, - ]); - assert.deepEqual(phases(matcher.handleKey(key('keydown', 'ControlLeft', {key: 'CapsLock', raw: 37}), true)), []); - assert.deepEqual(phases(matcher.handleKey(key('keyup', 'ControlLeft', {key: 'CapsLock', raw: 37}), true)), []); - }); -}); - describe('GlobalShortcutMatcher modifier-only kinds', () => { test('the trigger key counts as a required modifier alongside the flags', () => { const binding = bindingFromCombo( diff --git a/fluxer_desktop/src/main/GlobalShortcutMatcher.ts b/fluxer_desktop/src/main/GlobalShortcutMatcher.ts index ac5e2ec64..197c62e72 100644 --- a/fluxer_desktop/src/main/GlobalShortcutMatcher.ts +++ b/fluxer_desktop/src/main/GlobalShortcutMatcher.ts @@ -1,14 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { - hookKeyNameForLayoutKey, hookKeyNameToDomCode, MODIFIER_KEY_CODES, type ModifierKind, modifierKindForCode, - shouldPreferLayoutKey, usLayoutKeyToDomCode, } from '@electron/main/GlobalShortcutKeys'; +import {hookKeyNameForLayoutKey, shouldPreferLayoutKey} from '@fluxer/desktop_ipc/src/GlobalShortcutKeyNames'; interface ShortcutModifiers { ctrl: boolean; diff --git a/fluxer_desktop/src/main/GlobalShortcutsEngine.test.mjs b/fluxer_desktop/src/main/GlobalShortcutsEngine.test.mjs index ed4181656..509918083 100644 --- a/fluxer_desktop/src/main/GlobalShortcutsEngine.test.mjs +++ b/fluxer_desktop/src/main/GlobalShortcutsEngine.test.mjs @@ -598,17 +598,6 @@ describe('GlobalShortcutsNative translation', () => { return {type, ctrlKey: false, altKey: false, shiftKey: false, metaKey: false, ...overrides}; } - test('an injected Windows key without a scan code matches by virtual key', () => { - const matcher = new GlobalShortcutMatcher(); - matcher.setBindings([bindingFromCombo('custom:a', 'voice_push_to_talk', combo({key: 'F13', code: 'F13'}))]); - const identity = {keycode: 0x7c, keyName: 'F13', scanCode: 0, extended: false}; - const down = native.translateNativeHookEvent('windows', nativeKey('keydown', identity)); - const up = native.translateNativeHookEvent('windows', nativeKey('keyup', identity)); - assert.equal(down.code, 'F13'); - assert.equal(matcher.handleKey(down, true).length, 1); - assert.equal(matcher.handleKey(up, true).length, 1); - }); - test('an old renderer media select registration matches the media select key', () => { const binding = legacy.legacyRegistrationToBinding({id: 'key:media', keyName: 'LaunchMediaPlayer'}); assert.deepEqual(binding.trigger, {kind: 'key', layoutKey: 'LaunchMediaPlayer', code: 'MediaSelect'}); @@ -678,89 +667,6 @@ describe('GlobalShortcutsNative translation', () => { {sourceId: 'custom:a', action: 'voice_push_to_talk', phase: 'release'}, ]); }); - - test('Windows unicode packets report the typed character, and a space packet is the Space key', () => { - const matcher = new GlobalShortcutMatcher(); - matcher.setBindings([ - bindingFromCombo('custom:f13', 'voice_push_to_talk', combo({key: 'F13', code: 'F13'})), - bindingFromCombo('custom:space', 'voice_toggle_mute', combo({key: ' ', code: 'Space'})), - bindingFromCombo( - 'custom:shift', - 'voice_push_to_mute', - combo({key: 'Shift', code: 'ShiftRight', modifierOnly: true}), - ), - ]); - for (const character of ['d', '9', '6', '8']) { - const identity = {keycode: 0xe7, keyName: 'Key231', scanCode: character.charCodeAt(0), extended: false}; - const down = native.translateNativeHookEvent('windows', nativeKey('keydown', identity)); - assert.equal(down.code, null); - assert.equal(down.key, character.toUpperCase()); - assert.deepEqual(matcher.handleKey(down, true), []); - assert.deepEqual( - matcher.handleKey(native.translateNativeHookEvent('windows', nativeKey('keyup', identity)), true), - [], - ); - } - const letter = new GlobalShortcutMatcher(); - letter.setBindings([bindingFromCombo('custom:d', 'voice_toggle_mute', combo({key: 'd', code: 'KeyD'}))]); - const packetD = {keycode: 0xe7, keyName: 'Key231', scanCode: 0x64, extended: false}; - const packetE = {keycode: 0xe7, keyName: 'Key231', scanCode: 0x65, extended: false}; - assert.equal( - letter.handleKey(native.translateNativeHookEvent('windows', nativeKey('keydown', packetD)), true).length, - 1, - ); - assert.deepEqual( - letter.handleKey(native.translateNativeHookEvent('windows', nativeKey('keyup', packetE)), true), - [], - ); - assert.equal( - letter.handleKey(native.translateNativeHookEvent('windows', nativeKey('keyup', packetD)), true).length, - 1, - ); - const packetSpace = {keycode: 0xe7, keyName: 'Space', scanCode: 0x20, extended: false}; - const spaceDown = native.translateNativeHookEvent('windows', nativeKey('keydown', packetSpace)); - assert.equal(spaceDown.code, 'Space'); - assert.deepEqual( - matcher.handleKey(spaceDown, true).map((transition) => `${transition.phase}:${transition.sourceId}`), - ['press:custom:space'], - ); - assert.deepEqual( - matcher - .handleKey(native.translateNativeHookEvent('windows', nativeKey('keyup', packetSpace)), true) - .map((transition) => `${transition.phase}:${transition.sourceId}`), - ['release:custom:space'], - ); - }); - - test('injected Windows keys without a scan code resolve every virtual key the old names covered', () => { - const expected = [ - [0x0c, 'Numpad5'], - [0x15, 'KanaMode'], - [0x1c, 'Convert'], - [0x1d, 'NonConvert'], - [0x5f, 'Sleep'], - [0x6c, 'NumpadComma'], - [0x92, 'NumpadEqual'], - [0xa6, 'BrowserBack'], - [0xa7, 'BrowserForward'], - [0xa8, 'BrowserRefresh'], - [0xa9, 'BrowserStop'], - [0xaa, 'BrowserSearch'], - [0xab, 'BrowserFavorites'], - [0xac, 'BrowserHome'], - [0xb4, 'LaunchMail'], - [0xb5, 'MediaSelect'], - [0xb6, 'LaunchApp1'], - [0xb7, 'LaunchApp2'], - [0x87, 'F24'], - [0xde, 'Quote'], - [0xe2, 'IntlBackslash'], - ]; - for (const [vk, code] of expected) { - const identity = {keycode: vk, keyName: 'x', scanCode: 0, extended: false}; - assert.equal(native.translateNativeHookEvent('windows', nativeKey('keydown', identity)).code, code, code); - } - }); }); function loadNativeWith(modules) { @@ -1050,7 +956,7 @@ describe('LegacyGlobalKeyHookAdapter', () => { assert.equal(harness.engine.getStatus().backend, 'evdev'); }); - test('legacy evdev status never offers a prompt and grant is unsupported', () => { + test('legacy evdev status never offers a prompt', () => { assert.deepEqual(legacy.buildLegacyLinuxEvdevStatus({platform: 'linux', sandboxed: true, username: 'u'}), { supported: true, hasAccess: true, @@ -1065,9 +971,5 @@ describe('LegacyGlobalKeyHookAdapter', () => { legacy.buildLegacyLinuxEvdevStatus({platform: 'darwin', sandboxed: false, username: null}).hasAccess, true, ); - assert.deepEqual( - {...legacy.LEGACY_LINUX_EVDEV_GRANT_RESULT}, - {success: false, needsRelogin: false, error: 'unsupported'}, - ); }); }); diff --git a/fluxer_desktop/src/main/GlobalShortcutsEngine.ts b/fluxer_desktop/src/main/GlobalShortcutsEngine.ts index 520ee234c..060545cbc 100644 --- a/fluxer_desktop/src/main/GlobalShortcutsEngine.ts +++ b/fluxer_desktop/src/main/GlobalShortcutsEngine.ts @@ -1,3 +1,4 @@ +import {isRecord} from '@fluxer/record_utils/src/RecordGuards'; // SPDX-License-Identifier: AGPL-3.0-or-later import { @@ -118,10 +119,6 @@ const MAX_MOUSE_BUTTON = 31; const INPUT_DEVICE_REPROBE_DELAY_MS = 500; export const GLOBAL_SHORTCUT_CAPTURE_TIMEOUT_MS = 60000; -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - function sanitizeBoundedString(value: unknown, maxLength: number): string | null { if (typeof value !== 'string') return null; if (value.length === 0 || value.length > maxLength) return null; diff --git a/fluxer_desktop/src/main/InstanceModulePreference.test.mjs b/fluxer_desktop/src/main/InstanceModulePreference.test.mjs deleted file mode 100644 index c72b7e4de..000000000 --- a/fluxer_desktop/src/main/InstanceModulePreference.test.mjs +++ /dev/null @@ -1,123 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import assert from 'node:assert/strict'; -import {mkdirSync, mkdtempSync, readFileSync, writeFileSync} from 'node:fs'; -import {rm} from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import {after, describe, test} from 'node:test'; -import {installElectronStub} from './LocalAppTestSupport.test.mjs'; - -installElectronStub(); - -const { - INSTANCE_MODULE_PREFERENCE_FIELD, - getInstanceModulePreferencePath, - instanceTurnedModulesOff, - recordInstanceModulePreference, -} = await import('./InstanceModulePreference.ts'); -const {decideModuleSystemDisableRequest, resolveModuleSystemLaunch} = await import('@electron/common/ModuleSystem'); -const {localAppRuntimePlanFromDiscovery} = await import('./LocalAppRuntimePlans.ts'); -const {DEPLOYED_OFFICIAL_DOCUMENT} = await import( - '../../../packages/instance_bootstrap/src/__tests__/DiscoveryFixtures.ts' -); - -const temporaryRoots = []; - -after(async () => { - for (const root of temporaryRoots) { - await rm(root, {recursive: true, force: true}); - } -}); - -function createUserData() { - const root = mkdtempSync(path.join(os.tmpdir(), 'module-kill-switch-')); - temporaryRoots.push(root); - return root; -} - -function writeRawSwitch(userData, body) { - const target = getInstanceModulePreferencePath(userData); - mkdirSync(path.dirname(target), {recursive: true}); - writeFileSync(target, body); - return target; -} - -function planWithFeature(value) { - const features = {...DEPLOYED_OFFICIAL_DOCUMENT.features}; - if (value === undefined) { - delete features.desktop_modules_enabled; - } else { - features.desktop_modules_enabled = value; - } - return localAppRuntimePlanFromDiscovery({...DEPLOYED_OFFICIAL_DOCUMENT, features}); -} - -describe('the instance desktop module system switch', () => { - test('an install that never heard from an instance leaves the module system on', () => { - assert.equal(instanceTurnedModulesOff(createUserData()), false); - }); - - test('an instance that turned modules off engages the switch', async () => { - const userData = createUserData(); - await recordInstanceModulePreference(userData, false); - assert.equal(instanceTurnedModulesOff(userData), true); - }); - - test('an instance that turned modules back on clears the switch', async () => { - const userData = createUserData(); - await recordInstanceModulePreference(userData, false); - await recordInstanceModulePreference(userData, true); - assert.equal(instanceTurnedModulesOff(userData), false); - }); - - test('the switch is stored under the module store root, never in the app store database', async () => { - const userData = createUserData(); - await recordInstanceModulePreference(userData, false); - const stored = getInstanceModulePreferencePath(userData); - assert.equal(stored, path.join(userData, 'modules', 'instance-switch.json')); - assert.deepEqual(JSON.parse(readFileSync(stored, 'utf8')), {[INSTANCE_MODULE_PREFERENCE_FIELD]: false}); - }); - - test('a corrupt, oversized or wrongly shaped file fails open rather than bricking the module system', () => { - for (const body of ['', 'not json', '[]', 'null', '"false"', '{"desktop_modules_enabled":"false"}', '{}']) { - const userData = createUserData(); - writeRawSwitch(userData, body); - assert.equal(instanceTurnedModulesOff(userData), false, body); - } - const oversized = createUserData(); - writeRawSwitch(oversized, `{"desktop_modules_enabled":false,"pad":"${'x'.repeat(8192)}"}`); - assert.equal(instanceTurnedModulesOff(oversized), false); - }); -}); - -describe('what the instance module preference can and cannot do to a boot', () => { - test('it would turn the module system off on a build with an offline renderer, which no modules build has', () => { - assert.deepEqual(decideModuleSystemDisableRequest(true, true), {kind: 'honoured'}); - }); - - test('it cannot apply to a build with no offline renderer, the same rule the launch flag gets', () => { - assert.deepEqual(decideModuleSystemDisableRequest(true, false), {kind: 'ignored-without-offline-renderer'}); - }); - - test('it cannot turn the module system on, a build without the module system stays without it', () => { - assert.equal(resolveModuleSystemLaunch({argv: [], env: {}, hasOfflineRenderer: true}).kind, 'disabled-by-build'); - assert.deepEqual(decideModuleSystemDisableRequest(false, true), {kind: 'not-requested'}); - }); -}); - -describe('reading the module preference out of a discovery document', () => { - test('an instance that says nothing leaves the preference untouched', () => { - assert.equal(planWithFeature(undefined).desktopModulesEnabled, null); - }); - - test('an explicit false turns the module system off and an explicit true turns it back on', () => { - assert.equal(planWithFeature(false).desktopModulesEnabled, false); - assert.equal(planWithFeature(true).desktopModulesEnabled, true); - }); - - test('a non boolean is not a decision', () => { - assert.equal(planWithFeature('false').desktopModulesEnabled, null); - assert.equal(planWithFeature(0).desktopModulesEnabled, null); - }); -}); diff --git a/fluxer_desktop/src/main/InstanceModulePreference.ts b/fluxer_desktop/src/main/InstanceModulePreference.ts deleted file mode 100644 index 06605143b..000000000 --- a/fluxer_desktop/src/main/InstanceModulePreference.ts +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import fs from 'node:fs'; -import path from 'node:path'; -import {getModuleStoreRoot, writeFileAtomically} from '@electron/main/ModuleStore'; - -export const INSTANCE_MODULE_PREFERENCE_FIELD = 'desktop_modules_enabled'; - -const INSTANCE_MODULE_PREFERENCE_FILE_NAME = 'instance-switch.json'; -const INSTANCE_MODULE_PREFERENCE_FILE_MODE = 0o600; -const INSTANCE_MODULE_PREFERENCE_MAX_BYTES = 4096; - -export function getInstanceModulePreferencePath(userDataPath: string): string { - return path.join(getModuleStoreRoot(userDataPath), INSTANCE_MODULE_PREFERENCE_FILE_NAME); -} - -export function instanceTurnedModulesOff(userDataPath: string): boolean { - let raw: string; - try { - raw = fs.readFileSync(getInstanceModulePreferencePath(userDataPath), 'utf8'); - } catch { - return false; - } - if (raw.length > INSTANCE_MODULE_PREFERENCE_MAX_BYTES) { - return false; - } - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return false; - } - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - return false; - } - return (parsed as Record)[INSTANCE_MODULE_PREFERENCE_FIELD] === false; -} - -export async function recordInstanceModulePreference(userDataPath: string, enabled: boolean): Promise { - const target = getInstanceModulePreferencePath(userDataPath); - await fs.promises.mkdir(path.dirname(target), {recursive: true}); - await writeFileAtomically( - target, - `${JSON.stringify({[INSTANCE_MODULE_PREFERENCE_FIELD]: enabled})}\n`, - INSTANCE_MODULE_PREFERENCE_FILE_MODE, - ); -} diff --git a/fluxer_desktop/src/main/IpcHandlers.ts b/fluxer_desktop/src/main/IpcHandlers.ts index 55e23eec4..b8bfb25ed 100644 --- a/fluxer_desktop/src/main/IpcHandlers.ts +++ b/fluxer_desktop/src/main/IpcHandlers.ts @@ -1,3 +1,4 @@ +import {isObjectLike as isRecord} from '@fluxer/record_utils/src/RecordGuards'; // SPDX-License-Identifier: AGPL-3.0-or-later import {APP_PROTOCOL, DESKTOP_APP_ORIGIN, DESKTOP_PREBOOT_THEME_CHANNEL} from '@electron/common/Constants'; @@ -61,7 +62,6 @@ import { getStreamingPriorityDiagnostics, releaseStreamingPriority, } from '@electron/main/StreamingPriority'; -import {setTaskbarProgress, type TaskbarProgressMode} from '@electron/main/TaskbarProgress'; import {registerThemeLocalFileHandlers} from '@electron/main/ThemeLocalFiles'; import { relaunchAndExit, @@ -77,7 +77,6 @@ import { } from '@electron/main/VoiceDebugEventSinkPopout'; import { clearSavedWindowBounds, - closeThemeStudioPopoutWindow, desktopTransparencyPendingRestart, desktopUseNativeTitleBarPendingRestart, focusThemeStudioPopoutWindow, @@ -90,7 +89,6 @@ import { showWindow, THEME_STUDIO_POPOUT_KEY, } from '@electron/main/Window'; -import {flashWindowForAttention, stopFlashingWindow} from '@electron/main/WindowFlash'; import {setWindowsBadgeOverlay} from '@electron/main/WindowsBadge'; import { DESKTOP_MODULE_CHANNELS, @@ -109,10 +107,6 @@ interface TrayRuntimeStateUpdate { buildInfo?: string | null; } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object'; -} - function normalizeDesktopWindowBehaviorUpdate(value: unknown): Partial { if (!isRecord(value)) { return {}; @@ -340,9 +334,7 @@ export function registerIpcHandlers(): void { ipcMain.handle('theme-studio-popout-focus', (): boolean => { return focusThemeStudioPopoutWindow(); }); - ipcMain.handle('theme-studio-popout-close', (): boolean => { - return closeThemeStudioPopoutWindow(); - }); + ipcMain.handle('popout:set-always-on-top', (_event, key: unknown, flag: unknown): boolean => { if (typeof key !== 'string' || typeof flag !== 'boolean') { return false; @@ -585,3 +577,36 @@ export function cleanupIpcHandlers(_options: {quitting?: boolean} = {}): void { linuxAppearanceSubscription = null; } } + +type TaskbarProgressMode = 'normal' | 'indeterminate' | 'error' | 'paused' | 'none'; + +function setTaskbarProgress(fraction: number, mode: TaskbarProgressMode = 'normal'): void { + const mainWindow = getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + try { + if (mode === 'none' || fraction < 0) { + mainWindow.setProgressBar(-1); + return; + } + const clamped = Math.max(0, Math.min(1, fraction)); + mainWindow.setProgressBar(clamped, {mode}); + } catch {} +} + +function flashWindowForAttention(persistent: boolean): void { + if (process.platform === 'darwin') return; + const mainWindow = getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + if (mainWindow.isFocused()) return; + try { + mainWindow.flashFrame(persistent); + } catch {} +} + +function stopFlashingWindow(): void { + const mainWindow = getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + try { + mainWindow.flashFrame(false); + } catch {} +} diff --git a/fluxer_desktop/src/main/LaunchOptions.ts b/fluxer_desktop/src/main/LaunchOptions.ts index db65dc6d1..e8db79566 100644 --- a/fluxer_desktop/src/main/LaunchOptions.ts +++ b/fluxer_desktop/src/main/LaunchOptions.ts @@ -25,11 +25,11 @@ const DISABLE_V8_CODE_CACHE_ARGS = new Set(['--fluxer-disable-v8-code-cache']); const DISABLE_NATIVE_GATEWAY_ARGS = new Set(['--fluxer-disable-native-gateway']); const DESKTOP_UPDATE_ARGS = new Set(['--fluxer-update']); -function hasFlag(argv: ReadonlyArray, flags: ReadonlySet): boolean { +export function hasFlag(argv: ReadonlyArray, flags: ReadonlySet): boolean { return argv.some((arg) => flags.has(arg) || [...flags].some((flag) => arg.startsWith(`${flag}=`))); } -function getArgValue(argv: ReadonlyArray, names: ReadonlySet): string | null { +export function getArgValue(argv: ReadonlyArray, names: ReadonlySet): string | null { for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; for (const name of names) { diff --git a/fluxer_desktop/src/main/LegacyAppOrigin.test.mjs b/fluxer_desktop/src/main/LegacyAppOrigin.test.mjs index 0e9e7e9ff..a123dbfd1 100644 --- a/fluxer_desktop/src/main/LegacyAppOrigin.test.mjs +++ b/fluxer_desktop/src/main/LegacyAppOrigin.test.mjs @@ -26,6 +26,8 @@ function transform(relativePath) { const constantsSource = transform('../common/Constants.ts'); const localAppRouteContractSource = transform('../../../packages/desktop_ipc/src/LocalAppRouteContract.ts'); const desktopConfigSource = transform('../common/DesktopConfig.ts'); +const plainRecordSource = transform('../common/PlainRecord.ts'); +const recordGuardsSource = transform('../../../packages/record_utils/src/RecordGuards.ts'); const globalShortcutActionsSource = transform('../common/GlobalShortcutActions.ts'); const silentLog = {debug() {}, info() {}, warn() {}, error() {}}; @@ -62,7 +64,15 @@ function loadDesktop({channel = 'stable', settings} = {}) { const globalShortcutActions = runModule(globalShortcutActionsSource, (specifier) => { throw new Error(`Unexpected import: ${specifier}`); }); + const plainRecord = runModule(plainRecordSource, (specifier) => { + throw new Error(`Unexpected import: ${specifier}`); + }); + const recordGuards = runModule(recordGuardsSource, (specifier) => { + throw new Error(`Unexpected import: ${specifier}`); + }); const desktopConfig = runModule(desktopConfigSource, (specifier) => { + if (specifier === '@electron/common/PlainRecord') return plainRecord; + if (specifier === '@fluxer/record_utils/src/RecordGuards') return recordGuards; if (specifier === 'node:fs') return fs; if (specifier === '@electron/common/GlobalShortcutActions') return globalShortcutActions; if (specifier === 'node:path') return path; diff --git a/fluxer_desktop/src/main/LegacyGlobalKeyHookIpc.ts b/fluxer_desktop/src/main/LegacyGlobalKeyHookIpc.ts index 0e7f30f9e..ee8fb2790 100644 --- a/fluxer_desktop/src/main/LegacyGlobalKeyHookIpc.ts +++ b/fluxer_desktop/src/main/LegacyGlobalKeyHookIpc.ts @@ -27,12 +27,6 @@ interface LegacyLinuxEvdevStatus { inInputGroup: boolean; } -interface LegacyLinuxEvdevGrantResult { - success: boolean; - needsRelogin: boolean; - error?: string; -} - export interface LegacyHostEnvironment { platform: NodeJS.Platform; sandboxed: boolean; @@ -198,12 +192,6 @@ export function buildLegacyLinuxEvdevStatus( }; } -export const LEGACY_LINUX_EVDEV_GRANT_RESULT: LegacyLinuxEvdevGrantResult = Object.freeze({ - success: false, - needsRelogin: false, - error: 'unsupported', -}); - export function registerLegacyGlobalKeyHookHandlers( engine: GlobalShortcutsEngine, adapter: LegacyGlobalKeyHookAdapter, @@ -262,8 +250,4 @@ export function registerLegacyGlobalKeyHookHandlers( requirePrivilegedRendererDocumentSender(event, 'linux-evdev-status'); return buildLegacyLinuxEvdevStatus(host); }); - ipcMain.handle('linux-evdev-grant-access', (event): LegacyLinuxEvdevGrantResult => { - requirePrivilegedRendererDocumentSender(event, 'linux-evdev-grant-access'); - return {...LEGACY_LINUX_EVDEV_GRANT_RESULT}; - }); } diff --git a/fluxer_desktop/src/main/LegacyHarvestIpc.ts b/fluxer_desktop/src/main/LegacyHarvestIpc.ts index cdce7fb88..7bae4eaa9 100644 --- a/fluxer_desktop/src/main/LegacyHarvestIpc.ts +++ b/fluxer_desktop/src/main/LegacyHarvestIpc.ts @@ -1,3 +1,4 @@ +import {isRecord as isPlainObject} from '@fluxer/record_utils/src/RecordGuards'; // SPDX-License-Identifier: AGPL-3.0-or-later import {createChildLogger} from '@electron/common/Logger'; @@ -10,7 +11,10 @@ import { readStagedHarvestLocalStorageSync, type StagedLegacyHarvest, } from '@electron/main/LegacyOriginHarvestStore'; -import {requirePrivilegedRendererDocumentSender} from '@electron/main/PrivilegedRendererDocuments'; +import { + isPrivilegedRendererDocumentSender, + requirePrivilegedRendererDocumentSender, +} from '@electron/main/PrivilegedRendererDocuments'; import type { DesktopLegacyHarvestRecord, DesktopLegacyHarvestStore, @@ -26,7 +30,7 @@ import { DesktopLegacyImportPhase, readDesktopLegacyImportPhase, } from '@fluxer/desktop_ipc/src/StorageContract'; -import {type IpcMainEvent, ipcMain} from 'electron'; +import {ipcMain} from 'electron'; const logger = createChildLogger('LegacyHarvest'); @@ -34,10 +38,6 @@ type BlobReader = (blobId: string) => Promise; let handlersRegistered = false; -function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function readBlobId(value: unknown): string | null { if (!isPlainObject(value)) return null; const reference = value[DESKTOP_LEGACY_HARVEST_BLOB_REF_KEY]; @@ -113,15 +113,6 @@ function readPrebootLocalStorage(): Readonly> | null { return readStagedHarvestLocalStorageSync(); } -function isPrivilegedHarvestSender(event: IpcMainEvent, channel: string): boolean { - try { - requirePrivilegedRendererDocumentSender(event, channel); - return true; - } catch { - return false; - } -} - export function registerLegacyHarvestHandlers(): void { if (handlersRegistered) return; handlersRegistered = true; @@ -138,9 +129,7 @@ export function registerLegacyHarvestHandlers(): void { return discardHarvestWhenComplete(); }); ipcMain.on(DESKTOP_LEGACY_HARVEST_CHANNELS.readRawSync, (event) => { - event.returnValue = isPrivilegedHarvestSender(event, DESKTOP_LEGACY_HARVEST_CHANNELS.readRawSync) - ? readPrebootLocalStorage() - : null; + event.returnValue = isPrivilegedRendererDocumentSender(event) ? readPrebootLocalStorage() : null; }); } diff --git a/fluxer_desktop/src/main/LegacyHarvestOfficialInstance.ts b/fluxer_desktop/src/main/LegacyHarvestOfficialInstance.ts index 67c457c1c..af2144476 100644 --- a/fluxer_desktop/src/main/LegacyHarvestOfficialInstance.ts +++ b/fluxer_desktop/src/main/LegacyHarvestOfficialInstance.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {isPlainRecord as isPlainObject} from '@electron/common/PlainRecord'; + import type {StagedLegacyHarvest} from '@electron/main/LegacyOriginHarvestStore'; import {officialClientApiEndpointForAlias} from '@fluxer/instance_bootstrap/src/OfficialInstance'; @@ -14,14 +16,6 @@ interface OfficialInstanceRekey { readonly officialApiEndpoint: string; } -function isPlainObject(value: unknown): value is Record { - if (typeof value !== 'object' || value === null) { - return false; - } - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - function planOfficialInstanceRekey(harvestedOrigin: string): OfficialInstanceRekey | null { const harvestedApiEndpoint = `${harvestedOrigin}${SAME_ORIGIN_API_PATH}`; const officialApiEndpoint = officialClientApiEndpointForAlias(harvestedApiEndpoint); diff --git a/fluxer_desktop/src/main/LegacyOriginHarvest.ts b/fluxer_desktop/src/main/LegacyOriginHarvest.ts index f6c950f58..fdcc5a4a7 100644 --- a/fluxer_desktop/src/main/LegacyOriginHarvest.ts +++ b/fluxer_desktop/src/main/LegacyOriginHarvest.ts @@ -1,7 +1,9 @@ +import {isRecord as isPlainObject} from '@fluxer/record_utils/src/RecordGuards'; // SPDX-License-Identifier: AGPL-3.0-or-later import {BUILD_CHANNEL} from '@electron/common/BuildChannel'; import {getLegacyAppOrigin} from '@electron/common/DesktopConfig'; +import {errorMessage as describeError} from '@electron/common/ErrorMessage'; import {createChildLogger} from '@electron/common/Logger'; import {type DesktopAppStorage, getDesktopAppStorage} from '@electron/main/DesktopAppStorage'; import {getDesktopDistributionPath} from '@electron/main/DesktopDistributionPath'; @@ -71,14 +73,6 @@ interface LegacyHarvestMarker { readonly origin: string; } -function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function describeError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function parseHarvestMarker(value: string | null): LegacyHarvestMarker | null { if (value === null) return null; let parsed: unknown; diff --git a/fluxer_desktop/src/main/LegacyOriginHarvestStore.ts b/fluxer_desktop/src/main/LegacyOriginHarvestStore.ts index feed1ab32..10f172351 100644 --- a/fluxer_desktop/src/main/LegacyOriginHarvestStore.ts +++ b/fluxer_desktop/src/main/LegacyOriginHarvestStore.ts @@ -1,9 +1,10 @@ +import {isRecord as isPlainObject} from '@fluxer/record_utils/src/RecordGuards'; // SPDX-License-Identifier: AGPL-3.0-or-later -import crypto from 'node:crypto'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; +import {writeFileAtomically} from '@electron/main/AtomicFile'; import type { DesktopLegacyHarvest, DesktopLegacyHarvestMediaDevice, @@ -40,10 +41,6 @@ interface StagedLegacyHarvestHandle { let cachedRawLocalStorage: Readonly> | null = null; let rawLocalStorageResolved = false; -function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function parseStringRecord(value: unknown): Record | null { if (!isPlainObject(value)) return null; const result: Record = {}; @@ -180,17 +177,6 @@ async function pathExists(target: string): Promise { } } -async function writeFileAtomically(filePath: string, contents: Uint8Array): Promise { - const temporaryPath = `${filePath}.${crypto.randomBytes(8).toString('hex')}.tmp`; - await fsPromises.writeFile(temporaryPath, contents, {flag: 'wx', mode: FILE_MODE}); - try { - await fsPromises.rename(temporaryPath, filePath); - } catch (error) { - await fsPromises.rm(temporaryPath, {force: true}); - throw error; - } -} - export async function writeStagedHarvest( harvest: StagedLegacyHarvest, blobs: ReadonlyMap, @@ -199,9 +185,9 @@ export async function writeStagedHarvest( await fsPromises.rm(directory, {recursive: true, force: true}); await fsPromises.mkdir(path.join(directory, BLOB_DIRECTORY_NAME), {recursive: true, mode: DIRECTORY_MODE}); for (const [blobId, bytes] of blobs) { - await writeFileAtomically(blobPath(blobId), bytes); + await writeFileAtomically(blobPath(blobId), bytes, FILE_MODE); } - await writeFileAtomically(manifestPath(), Buffer.from(JSON.stringify(harvest), 'utf8')); + await writeFileAtomically(manifestPath(), Buffer.from(JSON.stringify(harvest), 'utf8'), FILE_MODE); cachedRawLocalStorage = null; rawLocalStorageResolved = false; } diff --git a/fluxer_desktop/src/main/LinuxAppearance.ts b/fluxer_desktop/src/main/LinuxAppearance.ts index 80c89cab3..a7cdf7c57 100644 --- a/fluxer_desktop/src/main/LinuxAppearance.ts +++ b/fluxer_desktop/src/main/LinuxAppearance.ts @@ -4,6 +4,7 @@ import {createRequire} from 'node:module'; import {createChildLogger} from '@electron/common/Logger'; import type {LinuxAppearanceSnapshot} from '@electron/common/Types'; import {getLinuxPortalsMode} from '@electron/main/LaunchOptions'; +import {loadOptionalNativeAddon} from '@electron/main/NativeAddon'; export type {LinuxAppearanceSnapshot} from '@electron/common/Types'; @@ -29,35 +30,6 @@ interface PortalsModule { loadError: Error | null; } -let cached: PortalsModule | null | undefined; - -function loadAddon(): PortalsModule | null { - if (cached !== undefined) return cached; - if (process.platform !== 'linux') { - cached = null; - return cached; - } - if (getLinuxPortalsMode(process.argv) === 'off') { - logger.info('@fluxer/linux-portals disabled by launch diagnostics; appearance reads disabled'); - cached = null; - return cached; - } - try { - const mod = requireModule('@fluxer/linux-portals') as PortalsModule; - if (mod.loadError) { - logger.info('@fluxer/linux-portals reported load error', {error: mod.loadError}); - cached = null; - return cached; - } - cached = mod; - return cached; - } catch (error) { - logger.info('@fluxer/linux-portals not available; appearance reads disabled', {error}); - cached = null; - return cached; - } -} - export function readLinuxAppearance(): LinuxAppearanceSnapshot { const mod = loadAddon(); if (!mod) { @@ -118,3 +90,13 @@ export function subscribeLinuxAppearance(listener: LinuxAppearanceListener): Lin }, }; } + +const loadAddon = loadOptionalNativeAddon({ + platform: 'linux', + load: () => requireModule('@fluxer/linux-portals'), + logger: logger, + level: 'info', + unavailableMessage: '@fluxer/linux-portals not available; appearance reads disabled', + disabled: () => getLinuxPortalsMode(process.argv) === 'off', + disabledMessage: '@fluxer/linux-portals disabled by launch diagnostics; appearance reads disabled', +}); diff --git a/fluxer_desktop/src/main/LinuxDesktopEntry.ts b/fluxer_desktop/src/main/LinuxDesktopEntry.ts index 4dc3e9ae0..f88c56d6b 100644 --- a/fluxer_desktop/src/main/LinuxDesktopEntry.ts +++ b/fluxer_desktop/src/main/LinuxDesktopEntry.ts @@ -88,11 +88,11 @@ function findThirdPartyDesktopEntry(execPath: string): string | null { return null; } -function escapeDesktopValue(value: string): string { +export function escapeDesktopValue(value: string): string { return value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/\t/g, '\\t').replace(/\r/g, '\\r'); } -function quoteExecArg(value: string): string { +export function quoteExecArg(value: string): string { return `"${value.replace(/(["`$\\])/g, '\\$1')}"`; } diff --git a/fluxer_desktop/src/main/LinuxGlobalShortcutsPortal.ts b/fluxer_desktop/src/main/LinuxGlobalShortcutsPortal.ts index 4cde34c73..c2e9229d0 100644 --- a/fluxer_desktop/src/main/LinuxGlobalShortcutsPortal.ts +++ b/fluxer_desktop/src/main/LinuxGlobalShortcutsPortal.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {GlobalShortcutsPortalConsent} from '@electron/common/DesktopConfig'; +import {errorMessage} from '@electron/common/ErrorMessage'; import { GLOBAL_SHORTCUT_ACTIONS, GLOBAL_SHORTCUT_HOLD_ACTIONS, @@ -95,10 +96,6 @@ function classifyOpenFailure(error: unknown): OpenFailure { return {kind: 'failed', message}; } -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function isKnownAction(id: string): id is GlobalShortcutAction { return (GLOBAL_SHORTCUT_ACTIONS as ReadonlyArray).includes(id); } diff --git a/fluxer_desktop/src/main/LinuxLaunchPath.test.mjs b/fluxer_desktop/src/main/LinuxLaunchPath.test.mjs index 3cff45a76..f16e0d933 100644 --- a/fluxer_desktop/src/main/LinuxLaunchPath.test.mjs +++ b/fluxer_desktop/src/main/LinuxLaunchPath.test.mjs @@ -102,7 +102,11 @@ describe('AppImage relaunch script', () => { const root = mkdtempSync(join(tmpdir(), 'fluxer-relaunch-test-')); const marker = join(root, 'replacement-argv'); const replacement = join(root, 'My Fluxer.AppImage'); - writeFileSync(replacement, `#!${shell}\nprintf '%s\\n' "$@" > '${marker}'\n`, {mode: 0o755}); + writeFileSync( + replacement, + `#!${shell}\nprintf '%s\\n' "$@" > '${marker}.tmp'\nmv '${marker}.tmp' '${marker}'\n`, + {mode: 0o755}, + ); const parent = spawn(shell, ['-c', 'sleep 30'], {stdio: 'ignore', detached: true}); parent.unref(); diff --git a/fluxer_desktop/src/main/LocalAppInstanceKeyAgreement.test.mjs b/fluxer_desktop/src/main/LocalAppInstanceKeyAgreement.test.mjs index b93468ffa..f004e3544 100644 --- a/fluxer_desktop/src/main/LocalAppInstanceKeyAgreement.test.mjs +++ b/fluxer_desktop/src/main/LocalAppInstanceKeyAgreement.test.mjs @@ -46,10 +46,6 @@ const AGREED_KEYS = Object.freeze([ const ACCOUNT_STORAGE_KEY_SEPARATOR = '::'; describe('the instance key derivation agrees between the main process and the renderer', () => { - test('the two implementations are not the same module', () => { - assert.notEqual(localAppRuntimeInstanceKey, runtimeInstanceKey); - }); - for (const [apiEndpoint, expected] of AGREED_KEYS) { test(`${JSON.stringify(apiEndpoint)} keys as ${JSON.stringify(expected)} on both sides`, () => { const main = localAppRuntimeInstanceKey(apiEndpoint); diff --git a/fluxer_desktop/src/main/LocalAppProtocol.test.mjs b/fluxer_desktop/src/main/LocalAppProtocol.test.mjs index f8cb76ea4..8dc3f52e8 100644 --- a/fluxer_desktop/src/main/LocalAppProtocol.test.mjs +++ b/fluxer_desktop/src/main/LocalAppProtocol.test.mjs @@ -46,7 +46,6 @@ function runtimePlan(origin) { instanceKey: `${origin}/api`, document: {}, selfHosted: true, - desktopModulesEnabled: null, endpoints: { apiEndpoint: `${origin}/api`, apiPublicEndpoint: null, diff --git a/fluxer_desktop/src/main/LocalAppRequestHandler.test.mjs b/fluxer_desktop/src/main/LocalAppRequestHandler.test.mjs index 9c144530d..b9fcc0911 100644 --- a/fluxer_desktop/src/main/LocalAppRequestHandler.test.mjs +++ b/fluxer_desktop/src/main/LocalAppRequestHandler.test.mjs @@ -29,7 +29,6 @@ const PLAN = { instanceKey: INSTANCE_KEY, document: {}, selfHosted: false, - desktopModulesEnabled: null, endpoints: { apiEndpoint: 'https://api.fluxer.app', apiPublicEndpoint: 'https://api.fluxer.app', diff --git a/fluxer_desktop/src/main/LocalAppRuntimePlans.ts b/fluxer_desktop/src/main/LocalAppRuntimePlans.ts index 0ec5e7d7e..81f350fb5 100644 --- a/fluxer_desktop/src/main/LocalAppRuntimePlans.ts +++ b/fluxer_desktop/src/main/LocalAppRuntimePlans.ts @@ -28,7 +28,6 @@ export interface LocalAppRuntimePlan { readonly document: unknown; readonly endpoints: LocalAppRuntimeEndpoints; readonly selfHosted: boolean; - readonly desktopModulesEnabled: boolean | null; } interface LocalAppRuntimeRoute { @@ -95,7 +94,6 @@ export function localAppRuntimePlanFromDiscovery(document: unknown): LocalAppRun document, endpoints: runtimeEndpointsFromDiscovery(discovery), selfHosted: discovery.features.self_hosted, - desktopModulesEnabled: discovery.features.desktop_modules_enabled ?? null, }; } diff --git a/fluxer_desktop/src/main/MacTcc.ts b/fluxer_desktop/src/main/MacTcc.ts index 07f59d447..d1423435f 100644 --- a/fluxer_desktop/src/main/MacTcc.ts +++ b/fluxer_desktop/src/main/MacTcc.ts @@ -3,6 +3,7 @@ import {createRequire} from 'node:module'; import {hasRequestedMacScreenRecording, markMacScreenRecordingRequested} from '@electron/common/DesktopConfig'; import {createChildLogger} from '@electron/common/Logger'; +import {loadOptionalNativeAddon} from '@electron/main/NativeAddon'; import {ipcMain, systemPreferences} from 'electron'; const logger = createChildLogger('MacTcc'); @@ -29,34 +30,11 @@ interface MacTccIpcOptions { onStatus?: (surface: TccSurface, status: TccStatus) => void; } -let cached: MacTccModule | null | undefined; let cachedProbe: ScreenRecordingProbeModule | null | undefined; let screenRecordingLiveGranted = false; let screenRecordingProbeInFlight: Promise | null = null; let inputMonitoringRequested = false; -function loadAddon(): MacTccModule | null { - if (cached !== undefined) return cached; - if (process.platform !== 'darwin') { - cached = null; - return cached; - } - try { - const mod = requireModule('@fluxer/mac-tcc') as MacTccModule; - if (mod.loadError) { - logger.info('@fluxer/mac-tcc reported load error', {error: mod.loadError}); - cached = null; - return cached; - } - cached = mod; - return cached; - } catch (error) { - logger.info('@fluxer/mac-tcc not available; TCC pre-flight disabled', {error}); - cached = null; - return cached; - } -} - function loadProbe(): ScreenRecordingProbeModule | null { if (cachedProbe !== undefined) return cachedProbe; try { @@ -181,3 +159,11 @@ export function registerMacTccIpcHandlers(options: MacTccIpcOptions = {}): void return status; }); } + +const loadAddon = loadOptionalNativeAddon({ + platform: 'darwin', + load: () => requireModule('@fluxer/mac-tcc'), + logger: logger, + level: 'info', + unavailableMessage: '@fluxer/mac-tcc not available; TCC pre-flight disabled', +}); diff --git a/fluxer_desktop/src/main/MainAppEntry.test.mjs b/fluxer_desktop/src/main/MainAppEntry.test.mjs deleted file mode 100644 index 7a3a8d717..000000000 --- a/fluxer_desktop/src/main/MainAppEntry.test.mjs +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import assert from 'node:assert/strict'; -import {readFileSync} from 'node:fs'; -import path from 'node:path'; -import {describe, test} from 'node:test'; -import {fileURLToPath} from 'node:url'; - -const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'index.ts'), 'utf8'); - -describe('main app entry point', () => { - test('destroying the harvest window during startup never quits the app', () => { - assert.match( - source, - /app\.on\('window-all-closed', \(\) => \{\n\t+if \(startupWindowsPending\) \{\n\t+log\.info\([^\n]*\);\n\t+return;\n\t+\}/, - 'The legacy origin harvest creates and destroys a hidden BrowserWindow before create-window runs. On the offline variant it is the only window in the process, so window-all-closed fires with no main window and no tray yet, and the unguarded quit tears the app down mid startup. The harvest markers then race the store close, so the next launch repeats it.', - ); - }); - - test('the startup window guard clears once create-window returns and when the startup chain rejects', () => { - assert.match( - source, - /try \{\n\t+createWindow\(\{startHidden: isStartMinimizedLaunch\(\)\}\);\n\t+\} finally \{\n\t+startupWindowsPending = false;/, - 'A createWindow that throws halfway would otherwise leave the guard armed and the app could never quit again.', - ); - assert.match( - source, - /\.catch\(\(error: unknown\) => \{\n\t+startupWindowsPending = false;\n\t+log\.error\('\[Startup\] whenReady chain rejected:', error\);/, - ); - }); - - test('the forwarded second instance launch is drained from the bootstrap buffer, never from a listener attached this late', () => { - assert.doesNotMatch( - source, - /app\.on\('second-instance'/, - 'This bundle is only imported once the module update loop has resolved. A listener attached here misses every launch forwarded during the splash, and on the blocked update paths this bundle is never imported at all.', - ); - assert.match(source, /armSecondInstanceForwarding\(\);\n\t\tsetSecondInstanceSink\(handleSecondInstance\);/); - }); - - test('the single instance lock the bootstrap already holds is reused rather than requested again', () => { - assert.match( - source, - /const gotTheLock = app\.hasSingleInstanceLock\(\) \|\| app\.requestSingleInstanceLock\(\);/, - 'Bootstrap.ts takes the lock before it opens the module store, and this bundle is imported from inside that bootstrap, so a second request here would be asking for a lock this very process already owns.', - ); - }); - - test('open-url is drained from the bootstrap buffer, never from a listener attached this late', () => { - assert.doesNotMatch(source, /app\.on\('open-url'/); - assert.doesNotMatch(source, /app\.on\('ready'/); - assert.match(source, /armOpenUrlForwarding\(\);\n\t\tsetOpenUrlSink\(handleOpenUrl\);/); - }); - - test('pre-ready Chromium setup goes through the shared idempotent helper', () => { - assert.doesNotMatch(source, /app\.disableHardwareAcceleration\(\)/); - assert.doesNotMatch(source, /appendSwitch\('autoplay-policy'/); - assert.match(source, /applyPreReadyChromiumConfiguration\(userDataConfig\.channel, process\.argv\);/); - }); -}); diff --git a/fluxer_desktop/src/main/MediaClipboard.ts b/fluxer_desktop/src/main/MediaClipboard.ts index b48cec46c..7b83fe59b 100644 --- a/fluxer_desktop/src/main/MediaClipboard.ts +++ b/fluxer_desktop/src/main/MediaClipboard.ts @@ -1,3 +1,4 @@ +import {isRecord} from '@fluxer/record_utils/src/RecordGuards'; // SPDX-License-Identifier: AGPL-3.0-or-later import crypto from 'node:crypto'; @@ -125,10 +126,6 @@ function isClipboardWriteFileMediaType(value: unknown): value is ClipboardWriteF return value === 'image' || value === 'gif' || value === 'video' || value === 'audio'; } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - export function parseClipboardWriteFileOptions(value: unknown): ClipboardWriteFileOptions { if (!isRecord(value)) { throw new Error('Invalid clipboard file payload'); diff --git a/fluxer_desktop/src/main/ModuleManifest.ts b/fluxer_desktop/src/main/ModuleManifest.ts index 492cfc851..0efe316e4 100644 --- a/fluxer_desktop/src/main/ModuleManifest.ts +++ b/fluxer_desktop/src/main/ModuleManifest.ts @@ -1,3 +1,4 @@ +import {isRecord} from '@fluxer/record_utils/src/RecordGuards'; // SPDX-License-Identifier: AGPL-3.0-or-later import type {Buffer} from 'node:buffer'; @@ -81,10 +82,6 @@ export function resolveDesktopModuleArchitecture(architecture: NodeJS.Architectu } } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function requireString(record: Record, key: string): string { const value = record[key]; if (typeof value !== 'string' || value.length === 0) { diff --git a/fluxer_desktop/src/main/ModuleManifestRepository.ts b/fluxer_desktop/src/main/ModuleManifestRepository.ts index d6377fb61..6c42df1e1 100644 --- a/fluxer_desktop/src/main/ModuleManifestRepository.ts +++ b/fluxer_desktop/src/main/ModuleManifestRepository.ts @@ -3,12 +3,13 @@ import {Buffer} from 'node:buffer'; import fs from 'node:fs/promises'; import path from 'node:path'; +import {writeFileAtomically} from '@electron/main/AtomicFile'; import { type DesktopModuleManifestTarget, type DesktopModuleUpdateManifest, parseModuleUpdateManifest, } from '@electron/main/ModuleManifest'; -import {type ModuleStore, writeFileAtomically} from '@electron/main/ModuleStore'; +import type {ModuleStore} from '@electron/main/ModuleStore'; const CHANNEL_MANIFEST_CACHE_FILE_NAME = 'manifest.json'; const MODULE_MANIFEST_TIMEOUT_MS = 15000; diff --git a/fluxer_desktop/src/main/ModulePackage.ts b/fluxer_desktop/src/main/ModulePackage.ts index dfa625729..bea13f565 100644 --- a/fluxer_desktop/src/main/ModulePackage.ts +++ b/fluxer_desktop/src/main/ModulePackage.ts @@ -235,7 +235,7 @@ function assertSafeModuleRelativePath(relative: string): void { } } -function isPathInsideRoot(root: string, candidate: string): boolean { +export function isPathInsideRoot(root: string, candidate: string): boolean { const relative = path.relative(root, candidate); return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); } diff --git a/fluxer_desktop/src/main/ModulePackageInstaller.ts b/fluxer_desktop/src/main/ModulePackageInstaller.ts index d69ffa357..b23fbd2fd 100644 --- a/fluxer_desktop/src/main/ModulePackageInstaller.ts +++ b/fluxer_desktop/src/main/ModulePackageInstaller.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {errorMessage} from '@electron/common/ErrorMessage'; + import { ModulePackageHashMismatchError, ModulePackageIOError, @@ -117,10 +119,6 @@ class ModulePackageUrlPolicyError extends Error { } } -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - export function describeErrorChain(error: unknown): string { const messages: Array = []; let cursor: unknown = error; diff --git a/fluxer_desktop/src/main/ModuleStore.ts b/fluxer_desktop/src/main/ModuleStore.ts index c102c33e2..cc7a93f34 100644 --- a/fluxer_desktop/src/main/ModuleStore.ts +++ b/fluxer_desktop/src/main/ModuleStore.ts @@ -6,11 +6,20 @@ import {type Dirent, constants as fsConstants, type Stats} from 'node:fs'; import type {FileHandle} from 'node:fs/promises'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { + fsyncDirectory, + ModuleStoreError, + ModuleStoreIOError, + renameWithRetry, + WINDOWS_TRANSIENT_FS_RETRY_DELAYS_MS, + writeFileAtomically, +} from '@electron/main/AtomicFile'; import {ModuleInstallCoordinator} from '@electron/main/ModuleInstallCoordinator'; import { DESKTOP_MODULE_FILE_LIST_NAME, DESKTOP_MODULE_MANIFEST_MAX_BYTES, type DesktopModuleManifest, + isPathInsideRoot, ModulePackageHashMismatchError, parseDesktopModuleManifest, verifyAndExtract, @@ -48,23 +57,6 @@ const MODULE_FILE_STAMPS_MAX_BYTES = 16 * 1024 * 1024; const MISSING_INSTALLATION_ERROR_CODES: ReadonlySet = new Set(['ENOENT', 'ENOTDIR', 'ELOOP']); const MODULE_PACKAGE_DIGEST_MISMATCH_PREFIX = 'package sha256 mismatch:'; const MODULE_LAUNCH_ATTEMPT = Symbol('fluxer.desktop.moduleLaunchAttempt'); -const WINDOWS_TRANSIENT_FS_ERROR_CODES: ReadonlySet = new Set(['EPERM', 'EACCES', 'EBUSY']); -const WINDOWS_TRANSIENT_FS_RETRY_DELAYS_MS: ReadonlyArray = [25, 50, 100, 200, 400, 800]; - -class ModuleStoreError extends Error { - public constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = 'ModuleStoreError'; - } -} - -class ModuleStoreIOError extends ModuleStoreError { - public constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = 'ModuleStoreIOError'; - } -} - class ModuleStoreStateCorruptError extends ModuleStoreError { public constructor(statePath: string, detail: string, options?: ErrorOptions) { super(`module state at ${statePath} is corrupt: ${detail}`, options); @@ -244,11 +236,6 @@ function assertSha256(sha256: string): void { } } -function isPathInsideRoot(root: string, candidate: string): boolean { - const relative = path.relative(root, candidate); - return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative); -} - async function createDirectory(directory: string): Promise { try { await fs.mkdir(directory, {recursive: true}); @@ -257,32 +244,6 @@ async function createDirectory(directory: string): Promise { } } -function isWindowsTransientFsError(error: unknown): boolean { - return ( - process.platform === 'win32' && - typeof error === 'object' && - error !== null && - 'code' in error && - typeof error.code === 'string' && - WINDOWS_TRANSIENT_FS_ERROR_CODES.has(error.code) - ); -} - -async function renameWithRetry(source: string, target: string): Promise { - for (let attempt = 0; ; attempt += 1) { - try { - await fs.rename(source, target); - return; - } catch (error) { - const delay = WINDOWS_TRANSIENT_FS_RETRY_DELAYS_MS[attempt]; - if (delay === undefined || !isWindowsTransientFsError(error)) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } -} - function removeOptions(): {recursive: true; force: true; maxRetries: number} { return { recursive: true, @@ -341,52 +302,6 @@ async function fsyncFile(filePath: string): Promise { } } -async function fsyncDirectory(directory: string): Promise { - if (process.platform === 'win32') { - return; - } - let handle: FileHandle; - try { - handle = await fs.open(directory, 'r'); - } catch (error) { - throw new ModuleStoreIOError(`failed to open ${directory} for fsync`, {cause: error}); - } - try { - await handle.sync(); - } catch (error) { - throw new ModuleStoreIOError(`failed to fsync ${directory}`, {cause: error}); - } finally { - await handle.close(); - } -} - -export async function writeFileAtomically(target: string, contents: Buffer | string, mode: number): Promise { - const directory = path.dirname(target); - const temporary = path.join(directory, `${path.basename(target)}.${randomBytes(8).toString('hex')}.tmp`); - let handle: FileHandle; - try { - handle = await fs.open(temporary, 'wx', mode); - } catch (error) { - throw new ModuleStoreIOError(`failed to create ${temporary}`, {cause: error}); - } - try { - await handle.writeFile(contents, 'utf8'); - await handle.sync(); - } catch (error) { - await handle.close(); - await fs.rm(temporary, {force: true}); - throw new ModuleStoreIOError(`failed to write ${temporary}`, {cause: error}); - } - await handle.close(); - try { - await renameWithRetry(temporary, target); - } catch (error) { - await fs.rm(temporary, {force: true}); - throw new ModuleStoreIOError(`failed to publish ${target}`, {cause: error}); - } - await fsyncDirectory(directory); -} - async function openPartialPackage(partial: string): Promise { try { return await fs.open(partial, 'r+'); diff --git a/fluxer_desktop/src/main/ModuleUpdater.ts b/fluxer_desktop/src/main/ModuleUpdater.ts index e7d5960f9..c12e76d00 100644 --- a/fluxer_desktop/src/main/ModuleUpdater.ts +++ b/fluxer_desktop/src/main/ModuleUpdater.ts @@ -2,6 +2,8 @@ import {createHash} from 'node:crypto'; import {BUILD_CHANNEL} from '@electron/common/BuildChannel'; +import {errorMessage} from '@electron/common/ErrorMessage'; +import {jitteredExponentialDelay} from '@electron/common/ExponentialBackoff'; import type {DesktopUpdateProbe} from '@electron/main/DesktopUpdateRun'; import {ModuleDownloadRate} from '@electron/main/ModuleDownloadRate'; import { @@ -170,10 +172,6 @@ class ModuleUpdaterStorageFullError extends Error { } } -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function hasErrorCode(error: unknown, codes: ReadonlySet): boolean { let cursor: unknown = error; for (let depth = 0; depth < CAUSE_CHAIN_LIMIT && cursor != null; depth += 1) { @@ -237,8 +235,12 @@ class ModuleUpdaterBackoff { if (exponential >= MODULE_UPDATER_BACKOFF_CAP_MS) { this.ceiling = true; } - const capped = Math.min(exponential, MODULE_UPDATER_BACKOFF_CAP_MS); - return Math.round(capped * (0.5 + this.random() * 0.5)); + return jitteredExponentialDelay( + MODULE_UPDATER_BACKOFF_BASE_MS, + MODULE_UPDATER_BACKOFF_CAP_MS, + this.attempt - 1, + this.random, + ); } public reset(): void { @@ -749,13 +751,6 @@ export class ModuleUpdater { await this.store.collectGarbage({now: this.now()}); } - public async canLaunchWhileUnreachable(): Promise { - return ( - (await this.planner.evaluateUnreachableLaunch(this.isStartupUpdateEnforced(), this.isSecurityUpdateRequired())) - .kind === ModuleUnreachableLaunchDecision.LAUNCH - ); - } - private async launchIfAtFloor(): Promise { if (this.isSecurityUpdateRequired()) { return this.blockUpdateRequired( diff --git a/fluxer_desktop/src/main/ModuleVersion.ts b/fluxer_desktop/src/main/ModuleVersion.ts index 9306691b5..2fc63d771 100644 --- a/fluxer_desktop/src/main/ModuleVersion.ts +++ b/fluxer_desktop/src/main/ModuleVersion.ts @@ -101,3 +101,7 @@ export function compareModuleVersions(left: ModuleVersion, right: ModuleVersion) } return left.prerelease.length < right.prerelease.length ? -1 : 1; } + +export function compareShellVersions(left: string, right: string): number { + return compareModuleVersions(parseModuleVersion(left, 'shell version'), parseModuleVersion(right, 'shell version')); +} diff --git a/fluxer_desktop/src/main/NativeAddon.ts b/fluxer_desktop/src/main/NativeAddon.ts new file mode 100644 index 000000000..459a5f6a0 --- /dev/null +++ b/fluxer_desktop/src/main/NativeAddon.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +interface OptionalNativeAddonOptions { + readonly platform: NodeJS.Platform; + readonly disabled?: () => boolean; + readonly load: () => unknown; + readonly logger: {info: (...args: Array) => void; warn: (...args: Array) => void}; + readonly level: 'info' | 'warn'; + readonly unavailableMessage: string; + readonly disabledMessage?: string; +} + +export function loadOptionalNativeAddon( + options: OptionalNativeAddonOptions, +): () => T | null { + let cached: T | null | undefined; + return () => { + if (cached !== undefined) return cached; + if (process.platform !== options.platform || options.disabled?.()) { + if (process.platform === options.platform && options.disabledMessage) { + options.logger[options.level](options.disabledMessage); + } + return (cached = null); + } + try { + const addon = options.load() as T; + if (addon.loadError) throw addon.loadError; + return (cached = addon); + } catch (error) { + options.logger[options.level](options.unavailableMessage, {error}); + return (cached = null); + } + }; +} diff --git a/fluxer_desktop/src/main/NativeAudioValidation.ts b/fluxer_desktop/src/main/NativeAudioValidation.ts index 361c10fda..7379a2082 100644 --- a/fluxer_desktop/src/main/NativeAudioValidation.ts +++ b/fluxer_desktop/src/main/NativeAudioValidation.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {isPlainRecord as isPlainObject} from '@electron/common/PlainRecord'; + import type {NativeAudioStartOptions, VirtmicNode} from '@electron/common/Types'; interface InternalAudioFrame { @@ -23,12 +25,6 @@ export function isValidTargetPid(pid: unknown): pid is number { return typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0 && pid <= MAX_UINT32; } -function isPlainObject(value: unknown): value is Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - const proto = Object.getPrototypeOf(value); - return proto === Object.prototype || proto === null; -} - function isBooleanOrUndefined(value: unknown): boolean { return value === undefined || typeof value === 'boolean'; } diff --git a/fluxer_desktop/src/main/NativeGatewayTransport.test.mjs b/fluxer_desktop/src/main/NativeGatewayTransport.test.mjs index 5d9935a80..4027bcfaf 100644 --- a/fluxer_desktop/src/main/NativeGatewayTransport.test.mjs +++ b/fluxer_desktop/src/main/NativeGatewayTransport.test.mjs @@ -10,7 +10,6 @@ const {DesktopNativeGatewayTransport} = await import('./NativeGatewayTransport.t const {GatewayOriginRegistry} = await import('./GatewayOriginRegistry.ts'); const {DesktopOutboundHTTP} = await import('./DesktopOutboundHTTP.ts'); const {RendererDocumentOwnerFactory} = await import('./RendererDocumentOwner.ts'); -const {parseNativeGatewayTransportEvent} = await import('./NativeGatewayTransportEventParser.ts'); const {getNativeGatewayDisableReason} = await import('./LaunchOptions.ts'); const {createNativeGatewayPreloadAPI} = await import('../preload/NativeGatewayPreload.ts'); const { @@ -872,42 +871,6 @@ describe('GatewayOriginRegistry', () => { }); }); -describe('NativeGatewayTransportEventParser', () => { - test('accepts the addon exact seven field shape and normalises binary to ArrayBuffer', () => { - const parsed = parseNativeGatewayTransportEvent( - 'id', - nativeEvent({kind: 'binary', binary: Buffer.from([1, 2, 3])}), - ); - assert.equal(parsed.kind, 'binary'); - assert.deepEqual([...new Uint8Array(parsed.binary)], [1, 2, 3]); - assert.equal(parsed.data, null); - }); - - test('rejects an inapplicable field that is not null', () => { - assert.throws( - () => parseNativeGatewayTransportEvent('id', nativeEvent({kind: 'open', message: ''})), - /message must be null/u, - ); - assert.throws( - () => parseNativeGatewayTransportEvent('id', nativeEvent({kind: 'open', data: undefined})), - /must be a plain object with exactly the declared keys|data must be null/u, - ); - }); - - test('rejects a close event with an out of range code', () => { - assert.throws( - () => - parseNativeGatewayTransportEvent('id', nativeEvent({kind: 'close', code: 70_000, reason: '', wasClean: true})), - /unsigned 16-bit integer/u, - ); - }); - - test('rejects a prototype-polluted record', () => { - const hostile = Object.create({kind: 'open'}); - assert.throws(() => parseNativeGatewayTransportEvent('id', hostile), /plain object/u); - }); -}); - describe('native gateway kill switches', () => { test('names the launch flag, the environment variable and safe mode as disable reasons', () => { assert.equal(getNativeGatewayDisableReason([]), null); diff --git a/fluxer_desktop/src/main/NativeGatewayTransport.ts b/fluxer_desktop/src/main/NativeGatewayTransport.ts index 133807805..3eaae7478 100644 --- a/fluxer_desktop/src/main/NativeGatewayTransport.ts +++ b/fluxer_desktop/src/main/NativeGatewayTransport.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {parseNativeGatewayTransportEvent} from '@electron/common/NativeGatewayTransportEvent'; import {getDesktopOutboundHTTP} from '@electron/main/DesktopOutboundHTTP'; import {isDirectProxyRoute} from '@electron/main/DesktopSessionHTTP'; import type {GatewayOriginRegistry} from '@electron/main/GatewayOriginRegistry'; @@ -8,7 +9,6 @@ import { type GatewaySocketBoundary, warmGatewaySocketTransport, } from '@electron/main/GatewaySocketNativeBoundary'; -import {parseNativeGatewayTransportEvent} from '@electron/main/NativeGatewayTransportEventParser'; import { requireNativeGatewayCloseRequest, requireNativeGatewayConnectionId, @@ -334,7 +334,7 @@ export class DesktopNativeGatewayTransport { if (record == null || record.disposing) return; let parsed: NativeGatewayTransportEvent; try { - parsed = parseNativeGatewayTransportEvent(connectionId, nativeEvent); + parsed = parseNativeGatewayTransportEvent(nativeEvent, connectionId); } catch (error) { this.dependencies.logger.error('[NativeGateway] Rejected a malformed native gateway event', error); this.failConnection( diff --git a/fluxer_desktop/src/main/NativeGatewayTransportEventParser.ts b/fluxer_desktop/src/main/NativeGatewayTransportEventParser.ts deleted file mode 100644 index 95eeb2d7c..000000000 --- a/fluxer_desktop/src/main/NativeGatewayTransportEventParser.ts +++ /dev/null @@ -1,104 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {readExactPlainRecord} from '@electron/common/PlainRecord'; -import { - NATIVE_GATEWAY_TRANSPORT_CLOSE_REASON_MAX_BYTES, - NATIVE_GATEWAY_TRANSPORT_ERROR_MAX_BYTES, - NATIVE_GATEWAY_TRANSPORT_FRAME_MAX_BYTES, - type NativeGatewayTransportEvent, - NativeGatewayTransportEventKind, -} from '@fluxer/desktop_ipc/src/GatewayTransportContract'; - -const NATIVE_GATEWAY_EVENT_KEYS = Object.freeze(['kind', 'data', 'binary', 'code', 'reason', 'wasClean', 'message']); -const OPEN_NULL_FIELDS = Object.freeze(['data', 'binary', 'code', 'reason', 'wasClean', 'message']); -const MESSAGE_NULL_FIELDS = Object.freeze(['binary', 'code', 'reason', 'wasClean', 'message']); -const BINARY_NULL_FIELDS = Object.freeze(['data', 'code', 'reason', 'wasClean', 'message']); -const CLOSE_NULL_FIELDS = Object.freeze(['data', 'binary', 'message']); -const ERROR_NULL_FIELDS = Object.freeze(['data', 'binary', 'code', 'reason', 'wasClean']); -const CLOSE_CODE_MAX = 65_535; - -class InvalidNativeGatewayTransportEventError extends TypeError { - public constructor(reason: string) { - super(`Native gateway transport event is invalid: ${reason}`); - this.name = 'InvalidNativeGatewayTransportEventError'; - } -} - -function requireNullFields(record: Readonly>, fields: ReadonlyArray): void { - for (const field of fields) { - if (record[field] !== null) { - throw new InvalidNativeGatewayTransportEventError(`${field} must be null for this event kind`); - } - } -} - -function requireBoundedString(value: unknown, maxBytes: number, name: string): string { - if (typeof value !== 'string') { - throw new InvalidNativeGatewayTransportEventError(`${name} must be a string`); - } - if (Buffer.byteLength(value) > maxBytes) { - throw new InvalidNativeGatewayTransportEventError(`${name} is too large`); - } - return value; -} - -function requireCloseCode(value: unknown): number { - if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > CLOSE_CODE_MAX) { - throw new InvalidNativeGatewayTransportEventError('close code must be an unsigned 16-bit integer'); - } - return value; -} - -function toArrayBuffer(value: Buffer): ArrayBuffer { - const copy = new Uint8Array(value.byteLength); - copy.set(value); - return copy.buffer; -} - -export function parseNativeGatewayTransportEvent(connectionId: string, event: unknown): NativeGatewayTransportEvent { - const record = readExactPlainRecord({value: event, expectedKeys: NATIVE_GATEWAY_EVENT_KEYS}); - if (record == null) { - throw new InvalidNativeGatewayTransportEventError('payload must be a plain object with exactly the declared keys'); - } - const base = {connectionId, data: null, binary: null, code: null, reason: null, wasClean: null, message: null}; - switch (record.kind) { - case NativeGatewayTransportEventKind.OPEN: - requireNullFields(record, OPEN_NULL_FIELDS); - return {...base, kind: NativeGatewayTransportEventKind.OPEN}; - case NativeGatewayTransportEventKind.MESSAGE: { - const data = requireBoundedString(record.data, NATIVE_GATEWAY_TRANSPORT_FRAME_MAX_BYTES, 'message data'); - requireNullFields(record, MESSAGE_NULL_FIELDS); - return {...base, kind: NativeGatewayTransportEventKind.MESSAGE, data}; - } - case NativeGatewayTransportEventKind.BINARY: { - if (!Buffer.isBuffer(record.binary)) { - throw new InvalidNativeGatewayTransportEventError('binary frame data must be a Buffer'); - } - if (record.binary.byteLength > NATIVE_GATEWAY_TRANSPORT_FRAME_MAX_BYTES) { - throw new InvalidNativeGatewayTransportEventError('binary frame data is too large'); - } - requireNullFields(record, BINARY_NULL_FIELDS); - return {...base, kind: NativeGatewayTransportEventKind.BINARY, binary: toArrayBuffer(record.binary)}; - } - case NativeGatewayTransportEventKind.CLOSE: { - const code = requireCloseCode(record.code); - const reason = requireBoundedString( - record.reason, - NATIVE_GATEWAY_TRANSPORT_CLOSE_REASON_MAX_BYTES, - 'close reason', - ); - if (typeof record.wasClean !== 'boolean') { - throw new InvalidNativeGatewayTransportEventError('close disposition must be a boolean'); - } - requireNullFields(record, CLOSE_NULL_FIELDS); - return {...base, kind: NativeGatewayTransportEventKind.CLOSE, code, reason, wasClean: record.wasClean}; - } - case NativeGatewayTransportEventKind.ERROR: { - const message = requireBoundedString(record.message, NATIVE_GATEWAY_TRANSPORT_ERROR_MAX_BYTES, 'error message'); - requireNullFields(record, ERROR_NULL_FIELDS); - return {...base, kind: NativeGatewayTransportEventKind.ERROR, message}; - } - default: - throw new InvalidNativeGatewayTransportEventError('event kind is unknown'); - } -} diff --git a/fluxer_desktop/src/main/NativeHardwareEncoder.ts b/fluxer_desktop/src/main/NativeHardwareEncoder.ts index 27fcd467c..18e617441 100644 --- a/fluxer_desktop/src/main/NativeHardwareEncoder.ts +++ b/fluxer_desktop/src/main/NativeHardwareEncoder.ts @@ -2,12 +2,12 @@ import {createRequire} from 'node:module'; import {createChildLogger} from '@electron/common/Logger'; +import {VOICE_ENGINE_V2_HARDWARE_ENCODER_IPC_CHANNEL} from '@fluxer/voice_engine_v2/bridge'; import { - normalizeVoiceEngineV2BridgeHardwareEncoderCapabilities, - unavailableVoiceEngineV2BridgeHardwareEncoderCapabilities, - VOICE_ENGINE_V2_HARDWARE_ENCODER_IPC_CHANNEL, - type VoiceEngineV2BridgeHardwareEncoderCapabilities, -} from '@fluxer/voice_engine_v2/bridge'; + normalizeVoiceEngineV2HardwareEncoderCapabilities, + unavailableVoiceEngineV2HardwareEncoderCapabilities, +} from '@fluxer/voice_engine_v2/src/policies/hardwareEncoderCapabilities'; +import type {VoiceEngineV2HardwareEncoderCapabilities} from '@fluxer/voice_engine_v2/src/protocol/types'; import {ipcMain} from 'electron'; const logger = createChildLogger('NativeHardwareEncoder'); @@ -33,20 +33,20 @@ function loadHardwareEncoderModule(): HardwareEncoderModule | null { return cachedModule; } -function getHardwareEncoderCapabilities(): VoiceEngineV2BridgeHardwareEncoderCapabilities { +function getHardwareEncoderCapabilities(): VoiceEngineV2HardwareEncoderCapabilities { const mod = loadHardwareEncoderModule(); if (!mod) { - return unavailableVoiceEngineV2BridgeHardwareEncoderCapabilities('load-failed', cachedModuleLoadErrorDetail); + return unavailableVoiceEngineV2HardwareEncoderCapabilities('load-failed', cachedModuleLoadErrorDetail); } if (typeof mod.getHardwareEncoderCapabilities !== 'function') { - return unavailableVoiceEngineV2BridgeHardwareEncoderCapabilities('unsupported-addon-version'); + return unavailableVoiceEngineV2HardwareEncoderCapabilities('unsupported-addon-version'); } try { - return normalizeVoiceEngineV2BridgeHardwareEncoderCapabilities(mod.getHardwareEncoderCapabilities()); + return normalizeVoiceEngineV2HardwareEncoderCapabilities(mod.getHardwareEncoderCapabilities()); } catch (error) { logger.warn('Native hardware encoder getHardwareEncoderCapabilities() threw', error); const detail = error instanceof Error ? error.message : String(error); - return unavailableVoiceEngineV2BridgeHardwareEncoderCapabilities('query-failed', detail); + return unavailableVoiceEngineV2HardwareEncoderCapabilities('query-failed', detail); } } @@ -57,7 +57,7 @@ export function registerNativeHardwareEncoderHandlers(): void { handlersRegistered = true; ipcMain.handle( VOICE_ENGINE_V2_HARDWARE_ENCODER_IPC_CHANNEL, - (): VoiceEngineV2BridgeHardwareEncoderCapabilities => getHardwareEncoderCapabilities(), + (): VoiceEngineV2HardwareEncoderCapabilities => getHardwareEncoderCapabilities(), ); } diff --git a/fluxer_desktop/src/main/NotificationState.ts b/fluxer_desktop/src/main/NotificationState.ts index fd73bc82c..e6fd0dba5 100644 --- a/fluxer_desktop/src/main/NotificationState.ts +++ b/fluxer_desktop/src/main/NotificationState.ts @@ -1,3 +1,4 @@ +import {isRecord} from '@fluxer/record_utils/src/RecordGuards'; // SPDX-License-Identifier: AGPL-3.0-or-later import {execFile} from 'node:child_process'; @@ -8,6 +9,7 @@ import path from 'node:path'; import {promisify} from 'node:util'; import {MACOS_BUNDLE_ID} from '@electron/common/DesktopIdentity'; import {createChildLogger} from '@electron/common/Logger'; +import {loadOptionalNativeAddon} from '@electron/main/NativeAddon'; const logger = createChildLogger('NotificationState'); const requireModule = createRequire(import.meta.url); @@ -29,12 +31,6 @@ interface WinShellModule { } let cachedPolicy: {expiresAt: number; promise: Promise} | null = null; -let cachedWinShellModule: WinShellModule | null | undefined; - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - function firstDataRecord(value: unknown): Record | null { if (!isRecord(value)) return null; const data = value.data; @@ -166,28 +162,6 @@ async function getMacosNotificationSoundPolicy(): Promise { export async function shouldPlayNotificationSound(): Promise { return (await getNotificationSoundPolicy()).shouldPlaySound; } + +const loadWinShellModule = loadOptionalNativeAddon({ + platform: 'win32', + load: () => requireModule('@fluxer/win-shell'), + logger: logger, + level: 'warn', + unavailableMessage: 'Failed to load @fluxer/win-shell; Windows notification state unavailable', +}); diff --git a/fluxer_desktop/src/main/Passkeys.test.mjs b/fluxer_desktop/src/main/Passkeys.test.mjs index 531ccd7fa..c3400a4c5 100644 --- a/fluxer_desktop/src/main/Passkeys.test.mjs +++ b/fluxer_desktop/src/main/Passkeys.test.mjs @@ -89,7 +89,6 @@ function plan(apiEndpoint, webAppEndpoint) { instanceKey: apiEndpoint, document: {}, selfHosted: false, - desktopModulesEnabled: null, endpoints: { apiEndpoint, apiPublicEndpoint: apiEndpoint, diff --git a/fluxer_desktop/src/main/PrivilegedRendererDocuments.ts b/fluxer_desktop/src/main/PrivilegedRendererDocuments.ts index 46a723f0e..fb731255e 100644 --- a/fluxer_desktop/src/main/PrivilegedRendererDocuments.ts +++ b/fluxer_desktop/src/main/PrivilegedRendererDocuments.ts @@ -15,7 +15,7 @@ class UntrustedRendererDocumentSenderError extends Error { } } -function isPrivilegedRendererDocumentSender(event: IpcMainInvokeEvent): boolean { +export function isPrivilegedRendererDocumentSender(event: IpcMainInvokeEvent): boolean { const frame = event.senderFrame; if (frame == null) { return false; diff --git a/fluxer_desktop/src/main/RecentDocuments.ts b/fluxer_desktop/src/main/RecentDocuments.ts deleted file mode 100644 index ba9c73686..000000000 --- a/fluxer_desktop/src/main/RecentDocuments.ts +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {createChildLogger} from '@electron/common/Logger'; -import {app} from 'electron'; - -const logger = createChildLogger('RecentDocuments'); - -export function recordRecentDeepLink(url: string): void { - if (process.platform !== 'win32' && process.platform !== 'darwin') return; - try { - app.addRecentDocument(url); - } catch (error) { - logger.debug('addRecentDocument failed', {error}); - } -} diff --git a/fluxer_desktop/src/main/ShellSelfUpdate.ts b/fluxer_desktop/src/main/ShellSelfUpdate.ts index b4822fdae..f9bcc5e99 100644 --- a/fluxer_desktop/src/main/ShellSelfUpdate.ts +++ b/fluxer_desktop/src/main/ShellSelfUpdate.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {createRequire} from 'node:module'; +import {errorMessage as errorDetail} from '@electron/common/ErrorMessage'; import {createChildLogger} from '@electron/common/Logger'; import { type AppImageTarget, @@ -10,7 +11,7 @@ import { } from '@electron/main/AppImageUpdate'; import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath'; import {moduleNetworkFetch} from '@electron/main/ModuleNetworkFetch'; -import {compareModuleVersions, parseModuleVersion} from '@electron/main/ModuleVersion'; +import {compareShellVersions} from '@electron/main/ModuleVersion'; import {getUpdateBaseUrl} from '@electron/main/ShellDownloadFormats'; import type {ShellUpdateCapability, ShellUpdatePlan} from '@electron/main/ShellUpdateCapability'; import { @@ -50,14 +51,6 @@ interface SelfUpdateControl { readonly addCleanup: (cleanup: () => void) => void; } -function errorDetail(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function compareShellVersions(left: string, right: string): number { - return compareModuleVersions(parseModuleVersion(left, 'shell version'), parseModuleVersion(right, 'shell version')); -} - function feedUserAgent(): string { return `Fluxer/${app.getVersion()} (${process.platform}: ${process.arch})`; } diff --git a/fluxer_desktop/src/main/Spellcheck.ts b/fluxer_desktop/src/main/Spellcheck.ts index b1f8523de..72b27fafa 100644 --- a/fluxer_desktop/src/main/Spellcheck.ts +++ b/fluxer_desktop/src/main/Spellcheck.ts @@ -7,6 +7,7 @@ import {createRequire} from 'node:module'; import * as path from 'node:path'; import {STATIC_CDN_URL} from '@electron/common/Constants'; import type {SpellcheckBundledDictionary} from '@electron/common/Types'; +import {writeFileAtomically} from '@electron/main/AtomicFile'; import {getSpellcheckLaunchMode, type SpellcheckLaunchMode} from '@electron/main/LaunchOptions'; import {getNativeLocale} from '@electron/main/MainI18n'; import { @@ -35,7 +36,6 @@ type SystemHunspellNative = { }) | null; discoverSystemDictionaries: (() => Array<{tag: string; affPath: string; dicPath: string}>) | null; - hashFile: ((path: string) => Promise) | null; loadError: Error | null; }; @@ -256,15 +256,8 @@ const writeCachedDictionaryFile = async ( ): Promise => { const dir = dictionaryCacheDir(dict); const filePath = path.join(dir, fileName); - const tmpPath = path.join(dir, `.${fileName}.${process.pid}.${Date.now()}.tmp`); await fs.mkdir(dir, {recursive: true}); - await fs.writeFile(tmpPath, data, {mode: 0o600}); - try { - await fs.rename(tmpPath, filePath); - } catch (error) { - await fs.rm(tmpPath, {force: true}).catch(() => {}); - throw error; - } + await writeFileAtomically(filePath, data, 0o600); }; const downloadDictionaryFile = async ( dict: SpellcheckDictionaryCatalogEntry, diff --git a/fluxer_desktop/src/main/SplashWindow.test.mjs b/fluxer_desktop/src/main/SplashWindow.test.mjs index 85db53475..889ad2ad7 100644 --- a/fluxer_desktop/src/main/SplashWindow.test.mjs +++ b/fluxer_desktop/src/main/SplashWindow.test.mjs @@ -1066,6 +1066,8 @@ class StubNode { } } +const {serializeSplashState: serializePreloadState} = await loadSplashWindow('preload-state-producer'); + function createPreloadHarness(options = {}) { const sent = []; const ipcListeners = new Map(); @@ -1148,7 +1150,7 @@ function createPreloadHarness(options = {}) { select.value = value; select.dispatch('change'); }, - sendState: (payload) => ipcListeners.get('desktop-splash:state')({}, payload), + sendState: (payload) => ipcListeners.get('desktop-splash:state')({}, serializePreloadState(payload)), reveal: () => ipcListeners.get('desktop-splash:revealed')({}), sent, channels: () => sent.map((entry) => entry.channel), @@ -1566,24 +1568,6 @@ describe('splash preload manual update layout', () => { assert.deepEqual(harness.sent.at(-1), {channel: 'desktop-splash:open-download', payload: undefined}); }); - - test('no manual update payload can normalise into a screen with nothing to click', () => { - const {context} = createPreloadHarness(); - - for (const patch of [ - {options: null, action: null}, - {options: [], action: {kind: 'launch-anyway', label: 'Launch anyway'}}, - {options: 'deb', action: {kind: 'retry', label: ' '}}, - {options: [{value: 'DEB', label: 'Debian', buttonLabel: 'Download', kind: 'download'}], action: undefined}, - ]) { - const state = context.normalizeState(manualState(patch)); - assert.notEqual( - state.options ?? state.action, - null, - `${JSON.stringify(patch)} must still carry a control the preload can route`, - ); - } - }); }); describe('splash preload contract', () => { @@ -1591,26 +1575,13 @@ describe('splash preload contract', () => { const {SplashLayout, SplashStatus} = await loadSplashWindow('preload-status-parity'); const {context} = createPreloadHarness(); - for (const status of Object.values(SplashStatus)) { - assert.equal(context.normalizeState({status}).status, status, `${status} is unknown to the preload`); - } - for (const layout of Object.values(SplashLayout)) { - assert.equal(context.normalizeState({layout}).layout, layout, `${layout} is unknown to the preload`); - } - }); - - test('falls back to checking for updates on a status the preload does not know', () => { - const {context} = createPreloadHarness(); - - assert.equal( - context.getStatusText(context.normalizeState({status: 'not-a-splash-status'})), - 'Checking for updates…', - ); + assert.deepEqual(JSON.parse(JSON.stringify(vm.runInContext('SplashStatus', context))), SplashStatus); + assert.deepEqual(JSON.parse(JSON.stringify(vm.runInContext('SplashLayout', context))), SplashLayout); }); test('shows the bar only for a numeric progress on a status that carries one', () => { const {context} = createPreloadHarness(); - const shows = (payload) => context.shouldShowProgress(context.normalizeState(payload)); + const shows = (payload) => context.shouldShowProgress(serializePreloadState(payload)); assert.equal(shows({status: 'downloading-updates', progress: 0}), true); assert.equal(shows({status: 'installing-updates', progress: 100}), true); @@ -1623,34 +1594,6 @@ describe('splash preload contract', () => { assert.equal(shows({status: 'launching', progress: 50}), false); }); - test('normalises the picker the same way the main process serialises it', async () => { - const {serializeSplashState} = await loadSplashWindow('preload-option-parity'); - const {context} = createPreloadHarness(); - const state = {status: 'blocked-shell-update', layout: 'manual-update', options: MANUAL_UPDATE_OPTIONS}; - - assert.deepEqual(JSON.parse(JSON.stringify(context.normalizeState(serializeSplashState(state)))), { - layout: 'manual-update', - status: 'blocked-shell-update', - requiredSecurityUpdate: false, - current: null, - total: null, - progress: null, - seconds: null, - receivedBytes: null, - totalBytes: null, - bytesPerSecond: null, - action: null, - message: null, - versionLabel: null, - options: MANUAL_UPDATE_OPTIONS.map((option) => ({...option})), - }); - assert.equal( - context.normalizeState({options: [{value: 'DEB', label: 'x', buttonLabel: 'y', kind: 'download'}]}).options, - null, - ); - assert.equal(context.normalizeState({options: []}).options, null); - }); - test('clamps the manual update message so a long one can never push the controls out of the window', () => { const message = styleRule('.dl-update-message'); diff --git a/fluxer_desktop/src/main/TaskbarProgress.ts b/fluxer_desktop/src/main/TaskbarProgress.ts deleted file mode 100644 index c0d35f186..000000000 --- a/fluxer_desktop/src/main/TaskbarProgress.ts +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {getMainWindow} from '@electron/main/Window'; - -export type TaskbarProgressMode = 'normal' | 'indeterminate' | 'error' | 'paused' | 'none'; - -export function setTaskbarProgress(fraction: number, mode: TaskbarProgressMode = 'normal'): void { - const mainWindow = getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) return; - try { - if (mode === 'none' || fraction < 0) { - mainWindow.setProgressBar(-1); - return; - } - const clamped = Math.max(0, Math.min(1, fraction)); - mainWindow.setProgressBar(clamped, {mode}); - } catch {} -} diff --git a/fluxer_desktop/src/main/ThemeLinkedFileWatch.test.mjs b/fluxer_desktop/src/main/ThemeLinkedFileWatch.test.mjs index b57ee49c7..8983d35be 100644 --- a/fluxer_desktop/src/main/ThemeLinkedFileWatch.test.mjs +++ b/fluxer_desktop/src/main/ThemeLinkedFileWatch.test.mjs @@ -32,7 +32,7 @@ function loadThemeLinkedFileWatch() { return module.exports; } -const {createThemeLinkedFileWatchSet, readThemeCssFile} = loadThemeLinkedFileWatch(); +const {readThemeCssFile} = loadThemeLinkedFileWatch(); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fluxer-theme-linked-')); let fileCounter = 0; @@ -45,113 +45,7 @@ function nextFilePath() { return path.join(tempRoot, `theme-${fileCounter}.css`); } -function createRecorder() { - const changes = []; - const waiters = []; - const emit = (change) => { - changes.push(change); - for (const waiter of [...waiters]) { - if (waiter.predicate(change)) { - waiters.splice(waiters.indexOf(waiter), 1); - clearTimeout(waiter.timer); - waiter.resolve(change); - } - } - }; - const waitFor = (predicate, timeoutMs = 3000) => - new Promise((resolve, reject) => { - const waiter = {predicate, resolve, timer: null}; - waiter.timer = setTimeout(() => { - waiters.splice(waiters.indexOf(waiter), 1); - reject(new Error(`Timed out waiting for change, saw ${JSON.stringify(changes)}`)); - }, timeoutMs); - waiters.push(waiter); - }); - return {changes, emit, waitFor}; -} - -function delay(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function writeLater(filePath, contents) { - await delay(40); - fs.writeFileSync(filePath, contents); -} - describe('ThemeLinkedFileWatch', () => { - test('emits css on the first read', async () => { - const filePath = nextFilePath(); - fs.writeFileSync(filePath, '.a{color:red}'); - const recorder = createRecorder(); - const watchSet = createThemeLinkedFileWatchSet(recorder.emit, 25); - try { - watchSet.replace([filePath]); - const change = await recorder.waitFor((item) => item.path === filePath); - assert.equal(change.css, '.a{color:red}'); - } finally { - watchSet.dispose(); - } - }); - - test('emits new css after an in-place write', async () => { - const filePath = nextFilePath(); - fs.writeFileSync(filePath, '.a{color:red}'); - const recorder = createRecorder(); - const watchSet = createThemeLinkedFileWatchSet(recorder.emit, 25); - try { - watchSet.replace([filePath]); - await recorder.waitFor((item) => item.css === '.a{color:red}'); - await writeLater(filePath, '.a{color:blue;padding:1px}'); - await recorder.waitFor((item) => item.css === '.a{color:blue;padding:1px}'); - } finally { - watchSet.dispose(); - } - }); - - test('emits new css after a temp file and rename save', async () => { - const filePath = nextFilePath(); - fs.writeFileSync(filePath, '.a{color:red}'); - const recorder = createRecorder(); - const watchSet = createThemeLinkedFileWatchSet(recorder.emit, 25); - try { - watchSet.replace([filePath]); - await recorder.waitFor((item) => item.css === '.a{color:red}'); - await delay(40); - const tempPath = `${filePath}.tmp`; - fs.writeFileSync(tempPath, '.a{color:green}'); - fs.renameSync(tempPath, filePath); - await recorder.waitFor((item) => item.css === '.a{color:green}'); - } finally { - watchSet.dispose(); - } - }); - - test('reports missing after unlink and css after the file returns', async () => { - const filePath = nextFilePath(); - fs.writeFileSync(filePath, '.a{color:red}'); - const recorder = createRecorder(); - const watchSet = createThemeLinkedFileWatchSet(recorder.emit, 25); - try { - watchSet.replace([filePath]); - await recorder.waitFor((item) => item.css === '.a{color:red}'); - await delay(40); - fs.unlinkSync(filePath); - await recorder.waitFor((item) => item.error === 'missing'); - await writeLater(filePath, '.a{color:purple}'); - await recorder.waitFor((item) => item.css === '.a{color:purple}'); - } finally { - watchSet.dispose(); - } - }); - - test('strips a leading byte order mark', async () => { - const filePath = nextFilePath(); - fs.writeFileSync(filePath, '.a{color:red}'); - const change = await readThemeCssFile(filePath); - assert.equal(change.css, '.a{color:red}'); - }); - test('rejects oversize files', async () => { const filePath = nextFilePath(); fs.writeFileSync(filePath, 'a'.repeat(1024 * 1024 + 1)); @@ -169,53 +63,4 @@ describe('ThemeLinkedFileWatch', () => { assert.equal(change.css, undefined); assert.equal(change.error, 'not_file'); }); - - test('reports missing for an absent file', async () => { - const change = await readThemeCssFile(nextFilePath()); - assert.equal(change.error, 'missing'); - }); - - test('stops emitting for a path dropped by replace', async () => { - const keptPath = nextFilePath(); - const droppedPath = nextFilePath(); - fs.writeFileSync(keptPath, '.kept{}'); - fs.writeFileSync(droppedPath, '.dropped{}'); - const recorder = createRecorder(); - const watchSet = createThemeLinkedFileWatchSet(recorder.emit, 25); - try { - const initialReads = Promise.all([ - recorder.waitFor((item) => item.path === droppedPath), - recorder.waitFor((item) => item.path === keptPath), - ]); - watchSet.replace([keptPath, droppedPath]); - await initialReads; - watchSet.replace([keptPath]); - await delay(40); - fs.writeFileSync(droppedPath, '.dropped{color:red}'); - fs.writeFileSync(keptPath, '.kept{color:red}'); - await recorder.waitFor((item) => item.css === '.kept{color:red}'); - await delay(150); - assert.equal( - recorder.changes.some((item) => item.css === '.dropped{color:red}'), - false, - ); - } finally { - watchSet.dispose(); - } - }); - - test('dispose stops every event', async () => { - const filePath = nextFilePath(); - fs.writeFileSync(filePath, '.a{color:red}'); - const recorder = createRecorder(); - const watchSet = createThemeLinkedFileWatchSet(recorder.emit, 25); - watchSet.replace([filePath]); - await recorder.waitFor((item) => item.css === '.a{color:red}'); - watchSet.dispose(); - const count = recorder.changes.length; - await delay(40); - fs.writeFileSync(filePath, '.a{color:blue}'); - await delay(200); - assert.equal(recorder.changes.length, count); - }); }); diff --git a/fluxer_desktop/src/main/ThemeLocalFiles.ts b/fluxer_desktop/src/main/ThemeLocalFiles.ts index a0067d432..7cbad3623 100644 --- a/fluxer_desktop/src/main/ThemeLocalFiles.ts +++ b/fluxer_desktop/src/main/ThemeLocalFiles.ts @@ -1,3 +1,4 @@ +import {isObjectLike as isRecord} from '@fluxer/record_utils/src/RecordGuards'; // SPDX-License-Identifier: AGPL-3.0-or-later import fs from 'node:fs'; @@ -32,10 +33,6 @@ interface ThemeLinkedFileWatchSubscription { const linkedFileWatchSets = new Map(); -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object'; -} - function getLinkedFileWatchSubscription(sender: WebContents): ThemeLinkedFileWatchSubscription { const existing = linkedFileWatchSets.get(sender.id); if (existing) return existing; diff --git a/fluxer_desktop/src/main/UpdateServerRetry.ts b/fluxer_desktop/src/main/UpdateServerRetry.ts index fdc87f032..6e7458eec 100644 --- a/fluxer_desktop/src/main/UpdateServerRetry.ts +++ b/fluxer_desktop/src/main/UpdateServerRetry.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {jitteredExponentialDelay} from '@electron/common/ExponentialBackoff'; import {ModuleUpdaterStatus} from '@electron/main/ModuleUpdater'; export const UPDATE_SERVER_RETRY_BASE_MS = 15_000; @@ -18,8 +19,12 @@ interface UpdateServerRetryDependencies { } export function nextUpdateServerRetryDelay(attempt: number, random: () => number = Math.random): number { - const exponential = Math.min(UPDATE_SERVER_RETRY_BASE_MS * 2 ** Math.max(0, attempt), UPDATE_SERVER_RETRY_CAP_MS); - return Math.round(exponential * (0.5 + random() * 0.5)); + return jitteredExponentialDelay( + UPDATE_SERVER_RETRY_BASE_MS, + UPDATE_SERVER_RETRY_CAP_MS, + Math.max(0, attempt), + random, + ); } export class UpdateServerRetry { diff --git a/fluxer_desktop/src/main/Updater.ts b/fluxer_desktop/src/main/Updater.ts index 4403ed777..ae6927569 100644 --- a/fluxer_desktop/src/main/Updater.ts +++ b/fluxer_desktop/src/main/Updater.ts @@ -1,6 +1,9 @@ +import {isRecord} from '@fluxer/record_utils/src/RecordGuards'; // SPDX-License-Identifier: AGPL-3.0-or-later +import {errorMessage as getErrorMessage} from '@electron/common/ErrorMessage'; import {checkDesktopUpdateNow} from '@electron/main/DesktopUpdateGate'; +import {compareShellVersions} from '@electron/main/ModuleVersion'; import {MANUAL_DESKTOP_FORMATS, type ManualDesktopFormat} from '@electron/main/ShellDownloadFormats'; import {resolveShellUpdatePlan, ShellUpdateCapability} from '@electron/main/ShellUpdateCapability'; import { @@ -48,47 +51,10 @@ function send(win: BrowserWindow | null, event: UpdaterEvent) { win?.webContents.send('updater-event', event); } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function getErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - let manualLatestCache: {at: number; info: ManualLatestInfo} | null = null; const MANUAL_CACHE_TTL_MS = 5 * 60 * 1000; -function parseSemverTuple(input: string): [number, number, number, string] { - const trimmed = input.trim().replace(/^v/, ''); - const [core, ...preParts] = trimmed.split('-'); - const pre = preParts.join('-'); - const segments = core.split('.').map((part) => Number.parseInt(part, 10)); - const [major = 0, minor = 0, patch = 0] = segments; - return [ - Number.isFinite(major) ? major : 0, - Number.isFinite(minor) ? minor : 0, - Number.isFinite(patch) ? patch : 0, - pre, - ]; -} - -function compareVersions(a: string, b: string): number { - const [aMaj, aMin, aPat, aPre] = parseSemverTuple(a); - const [bMaj, bMin, bPat, bPre] = parseSemverTuple(b); - if (aMaj !== bMaj) return aMaj < bMaj ? -1 : 1; - if (aMin !== bMin) return aMin < bMin ? -1 : 1; - if (aPat !== bPat) return aPat < bPat ? -1 : 1; - if (aPre === bPre) return 0; - if (!aPre) return 1; - if (!bPre) return -1; - return aPre < bPre ? -1 : 1; -} - function parseManualLatestFiles(value: unknown): Partial> { if (!isRecord(value)) { return {}; @@ -142,7 +108,7 @@ export async function findNewerManualShell(): Promise { return null; } const latest = await fetchManualLatest({forceRefresh: true}); - return compareVersions(latest.version, app.getVersion()) > 0 ? latest : null; + return compareShellVersions(latest.version, app.getVersion()) > 0 ? latest : null; } function sendManualUpdateAvailable( @@ -165,7 +131,7 @@ async function checkManualUpdate(context: UpdaterContext, getMainWindow: () => B try { const latest = await fetchManualLatest({forceRefresh: context === 'user'}); const current = app.getVersion(); - if (compareVersions(latest.version, current) > 0) { + if (compareShellVersions(latest.version, current) > 0) { sendManualUpdateAvailable(getMainWindow, context, latest); } else { send(getMainWindow(), {type: 'not-available', context}); diff --git a/fluxer_desktop/src/main/VoiceBackgroundMediaCache.ts b/fluxer_desktop/src/main/VoiceBackgroundMediaCache.ts index e6d2bc8cb..e56fb79ef 100644 --- a/fluxer_desktop/src/main/VoiceBackgroundMediaCache.ts +++ b/fluxer_desktop/src/main/VoiceBackgroundMediaCache.ts @@ -9,6 +9,7 @@ import type { VoiceBackgroundMediaKind, VoiceBackgroundMediaReadResult, } from '@electron/common/Types'; +import {writeFileAtomically} from '@electron/main/AtomicFile'; import {app, ipcMain} from 'electron'; const CACHE_DIR_NAME = 'voice-background-media'; @@ -179,14 +180,7 @@ async function cacheVoiceBackgroundMedia( const mediaKind = classifyMediaKind(mediaType, data); await fs.mkdir(cacheDir, {recursive: true}); const filePath = path.join(cacheDir, `${cacheKey}.${mediaType.extension}`); - const tempPath = path.join(cacheDir, `${cacheKey}.${process.pid}.${Date.now()}.tmp`); - await fs.writeFile(tempPath, data, {mode: 0o600}); - try { - await fs.rename(tempPath, filePath); - } catch (error) { - await fs.rm(tempPath, {force: true}); - throw error; - } + await writeFileAtomically(filePath, data, 0o600); await trimVoiceBackgroundMediaCache(cacheDir); return {path: filePath, mediaKind}; } diff --git a/fluxer_desktop/src/main/VoiceDebugEventSinkPopout.ts b/fluxer_desktop/src/main/VoiceDebugEventSinkPopout.ts index 30114a16e..ea5f7c2a6 100644 --- a/fluxer_desktop/src/main/VoiceDebugEventSinkPopout.ts +++ b/fluxer_desktop/src/main/VoiceDebugEventSinkPopout.ts @@ -2,7 +2,8 @@ import type {DesktopVoiceDebugEventSinkEntry} from '@electron/common/Types'; import {getDesktopDistributionPath} from '@electron/main/DesktopDistributionPath'; -import {focusWindow} from '@electron/main/Window'; +import {focusWindow, isAliveWindow} from '@electron/main/Window'; +import {isObjectLike as isRecord} from '@fluxer/record_utils/src/RecordGuards'; import {BrowserWindow, ipcMain} from 'electron'; import log from 'electron-log'; @@ -264,14 +265,6 @@ let eventSinkStatsHtml = ''; let pendingPopoutEntries: Array = []; let flushTimer: NodeJS.Timeout | null = null; -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object'; -} - -function isAliveWindow(window: BrowserWindow | null): window is BrowserWindow { - return Boolean(window && !window.isDestroyed()); -} - function truncateLine(line: string): string { if (line.length <= VOICE_DEBUG_EVENT_SINK_MAX_LINE_CHARS) return line; const omittedChars = line.length - VOICE_DEBUG_EVENT_SINK_MAX_LINE_CHARS; diff --git a/fluxer_desktop/src/main/Window.ts b/fluxer_desktop/src/main/Window.ts index f537d8929..928e0664e 100644 --- a/fluxer_desktop/src/main/Window.ts +++ b/fluxer_desktop/src/main/Window.ts @@ -352,7 +352,7 @@ export function getActiveAllowTransparency(): boolean { return initialAllowTransparency ?? false; } -function isAliveWindow(window: BrowserWindow | null): window is BrowserWindow { +export function isAliveWindow(window: BrowserWindow | null): window is BrowserWindow { return Boolean(window && !window.isDestroyed()); } @@ -416,15 +416,6 @@ export function focusThemeStudioPopoutWindow(): boolean { return true; } -export function closeThemeStudioPopoutWindow(): boolean { - const window = getThemeStudioPopoutWindow(); - if (!window) { - return false; - } - window.close(); - return true; -} - function isVoicePopoutWindowName(frameName: string | undefined): frameName is string { if (typeof frameName !== 'string') return false; if (!frameName.startsWith(VOICE_POPOUT_WINDOW_NAME_PREFIX)) return false; diff --git a/fluxer_desktop/src/main/WindowFlash.ts b/fluxer_desktop/src/main/WindowFlash.ts deleted file mode 100644 index 0afe3bbc4..000000000 --- a/fluxer_desktop/src/main/WindowFlash.ts +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {getMainWindow} from '@electron/main/Window'; - -export function flashWindowForAttention(persistent: boolean): void { - if (process.platform === 'darwin') return; - const mainWindow = getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) return; - if (mainWindow.isFocused()) return; - try { - mainWindow.flashFrame(persistent); - } catch {} -} - -export function stopFlashingWindow(): void { - const mainWindow = getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) return; - try { - mainWindow.flashFrame(false); - } catch {} -} diff --git a/fluxer_desktop/src/main/WindowsShortcuts.test.mjs b/fluxer_desktop/src/main/WindowsShortcuts.test.mjs index 9008b4efa..398eacc20 100644 --- a/fluxer_desktop/src/main/WindowsShortcuts.test.mjs +++ b/fluxer_desktop/src/main/WindowsShortcuts.test.mjs @@ -84,6 +84,19 @@ function loadWindowsShortcuts(initialFiles) { Buffer, process: {platform: 'win32', execPath: CURRENT_EXE, env: {APPDATA, USERPROFILE}}, require: (specifier) => { + if (specifier === '@electron/main/NativeAddon') { + const addonModule = {exports: {}}; + const addonSource = esbuild.transformSync(readFileSync(new URL('./NativeAddon.ts', import.meta.url), 'utf8'), { + loader: 'ts', + format: 'cjs', + target: 'node20', + }).code; + vm.runInContext('(function (module, exports) {' + addonSource + '\n})', context)( + addonModule, + addonModule.exports, + ); + return addonModule.exports; + } if (specifier === 'node:fs') { return {default: fakeFs, ...fakeFs}; } diff --git a/fluxer_desktop/src/main/WindowsShortcuts.ts b/fluxer_desktop/src/main/WindowsShortcuts.ts index 0c55874b0..6c981bbfe 100644 --- a/fluxer_desktop/src/main/WindowsShortcuts.ts +++ b/fluxer_desktop/src/main/WindowsShortcuts.ts @@ -10,6 +10,7 @@ import { WINDOWS_SHORTCUT_AUTHOR, WINDOWS_TOAST_ACTIVATOR_CLSID, } from '@electron/common/DesktopIdentity'; +import {loadOptionalNativeAddon} from '@electron/main/NativeAddon'; const requireModule = createRequire(import.meta.url); @@ -82,22 +83,6 @@ interface ShellLinkBinding { loadError: Error | null; } -let cachedBinding: ShellLinkBinding | null = null; - -function loadWinShell(): ShellLinkBinding | null { - if (cachedBinding) return cachedBinding; - try { - cachedBinding = requireModule('@fluxer/win-shell') as ShellLinkBinding; - } catch (error) { - console.warn('[WindowsShortcuts] Failed to load @fluxer/win-shell', error); - return null; - } - if (cachedBinding.loadError) { - console.warn('[WindowsShortcuts] @fluxer/win-shell load error', cachedBinding.loadError); - } - return cachedBinding; -} - function lnkContainsString(lnkPath: string, needle: string): boolean { let buf: Buffer; try { @@ -173,3 +158,11 @@ export function repairWindowsShortcuts(): void { console.warn('[WindowsShortcuts] Failed to repair Fluxer shortcuts', error); }); } + +const loadWinShell = loadOptionalNativeAddon({ + platform: 'win32', + load: () => requireModule('@fluxer/win-shell'), + logger: console, + level: 'warn', + unavailableMessage: '[WindowsShortcuts] Failed to load @fluxer/win-shell', +}); diff --git a/fluxer_desktop/src/preload/LegacyHarvestPreload.ts b/fluxer_desktop/src/preload/LegacyHarvestPreload.ts index c215b4eeb..ce6f9dc8f 100644 --- a/fluxer_desktop/src/preload/LegacyHarvestPreload.ts +++ b/fluxer_desktop/src/preload/LegacyHarvestPreload.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {errorMessage as describeError} from '@electron/common/ErrorMessage'; + import { DESKTOP_LEGACY_HARVEST_BLOB_REF_KEY, DESKTOP_LEGACY_HARVEST_CHANNELS, @@ -80,10 +82,6 @@ interface LegacyHarvestContext { totalBlobBytes: number; } -function describeError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function withTimeout(promise: Promise, timeoutMs: number, operation: string): Promise { return new Promise((resolve, reject) => { const timer = window.setTimeout(() => reject(new LegacyHarvestTimeoutError(operation, timeoutMs)), timeoutMs); diff --git a/fluxer_desktop/src/preload/LegacyRawStorageSeed.ts b/fluxer_desktop/src/preload/LegacyRawStorageSeed.ts index 699ac5fdb..f0ed41f5e 100644 --- a/fluxer_desktop/src/preload/LegacyRawStorageSeed.ts +++ b/fluxer_desktop/src/preload/LegacyRawStorageSeed.ts @@ -13,6 +13,7 @@ import { SHELL_HINT_STORAGE_KEY, ZOOM_PREBOOT_MIRROR_STORAGE_KEY, } from '@fluxer/desktop_ipc/src/StorageContract'; +import {isRecord} from '@fluxer/record_utils/src/RecordGuards'; const LEGACY_SESSION_TOKEN_KEY = 'token'; const LEGACY_SESSION_USER_ID_KEY = 'userId'; @@ -26,10 +27,6 @@ interface LegacyRawStorageSeedRenderer { sendSync: (channel: string, ...args: Array) => unknown; } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - function parseJSON(value: unknown): unknown { if (typeof value !== 'string') return null; try { diff --git a/fluxer_desktop/src/preload/NativeGatewayPreload.ts b/fluxer_desktop/src/preload/NativeGatewayPreload.ts index db6254b7d..a429a3f9a 100644 --- a/fluxer_desktop/src/preload/NativeGatewayPreload.ts +++ b/fluxer_desktop/src/preload/NativeGatewayPreload.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {reconstructNativeGatewayTransportEvent} from '@electron/preload/PreloadNativeGatewayEvent'; +import {parseNativeGatewayTransportEvent} from '@electron/common/NativeGatewayTransportEvent'; import { NATIVE_GATEWAY_TRANSPORT_AVAILABLE_RENDERER_ARG, NATIVE_GATEWAY_TRANSPORT_CHANNELS, @@ -72,7 +72,7 @@ export function createNativeGatewayPreloadAPI( const listener = (_event: unknown, payload: unknown): void => { let reconstructed: NativeGatewayTransportEvent; try { - reconstructed = reconstructNativeGatewayTransportEvent(payload); + reconstructed = parseNativeGatewayTransportEvent(payload); } catch { return; } diff --git a/fluxer_desktop/src/preload/index.ts b/fluxer_desktop/src/preload/index.ts index 07ef67767..81cc1ddab 100644 --- a/fluxer_desktop/src/preload/index.ts +++ b/fluxer_desktop/src/preload/index.ts @@ -1,3 +1,4 @@ +import {isRecord} from '@fluxer/record_utils/src/RecordGuards'; // SPDX-License-Identifier: AGPL-3.0-or-later import {BUILD_CHANNEL} from '@electron/common/BuildChannel'; @@ -107,10 +108,6 @@ interface AccessibilityStartupSettings { reducedMotionOverride: boolean | null; } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - const NATIVE_SCREEN_CAPTURE_LIFECYCLE_KINDS: ReadonlySet = new Set([ 'error', 'closed', @@ -460,7 +457,7 @@ const api: ElectronAPI = { }, windowIsMaximized: (): Promise => ipcRenderer.invoke('window-is-maximized'), focusThemeStudioPopout: (): Promise => ipcRenderer.invoke('theme-studio-popout-focus'), - closeThemeStudioPopout: (): Promise => ipcRenderer.invoke('theme-studio-popout-close'), + popoutSetAlwaysOnTop: (key: string, flag: boolean): Promise => ipcRenderer.invoke('popout:set-always-on-top', key, flag), popoutFocus: (key: string): Promise => ipcRenderer.invoke('popout:focus', key), @@ -663,7 +660,7 @@ const api: ElectronAPI = { globalKeyHookUnregister: (id: string): Promise => ipcRenderer.invoke('global-key-hook-unregister', id), globalKeyHookUnregisterAll: (): Promise => ipcRenderer.invoke('global-key-hook-unregister-all'), linuxEvdevStatus: () => ipcRenderer.invoke('linux-evdev-status'), - linuxEvdevGrantAccess: () => ipcRenderer.invoke('linux-evdev-grant-access'), + onGlobalKeyEvent: (callback: (event: GlobalKeyEvent) => void): (() => void) => { const handler = (_event: Electron.IpcRendererEvent, data: GlobalKeyEvent): void => { callback(data); diff --git a/fluxer_desktop/src/preload/splash.cjs b/fluxer_desktop/src/preload/splash.cjs index dec246bbd..7392d8648 100644 --- a/fluxer_desktop/src/preload/splash.cjs +++ b/fluxer_desktop/src/preload/splash.cjs @@ -19,13 +19,6 @@ const COUNTDOWN_INTERVAL_MS = 1000; const SPLASH_BRANDING_WAIT_MS = 400; const SPLASH_FONT_FACES = Object.freeze(['400 12px "Fluxer Sans"', '500 16px "Fluxer Sans"']); const NON_BREAKING_SPACE = '\u00a0'; -const SPLASH_ACTION_LABEL_MAX_LENGTH = 48; -const SPLASH_MESSAGE_MAX_LENGTH = 160; -const SPLASH_VERSION_LABEL_MAX_LENGTH = 64; -const SPLASH_OPTION_LABEL_MAX_LENGTH = 48; -const SPLASH_OPTION_BUTTON_LABEL_MAX_LENGTH = 24; -const SPLASH_OPTION_MAX_COUNT = 8; -const SPLASH_OPTION_VALUE_PATTERN = /^[a-z0-9_]{1,32}$/; const DIAGNOSTICS_REVEAL_MS = 30000; const DIAGNOSTICS_COPIED_MS = 2000; const BYTES_PER_MEGABYTE = 1000000; @@ -57,8 +50,6 @@ const SplashStatus = Object.freeze({ WAITING_LOCAL_NETWORK: 'waiting-local-network', }); -const KNOWN_LAYOUTS = new Set(Object.values(SplashLayout)); -const KNOWN_STATUSES = new Set(Object.values(SplashStatus)); const PROGRESS_STATUSES = new Set([ SplashStatus.DOWNLOADING_UPDATES, SplashStatus.DOWNLOAD_STALLED, @@ -81,7 +72,22 @@ const ACTION_CHANNELS = new Map([ ['download', SPLASH_OPEN_DOWNLOAD_CHANNEL], ['quit', SPLASH_QUIT_CHANNEL], ]); -const SPLASH_FALLBACK_ACTION = Object.freeze({kind: 'quit', label: 'Quit'}); +const DEFAULT_STATE = Object.freeze({ + layout: SplashLayout.SPLASH, + status: SplashStatus.CHECKING_FOR_UPDATES, + requiredSecurityUpdate: false, + current: null, + total: null, + progress: null, + seconds: null, + receivedBytes: null, + totalBytes: null, + bytesPerSecond: null, + action: null, + message: null, + versionLabel: null, + options: null, +}); let splashState = null; let renderedLayout = null; @@ -92,92 +98,6 @@ let splashStartedAt = Number.POSITIVE_INFINITY; let diagnosticsRevealTimer = null; let diagnosticsCopiedTimer = null; -function toCount(value) { - if (typeof value !== 'number' || !Number.isFinite(value)) return null; - return Math.max(0, Math.trunc(value)); -} - -function toText(value, maxLength) { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - if (trimmed.length === 0) return null; - return trimmed.slice(0, maxLength); -} - -function normalizeAction(value) { - if (value == null || typeof value !== 'object') return null; - if (typeof value.kind !== 'string' || !ACTION_CHANNELS.has(value.kind)) return null; - const label = toText(value.label, SPLASH_ACTION_LABEL_MAX_LENGTH); - if (label == null) return null; - return {kind: value.kind, label}; -} - -function normalizeOption(value) { - if (value == null || typeof value !== 'object') return null; - if (typeof value.value !== 'string' || !SPLASH_OPTION_VALUE_PATTERN.test(value.value)) return null; - if (typeof value.kind !== 'string' || !ACTION_CHANNELS.has(value.kind)) return null; - const label = toText(value.label, SPLASH_OPTION_LABEL_MAX_LENGTH); - if (label == null) return null; - const buttonLabel = toText(value.buttonLabel, SPLASH_OPTION_BUTTON_LABEL_MAX_LENGTH); - if (buttonLabel == null) return null; - return {value: value.value, label, buttonLabel, kind: value.kind}; -} - -function normalizeOptions(value) { - if (!Array.isArray(value)) return null; - const accepted = []; - for (const entry of value.slice(0, SPLASH_OPTION_MAX_COUNT)) { - const option = normalizeOption(entry); - if (option != null) { - accepted.push(option); - } - } - return accepted.length === 0 ? null : accepted; -} - -function withManualUpdateAffordance(state) { - if (state.layout !== SplashLayout.MANUAL_UPDATE) return state; - if (state.options != null || state.action != null) return state; - return {...state, action: SPLASH_FALLBACK_ACTION}; -} - -function normalizeState(payload) { - if (payload == null || typeof payload !== 'object') { - return { - layout: SplashLayout.SPLASH, - status: SplashStatus.CHECKING_FOR_UPDATES, - requiredSecurityUpdate: false, - current: null, - total: null, - progress: null, - seconds: null, - receivedBytes: null, - totalBytes: null, - bytesPerSecond: null, - action: null, - message: null, - versionLabel: null, - options: null, - }; - } - return withManualUpdateAffordance({ - layout: KNOWN_LAYOUTS.has(payload.layout) ? payload.layout : SplashLayout.SPLASH, - status: KNOWN_STATUSES.has(payload.status) ? payload.status : SplashStatus.CHECKING_FOR_UPDATES, - requiredSecurityUpdate: payload.requiredSecurityUpdate === true, - current: toCount(payload.current), - total: toCount(payload.total), - progress: typeof payload.progress === 'number' && Number.isFinite(payload.progress) ? payload.progress : null, - seconds: toCount(payload.seconds), - receivedBytes: toCount(payload.receivedBytes), - totalBytes: toCount(payload.totalBytes), - bytesPerSecond: toCount(payload.bytesPerSecond), - action: normalizeAction(payload.action), - message: toText(payload.message, SPLASH_MESSAGE_MAX_LENGTH), - versionLabel: toText(payload.versionLabel, SPLASH_VERSION_LABEL_MAX_LENGTH), - options: normalizeOptions(payload.options), - }); -} - function getStatusText(state) { switch (state.status) { case SplashStatus.DOWNLOADING_UPDATES: @@ -499,7 +419,7 @@ function buildManualUpdatePicker(state) { function render() { const mount = document.getElementById(SPLASH_MOUNT_ID); if (mount == null) return; - const state = splashState ?? normalizeState(null); + const state = splashState ?? DEFAULT_STATE; if (state.layout === SplashLayout.MANUAL_UPDATE) { const signature = manualUpdateSignature(state); if (renderedLayout !== SplashLayout.MANUAL_UPDATE || renderedManualSignature !== signature) { @@ -562,7 +482,7 @@ function syncCountdown(state) { } ipcRenderer.on(SPLASH_STATE_CHANNEL, (_event, payload) => { - splashState = normalizeState(payload); + splashState = payload ?? DEFAULT_STATE; syncCountdown(splashState); render(); }); diff --git a/fluxer_desktop/tsconfig.json b/fluxer_desktop/tsconfig.json index 823e0664f..078014d68 100644 --- a/fluxer_desktop/tsconfig.json +++ b/fluxer_desktop/tsconfig.json @@ -14,7 +14,6 @@ "jsx": "react-jsx", "paths": { "@electron/*": ["./src/*"], - "@fluxer/voice_engine_v2": ["../packages/voice_engine_v2/src/index.ts"], "@fluxer/voice_engine_v2/*": ["../packages/voice_engine_v2/*"], "@fluxer/*": ["../packages/*", "../packages/*/src/index.ts"] } diff --git a/fluxer_docs/package.json b/fluxer_docs/package.json index 394589dde..34580bf75 100644 --- a/fluxer_docs/package.json +++ b/fluxer_docs/package.json @@ -25,7 +25,8 @@ "astro": "catalog:", "send": "catalog:", "sharp": "catalog:", - "tsx": "catalog:" + "tsx": "catalog:", + "@fluxer/constants": "workspace:*" }, "devDependencies": { "@astrojs/check": "catalog:", diff --git a/fluxer_docs/scripts/DocsSource.ts b/fluxer_docs/scripts/DocsSource.ts index e0a44e16c..862547ffc 100644 --- a/fluxer_docs/scripts/DocsSource.ts +++ b/fluxer_docs/scripts/DocsSource.ts @@ -103,3 +103,17 @@ export function slugifyHeading(heading: string): string { .trim() .replace(/\s+/gu, '-'); } + +export function stripVersion(routePath: string): string { + if (routePath.startsWith('/v1/')) { + return routePath.slice(3); + } + return routePath; +} + +export function pageSlug(relativePath: string): string { + return relativePath + .replace(/\.(mdx|md)$/u, '') + .replace(/\/index$/u, '') + .replace(/^index$/u, ''); +} diff --git a/fluxer_docs/scripts/StartServer.ts b/fluxer_docs/scripts/StartServer.ts index 7b42f2c7b..aaf7f2b96 100644 --- a/fluxer_docs/scripts/StartServer.ts +++ b/fluxer_docs/scripts/StartServer.ts @@ -5,11 +5,7 @@ import {docsListenHost, docsListenPort, docsPublicEndpoint} from '@/server/DocsC await runAstroServer({ entrypoint: new URL('../dist/server/entry.mjs', import.meta.url), - initialize: null, listenHost: docsListenHost(), listenPort: docsListenPort(), publicEndpoint: docsPublicEndpoint(), - readiness: null, - requestLocals: null, - shutdown: null, }); diff --git a/fluxer_docs/scripts/VerifyDocsCoverage.ts b/fluxer_docs/scripts/VerifyDocsCoverage.ts index 6914a9ed1..6734c72d6 100644 --- a/fluxer_docs/scripts/VerifyDocsCoverage.ts +++ b/fluxer_docs/scripts/VerifyDocsCoverage.ts @@ -1,3 +1,4 @@ +import {stripVersion as stripVersionPrefix} from './DocsSource.ts'; // SPDX-License-Identifier: AGPL-3.0-or-later import {spawnSync} from 'node:child_process'; @@ -279,16 +280,6 @@ interface EffectiveSpecSecurity { readonly allowsAnonymous: boolean; } -function stripVersionPrefix(routePath: string): string { - if (routePath === '/v1') { - return '/'; - } - if (routePath.startsWith('/v1/')) { - return routePath.slice(3); - } - return routePath; -} - function indexDocumentedRoutes(routes: ReadonlyArray): Map { const index = new Map(); for (const route of routes) { diff --git a/fluxer_docs/scripts/VerifyDocsSchemas.ts b/fluxer_docs/scripts/VerifyDocsSchemas.ts index ec5f7c542..a0618274d 100644 --- a/fluxer_docs/scripts/VerifyDocsSchemas.ts +++ b/fluxer_docs/scripts/VerifyDocsSchemas.ts @@ -13,23 +13,25 @@ import { DOCS_ROOT, HTTP_METHODS, type MarkdownPage, + pageSlug, readMarkdownPages, routeShape, slugifyHeading, splitTableRow, + stripVersion, } from './DocsSource.ts'; const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url)); const MAIN_SPEC = path.join(REPO_ROOT, 'fluxer_api/src/api/openapi/openapi.json'); const ADMIN_SPEC = path.join(REPO_ROOT, 'fluxer_admin/openapi-admin.json'); -export interface Mismatch { +interface Mismatch { readonly page: string; readonly operation: string; readonly kind: string; readonly detail: string; } -export interface SchemaCounters { +interface SchemaCounters { bodyTables: number; bodiesByReference: number; unionBodies: number; @@ -111,13 +113,6 @@ interface ObjectBlock { readonly declaredOnly: Set; } -function stripVersion(routePath: string): string { - if (routePath.startsWith('/v1/')) { - return routePath.slice(3); - } - return routePath; -} - function resolveSchemaPointer(spec: Spec, reference: string): SchemaNode | boolean { if (!reference.startsWith('#')) throw new Error(`Unsupported schema reference: ${reference}`); const pointer = decodeURIComponent(reference.slice(1)); @@ -384,13 +379,6 @@ function operationIndex(spec: Spec): Map { return index; } -function pageSlug(relativePath: string): string { - return relativePath - .replace(/\.(mdx|md)$/u, '') - .replace(/\/index$/u, '') - .replace(/^index$/u, ''); -} - function documentReferences(page: string, line: string): Set { const references = new Set(); for (const link of line.matchAll(/\]\(([^)\s]*)#([a-z0-9-]+)\)/gu)) { diff --git a/fluxer_docs/scripts/VerifyDocsSidebar.ts b/fluxer_docs/scripts/VerifyDocsSidebar.ts index d362dbd81..2bc9434fb 100644 --- a/fluxer_docs/scripts/VerifyDocsSidebar.ts +++ b/fluxer_docs/scripts/VerifyDocsSidebar.ts @@ -3,7 +3,7 @@ import {readFile} from 'node:fs/promises'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; -import {DOCS_ROOT, listDraftMarkdownFiles, listMarkdownFiles, slugifyHeading} from './DocsSource.ts'; +import {DOCS_ROOT, listDraftMarkdownFiles, listMarkdownFiles, pageSlug, slugifyHeading} from './DocsSource.ts'; const ASTRO_CONFIG = fileURLToPath(new URL('../astro.config.ts', import.meta.url)); @@ -11,15 +11,6 @@ const SIDEBAR_START = 'sidebar: ['; const QUOTED_ENTRY = /'([^']+)'/gu; const LINK_ENTRY = /link:\s*'([^']+)'/gu; -function slugOf(file: string): string { - const relative = path.relative(DOCS_ROOT, file); - const withoutExtension = relative.replace(/\.(mdx|md)$/u, ''); - if (withoutExtension === 'index') { - return ''; - } - return withoutExtension.replace(/\/index$/u, ''); -} - function sidebarBlock(source: string): string { const start = source.indexOf(SIDEBAR_START); if (start === -1) { @@ -70,7 +61,7 @@ for (const match of block.matchAll(QUOTED_ENTRY)) { } const files = await listMarkdownFiles(DOCS_ROOT); -const actual = new Set(files.map(slugOf)); +const actual = new Set(files.map((file) => pageSlug(path.relative(DOCS_ROOT, file)))); const missingPages = [...referenced].filter((slug) => !actual.has(slug)).sort(); const orphanPages = [...actual].filter((slug) => !referenced.has(slug)).sort(); @@ -107,7 +98,7 @@ const anchorable = new Set(actual); const anchorsByPage = new Map>(); const ambiguousByPage = new Map>(); for (const file of files) { - const slug = slugOf(file); + const slug = pageSlug(path.relative(DOCS_ROOT, file)); const source = await readFile(file, 'utf8'); const anchors = new Set(); const counts = new Map(); @@ -134,7 +125,7 @@ for (const file of files) { for (const file of files) { const relative = path.relative(DOCS_ROOT, file); - const selfSlug = slugOf(file); + const selfSlug = pageSlug(path.relative(DOCS_ROOT, file)); const source = await readFile(file, 'utf8'); for (const match of source.matchAll(INTERNAL_LINK)) { const target = match[1].replace(/^\//u, '').replace(/\/$/u, ''); diff --git a/fluxer_docs/src/content/docs/http-api/errors.md b/fluxer_docs/src/content/docs/http-api/errors.md index e552ad01b..437118cb4 100644 --- a/fluxer_docs/src/content/docs/http-api/errors.md +++ b/fluxer_docs/src/content/docs/http-api/errors.md @@ -174,10 +174,6 @@ You're already friends with this user This action is only available in announcement channels -### `APPLICATION_NOT_OWNED` - -You don't own this application - ### `BAD_GATEWAY` Bad gateway diff --git a/fluxer_docs/src/content/docs/operator/configuration.mdx b/fluxer_docs/src/content/docs/operator/configuration.mdx index 2dc8ba230..2665be3d5 100644 --- a/fluxer_docs/src/content/docs/operator/configuration.mdx +++ b/fluxer_docs/src/content/docs/operator/configuration.mdx @@ -1132,10 +1132,6 @@ Falls back to `FLUXER_GATEWAY_LOGGER_LEVEL`. The Gateway log level at runtime. O Default `dev`. The reported build. Baked into the images, so Compose does not set it. When `BUILD_VERSION` is unset or empty outside development, the process prints a warning. -#### `RELEASE_CHANNEL` - -Default `stable`. The reported channel. Only `canary` is recognised as non-stable. Read by `app-proxy` only. It describes the shipped build, so Compose does not set it. - #### `HOSTNAME` Set by Docker. Node identity in metrics and logs. @@ -1746,7 +1742,7 @@ Compose writes each of these from the `.env` name given, so setting the name its #### Build and platform metadata -`BUILD_VERSION`, `FLUXER_BUILD_VERSION` and `RELEASE_CHANNEL` describe the shipped build. `POD_NAME` and `HOSTNAME` come from the platform. +`BUILD_VERSION` and `FLUXER_BUILD_VERSION` describe the shipped build. `POD_NAME` and `HOSTNAME` come from the platform. #### Test and development switches diff --git a/fluxer_docs/src/server/AstroServer.ts b/fluxer_docs/src/server/AstroServer.ts index e4713807e..e0f3a0f43 100644 --- a/fluxer_docs/src/server/AstroServer.ts +++ b/fluxer_docs/src/server/AstroServer.ts @@ -4,16 +4,15 @@ import {lstat} from 'node:fs/promises'; import {createServer, type IncomingMessage, type Server, type ServerResponse} from 'node:http'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; +import {HttpStatus, MimeType} from '@fluxer/constants/src/HttpConstants'; import send from 'send'; -import {type AstroServerShutdown, AstroServerTermination} from '@/server/AstroServerTermination'; +import {AstroServerTermination} from '@/server/AstroServerTermination'; import { CanonicalNetworkProtocol, getNodeErrorCode, HTTPHeader, HTTPMethod, - HTTPStatusCode, isCanonicalHTTPNetworkProtocol, - MIMEType, NodeErrorCode, OUTBOUND_USER_AGENT, } from '@/server/HTTPConstants'; @@ -21,7 +20,6 @@ import {siteSecurityHeaders} from '@/server/SecurityHeaders'; const HEALTH_PATH = '/_health'; const HEALTH_RESPONSE_BODY = JSON.stringify({status: 'ok'}); -const NOT_READY_RESPONSE_BODY = JSON.stringify({status: 'not_ready'}); const WELL_KNOWN_PATH_PREFIX = '.well-known/'; const IMMUTABLE_ASSET_PATH_PREFIX = '_astro/'; const PARENT_SEGMENT_PATTERN = /(?:^|[\\/])\.\.(?:[\\/]|$)/u; @@ -41,11 +39,10 @@ const ASTRO_SERVER_KEEP_ALIVE_TIMEOUT_MS = 5_000; const ASTRO_SERVER_SOCKET_TIMEOUT_MS = 30_000; const ALLOWED_SITE_METHODS = `${HTTPMethod.GET}, ${HTTPMethod.HEAD}`; const NO_STORE_CACHE_CONTROL = 'no-store'; -const TEXT_CONTENT_TYPE = `${MIMEType.PLAIN}; charset=utf-8`; +const TEXT_CONTENT_TYPE = `${MimeType.PLAIN}; charset=utf-8`; const AstroServerStartupPhase = Object.freeze({ ENTRYPOINT: 'entrypoint load', - INITIALIZE: 'initialization', LISTEN: 'listener bind', } as const); @@ -87,21 +84,11 @@ interface ResolvedPublicEndpoint { readonly secureOrigin: boolean; } -type AstroServerInitialize = () => Promise; - -type AstroServerReadiness = () => boolean; - -type AstroServerRequestLocals = () => object; - interface AstroServerOptions { readonly entrypoint: URL; - readonly initialize: AstroServerInitialize | null; readonly listenHost: string; readonly listenPort: number; readonly publicEndpoint: string; - readonly readiness: AstroServerReadiness | null; - readonly requestLocals: AstroServerRequestLocals | null; - readonly shutdown: AstroServerShutdown | null; } interface AstroServerHealthcheckOptions { @@ -302,36 +289,21 @@ async function resolveStaticFile(clientDirectory: string, staticPath: string): P return {isDirectory, normalizedPath}; } -function respondToHealthcheck( - request: IncomingMessage, - requestURL: URL, - response: ServerResponse, - readiness: AstroServerReadiness | null, -): boolean { +function respondToHealthcheck(request: IncomingMessage, requestURL: URL, response: ServerResponse): boolean { if (requestURL.pathname !== HEALTH_PATH) { return false; } - if (isAstroServerReady(readiness)) { - return writeHealthcheckResponse(request, response, HTTPStatusCode.OK, HEALTH_RESPONSE_BODY); - } - return writeHealthcheckResponse(request, response, HTTPStatusCode.SERVICE_UNAVAILABLE, NOT_READY_RESPONSE_BODY); -} - -function isAstroServerReady(readiness: AstroServerReadiness | null): boolean { - if (readiness == null) { - return true; - } - return readiness(); + return writeHealthcheckResponse(request, response, HttpStatus.OK, HEALTH_RESPONSE_BODY); } function writeHealthcheckResponse( request: IncomingMessage, response: ServerResponse, - status: HTTPStatusCode, + status: number, body: string, ): boolean { response.writeHead(status, { - [HTTPHeader.CONTENT_TYPE]: MIMEType.JSON, + [HTTPHeader.CONTENT_TYPE]: MimeType.JSON, [HTTPHeader.CONTENT_LENGTH]: Buffer.byteLength(body).toString(), [HTTPHeader.CACHE_CONTROL]: NO_STORE_CACHE_CONTROL, }); @@ -343,12 +315,7 @@ function writeHealthcheckResponse( return true; } -function writeTextResponse( - request: IncomingMessage, - response: ServerResponse, - status: HTTPStatusCode, - body: string, -): void { +function writeTextResponse(request: IncomingMessage, response: ServerResponse, status: number, body: string): void { response.writeHead(status, { [HTTPHeader.CONTENT_TYPE]: TEXT_CONTENT_TYPE, [HTTPHeader.CONTENT_LENGTH]: Buffer.byteLength(body).toString(), @@ -361,7 +328,7 @@ function writeTextResponse( response.end(body); } -function writeEmptyResponse(response: ServerResponse, status: HTTPStatusCode): void { +function writeEmptyResponse(response: ServerResponse, status: number): void { response.writeHead(status, { [HTTPHeader.CONTENT_LENGTH]: '0', [HTTPHeader.CACHE_CONTROL]: NO_STORE_CACHE_CONTROL, @@ -369,7 +336,7 @@ function writeEmptyResponse(response: ServerResponse, status: HTTPStatusCode): v response.end(); } -function rejectRequest(response: ServerResponse, status: HTTPStatusCode, allow: string | null): void { +function rejectRequest(response: ServerResponse, status: number, allow: string | null): void { response.shouldKeepAlive = false; response.setHeader(HTTPHeader.CONTENT_LENGTH, '0'); response.setHeader(HTTPHeader.CACHE_CONTROL, NO_STORE_CACHE_CONTROL); @@ -409,13 +376,12 @@ function sendFailure(request: IncomingMessage, response: ServerResponse, error: response.destroy(resolvedError); return; } - writeTextResponse(request, response, HTTPStatusCode.INTERNAL_SERVER_ERROR, 'Internal server error'); + writeTextResponse(request, response, HttpStatus.INTERNAL_SERVER_ERROR, 'Internal server error'); } interface AstroHandlerRequest { readonly handler: AstroRequestHandler; readonly request: IncomingMessage; - readonly requestLocals: AstroServerRequestLocals | null; readonly response: ServerResponse; } @@ -425,22 +391,18 @@ interface StaticOrRenderRequest extends AstroHandlerRequest { readonly requestURL: URL; } -async function runAstroHandler({handler, request, requestLocals, response}: AstroHandlerRequest): Promise { +async function runAstroHandler({handler, request, response}: AstroHandlerRequest): Promise { const next = (error: unknown): void => { if (error != null) { sendFailure(request, response, error); return; } if (!response.writableEnded) { - writeEmptyResponse(response, HTTPStatusCode.NOT_FOUND); + writeEmptyResponse(response, HttpStatus.NOT_FOUND); } }; try { - if (requestLocals == null) { - await handler(request, response, next, {}); - return; - } - await handler(request, response, next, requestLocals()); + await handler(request, response, next, {}); } catch (error) { sendFailure(request, response, error); } @@ -456,21 +418,20 @@ async function serveStaticOrRender({ basePath, request, requestURL, - requestLocals, response, }: StaticOrRenderRequest): Promise { const requestPath = resolveStaticRequestPath(request, requestURL, basePath); if (requestPath == null) { - await runAstroHandler({handler, request, requestLocals, response}); + await runAstroHandler({handler, request, response}); return; } const staticFile = await resolveStaticFile(clientDirectory, requestPath.staticPath); if (staticFile == null) { - writeTextResponse(request, response, HTTPStatusCode.BAD_REQUEST, 'Bad request'); + writeTextResponse(request, response, HttpStatus.BAD_REQUEST, 'Bad request'); return; } if (shouldRedirectStaticDirectory(staticFile, requestPath)) { - response.writeHead(HTTPStatusCode.MOVED_PERMANENTLY, { + response.writeHead(HttpStatus.MOVED_PERMANENTLY, { [HTTPHeader.LOCATION]: `${requestPath.pathname}/${requestPath.search}`, [HTTPHeader.CONTENT_LENGTH]: '0', [HTTPHeader.CACHE_CONTROL]: 'no-cache', @@ -505,7 +466,7 @@ async function serveStaticOrRender({ }); stream.on('error', (error) => { if (!fileOpened) { - runAstroHandler({handler, request, requestLocals, response}).catch((handlerError: unknown) => { + runAstroHandler({handler, request, response}).catch((handlerError: unknown) => { sendFailure(request, response, handlerError); }); return; @@ -551,10 +512,6 @@ export async function runAstroServer(options: AstroServerOptions): Promise process.on('beforeExit', requireStartupProgress); try { const entrypoint = await loadServerEntrypoint(options.entrypoint); - if (options.initialize != null) { - startupPhase = AstroServerStartupPhase.INITIALIZE; - await options.initialize(); - } const clientDirectory = fileURLToPath(new URL('../client/', options.entrypoint)); let activeRequests = 0; const handleRequest = (request: IncomingMessage, response: ServerResponse): void => { @@ -563,22 +520,22 @@ export async function runAstroServer(options: AstroServerOptions): Promise } const parsedRequestURL = requestURL(request); if (parsedRequestURL == null) { - rejectRequest(response, HTTPStatusCode.BAD_REQUEST, null); + rejectRequest(response, HttpStatus.BAD_REQUEST, null); return; } if (request.method !== HTTPMethod.GET && request.method !== HTTPMethod.HEAD) { - rejectRequest(response, HTTPStatusCode.METHOD_NOT_ALLOWED, ALLOWED_SITE_METHODS); + rejectRequest(response, HttpStatus.METHOD_NOT_ALLOWED, ALLOWED_SITE_METHODS); return; } if (requestDeclaresBody(request)) { - rejectRequest(response, HTTPStatusCode.PAYLOAD_TOO_LARGE, null); + rejectRequest(response, HttpStatus.PAYLOAD_TOO_LARGE, null); return; } - if (respondToHealthcheck(request, parsedRequestURL, response, options.readiness)) { + if (respondToHealthcheck(request, parsedRequestURL, response)) { return; } if (activeRequests >= ASTRO_SERVER_MAX_ACTIVE_REQUESTS) { - rejectRequest(response, HTTPStatusCode.SERVICE_UNAVAILABLE, null); + rejectRequest(response, HttpStatus.SERVICE_UNAVAILABLE, null); return; } activeRequests += 1; @@ -598,7 +555,6 @@ export async function runAstroServer(options: AstroServerOptions): Promise basePath, request, requestURL: parsedRequestURL, - requestLocals: options.requestLocals, response, }).catch((error: unknown) => { sendFailure(request, response, error); @@ -620,7 +576,6 @@ export async function runAstroServer(options: AstroServerOptions): Promise new AstroServerTermination({ closeListener: () => closeServerListener(server), serverName: ASTRO_SERVER_NAME, - shutdown: options.shutdown, timeoutMs: ASTRO_SERVER_SHUTDOWN_TIMEOUT_MS, }).install(); startupPhase = AstroServerStartupPhase.LISTEN; diff --git a/fluxer_docs/src/server/AstroServerTermination.ts b/fluxer_docs/src/server/AstroServerTermination.ts index 59d7397b0..a2e3b8664 100644 --- a/fluxer_docs/src/server/AstroServerTermination.ts +++ b/fluxer_docs/src/server/AstroServerTermination.ts @@ -1,11 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -export type AstroServerShutdown = () => Promise; - interface AstroServerTerminationDependencies { readonly closeListener: () => Promise; readonly serverName: string; - readonly shutdown: AstroServerShutdown | null; readonly timeoutMs: number; } @@ -64,15 +61,6 @@ export class AstroServerTermination { } catch (cleanupFailure) { failures.push(cleanupFailure); } - const shutdown = this.dependencies.shutdown; - if (shutdown == null) { - return failures; - } - try { - await shutdown(); - } catch (cleanupFailure) { - failures.push(cleanupFailure); - } return failures; } } diff --git a/fluxer_docs/src/server/HTTPConstants.ts b/fluxer_docs/src/server/HTTPConstants.ts index 82ddfbc2b..d40f01557 100644 --- a/fluxer_docs/src/server/HTTPConstants.ts +++ b/fluxer_docs/src/server/HTTPConstants.ts @@ -7,24 +7,6 @@ export const HTTPMethod = Object.freeze({ export type HTTPMethod = (typeof HTTPMethod)[keyof typeof HTTPMethod]; -export const HTTPStatusCode = Object.freeze({ - OK: 200, - MOVED_PERMANENTLY: 301, - BAD_REQUEST: 400, - NOT_FOUND: 404, - METHOD_NOT_ALLOWED: 405, - PAYLOAD_TOO_LARGE: 413, - INTERNAL_SERVER_ERROR: 500, - SERVICE_UNAVAILABLE: 503, -} as const); - -export type HTTPStatusCode = (typeof HTTPStatusCode)[keyof typeof HTTPStatusCode]; - -export const MIMEType = Object.freeze({ - JSON: 'application/json', - PLAIN: 'text/plain', -} as const); - export const HTTPHeader = Object.freeze({ ALLOW: 'Allow', CACHE_CONTROL: 'Cache-Control', diff --git a/fluxer_gateway/rebar.config b/fluxer_gateway/rebar.config index c74f8f6f1..1d880dd2c 100644 --- a/fluxer_gateway/rebar.config +++ b/fluxer_gateway/rebar.config @@ -46,9 +46,6 @@ {include_erts, true} ]} ]}, - {dev, [ - {erl_opts, [debug_info, nowarn_deprecated_function, {d, 'DEV_MODE'}]} - ]}, {test, [ {erl_opts, [{d, 'TEST'}]}, {deps, [ diff --git a/fluxer_gateway/src/call/call.erl b/fluxer_gateway/src/call/call.erl index ee40c22ed..4b9a59b0c 100644 --- a/fluxer_gateway/src/call/call.erl +++ b/fluxer_gateway/src/call/call.erl @@ -30,11 +30,9 @@ | {disconnect_user_if_in_channel, integer(), integer(), binary() | undefined} | {leave, binary()} | {update_voice_state, integer(), map()} - | {get_sessions} - | {get_pending_connections}. + | {get_sessions}. -type cast_request() :: - {join_async, integer(), map(), binary(), pid()} - | {set_caller, map()} + {set_caller, map()} | {voice_signal, binary(), binary(), map()}. -type info_message() :: {'DOWN', reference(), process, pid(), term()} @@ -150,9 +148,7 @@ handle_call_request({leave, SessionId}, State) -> handle_call_request({update_voice_state, UserId, VoiceState}, State) -> handle_update_voice_state(UserId, VoiceState, State); handle_call_request({get_sessions}, State) -> - handle_get_sessions(State); -handle_call_request({get_pending_connections}, State) -> - handle_get_pending_connections(State). + handle_get_sessions(State). -spec handle_join_request( integer(), map(), binary(), pid(), binary() | undefined, pos_integer() | undefined, map() @@ -170,8 +166,6 @@ handle_join_request( -spec handle_cast(term(), map()) -> {noreply, map()}. handle_cast(Request, State) -> case decode_cast_request(Request) of - {ok, {join_async, UserId, VoiceState, SessionId, SessionPid}} -> - call_voice:handle_join_async(UserId, VoiceState, SessionId, SessionPid, State); {ok, {set_caller, Caller}} -> {noreply, call_state:put_caller(Caller, State)}; {ok, {voice_signal, SessionId, To, Data}} -> @@ -304,8 +298,6 @@ decode_session_call_request({update_voice_state, UserId, VoiceState}) when {ok, {update_voice_state, UserId, VoiceState}}; decode_session_call_request({get_sessions}) -> {ok, {get_sessions}}; -decode_session_call_request({get_pending_connections}) -> - {ok, {get_pending_connections}}; decode_session_call_request(_) -> error. @@ -338,10 +330,6 @@ decode_integer_list([_Value | _Rest], _Acc) -> error. -spec decode_cast_request(term()) -> {ok, cast_request()} | error. -decode_cast_request({join_async, UserId, VoiceState, SessionId, SessionPid}) when - is_integer(UserId), is_map(VoiceState), is_binary(SessionId), is_pid(SessionPid) --> - {ok, {join_async, UserId, VoiceState, SessionId, SessionPid}}; decode_cast_request({set_caller, Caller}) when is_map(Caller) -> {ok, {set_caller, Caller}}; decode_cast_request({voice_signal, SessionId, To, Data}) when @@ -445,13 +433,6 @@ handle_get_sessions(State) -> }, {reply, StateMap, State}. --spec handle_get_pending_connections(map()) -> {reply, map(), map()}. -handle_get_pending_connections(State) -> - PendingJoins = call_state:format_pending_connections( - maps:get(pending_connections, State) - ), - {reply, #{pending_joins => PendingJoins}, State}. - -spec handle_pending_timeout(binary(), map()) -> {noreply, map()}. handle_pending_timeout(ConnectionId, State) -> Pending = maps:get(pending_connections, State), diff --git a/fluxer_gateway/src/call/call_handoff.erl b/fluxer_gateway/src/call/call_handoff.erl index 5897bd463..cbe4328d7 100644 --- a/fluxer_gateway/src/call/call_handoff.erl +++ b/fluxer_gateway/src/call/call_handoff.erl @@ -198,25 +198,4 @@ restore_state_remonitors_live_sessions_test() -> ?assertEqual(#{1 => VoiceState}, maps:get(voice_states, Restored)), SessionPid ! stop. -export_and_restore_round_trips_the_caller_test() -> - State = #{ - channel_id => 123, - message_id => 456, - caller_id => 7, - caller_name => <<"Ada">>, - caller_avatar => <<"a1b2c3d4">> - }, - Restored = restore_state(export_state(State)), - ?assertEqual( - #{caller_id => 7, caller_name => <<"Ada">>, caller_avatar => <<"a1b2c3d4">>}, - call_state:caller_from_state(Restored) - ). - -restore_state_reads_a_transfer_without_a_caller_as_undefined_test() -> - Restored = restore_state(#{channel_id => 123, message_id => 456}), - ?assertEqual( - #{caller_id => undefined, caller_name => undefined, caller_avatar => undefined}, - call_state:caller_from_state(Restored) - ). - -endif. diff --git a/fluxer_gateway/src/call/call_manager.erl b/fluxer_gateway/src/call/call_manager.erl index 5a0e1fbcc..fd76e1bc3 100644 --- a/fluxer_gateway/src/call/call_manager.erl +++ b/fluxer_gateway/src/call/call_manager.erl @@ -1,6 +1,10 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(call_manager). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -behaviour(gen_server). @@ -10,10 +14,6 @@ -export([lookup/1]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -type channel_id() :: integer(). -type call_data() :: map(). -type state() :: #{calls := map()}. diff --git a/fluxer_gateway/src/call/call_ringing.erl b/fluxer_gateway/src/call/call_ringing.erl index 7df905405..9979d2d59 100644 --- a/fluxer_gateway/src/call/call_ringing.erl +++ b/fluxer_gateway/src/call/call_ringing.erl @@ -403,9 +403,6 @@ published_ring_args() -> published_ring_users() -> lists:usort([UserId || [UserId, _C, _M, _S, _E, _Caller] <- published_ring_args()]). -published_ring_callers() -> - [Caller || [_U, _C, _M, _S, _E, Caller] <- published_ring_args()]. - mock_ring_publisher() -> ok = meck:new(push_job_publisher, [passthrough, no_link]), meck:expect(push_job_publisher, publish_ring, fun(_U, _C, _M, _S, _E, _Caller) -> ok end). @@ -478,44 +475,6 @@ publish_ring_jobs_suppresses_a_fully_blocked_ring_test() -> meck:unload(push_job_publisher) end. -apply_pending_ringing_publishes_the_caller_held_on_state_test() -> - ok = mock_ring_publisher(), - try - State = (ring_state([1, 2], [], [2], #{}))#{ - caller_id => 1, - caller_name => <<"Ada">>, - caller_avatar => <<"a1b2c3d4">> - }, - _ = maybe_dispatch_pending_ringing(State, false), - ok = meck:wait(1, push_job_publisher, publish_ring, '_', 2000), - ?assertEqual( - [ - #{ - caller_id => 1, - caller_name => <<"Ada">>, - caller_avatar => <<"a1b2c3d4">> - } - ], - published_ring_callers() - ) - after - meck:unload(push_job_publisher) - end. - -apply_pending_ringing_publishes_an_unresolved_caller_when_state_has_none_test() -> - ok = mock_ring_publisher(), - try - State = ring_state([1, 2], [], [2], #{}), - _ = maybe_dispatch_pending_ringing(State, false), - ok = meck:wait(1, push_job_publisher, publish_ring, '_', 2000), - ?assertEqual( - [#{caller_id => undefined, caller_name => undefined, caller_avatar => undefined}], - published_ring_callers() - ) - after - meck:unload(push_job_publisher) - end. - ring_is_suppressed_when_every_other_recipient_is_blocked_test() -> ok = meck:new(push_eligibility, [passthrough, no_link]), try diff --git a/fluxer_gateway/src/call/call_state.erl b/fluxer_gateway/src/call/call_state.erl index 4d18b58e7..c6fa2a5c3 100644 --- a/fluxer_gateway/src/call/call_state.erl +++ b/fluxer_gateway/src/call/call_state.erl @@ -9,7 +9,6 @@ put_caller/2, format_voice_state/1, format_voice_states/1, - format_pending_connections/1, integer_list_to_binaries/1, find_session_by_pid/2, remove_session_entry/2, @@ -71,89 +70,6 @@ format_voice_states(VoiceStates) -> Sorted = lists:keysort(1, maps:to_list(VoiceStates)), [format_voice_state(VS) || {_UserId, VS} <- Sorted]. --spec format_pending_connections(term()) -> [map()]. -format_pending_connections(PendingConnections) when is_map(PendingConnections) -> - Sorted = lists:keysort(1, maps:to_list(PendingConnections)), - lists:filtermap( - fun format_pending_connection_entry/1, - Sorted - ); -format_pending_connections(_) -> - []. - --spec format_pending_connection_entry({term(), term()}) -> false | {true, map()}. -format_pending_connection_entry({ConnectionId, Metadata}) -> - case format_pending_connection(ConnectionId, Metadata) of - undefined -> false; - Entry -> {true, Entry} - end. - --spec format_pending_connection(term(), term()) -> map() | undefined. -format_pending_connection(ConnectionId, Metadata) when is_map(Metadata) -> - NormalizedConnectionId = normalize_pending_binary(ConnectionId), - UserId = normalize_pending_id(maps:get(user_id, Metadata, undefined)), - case {NormalizedConnectionId, UserId} of - {undefined, _} -> - undefined; - {_, undefined} -> - undefined; - _ -> - JoinedAt = normalize_pending_millisecond( - maps:get(joined_at, Metadata, erlang:system_time(millisecond)) - ), - #{ - connection_id => NormalizedConnectionId, - user_id => UserId, - token_nonce => normalize_pending_binary( - maps:get(token_nonce, Metadata, <<>>), <<>> - ), - expires_at => JoinedAt + ?PENDING_CONNECTION_TIMEOUT_MS - } - end; -format_pending_connection(_, _) -> - undefined. - --spec normalize_pending_id(term()) -> binary() | undefined. -normalize_pending_id(Value) when is_integer(Value), Value > 0 -> - integer_to_binary(Value); -normalize_pending_id(Value) -> - try snowflake_id:parse_optional(Value) of - Id when is_integer(Id), Id > 0 -> integer_to_binary(Id); - _ -> undefined - catch - error:{invalid_snowflake, _} -> undefined - end. - --spec normalize_pending_binary(term()) -> binary() | undefined. -normalize_pending_binary(Value) -> - normalize_pending_binary(Value, undefined). - --spec normalize_pending_binary(term(), binary() | undefined) -> binary() | undefined. -normalize_pending_binary(Value, _Default) when is_binary(Value) -> - Value; -normalize_pending_binary(Value, _Default) when is_integer(Value) -> - integer_to_binary(Value); -normalize_pending_binary(Value, Default) when is_list(Value), is_binary(Default) -> - type_conv:ensure_binary(Value, Default); -normalize_pending_binary(Value, Default) when is_list(Value) -> - case type_conv:ensure_binary(Value) of - <<>> -> Default; - Binary -> Binary - end; -normalize_pending_binary(_, Default) -> - Default. - --spec normalize_pending_millisecond(term()) -> integer(). -normalize_pending_millisecond(Value) when is_integer(Value) -> - Value; -normalize_pending_millisecond(Value) when is_binary(Value); is_list(Value) -> - case type_conv:to_integer(Value) of - Millisecond when is_integer(Millisecond) -> Millisecond; - undefined -> erlang:system_time(millisecond) - end; -normalize_pending_millisecond(_) -> - erlang:system_time(millisecond). - -spec user_has_session(integer(), #{binary() => {integer(), pid(), reference()}}) -> boolean(). user_has_session(UserId, Sessions) -> maps:fold( @@ -267,39 +183,3 @@ voice_state_connection_id(VoiceState) -> VoiceState, maps:get(connection_id, VoiceState, undefined) ). - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). - -put_caller_stores_a_resolved_caller_test() -> - Caller = #{caller_id => 7, caller_name => <<"Ada">>, caller_avatar => <<"a1b2c3d4">>}, - ?assertEqual(Caller, caller_from_state(put_caller(Caller, #{}))). - -put_caller_overwrites_an_earlier_caller_test() -> - First = #{caller_id => 7, caller_name => <<"Ada">>, caller_avatar => <<"a1b2c3d4">>}, - Second = #{caller_id => 9, caller_name => <<"Bo">>, caller_avatar => undefined}, - State = put_caller(Second, put_caller(First, #{})), - ?assertEqual(Second, caller_from_state(State)). - -put_caller_keeps_the_stored_caller_when_the_new_one_is_unresolved_test() -> - Caller = #{caller_id => 7, caller_name => <<"Ada">>, caller_avatar => <<"a1b2c3d4">>}, - State = put_caller(Caller, #{}), - Unresolved = #{ - caller_id => undefined, caller_name => undefined, caller_avatar => undefined - }, - ?assertEqual(Caller, caller_from_state(put_caller(Unresolved, State))). - -put_caller_rejects_a_caller_without_a_name_test() -> - Caller = #{caller_id => 7, caller_name => <<>>, caller_avatar => undefined}, - ?assertEqual( - #{caller_id => undefined, caller_name => undefined, caller_avatar => undefined}, - caller_from_state(put_caller(Caller, #{})) - ). - -caller_from_state_reads_an_absent_caller_as_undefined_test() -> - ?assertEqual( - #{caller_id => undefined, caller_name => undefined, caller_avatar => undefined}, - caller_from_state(#{}) - ). - --endif. diff --git a/fluxer_gateway/src/call/call_voice.erl b/fluxer_gateway/src/call/call_voice.erl index d97c98df1..33108dace 100644 --- a/fluxer_gateway/src/call/call_voice.erl +++ b/fluxer_gateway/src/call/call_voice.erl @@ -5,7 +5,6 @@ -export([ handle_join_internal/7, - handle_join_async/5, handle_session_down/2, handle_disconnect_user/4, handle_leave/2, @@ -112,22 +111,6 @@ monitor_session_entry(Sessions, SessionId, UserId, SessionPid) -> SessionRef = monitor(process, SessionPid), Sessions#{SessionId => {UserId, SessionPid, SessionRef}}. --spec handle_join_async(integer(), map(), binary(), pid(), map()) -> {noreply, map()}. -handle_join_async( - UserId, VoiceState, SessionId, SessionPid, #{channel_id := ChannelId} = State -) -> - case join_violation(UserId, VoiceState, undefined, State) of - {error, ErrorAtom} -> - SessionPid ! {call_join_result, ChannelId, {error, ErrorAtom}}, - {noreply, State}; - ok -> - FinalState = admit_join( - UserId, VoiceState, SessionId, SessionPid, undefined, State - ), - SessionPid ! {call_join_result, ChannelId, {ok, FinalState}}, - {noreply, FinalState} - end. - -spec maybe_remove_user_voice_state(integer(), map(), map()) -> map(). maybe_remove_user_voice_state(UserId, RemainingSessions, VoiceStates) -> case call_state:user_has_session(UserId, RemainingSessions) of diff --git a/fluxer_gateway/src/channel_threads/channel_threads_config.erl b/fluxer_gateway/src/channel_threads/channel_threads_config.erl index 1643755f5..f8d877474 100644 --- a/fluxer_gateway/src/channel_threads/channel_threads_config.erl +++ b/fluxer_gateway/src/channel_threads/channel_threads_config.erl @@ -323,7 +323,7 @@ default_config() -> -spec fetch_config_from_api() -> store_result(). fetch_config_from_api() -> RpcRequest = #{<<"type">> => <<"get_channel_threads_config">>}, - case api_rpc_client:call(RpcRequest) of + case rpc_client:call(RpcRequest) of {ok, #{<<"config">> := Config}} when is_map(Config) -> store_valid_config(Config, api); {ok, _Other} -> diff --git a/fluxer_gateway/src/gateway/api_rpc_client.erl b/fluxer_gateway/src/gateway/api_rpc_client.erl deleted file mode 100644 index de76a60d7..000000000 --- a/fluxer_gateway/src/gateway/api_rpc_client.erl +++ /dev/null @@ -1,22 +0,0 @@ -%% SPDX-License-Identifier: AGPL-3.0-or-later - --module(api_rpc_client). --typing([eqwalizer]). - --export([call/1, call_with_retry/2]). --export_type([retry_config/0]). - --type retry_config() :: { - MaxAttempts :: pos_integer(), - BaseMs :: pos_integer(), - MaxMs :: pos_integer(), - JitterMs :: non_neg_integer() -}. - --spec call(map()) -> {ok, map()} | {error, term()}. -call(Request) -> - rpc_client:call(Request). - --spec call_with_retry(map(), retry_config()) -> {ok, map()} | {error, term()}. -call_with_retry(Request, RetryConfig) -> - rpc_client:call_with_retry(Request, RetryConfig). diff --git a/fluxer_gateway/src/gateway/fluxer_gateway_config.erl b/fluxer_gateway/src/gateway/fluxer_gateway_config.erl index 348969765..726068955 100644 --- a/fluxer_gateway/src/gateway/fluxer_gateway_config.erl +++ b/fluxer_gateway/src/gateway/fluxer_gateway_config.erl @@ -172,10 +172,7 @@ build_core_config(Service, Internal, Nats, Proxy) -> nats_auth_token => get_string(Nats, <<"auth_token">>, ""), rpc_auth_token => get_string(Service, <<"rpc_auth_token">>, ""), gateway_nats_rpc_max_handlers => - get_int(Service, <<"gateway_nats_rpc_max_handlers">>, 512), - identify_rate_limit_enabled => get_bool( - Service, <<"identify_rate_limit_enabled">>, false - ) + get_int(Service, <<"gateway_nats_rpc_max_handlers">>, 512) }. -spec build_push_config(map(), map(), map()) -> config(). @@ -284,9 +281,6 @@ parse_guild_id(Token) -> -spec build_misc_config(map()) -> config(). build_misc_config(Service) -> #{ - handoff_enable_event_pause => get_bool( - Service, <<"handoff_enable_event_pause">>, false - ), voice_state_counts_sync_interval_ms => get_int(Service, <<"voice_state_counts_sync_interval_ms">>, 30000), logger_level => get_log_level(Service, <<"logger_level">>, info) diff --git a/fluxer_gateway/src/gateway/fluxer_gateway_env.erl b/fluxer_gateway/src/gateway/fluxer_gateway_env.erl index e1aeed7b5..c85669083 100644 --- a/fluxer_gateway/src/gateway/fluxer_gateway_env.erl +++ b/fluxer_gateway/src/gateway/fluxer_gateway_env.erl @@ -3,7 +3,7 @@ -module(fluxer_gateway_env). -typing([eqwalizer]). --export([load/0, get/1, get_optional/1, get_map/0, patch/1, update/1]). +-export([load/0, get/1, get_optional/1, get_map/0, update/1]). -export_type([config/0]). -define(CONFIG_TERM_KEY, {fluxer_gateway, runtime_config}). @@ -30,11 +30,6 @@ get_optional(Key) when is_atom(Key) -> get_map() -> ensure_loaded(). --spec patch(map()) -> config(). -patch(Patch) when is_map(Patch) -> - Map = get_map(), - set_config(maps:merge(Map, Patch)). - -spec update(fun((config()) -> config())) -> config(). update(Fun) when is_function(Fun, 1) -> Map = get_map(), diff --git a/fluxer_gateway/src/gateway/fluxer_gateway_sup.erl b/fluxer_gateway/src/gateway/fluxer_gateway_sup.erl index 657f89df0..7a9d8b367 100644 --- a/fluxer_gateway/src/gateway/fluxer_gateway_sup.erl +++ b/fluxer_gateway/src/gateway/fluxer_gateway_sup.erl @@ -1,16 +1,16 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(fluxer_gateway_sup). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -behaviour(supervisor). -export([ start_link/0, init/1, cluster_children/0, role_enabled/1, role_enabled/2, current_role/0 ]). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -spec start_link() -> {ok, pid()} | {error, term()}. start_link() -> case supervisor:start_link({local, ?MODULE}, ?MODULE, []) of @@ -36,7 +36,6 @@ common_children() -> child_spec(guild_ets_owner, guild_ets_owner), child_spec(gateway_nats_rpc, gateway_nats_rpc), child_spec(gateway_nats_pool, gateway_nats_pool), - child_spec(gateway_event_pause, gateway_event_pause), child_spec(gateway_concurrency, gateway_concurrency), child_spec(gateway_rollout_config, gateway_rollout_config), child_spec(channel_threads_flip, channel_threads_flip), diff --git a/fluxer_gateway/src/gateway/gateway_cluster_handoff_transfer.erl b/fluxer_gateway/src/gateway/gateway_cluster_handoff_transfer.erl index 7c24b6dfe..5763b0550 100644 --- a/fluxer_gateway/src/gateway/gateway_cluster_handoff_transfer.erl +++ b/fluxer_gateway/src/gateway/gateway_cluster_handoff_transfer.erl @@ -3,46 +3,31 @@ -module(gateway_cluster_handoff_transfer). -typing([eqwalizer]). --export([ - run_handoff/1, - normalize_members/1, - drain_targets/1, - broadcast_ws_reconnect/0 -]). - --ifdef(TEST). --export([handoff_status/1]). --endif. +-export([run_handoff/1, normalize_members/1, drain_targets/1, broadcast_ws_reconnect/0]). -spec run_handoff([node()]) -> map(). run_handoff(Members) -> logger:info("Gateway cluster handoff start", #{members => Members}), - Pause = maybe_enable_handoff_pause(), - Result0 = handoff_result(Members, Pause), + Result0 = handoff_result(Members), Result = Result0#{status => handoff_status(Result0)}, logger:info("Gateway cluster handoff complete", #{result => Result}), Result. --spec handoff_result([node()], enabled | disabled) -> map(). -handoff_result(Members, Pause) -> +-spec handoff_result([node()]) -> map(). +handoff_result(Members) -> GuildNodes = role_aware_targets(guilds, Members), SessionNodes = role_aware_targets(sessions, Members), CallNodes = role_aware_targets(calls, Members), - try - #{ - members => Members, - handoff_event_pause => Pause, - guilds => role_apply(guilds, guild_manager, handoff_to_topology, [GuildNodes]), - sessions => role_apply( - sessions, session_manager, handoff_to_topology, [SessionNodes] - ), - presence => role_apply(presence, presence_manager, handoff_for_drain, []), - calls => role_apply(calls, call_manager, handoff_to_topology, [CallNodes]), - caches => cache_handoff_result() - } - after - maybe_disable_handoff_pause(Pause) - end. + #{ + members => Members, + guilds => role_apply(guilds, guild_manager, handoff_to_topology, [GuildNodes]), + sessions => role_apply( + sessions, session_manager, handoff_to_topology, [SessionNodes] + ), + presence => role_apply(presence, presence_manager, handoff_for_drain, []), + calls => role_apply(calls, call_manager, handoff_to_topology, [CallNodes]), + caches => cache_handoff_result() + }. -spec cache_handoff_result() -> map(). cache_handoff_result() -> @@ -82,35 +67,6 @@ role_apply(Role, Module, Function, Args) -> false -> skipped end. --spec maybe_enable_handoff_pause() -> enabled | disabled. -maybe_enable_handoff_pause() -> - case handoff_event_pause_enabled() of - true -> - shard_utils:safe_apply(fun gateway_event_pause:enable/0, ok), - shard_utils:safe_apply(fun gateway_event_pause:freeze/0, ok), - enabled; - false -> - disabled - end. - --spec maybe_disable_handoff_pause(enabled | disabled) -> ok. -maybe_disable_handoff_pause(enabled) -> - shard_utils:safe_apply(fun gateway_event_pause:disable/0, ok), - ok; -maybe_disable_handoff_pause(disabled) -> - ok. - --spec handoff_event_pause_enabled() -> boolean(). -handoff_event_pause_enabled() -> - try fluxer_gateway_env:get(handoff_enable_event_pause) of - true -> true; - _ -> false - catch - throw:_ -> false; - error:_ -> false; - exit:_ -> false - end. - -spec safe_apply(module(), atom(), [term()]) -> term(). safe_apply(Module, Function, Args) -> try @@ -221,25 +177,6 @@ drain_targets_prefers_survivors_test() -> ), ?assertEqual([node()], drain_targets([node()])). -handoff_pause_lifecycle_test() -> - persistent_term:put( - {fluxer_gateway, runtime_config}, - #{handoff_enable_event_pause => true} - ), - case whereis(gateway_event_pause) of - undefined -> ok; - Existing when is_pid(Existing) -> gen_server:stop(Existing) - end, - {ok, Pid} = gateway_event_pause:start_link(), - ?assertEqual(enabled, maybe_enable_handoff_pause()), - ?assert(gateway_event_pause:is_paused()), - ?assert(gateway_event_pause:is_frozen()), - ?assertEqual(ok, maybe_disable_handoff_pause(enabled)), - ?assertNot(gateway_event_pause:is_paused()), - ?assertNot(gateway_event_pause:is_frozen()), - gen_server:stop(Pid), - persistent_term:erase({fluxer_gateway, runtime_config}). - role_aware_targets_uses_role_nodes_test() -> persistent_term:put( {gateway_cluster_membership, members_by_role}, diff --git a/fluxer_gateway/src/gateway/gateway_compress.erl b/fluxer_gateway/src/gateway/gateway_compress.erl index 0da4ef2e9..4ca65d6b5 100644 --- a/fluxer_gateway/src/gateway/gateway_compress.erl +++ b/fluxer_gateway/src/gateway/gateway_compress.erl @@ -432,36 +432,6 @@ legacy_zstd_stream_ctx_routes_to_frame_test() -> ?assertEqual(skip, skip) end. -new_context_test_() -> - [ - ?_assertEqual(none, get_type(new_context(none))), - ?_assertEqual(zstd_frame, get_type(new_context(zstd_frame))), - ?_assertEqual(zstd_stream, get_type(new_context(zstd_stream))) - ]. - -close_context_test() -> - Ctx = new_context(none), - ?assertEqual(ok, close_context(Ctx)). - -compress_none_test() -> - Ctx = new_context(none), - Data = <<"hello world">>, - {ok, Compressed, Ctx2} = compress(Data, Ctx), - ?assertEqual(Data, Compressed), - ?assertEqual(none, get_type(Ctx2)). - -compress_none_iolist_test() -> - Ctx = new_context(none), - Data = [<<"hello">>, <<" ">>, <<"world">>], - {ok, Compressed, _} = compress(Data, Ctx), - ?assertEqual(<<"hello world">>, Compressed). - -decompress_none_test() -> - Ctx = new_context(none), - Data = <<"hello world">>, - {ok, Decompressed, _} = decompress(Data, Ctx), - ?assertEqual(Data, Decompressed). - check_decompressed_size_allows_normal_payload_test() -> Ctx = new_context(zstd_frame), Data = <<"normal payload">>, @@ -508,113 +478,12 @@ zstd_stream_decompress_allows_payload_at_max_size_test() -> ?assertEqual(skip, skip) end. -zstd_stream_decompress_keeps_context_across_frames_test() -> - case probe_ezstd_stream_available() of - true -> - {ok, First, EncodeCtx} = compress(<<"first message">>, new_context(zstd_stream)), - {ok, Second, _} = compress(<<"second message">>, EncodeCtx), - {ok, <<"first message">>, DecodeCtx} = - decompress(First, new_context(zstd_stream), 4096), - ?assertMatch({ok, <<"second message">>, _}, decompress(Second, DecodeCtx, 4096)); - false -> - ?assertEqual(skip, skip) - end. - stream_compress_zeros(_Ctx, 0, Acc) -> Acc; stream_compress_zeros(Ctx, Remaining, Acc) -> {ok, Chunk, NextCtx} = compress(binary:copy(<<0>>, 8 * 1024 * 1024), Ctx), stream_compress_zeros(NextCtx, Remaining - 1, <>). -init_caches_availability_test() -> - with_saved_availability(fun() -> - erase_cached_availability(), - ok = init(), - ?assertEqual(probe_ezstd_available(), persistent_term:get(?ZSTD_AVAILABLE_KEY)), - ?assertEqual( - probe_ezstd_stream_available(), persistent_term:get(?ZSTD_STREAM_AVAILABLE_KEY) - ) - end). - -availability_falls_back_to_probe_when_uncached_test() -> - with_saved_availability(fun() -> - erase_cached_availability(), - ?assertEqual(probe_ezstd_available(), ezstd_available()), - ?assertEqual(probe_ezstd_stream_available(), ezstd_stream_available()), - ?assertEqual(probe_ezstd_available(), persistent_term:get(?ZSTD_AVAILABLE_KEY)), - ?assertEqual( - probe_ezstd_stream_available(), persistent_term:get(?ZSTD_STREAM_AVAILABLE_KEY) - ) - end). - -cached_availability_false_rejects_zstd_test() -> - with_cached_availability(false, fun assert_zstd_unavailable/0). - -cached_availability_true_round_trips_test() -> - case probe_ezstd_stream_available() of - true -> with_cached_availability(true, fun assert_zstd_round_trip/0); - false -> ?assertEqual(skip, skip) - end. - -assert_zstd_unavailable() -> - Data = <<"cached availability payload">>, - NoneCtx = new_context(none), - ?assertEqual({ok, Data, NoneCtx}, compress(Data, NoneCtx)), - ?assertEqual({ok, Data, NoneCtx}, decompress(Data, NoneCtx)), - FrameCtx = new_context(zstd_frame), - ?assertEqual({error, {compress_failed, zstd_not_available}}, compress(Data, FrameCtx)), - ?assertEqual({error, {decompress_failed, zstd_not_available}}, decompress(Data, FrameCtx)), - StreamCtx = new_context(zstd_stream), - ?assertEqual( - {error, {compress_failed, zstd_stream_not_available}}, compress(Data, StreamCtx) - ), - ?assertEqual( - {error, {decompress_failed, zstd_stream_not_available}}, decompress(Data, StreamCtx) - ). - -assert_zstd_round_trip() -> - Data = <<"cached availability round trip payload">>, - FrameCtx = new_context(zstd_frame), - {ok, Frame, FrameCtx2} = compress(Data, FrameCtx), - ?assertMatch({ok, Data, _}, decompress(Frame, FrameCtx2)), - {ok, Streamed, _} = compress(Data, new_context(zstd_stream)), - ?assertMatch({ok, Data, _}, decompress(Streamed, new_context(zstd_stream))). - -availability_keys() -> - [?ZSTD_AVAILABLE_KEY, ?ZSTD_STREAM_AVAILABLE_KEY]. - -with_saved_availability(Fun) -> - Previous = [{Key, persistent_term:get(Key, undefined)} || Key <- availability_keys()], - try - Fun() - after - lists:foreach(fun({Key, Value}) -> restore_availability(Key, Value) end, Previous) - end. - -with_cached_availability(Available, Fun) -> - with_saved_availability(fun() -> - put_cached_availability(Available), - Fun() - end). - -put_cached_availability(Available) -> - lists:foreach(fun(Key) -> cache_availability(Key, Available) end, availability_keys()). - -erase_cached_availability() -> - lists:foreach(fun(Key) -> restore_availability(Key, undefined) end, availability_keys()). - -restore_availability(Key, undefined) -> - persistent_term:erase(Key); -restore_availability(Key, Value) -> - persistent_term:put(Key, Value). - --ifdef(DEV_MODE). -zstd_roundtrip_test() -> - ?assertEqual(skip, skip). - -zstd_compression_ratio_test() -> - ?assertEqual(skip, skip). --else. zstd_roundtrip_test() -> case ezstd_available() of true -> @@ -639,6 +508,5 @@ zstd_compression_ratio_test() -> false -> ?assertEqual(skip, skip) end. --endif. -endif. diff --git a/fluxer_gateway/src/gateway/gateway_concurrency.erl b/fluxer_gateway/src/gateway/gateway_concurrency.erl index 398d9626f..a92916b56 100644 --- a/fluxer_gateway/src/gateway/gateway_concurrency.erl +++ b/fluxer_gateway/src/gateway/gateway_concurrency.erl @@ -135,15 +135,6 @@ concurrency_acquire_release_test() -> ?assertEqual(0, session_start_count()), gen_server:stop(?MODULE). -guild_concurrency_acquire_release_test() -> - {ok, _Pid} = start_link(), - ?assertEqual(0, guild_start_count()), - ?assertEqual(ok, try_acquire_guild_start()), - ?assertEqual(1, guild_start_count()), - ?assertEqual(ok, release_guild_start()), - ?assertEqual(0, guild_start_count()), - gen_server:stop(?MODULE). - release_without_acquire_does_not_underflow_test() -> {ok, _Pid} = start_link(), ?assertEqual(ok, release_session_start()), diff --git a/fluxer_gateway/src/gateway/gateway_dispatch_relay.erl b/fluxer_gateway/src/gateway/gateway_dispatch_relay.erl index 59099bcfe..c2d9ccab1 100644 --- a/fluxer_gateway/src/gateway/gateway_dispatch_relay.erl +++ b/fluxer_gateway/src/gateway/gateway_dispatch_relay.erl @@ -243,15 +243,6 @@ handle_worker_exit(Index, Reason, Workers, State) -> -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -dispatch_direct_local_cast_test() -> - Payload = #{<<"k">> => <<"v">>}, - ?assertEqual(ok, dispatch_direct(self(), guild_update, Payload)), - receive - {'$gen_cast', {dispatch, guild_update, Payload}} -> ok - after 1000 -> - ?assert(false) - end. - dispatch_direct_local_cast_under_mailbox_pressure_test() -> Self = self(), Receiver = spawn(fun() -> test_dispatch_receiver(Self, pressured) end), @@ -262,20 +253,6 @@ dispatch_direct_local_cast_under_mailbox_pressure_test() -> ?assertEqual(ok, dispatch_direct(Receiver, guild_update, Payload)), assert_dispatch_received(pressured, guild_update, Payload). -group_by_node_empty_test() -> - ?assertEqual([], group_by_node([])). - -group_by_node_local_pids_test() -> - P1 = spawn(fun test_wait_until_stop/0), - P2 = spawn(fun test_wait_until_stop/0), - Grouped = group_by_node([P1, P2]), - ?assertEqual(1, length(Grouped)), - [{Node, Pids}] = Grouped, - ?assertEqual(node(), Node), - ?assertEqual(2, length(Pids)), - P1 ! stop, - P2 ! stop. - group_by_node_filters_non_pids_test() -> P1 = spawn(fun test_wait_until_stop/0), Grouped = group_by_node([P1, not_a_pid, 42, <<"binary">>]), @@ -283,19 +260,6 @@ group_by_node_filters_non_pids_test() -> ?assertEqual([P1], Pids), P1 ! stop. -dispatch_grouped_empty_test() -> - ?assertEqual(ok, dispatch_grouped([], test_event, #{}, fun dispatch_direct/3)). - -dispatch_grouped_local_pids_test() -> - Self = self(), - Receiver1 = spawn(fun() -> test_dispatch_receiver(Self, 1) end), - Receiver2 = spawn(fun() -> test_dispatch_receiver(Self, 2) end), - Grouped = [{node(), [Receiver1, Receiver2]}], - Payload = #{<<"test">> => true}, - ?assertEqual(ok, dispatch_grouped(Grouped, my_event, Payload, fun dispatch_direct/3)), - assert_dispatch_received(1, my_event, Payload), - assert_dispatch_received(2, my_event, Payload). - test_wait_until_stop() -> receive stop -> ok diff --git a/fluxer_gateway/src/gateway/gateway_event_pause.erl b/fluxer_gateway/src/gateway/gateway_event_pause.erl deleted file mode 100644 index 831b8e2f9..000000000 --- a/fluxer_gateway/src/gateway/gateway_event_pause.erl +++ /dev/null @@ -1,107 +0,0 @@ -%% SPDX-License-Identifier: AGPL-3.0-or-later - --module(gateway_event_pause). --typing([eqwalizer]). --behaviour(gen_server). - --export([ - start_link/0, - enable/0, - freeze/0, - disable/0, - is_paused/0, - is_frozen/0 -]). --export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). - --define(PAUSED_KEY, {gateway_event_pause, paused}). --define(FROZEN_KEY, {gateway_event_pause, frozen}). - --type state() :: #{}. - --spec start_link() -> {ok, pid()} | {error, term()}. -start_link() -> - case gen_server:start_link({local, ?MODULE}, ?MODULE, [], []) of - {ok, Pid} -> {ok, Pid}; - {error, Reason} -> {error, Reason}; - ignore -> {error, ignore} - end. - --spec enable() -> ok. -enable() -> - gen_server:call(?MODULE, enable, 5000). - --spec freeze() -> ok. -freeze() -> - gen_server:call(?MODULE, freeze, 5000). - --spec disable() -> ok. -disable() -> - gen_server:call(?MODULE, disable, 5000). - --spec is_paused() -> boolean(). -is_paused() -> - persistent_term:get(?PAUSED_KEY, false). - --spec is_frozen() -> boolean(). -is_frozen() -> - persistent_term:get(?FROZEN_KEY, false). - --spec init([]) -> {ok, state()}. -init([]) -> - erlang:process_flag(fullsweep_after, 50), - persistent_term:put(?PAUSED_KEY, false), - persistent_term:put(?FROZEN_KEY, false), - {ok, #{}}. - --spec handle_call(term(), gen_server:from(), state()) -> {reply, term(), state()}. -handle_call(enable, _From, State) -> - persistent_term:put(?PAUSED_KEY, true), - logger:info("Gateway event pause enabled: mutations paused"), - {reply, ok, State}; -handle_call(freeze, _From, State) -> - persistent_term:put(?FROZEN_KEY, true), - logger:info("Gateway event pause frozen: event dispatch stopped"), - {reply, ok, State}; -handle_call(disable, _From, State) -> - persistent_term:put(?PAUSED_KEY, false), - persistent_term:put(?FROZEN_KEY, false), - logger:info("Gateway event pause disabled: normal operation resumed"), - {reply, ok, State}; -handle_call(_Request, _From, State) -> - {reply, ok, State}. - --spec handle_cast(term(), state()) -> {noreply, state()}. -handle_cast(_Msg, State) -> - {noreply, State}. - --spec handle_info(term(), state()) -> {noreply, state()}. -handle_info(_Info, State) -> - {noreply, State}. - --spec terminate(term(), state()) -> ok. -terminate(_Reason, _State) -> - ok. - --spec code_change(term(), state(), term()) -> {ok, state()}. -code_change(_OldVsn, State, _Extra) -> - erlang:garbage_collect(), - {ok, State}. - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). - -pause_lifecycle_test() -> - ?assertEqual(false, persistent_term:get(?PAUSED_KEY, false)), - ?assertEqual(false, persistent_term:get(?FROZEN_KEY, false)), - persistent_term:put(?PAUSED_KEY, true), - ?assertEqual(true, is_paused()), - ?assertEqual(false, is_frozen()), - persistent_term:put(?FROZEN_KEY, true), - ?assertEqual(true, is_frozen()), - persistent_term:put(?PAUSED_KEY, false), - persistent_term:put(?FROZEN_KEY, false), - ?assertEqual(false, is_paused()), - ?assertEqual(false, is_frozen()). - --endif. diff --git a/fluxer_gateway/src/gateway/gateway_handler.erl b/fluxer_gateway/src/gateway/gateway_handler.erl index f45612dd1..566a3d9b9 100644 --- a/fluxer_gateway/src/gateway/gateway_handler.erl +++ b/fluxer_gateway/src/gateway/gateway_handler.erl @@ -8,7 +8,7 @@ -export([new_state/0]). -ifdef(TEST). --export([parse_forwarded_for/1, parse_version/1]). +-export([parse_forwarded_for/1]). -endif. -type state() :: #{ @@ -26,7 +26,7 @@ request_workers := map(), voice_queue_timer := reference() | undefined, pending_identify := {map(), pid()} | undefined, - pending_identify_retry_timer := {reference(), reference()} | reference() | undefined, + pending_identify_retry_timer := {reference(), reference()} | undefined, connection_acquired => boolean() }. @@ -107,8 +107,6 @@ websocket_handle(_, State) -> -spec websocket_info(term(), state()) -> ws_result(). websocket_info({heartbeat_check, Token}, State) when is_reference(Token) -> gateway_handler_heartbeat:handle_heartbeat_check(Token, State); -websocket_info({heartbeat_check}, State) -> - gateway_handler_heartbeat:handle_legacy_heartbeat_check(State); websocket_info({dispatch, Event, Data, Seq}, State) when is_integer(Seq), is_atom(Event), is_map(Data); is_integer(Seq), is_binary(Event), is_map(Data); @@ -131,8 +129,6 @@ websocket_info(rollout_config_changed, State) -> gateway_handler_identify:handle_rollout_config_changed(State); websocket_info({retry_pending_identify, Token}, State) when is_reference(Token) -> gateway_handler_identify:handle_pending_identify_retry(Token, State); -websocket_info(retry_pending_identify, State) -> - gateway_handler_identify:handle_pending_identify_retry(State); websocket_info(session_reconnect, State) -> gateway_handler_dispatch:handle_session_reconnect(State); websocket_info({'DOWN', _, process, Pid, _}, #{session_pid := Pid} = State) -> diff --git a/fluxer_gateway/src/gateway/gateway_handler_dispatch.erl b/fluxer_gateway/src/gateway/gateway_handler_dispatch.erl index 3a28d69d7..7b54fb0da 100644 --- a/fluxer_gateway/src/gateway/gateway_handler_dispatch.erl +++ b/fluxer_gateway/src/gateway/gateway_handler_dispatch.erl @@ -1,11 +1,11 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(gateway_handler_dispatch). +-export([handle_dispatch/4]). -typing([eqwalizer]). -export([ handle_opcode/3, - handle_dispatch/4, handle_gateway_error/2, handle_session_down/1, handle_session_reconnect/1, @@ -117,20 +117,11 @@ handle_rate_limited_resume(Data, State) -> -spec handle_dispatch( atom() | binary(), map() | list() | null | {pre_encoded, binary()}, integer(), state() -) -> ws_result(). -handle_dispatch(Event, Data, Seq, State) -> - case gateway_event_pause:is_frozen() of - true -> {ok, State}; - false -> do_dispatch(Event, Data, Seq, State) - end. - --spec do_dispatch( - atom() | binary(), map() | list() | null | {pre_encoded, binary()}, integer(), state() ) -> ws_result(). -do_dispatch(Event, {pre_encoded, EncodedData}, Seq, State) -> +handle_dispatch(Event, {pre_encoded, EncodedData}, Seq, State) -> dispatch_pre_encoded(Event, EncodedData, Seq, State); -do_dispatch(Event, Data, Seq, State) -> +handle_dispatch(Event, Data, Seq, State) -> dispatch_standard(Event, Data, Seq, State). -spec handle_gateway_error(atom(), state()) -> ws_result(). diff --git a/fluxer_gateway/src/gateway/gateway_handler_heartbeat.erl b/fluxer_gateway/src/gateway/gateway_handler_heartbeat.erl index 90dc4649f..1d0f4f28e 100644 --- a/fluxer_gateway/src/gateway/gateway_handler_heartbeat.erl +++ b/fluxer_gateway/src/gateway/gateway_handler_heartbeat.erl @@ -4,9 +4,7 @@ -typing([eqwalizer]). -export([ - handle_heartbeat_check/1, - handle_heartbeat_check/2, - handle_legacy_heartbeat_check/1, + handle_heartbeat_check/1, handle_heartbeat_check/2, handle_heartbeat/2, schedule_heartbeat_check/1, cancel_heartbeat_timer/1 @@ -38,13 +36,6 @@ handle_heartbeat_check(#{heartbeat_state := HeartbeatState} = State) -> Interval = constants:heartbeat_interval(), evaluate_heartbeat(State, Now, LastAck, WaitingForAck, Timeout, Interval). --spec handle_legacy_heartbeat_check(state()) -> ws_result(). -handle_legacy_heartbeat_check(State) -> - case maps:get(heartbeat_timer, State, undefined) of - undefined -> handle_heartbeat_check(State); - _Timer -> {ok, State} - end. - -spec evaluate_heartbeat(state(), integer(), integer(), boolean(), integer(), integer()) -> ws_result(). evaluate_heartbeat(State, Now, LastAck, true, Timeout, _Interval) when diff --git a/fluxer_gateway/src/gateway/gateway_handler_identify.erl b/fluxer_gateway/src/gateway/gateway_handler_identify.erl index e04fca5f6..216636a3b 100644 --- a/fluxer_gateway/src/gateway/gateway_handler_identify.erl +++ b/fluxer_gateway/src/gateway/gateway_handler_identify.erl @@ -7,7 +7,6 @@ handle_identify/3, handle_resume/2, handle_rollout_config_changed/1, - handle_pending_identify_retry/1, handle_pending_identify_retry/2, start_session/3, start_session_with_drain_guard/4, @@ -19,7 +18,7 @@ -type state() :: gateway_handler:state(). -type ws_result() :: gateway_handler:ws_result(). --type pending_retry_timer() :: {reference(), reference()} | reference() | undefined. +-type pending_retry_timer() :: {reference(), reference()} | undefined. -export_type([state/0, ws_result/0]). @@ -366,10 +365,6 @@ log_session_start_error(Reason, State) -> handle_rollout_config_changed(State) -> retry_pending_identify(State). --spec handle_pending_identify_retry(state()) -> ws_result(). -handle_pending_identify_retry(State) -> - handle_legacy_pending_identify_retry(State). - -spec handle_pending_identify_retry(reference(), state()) -> ws_result(). handle_pending_identify_retry(Token, State) -> case maps:get(pending_identify_retry_timer, State, undefined) of @@ -380,16 +375,6 @@ handle_pending_identify_retry(Token, State) -> {ok, State} end. --spec handle_legacy_pending_identify_retry(state()) -> ws_result(). -handle_legacy_pending_identify_retry(State) -> - case maps:get(pending_identify_retry_timer, State, undefined) of - TimerRef when is_reference(TimerRef) -> - cancel_pending_identify_retry_value(TimerRef), - retry_pending_identify(State#{pending_identify_retry_timer => undefined}); - _ -> - {ok, State} - end. - -spec retry_pending_identify(state()) -> ws_result(). retry_pending_identify(#{pending_identify := {Request, SocketPid}} = State) when is_map(Request), is_pid(SocketPid) @@ -432,10 +417,6 @@ schedule_pending_identify_retry( is_reference(TimerRef), is_reference(Token) -> State; -schedule_pending_identify_retry(#{pending_identify_retry_timer := TimerRef} = State) when - is_reference(TimerRef) --> - State; schedule_pending_identify_retry(State) -> Delay = ?PENDING_IDENTIFY_RETRY_BASE_MS + @@ -458,15 +439,11 @@ clear_pending_identify(State) -> -spec maybe_cancel_pending_identify_retry(pending_retry_timer()) -> ok. maybe_cancel_pending_identify_retry(undefined) -> ok; -maybe_cancel_pending_identify_retry({TimerRef, _Token}) when is_reference(TimerRef) -> - cancel_pending_identify_retry_value(TimerRef); -maybe_cancel_pending_identify_retry(TimerRef) when is_reference(TimerRef) -> - cancel_pending_identify_retry_value(TimerRef). +maybe_cancel_pending_identify_retry({TimerRef, Token}) when is_reference(TimerRef) -> + cancel_pending_identify_retry_value({TimerRef, Token}). --spec cancel_pending_identify_retry_value({reference(), reference()} | reference()) -> ok. +-spec cancel_pending_identify_retry_value({reference(), reference()}) -> ok. cancel_pending_identify_retry_value({TimerRef, _Token}) -> - cancel_pending_identify_retry_value(TimerRef); -cancel_pending_identify_retry_value(TimerRef) -> _ = erlang:cancel_timer(TimerRef, [{async, true}, {info, false}]), ok. diff --git a/fluxer_gateway/src/gateway/gateway_handler_rate_limit.erl b/fluxer_gateway/src/gateway/gateway_handler_rate_limit.erl index 1e68d2bba..121785ae8 100644 --- a/fluxer_gateway/src/gateway/gateway_handler_rate_limit.erl +++ b/fluxer_gateway/src/gateway/gateway_handler_rate_limit.erl @@ -4,10 +4,7 @@ -typing([eqwalizer]). -export([ - check_rate_limit/2, - check_shared_ip_rate/1, - check_shared_user_rate/1, - rate_limits_disabled/0 + check_rate_limit/2, check_shared_ip_rate/1, check_shared_user_rate/1, rate_limits_disabled/0 ]). -export_type([state/0]). @@ -33,7 +30,7 @@ -type state() :: gateway_handler:state(). -ifdef(TEST). --export([prune_old_window_entries/2]). + -include_lib("eunit/include/eunit.hrl"). -endif. diff --git a/fluxer_gateway/src/gateway/gateway_ip_connections.erl b/fluxer_gateway/src/gateway/gateway_ip_connections.erl index 94581a3d7..67abbced8 100644 --- a/fluxer_gateway/src/gateway/gateway_ip_connections.erl +++ b/fluxer_gateway/src/gateway/gateway_ip_connections.erl @@ -1,6 +1,10 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(gateway_ip_connections). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -export([acquire/1, release/1, note_disconnect/1]). @@ -10,10 +14,6 @@ -define(MAX_CONNECTIONS_PER_IP, 256). -define(OWNER_SWEEP_INTERVAL_MS, 60000). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -spec acquire(term()) -> ok | {error, too_many_connections}. acquire(PeerIP) when is_binary(PeerIP), PeerIP =/= <<"unknown">> -> case gateway_handler_rate_limit:rate_limits_disabled() of diff --git a/fluxer_gateway/src/gateway/gateway_nats_pool.erl b/fluxer_gateway/src/gateway/gateway_nats_pool.erl index ceeb2f095..8aab37cbb 100644 --- a/fluxer_gateway/src/gateway/gateway_nats_pool.erl +++ b/fluxer_gateway/src/gateway/gateway_nats_pool.erl @@ -392,7 +392,7 @@ dispatch_rpc_msg(Subject, Payload, MsgOpts, HC, MaxH, HRefs, State) -> -spec spawn_rpc_handler(binary(), binary(), binary()) -> {pid(), reference()}. spawn_rpc_handler(Subject, Payload, ReplyTo) -> spawn_monitor(fun() -> - gateway_nats_rpc:handle_rpc_request(Subject, Payload, ReplyTo) + gateway_nats_rpc_handler:handle_rpc_request(Subject, Payload, ReplyTo) end). -spec send_overloaded(binary()) -> ok | {error, term()}. diff --git a/fluxer_gateway/src/gateway/gateway_nats_pool_conn.erl b/fluxer_gateway/src/gateway/gateway_nats_pool_conn.erl index a8fe08e8b..8e03ee331 100644 --- a/fluxer_gateway/src/gateway/gateway_nats_pool_conn.erl +++ b/fluxer_gateway/src/gateway/gateway_nats_pool_conn.erl @@ -200,7 +200,7 @@ subscribe_slot(Idx, Conn, #{subs := Subs} = State) -> -spec subscribe_new_slot(non_neg_integer(), nats:conn(), map(), map()) -> map(). subscribe_new_slot(Idx, Conn, Subs, State) -> Role = fluxer_gateway_sup:current_role(), - Subjects = gateway_nats_rpc:rpc_subjects_for_role(Role), + Subjects = gateway_nats_rpc_handler:rpc_subjects_for_role(Role), Sids = subscribe_slot_subjects(Idx, Conn, Role, Subjects), case Sids of [] -> State; @@ -351,16 +351,6 @@ coerce_sid(_) -> -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -reconnect_delay_first_failure_test() -> - ?assertEqual(?RECONNECT_BASE_DELAY_MS, reconnect_delay(1)). - -reconnect_delay_exponential_backoff_test() -> - ?assertEqual(?RECONNECT_BASE_DELAY_MS * 2, reconnect_delay(2)), - ?assertEqual(?RECONNECT_BASE_DELAY_MS * 4, reconnect_delay(3)). - -reconnect_delay_capped_at_max_test() -> - ?assert(reconnect_delay(4) =< ?RECONNECT_MAX_DELAY_MS). - reconnect_delay_circuit_breaker_kicks_in_test() -> ?assertEqual(?CIRCUIT_BREAKER_COOLDOWN_MS, reconnect_delay(?CIRCUIT_BREAKER_THRESHOLD)), ?assertEqual( diff --git a/fluxer_gateway/src/gateway/gateway_nats_rpc.erl b/fluxer_gateway/src/gateway/gateway_nats_rpc.erl index 2af8b1b70..020046f4a 100644 --- a/fluxer_gateway/src/gateway/gateway_nats_rpc.erl +++ b/fluxer_gateway/src/gateway/gateway_nats_rpc.erl @@ -4,9 +4,9 @@ -typing([eqwalizer]). -behaviour(gen_server). --export([start_link/0, get_connection/0, is_connected/0, subscribe/2]). +-export([start_link/0, get_connection/0, subscribe/2]). -export([enable_rpc_subscription/0, disable_rpc_subscription/0]). --export([rpc_subjects_for_role/1, handle_rpc_request/3]). + -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). -dialyzer( @@ -17,14 +17,6 @@ ]} ). --spec rpc_subjects_for_role(atom()) -> [binary()]. -rpc_subjects_for_role(Role) -> - gateway_nats_rpc_handler:rpc_subjects_for_role(Role). - --spec handle_rpc_request(binary(), binary(), binary()) -> ok. -handle_rpc_request(Subject, Payload, ReplyTo) -> - gateway_nats_rpc_handler:handle_rpc_request(Subject, Payload, ReplyTo). - -spec start_link() -> gen_server:start_ret(). start_link() -> gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). @@ -33,17 +25,6 @@ start_link() -> get_connection() -> gen_server:call(?MODULE, get_connection). --spec is_connected() -> boolean(). -is_connected() -> - try get_connection() of - {ok, Conn} when Conn =/= undefined -> true; - _ -> false - catch - throw:_ -> false; - error:_ -> false; - exit:_ -> false - end. - -spec subscribe(binary(), binary()) -> {ok, term()} | {error, term()}. subscribe(Subject, QueueGroup) -> gen_server:call(?MODULE, {subscribe_subject, Subject, QueueGroup}, 5000). diff --git a/fluxer_gateway/src/gateway/gateway_nats_rpc_handler.erl b/fluxer_gateway/src/gateway/gateway_nats_rpc_handler.erl index 978687eff..dda49b861 100644 --- a/fluxer_gateway/src/gateway/gateway_nats_rpc_handler.erl +++ b/fluxer_gateway/src/gateway/gateway_nats_rpc_handler.erl @@ -10,9 +10,7 @@ do_connect/1, do_subscribe/1, schedule_reconnect/1, - max_handlers/0, - parse_nats_url/1, - build_connect_opts/1 + max_handlers/0 ]). -define(DEFAULT_MAX_HANDLERS, 512). @@ -128,7 +126,7 @@ is_rpc_subject(_) -> false. do_connect(State) -> NatsUrl = fluxer_gateway_env:get(nats_core_url), AuthToken = fluxer_gateway_env:get(nats_auth_token), - case parse_nats_url(NatsUrl) of + case gateway_nats_pool_conn:parse_nats_url(NatsUrl) of {ok, Host, Port} -> spawn_connect(Host, Port, AuthToken, State); {error, Reason} -> @@ -138,7 +136,7 @@ do_connect(State) -> -spec spawn_connect(string(), inet:port_number(), term(), map()) -> map(). spawn_connect(Host, Port, AuthToken, State) -> - Opts = build_connect_opts(AuthToken), + Opts = gateway_nats_pool_conn:build_connect_opts(AuthToken), Parent = self(), Token = make_ref(), ConnPid = spawn(fun() -> connect_and_notify(Host, Port, Opts, Parent) end), @@ -225,14 +223,6 @@ max_handlers() -> _ -> ?DEFAULT_MAX_HANDLERS end. --spec parse_nats_url(term()) -> {ok, string(), inet:port_number()} | {error, term()}. -parse_nats_url(Url) -> - gateway_nats_pool_conn:parse_nats_url(Url). - --spec build_connect_opts(term()) -> map(). -build_connect_opts(AuthToken) -> - gateway_nats_pool_conn:build_connect_opts(AuthToken). - -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). diff --git a/fluxer_gateway/src/gateway/gateway_node_router.erl b/fluxer_gateway/src/gateway/gateway_node_router.erl index 0194b00e6..67603cf25 100644 --- a/fluxer_gateway/src/gateway/gateway_node_router.erl +++ b/fluxer_gateway/src/gateway/gateway_node_router.erl @@ -177,21 +177,6 @@ owner_result(_Owner) -> -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -active_nodes_falls_back_to_self_test() -> - persistent_term:erase({gateway_cluster_membership, members}), - ?assertEqual([node()], active_nodes()). - -active_nodes_falls_back_when_empty_test() -> - persistent_term:put({gateway_cluster_membership, members}, []), - ?assertEqual([node()], active_nodes()), - persistent_term:erase({gateway_cluster_membership, members}). - -active_nodes_returns_membership_test() -> - Peers = [node(), 'peer@a', 'peer@b'], - persistent_term:put({gateway_cluster_membership, members}, Peers), - ?assertEqual(Peers, active_nodes()), - persistent_term:erase({gateway_cluster_membership, members}). - active_nodes_for_role_returns_role_and_monolith_nodes_test() -> persistent_term:put({gateway_cluster_membership, members}, ['sessions@a', 'all@a']), persistent_term:put({gateway_cluster_membership, members_by_role}, #{ @@ -246,23 +231,6 @@ owner_node_for_role_without_role_members_errors_on_wrong_role_test() -> persistent_term:erase({gateway_cluster_membership, members_by_role}), persistent_term:erase({fluxer_gateway, runtime_config}). -owner_node_with_single_member_returns_self_test() -> - persistent_term:erase({gateway_cluster_membership, members}), - ?assertEqual(node(), owner_node(<<"guild-123">>)), - ?assertEqual(node(), owner_node(456)), - ?assertEqual(node(), owner_node(some_atom)). - -select_owner_node_deterministic_for_same_input_test() -> - Peers = ['n@a', 'n@b', 'n@c'], - ?assertEqual( - select_owner_node(<<"guild-1">>, Peers), - select_owner_node(<<"guild-1">>, Peers) - ), - ?assertEqual( - select_owner_node(42, Peers), - select_owner_node(42, Peers) - ). - select_owner_node_partitions_keys_across_nodes_test() -> Peers = ['n@a', 'n@b', 'n@c'], Owners = [ @@ -272,35 +240,6 @@ select_owner_node_partitions_keys_across_nodes_test() -> Unique = lists:usort(Owners), ?assert(length(Unique) >= 2). -select_owner_node_stable_under_sort_order_test() -> - Peers1 = ['n@a', 'n@b', 'n@c'], - Peers2 = ['n@c', 'n@a', 'n@b'], - ?assertEqual( - select_owner_node(<<"k">>, Peers1), - select_owner_node(<<"k">>, Peers2) - ). - -select_owner_node_with_empty_returns_self_test() -> - ?assertEqual(node(), select_owner_node(<<"k">>, [])). - -owner_node_uses_membership_when_present_test() -> - gateway_cluster_metrics:reset_for_tests(), - Peers = [node(), 'peer@b'], - persistent_term:put({gateway_cluster_membership, members}, Peers), - Owner = owner_node(<<"some-key">>), - ?assert(lists:member(Owner, Peers)), - Snapshot = gateway_cluster_metrics:snapshot(), - Owners = maps:get(<<"gateway_node_router_owner_resolutions_total">>, Snapshot), - ?assertEqual(1, maps:get(<<"self">>, Owners) + maps:get(<<"peer">>, Owners)), - persistent_term:erase({gateway_cluster_membership, members}). - -is_draining_reads_persistent_term_test() -> - persistent_term:erase({fluxer_gateway, draining}), - ?assertNot(is_draining()), - persistent_term:put({fluxer_gateway, draining}, true), - ?assert(is_draining()), - persistent_term:erase({fluxer_gateway, draining}). - -define(PINNED_GUILD, 1100000000000000001). with_guild_members(Fun) -> diff --git a/fluxer_gateway/src/gateway/gateway_rollout_config.erl b/fluxer_gateway/src/gateway/gateway_rollout_config.erl index 47fdcc744..259bb0f82 100644 --- a/fluxer_gateway/src/gateway/gateway_rollout_config.erl +++ b/fluxer_gateway/src/gateway/gateway_rollout_config.erl @@ -206,7 +206,7 @@ validate_config(Config) -> -spec fetch_config_from_api() -> store_result(). fetch_config_from_api() -> RpcRequest = #{<<"type">> => <<"get_gateway_rollout_config">>}, - case api_rpc_client:call(RpcRequest) of + case rpc_client:call(RpcRequest) of {ok, #{<<"config">> := Config}} when is_map(Config) -> store_valid_config(Config, api); {ok, _Other} -> diff --git a/fluxer_gateway/src/gateway/gateway_rpc_call.erl b/fluxer_gateway/src/gateway/gateway_rpc_call.erl index fe6c59adc..eceea0693 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_call.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_call.erl @@ -11,8 +11,6 @@ -spec execute_method(binary(), map()) -> term(). execute_method(<<"call.get">>, #{<<"channel_id">> := ChannelIdBin}) -> handle_call_get(ChannelIdBin); -execute_method(<<"call.get_pending_joins">>, #{<<"channel_id">> := ChannelIdBin}) -> - handle_call_get_pending_joins(ChannelIdBin); execute_method(<<"call.create">>, Params) -> handle_call_create(Params); execute_method(<<"call.update_region">>, #{ @@ -32,19 +30,6 @@ execute_method(Method, Params) -> execute_session_method(Method, Params). -spec execute_session_method(binary(), map()) -> term(). -execute_session_method(<<"call.join">>, #{ - <<"channel_id">> := ChannelIdBin, - <<"user_id">> := UserIdBin, - <<"session_id">> := SessionIdBin, - <<"voice_state">> := VoiceState -}) -> - handle_call_join(ChannelIdBin, UserIdBin, SessionIdBin, VoiceState); -execute_session_method(<<"call.leave">>, #{ - <<"channel_id">> := ChannelIdBin, <<"session_id">> := SessionId -}) -> - handle_call_leave(ChannelIdBin, SessionId); -execute_session_method(<<"call.delete">>, #{<<"channel_id">> := ChannelIdBin}) -> - handle_call_delete(ChannelIdBin); execute_session_method(<<"call.confirm_connection">>, #{ <<"channel_id">> := ChannelIdBin, <<"connection_id">> := ConnectionId }) -> @@ -78,33 +63,6 @@ get_call_state(Pid) -> gateway_rpc_error:raise(<<"call_state_error">>) end. --spec handle_call_get_pending_joins(binary()) -> term(). -handle_call_get_pending_joins(ChannelIdBin) -> - ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin), - case gateway_rpc_call_lookup:lookup_call(ChannelId) of - {ok, Pid} -> - get_pending_connections(Pid); - not_found -> - #{<<"pending_joins">> => []}; - {error, _Reason} -> - gateway_rpc_error:raise(<<"call_lookup_error">>) - end. - --spec get_pending_connections(pid()) -> map(). -get_pending_connections(Pid) -> - case - gateway_rpc_call_lookup:safe_gen_server_call( - Pid, {get_pending_connections}, ?CALL_LOOKUP_TIMEOUT - ) - of - {ok, #{pending_joins := PendingJoins}} -> - #{<<"pending_joins">> => PendingJoins}; - {error, not_found} -> - #{<<"pending_joins">> => []}; - _ -> - gateway_rpc_error:raise(<<"call_pending_joins_error">>) - end. - -spec handle_call_create(map()) -> term(). handle_call_create(Params) -> #{ @@ -205,62 +163,6 @@ stop_ringing(Pid, Recipients) -> _ -> gateway_rpc_error:raise(<<"stop_ringing_error">>) end. --spec handle_call_join(binary(), binary(), binary(), map()) -> term(). -handle_call_join(ChannelIdBin, UserIdBin, SessionIdBin, VoiceState) -> - ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin), - UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin), - SessionId = SessionIdBin, - case session_manager:lookup(SessionId) of - {ok, SessionPid} -> - join_call(ChannelId, UserId, VoiceState, SessionId, SessionPid); - {error, not_found} -> - gateway_rpc_error:raise(<<"session_not_found">>) - end. - --spec join_call(integer(), integer(), map(), binary(), pid()) -> true. -join_call(ChannelId, UserId, VoiceState, SessionId, SessionPid) -> - case gateway_rpc_call_lookup:lookup_call(ChannelId) of - {ok, CallPid} -> - join_call_pid(CallPid, UserId, VoiceState, SessionId, SessionPid); - not_found -> - gateway_rpc_error:raise(<<"call_not_found">>); - {error, _Reason} -> - gateway_rpc_error:raise(<<"call_lookup_error">>) - end. - --spec join_call_pid(pid(), integer(), map(), binary(), pid()) -> true. -join_call_pid(CallPid, UserId, VoiceState, SessionId, SessionPid) -> - gen_server:cast(CallPid, {join_async, UserId, VoiceState, SessionId, SessionPid}), - true. - --spec handle_call_leave(binary(), binary()) -> term(). -handle_call_leave(ChannelIdBin, SessionId) -> - ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin), - gateway_rpc_call_lookup:with_call(ChannelId, fun(Pid) -> leave_call(Pid, SessionId) end). - --spec leave_call(pid(), binary()) -> true. -leave_call(Pid, SessionId) -> - case - gateway_rpc_call_lookup:safe_gen_server_call( - Pid, {leave, SessionId}, ?CALL_LOOKUP_TIMEOUT - ) - of - {ok, ok} -> true; - _ -> gateway_rpc_error:raise(<<"leave_call_error">>) - end. - --spec handle_call_delete(binary()) -> term(). -handle_call_delete(ChannelIdBin) -> - ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin), - case gateway_rpc_call_lookup:terminate_call_any(ChannelId) of - ok -> - true; - {error, not_found} -> - gateway_rpc_error:raise(<<"call_not_found">>); - _ -> - gateway_rpc_error:raise(<<"delete_call_error">>) - end. - -spec handle_call_confirm_connection(binary(), binary()) -> term(). handle_call_confirm_connection(ChannelIdBin, ConnectionId) -> ChannelId = validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin), diff --git a/fluxer_gateway/src/gateway/gateway_rpc_call_lookup.erl b/fluxer_gateway/src/gateway/gateway_rpc_call_lookup.erl index e99253844..84426ab3a 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_call_lookup.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_call_lookup.erl @@ -3,17 +3,7 @@ -module(gateway_rpc_call_lookup). -typing([eqwalizer]). --export([ - call_owner_call_manager/3, - lookup_call/1, - safe_gen_server_call/3, - terminate_call_any/1, - with_call/2 -]). - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. +-export([call_owner_call_manager/3, lookup_call/1, safe_gen_server_call/3, with_call/2]). -define(CALL_LOOKUP_TIMEOUT, 2000). @@ -70,45 +60,6 @@ safe_gen_server_call(Pid, Request, Timeout) -> {error, unavailable} end. --spec terminate_call_any(integer()) -> ok | {error, timeout | unavailable | not_found | term()}. -terminate_call_any(ChannelId) -> - case resolve_owner_node(ChannelId) of - {ok, OwnerNode} -> - normalize_terminate_result( - call_call_manager_node( - OwnerNode, {terminate_call, ChannelId}, ?CALL_LOOKUP_TIMEOUT - ) - ); - {error, unavailable} -> - {error, unavailable} - end. - --spec normalize_terminate_result(term()) -> ok | {error, term()}. -normalize_terminate_result(ok) -> - ok; -normalize_terminate_result({error, not_found}) -> - {error, not_found}; -normalize_terminate_result({error, _Reason} = Error) -> - Error; -normalize_terminate_result(_Result) -> - {error, unavailable}. - --spec call_call_manager_node(node(), term(), timeout()) -> term(). -call_call_manager_node(TargetNode, Request, Timeout) -> - try gen_server:call(call_manager_server_ref(TargetNode), Request, Timeout) of - Reply -> - Reply - catch - exit:{timeout, _} -> - {error, timeout}; - exit:{nodedown, _} -> - {error, unavailable}; - exit:{noproc, _} -> - {error, unavailable}; - exit:_Reason -> - {error, unavailable} - end. - -spec lookup_call_on_node(node(), integer()) -> {ok, pid()} | {error, not_found | timeout | unavailable | term()}. lookup_call_on_node(TargetNode, ChannelId) -> @@ -168,23 +119,17 @@ maybe_valid_node(OwnerNode) -> {error, unavailable} end. --ifdef(TEST). - -resolve_owner_node_uses_remote_owner_when_valid_test() -> - RemoteNode = 'gateway_b@127.0.0.1', - ?assertEqual( - {ok, RemoteNode}, - resolve_owner_node(456, fun(_ChannelId) -> {ok, RemoteNode} end) - ). - -resolve_owner_node_returns_unavailable_when_invalid_owner_test() -> - ?assertEqual( - {error, unavailable}, - resolve_owner_node(456, fun(_ChannelId) -> {ok, bad_owner} end) - ), - ?assertEqual( - {error, unavailable}, - resolve_owner_node(456, fun(_ChannelId) -> {bad_owner} end) - ). - --endif. +call_call_manager_node(TargetNode, Request, Timeout) -> + try gen_server:call(call_manager_server_ref(TargetNode), Request, Timeout) of + Reply -> + Reply + catch + exit:{timeout, _} -> + {error, timeout}; + exit:{nodedown, _} -> + {error, unavailable}; + exit:{noproc, _} -> + {error, unavailable}; + exit:_Reason -> + {error, unavailable} + end. diff --git a/fluxer_gateway/src/gateway/gateway_rpc_guild.erl b/fluxer_gateway/src/gateway/gateway_rpc_guild.erl index b68e9ee60..e2ac555ff 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_guild.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_guild.erl @@ -61,27 +61,17 @@ route(M, P) -> <<"guild.get_user_max_role_position">> => fun gateway_rpc_guild_permissions:handle/2, <<"guild.get_viewable_channels">> => fun gateway_rpc_guild_channels:handle/2, <<"guild.resolve_channel_mentions">> => fun gateway_rpc_guild_channels:handle/2, - <<"guild.get_vanity_url_channel">> => fun gateway_rpc_guild_channels:handle/2, <<"guild.get_first_viewable_text_channel">> => fun gateway_rpc_guild_channels:handle/2, <<"guild.get_category_channel_count">> => fun gateway_rpc_guild_channels:handle/2, <<"guild.get_channel_count">> => fun gateway_rpc_guild_channels:handle/2, - <<"guild.get_users_to_mention_by_roles">> => fun gateway_rpc_guild_mentions:handle/2, - <<"guild.get_users_to_mention_by_user_ids">> => fun gateway_rpc_guild_mentions:handle/2, - <<"guild.get_all_users_to_mention">> => fun gateway_rpc_guild_mentions:handle/2, - <<"guild.resolve_all_mentions">> => fun gateway_rpc_guild_mentions:handle/2, - <<"guild.resolve_mention_sources">> => fun gateway_rpc_guild_mentions:handle/2, <<"guild.resolve_mention_sources_page">> => fun gateway_rpc_guild_mentions:handle/2, <<"guild.update_member_voice">> => fun gateway_rpc_guild_voice:handle/2, - <<"guild.disconnect_voice_user">> => fun gateway_rpc_guild_voice:handle/2, <<"guild.disconnect_voice_user_if_in_channel">> => fun gateway_rpc_guild_voice:handle/2, - <<"guild.disconnect_all_voice_users_in_channel">> => fun gateway_rpc_guild_voice:handle/2, <<"guild.confirm_voice_connection_from_livekit">> => fun gateway_rpc_guild_voice:handle/2, <<"guild.get_voice_states_for_channel">> => fun gateway_rpc_guild_voice:handle/2, - <<"guild.get_pending_joins_for_channel">> => fun gateway_rpc_guild_voice:handle/2, <<"guild.move_member">> => fun gateway_rpc_guild_voice:handle/2, <<"guild.get_voice_state">> => fun gateway_rpc_guild_voice:handle/2, - <<"guild.switch_voice_region">> => fun gateway_rpc_guild_voice:handle/2, - <<"guild.batch_voice_state_update">> => fun gateway_rpc_guild_voice:handle/2 + <<"guild.switch_voice_region">> => fun gateway_rpc_guild_voice:handle/2 }). -spec route_handler(binary()) -> fun((binary(), map()) -> term()). diff --git a/fluxer_gateway/src/gateway/gateway_rpc_guild_channels.erl b/fluxer_gateway/src/gateway/gateway_rpc_guild_channels.erl index 596338b96..b871fd263 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_guild_channels.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_guild_channels.erl @@ -10,7 +10,6 @@ -spec handle(binary(), map()) -> term(). handle(<<"guild.get_viewable_channels">>, P) -> handle_get_viewable_channels(P); handle(<<"guild.resolve_channel_mentions">>, P) -> handle_resolve_channel_mentions(P); -handle(<<"guild.get_vanity_url_channel">>, P) -> handle_get_vanity_url_channel(P); handle(<<"guild.get_first_viewable_text_channel">>, P) -> handle_get_first_viewable(P); handle(<<"guild.get_category_channel_count">>, P) -> handle_get_category_count(P); handle(<<"guild.get_channel_count">>, P) -> handle_get_channel_count(P). @@ -52,18 +51,6 @@ parse_channel_ids(ChannelIdBins) -> error:{invalid_snowflake, _} -> [] end. --spec handle_get_vanity_url_channel(map()) -> term(). -handle_get_vanity_url_channel(#{<<"guild_id">> := GIB}) -> - GuildId = snowflake_or_throw(<<"guild_id">>, GIB), - gateway_rpc_guild_infra:with_guild(GuildId, fun get_vanity_url_channel/1). - --spec get_vanity_url_channel(pid()) -> map(). -get_vanity_url_channel(Pid) -> - case gen_server:call(Pid, {get_vanity_url_channel}, ?GUILD_CALL_TIMEOUT) of - #{channel_id := CId} -> format_channel_id_reply(CId, <<"vanity_url_channel_error">>); - _ -> gateway_rpc_error:raise(<<"vanity_url_channel_error">>) - end. - -spec handle_get_first_viewable(map()) -> term(). handle_get_first_viewable(#{<<"guild_id">> := GIB}) -> GuildId = snowflake_or_throw(<<"guild_id">>, GIB), @@ -120,15 +107,15 @@ get_viewable_channels_cached_or_rpc(GuildId, UserId) -> get_viewable_channels_from_snapshot(GuildId, UserId, Snapshot) -> case should_bypass_viewable_cache(UserId, Snapshot) of true -> get_viewable_channels_via_rpc(GuildId, UserId); - false -> {ok, guild_visibility:get_user_viewable_channels(UserId, Snapshot)} + false -> {ok, guild_visibility_channels:get_user_viewable_channels(UserId, Snapshot)} end. -spec should_bypass_viewable_cache(integer(), map()) -> boolean(). should_bypass_viewable_cache(UserId, Snapshot) -> Data = map_utils:ensure_map(maps:get(data, Snapshot, #{})), - Member = guild_permissions:find_member_by_user_id(UserId, Snapshot), + Member = guild_permissions_check:find_member_by_user_id(UserId, Snapshot), Channels = maps:get(<<"channels">>, Data, undefined), - ChannelIndex = guild_data_index:channel_index(Data), + ChannelIndex = guild_data_index_channels:channel_index(Data), HasIndexedChannels = is_map(ChannelIndex) andalso map_size(ChannelIndex) > 0, Member =/= undefined andalso HasIndexedChannels andalso empty_channels_list(Channels). @@ -175,8 +162,8 @@ resolve_from_snapshot_or_rpc(GuildId, ChannelIds, Snapshot) -> {ok, [map()]} | fallback. resolve_from_snapshot(ChannelIds, Snapshot, GuildId) -> Data = map_utils:ensure_map(maps:get(data, Snapshot, #{})), - Roles = guild_data_index:role_index(Data), - ChIndex = guild_data_index:channel_index(Data), + Roles = guild_data_index_roles:role_index(Data), + ChIndex = guild_data_index_channels:channel_index(Data), BasePermissions = everyone_role_permissions(GuildId, Roles), NeedsFallback = snapshot_needs_fallback(ChannelIds, map_utils:ensure_map(ChIndex)), case {BasePermissions, NeedsFallback} of @@ -258,21 +245,21 @@ map_entries(Entries) -> -spec everyone_role_permissions(integer(), [map()] | map()) -> integer() | undefined. everyone_role_permissions(GuildId, Roles) -> - case guild_permissions:find_role_by_id(GuildId, Roles) of + case guild_permissions_check:find_role_by_id(GuildId, Roles) of undefined -> undefined; Role -> - permission_bits:parse_optional(maps:get(<<"permissions">>, Role, undefined)) + bitset:parse_optional(maps:get(<<"permissions">>, Role, undefined)) end. -spec everyone_can_view(map(), integer(), integer()) -> boolean(). everyone_can_view(Channel, GuildId, BasePerm) -> - case permission_bits:has(BasePerm, constants:administrator_permission()) of + case bitset:has(BasePerm, constants:administrator_permission()) of true -> true; false -> P = apply_everyone_overwrites(BasePerm, Channel, GuildId), - permission_bits:has(P, constants:view_channel_permission()) + bitset:has(P, constants:view_channel_permission()) end. -spec build_channel_mention(map()) -> {true, map()} | false. @@ -338,12 +325,12 @@ apply_everyone_overwrite(Overwrite, GuildId, Acc) when is_map(Overwrite) -> { snowflake_id:parse_optional(maps:get(<<"id">>, Overwrite, undefined)), guild_data_normalize_schema:int(maps:get(<<"type">>, Overwrite, undefined)), - permission_bits:parse_optional(maps:get(<<"allow">>, Overwrite, undefined)), - permission_bits:parse_optional(maps:get(<<"deny">>, Overwrite, undefined)) + bitset:parse_optional(maps:get(<<"allow">>, Overwrite, undefined)), + bitset:parse_optional(maps:get(<<"deny">>, Overwrite, undefined)) } of {GuildId, 0, Allow, Deny} when is_integer(Allow), is_integer(Deny) -> - permission_bits:apply_allow_deny(Acc, Allow, Deny); + bitset:apply_allow_deny(Acc, Allow, Deny); _ -> Acc end; diff --git a/fluxer_gateway/src/gateway/gateway_rpc_guild_infra.erl b/fluxer_gateway/src/gateway/gateway_rpc_guild_infra.erl index a1c14763f..98bdfb30a 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_guild_infra.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_guild_infra.erl @@ -2,6 +2,10 @@ -module(gateway_rpc_guild_infra). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -export([ @@ -21,10 +25,6 @@ is_cached_guild_pid_alive/1 ]). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -define(GUILD_LOOKUP_TIMEOUT, 2500). -define(GUILD_START_LOOKUP_TIMEOUT, 8000). -define(GUILD_START_MAX_ATTEMPTS, 3). @@ -449,17 +449,6 @@ overloaded_guild_is_rejected_before_enqueuing_work_test() -> Pid ! stop end. -guild_start_backoff_delay_exponential_test() -> - Delay1 = guild_start_backoff_delay(1), - ?assert(Delay1 >= ?GUILD_START_BASE_MS), - ?assert(Delay1 =< ?GUILD_START_BASE_MS + ?GUILD_START_JITTER_MS), - Delay2 = guild_start_backoff_delay(2), - ?assert(Delay2 >= ?GUILD_START_BASE_MS * 2), - ?assert(Delay2 =< ?GUILD_START_BASE_MS * 2 + ?GUILD_START_JITTER_MS). - -guild_start_backoff_delay_caps_at_max_test() -> - ?assert(guild_start_backoff_delay(10) =< ?GUILD_START_MAX_MS + ?GUILD_START_JITTER_MS). - run_with_guild_pid_guard_generic_exit_returns_domain_error_test() -> ?assertError( {gateway_rpc_error, <<"guild_not_found">>}, diff --git a/fluxer_gateway/src/gateway/gateway_rpc_guild_members.erl b/fluxer_gateway/src/gateway/gateway_rpc_guild_members.erl index d1b19afb8..87d81dc65 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_guild_members.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_guild_members.erl @@ -6,10 +6,6 @@ -export([handle/2]). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -define(GUILD_CALL_TIMEOUT, 4000). -spec handle(binary(), map()) -> term(). @@ -246,10 +242,4 @@ parse_optional_snowflake(Value, Field) -> validation:snowflake_or_throw(Field, V -ifdef(TEST). -clamp_limit_test() -> - ?assertEqual(1, clamp_limit(undefined)), - ?assertEqual(1, clamp_limit(0)), - ?assertEqual(50, clamp_limit(50)), - ?assertEqual(1000, clamp_limit(2000)). - -endif. diff --git a/fluxer_gateway/src/gateway/gateway_rpc_guild_mentions.erl b/fluxer_gateway/src/gateway/gateway_rpc_guild_mentions.erl index 453ae31cd..67ac6125c 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_guild_mentions.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_guild_mentions.erl @@ -2,124 +2,21 @@ -module(gateway_rpc_guild_mentions). --typing([eqwalizer]). - --export([handle/2]). - -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -endif. +-typing([eqwalizer]). + +-export([handle/2]). + -define(GUILD_CALL_TIMEOUT, 4000). -define(MENTION_SOURCE_PAGE_DEFAULT_LIMIT, 1000). -define(MENTION_SOURCE_PAGE_MAX_LIMIT, 5000). -spec handle(binary(), map()) -> term(). -handle(<<"guild.get_users_to_mention_by_roles">>, P) -> handle_mention_by_roles(P); -handle(<<"guild.get_users_to_mention_by_user_ids">>, P) -> handle_mention_by_user_ids(P); -handle(<<"guild.get_all_users_to_mention">>, P) -> handle_mention_all(P); -handle(<<"guild.resolve_all_mentions">>, P) -> handle_resolve_all(P); -handle(<<"guild.resolve_mention_sources">>, P) -> handle_resolve_sources(P); handle(<<"guild.resolve_mention_sources_page">>, P) -> handle_resolve_sources_page(P). --spec handle_mention_by_roles(map()) -> term(). -handle_mention_by_roles( - #{ - <<"guild_id">> := GIB, - <<"channel_id">> := CIB, - <<"role_ids">> := RIds, - <<"author_id">> := AIB - } -) -> - GuildId = validation:snowflake_or_throw(<<"guild_id">>, GIB), - ChannelId = validation:snowflake_or_throw(<<"channel_id">>, CIB), - AuthorId = validation:snowflake_or_throw(<<"author_id">>, AIB), - RoleIdsList = validation:snowflake_list_or_throw(<<"role_ids">>, RIds), - Req = #{channel_id => ChannelId, role_ids => RoleIdsList, author_id => AuthorId}, - mention_guild_call(GuildId, {get_users_to_mention_by_roles, Req}, <<"users_error">>). - --spec handle_mention_by_user_ids(map()) -> term(). -handle_mention_by_user_ids( - #{ - <<"guild_id">> := GIB, - <<"channel_id">> := CIB, - <<"user_ids">> := UIDs, - <<"author_id">> := AIB - } -) -> - GuildId = validation:snowflake_or_throw(<<"guild_id">>, GIB), - ChannelId = validation:snowflake_or_throw(<<"channel_id">>, CIB), - AuthorId = validation:snowflake_or_throw(<<"author_id">>, AIB), - UserIdsList = validation:snowflake_list_or_throw(<<"user_ids">>, UIDs), - Req = #{channel_id => ChannelId, user_ids => UserIdsList, author_id => AuthorId}, - mention_guild_call(GuildId, {get_users_to_mention_by_user_ids, Req}, <<"users_error">>). - --spec handle_mention_all(map()) -> term(). -handle_mention_all( - #{<<"guild_id">> := GIB, <<"channel_id">> := CIB, <<"author_id">> := AIB} -) -> - GuildId = validation:snowflake_or_throw(<<"guild_id">>, GIB), - ChannelId = validation:snowflake_or_throw(<<"channel_id">>, CIB), - AuthorId = validation:snowflake_or_throw(<<"author_id">>, AIB), - Req = #{channel_id => ChannelId, author_id => AuthorId}, - mention_guild_call(GuildId, {get_all_users_to_mention, Req}, <<"users_error">>). - --spec handle_resolve_all(map()) -> term(). -handle_resolve_all( - #{ - <<"guild_id">> := GIB, - <<"channel_id">> := CIB, - <<"author_id">> := AIB, - <<"mention_everyone">> := ME, - <<"mention_here">> := MH, - <<"role_ids">> := RIds, - <<"user_ids">> := UIDs - } -) -> - GuildId = validation:snowflake_or_throw(<<"guild_id">>, GIB), - ChannelId = validation:snowflake_or_throw(<<"channel_id">>, CIB), - AuthorId = validation:snowflake_or_throw(<<"author_id">>, AIB), - Req = build_mention_req(ChannelId, AuthorId, ME, MH, RIds, UIDs), - mention_guild_call(GuildId, {resolve_all_mentions, Req}, <<"resolve_mentions_error">>). - --spec handle_resolve_sources(map()) -> term(). -handle_resolve_sources( - #{ - <<"guild_id">> := GIB, - <<"channel_id">> := CIB, - <<"author_id">> := AIB, - <<"mention_everyone">> := ME, - <<"mention_here">> := MH, - <<"role_ids">> := RIds, - <<"user_ids">> := UIDs - } -) -> - GuildId = validation:snowflake_or_throw(<<"guild_id">>, GIB), - ChannelId = validation:snowflake_or_throw(<<"channel_id">>, CIB), - AuthorId = validation:snowflake_or_throw(<<"author_id">>, AIB), - Req = build_mention_req(ChannelId, AuthorId, ME, MH, RIds, UIDs), - do_resolve_sources(GuildId, Req). - --spec do_resolve_sources(integer(), map()) -> term(). -do_resolve_sources(GuildId, Req) -> - gateway_rpc_guild_infra:with_guild(GuildId, fun(Pid) -> - guild_call_sources(Pid, Req) - end). - --spec guild_call_sources(pid(), map()) -> map(). -guild_call_sources(Pid, Req) -> - Msg = {resolve_mention_sources, Req}, - case guild_query_handler:call(Pid, Msg, ?GUILD_CALL_TIMEOUT) of - #{direct_user_ids := D, role_user_ids := R, everyone_user_ids := E} -> - #{ - <<"direct_user_ids">> => fmt_ids(D), - <<"role_user_ids">> => fmt_ids(R), - <<"everyone_user_ids">> => fmt_ids(E) - }; - _ -> - gateway_rpc_error:raise(<<"resolve_mention_sources_error">>) - end. - -spec handle_resolve_sources_page(term()) -> term(). handle_resolve_sources_page(Params) -> case parse_page_params(Params) of @@ -150,35 +47,6 @@ guild_call_sources_page(Pid, Request) -> gateway_rpc_error:raise(ErrBin) end. --spec build_mention_req(integer(), integer(), term(), term(), term(), term()) -> map(). -build_mention_req(ChannelId, AuthorId, ME, MH, RIds, UIDs) -> - #{ - channel_id => ChannelId, - author_id => AuthorId, - mention_everyone => ME, - mention_here => MH, - role_ids => validation:snowflake_list_or_throw(<<"role_ids">>, RIds), - user_ids => validation:snowflake_list_or_throw(<<"user_ids">>, UIDs) - }. - --spec mention_guild_call(integer(), {atom(), map()}, binary()) -> term(). -mention_guild_call(GuildId, Msg, ErrorBin) -> - gateway_rpc_guild_infra:with_guild(GuildId, fun(Pid) -> - guild_call_user_ids(Pid, Msg, ErrorBin) - end). - --spec guild_call_user_ids(pid(), {atom(), map()}, binary()) -> map(). -guild_call_user_ids(Pid, Msg, ErrorBin) -> - case guild_query_handler:call(Pid, Msg, ?GUILD_CALL_TIMEOUT) of - #{user_ids := Ids} -> - #{<<"user_ids">> => [integer_to_binary(U) || U <- Ids]}; - _ -> - gateway_rpc_error:raise(ErrorBin) - end. - --spec fmt_ids([integer()]) -> [binary()]. -fmt_ids(Ids) -> [integer_to_binary(I) || I <- Ids]. - -spec fmt_mention_entries([map()]) -> [map()]. fmt_mention_entries(Entries) -> [fmt_mention_entry(E) || E <- Entries]. @@ -323,34 +191,13 @@ parse_page_params_defaults_optional_fields_test() -> ). mention_guild_calls_pass_the_caller_deadline_test() -> - Cases = [ + assert_call_passes_deadline( { fun(Pid) -> guild_call_sources_page(Pid, #{limit => 5}) end, #{mentions => [], next_cursor => undefined}, #{<<"mentions">> => [], <<"next_cursor">> => null} - }, - { - fun(Pid) -> guild_call_sources(Pid, #{user_ids => [7]}) end, - #{direct_user_ids => [7], role_user_ids => [], everyone_user_ids => []}, - #{ - <<"direct_user_ids">> => [<<"7">>], - <<"role_user_ids">> => [], - <<"everyone_user_ids">> => [] - } - }, - { - fun(Pid) -> - guild_call_user_ids( - Pid, - {get_users_to_mention_by_user_ids, #{user_ids => [7]}}, - <<"users_error">> - ) - end, - #{user_ids => [7]}, - #{<<"user_ids">> => [<<"7">>]} } - ], - lists:foreach(fun assert_call_passes_deadline/1, Cases). + ). assert_call_passes_deadline({Call, GuildReply, Expected}) -> Self = self(), diff --git a/fluxer_gateway/src/gateway/gateway_rpc_guild_permissions.erl b/fluxer_gateway/src/gateway/gateway_rpc_guild_permissions.erl index 44557959b..3ad098bd8 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_guild_permissions.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_guild_permissions.erl @@ -2,14 +2,14 @@ -module(gateway_rpc_guild_permissions). --typing([eqwalizer]). - --export([handle/2, get_permissions_cached_or_rpc/3]). - -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -endif. +-typing([eqwalizer]). + +-export([handle/2, get_permissions_cached_or_rpc/3]). + -define(GUILD_CALL_TIMEOUT, 4000). -spec handle(binary(), map()) -> term(). @@ -79,7 +79,7 @@ handle_check_permission( ChannelId = parse_channel_id(CIB), case get_permissions_cached_or_rpc(GuildId, UserId, ChannelId) of {ok, Perms} -> - #{<<"has_permission">> => permission_bits:has(Perms, Permission)}; + #{<<"has_permission">> => bitset:has(Perms, Permission)}; {error, guild_not_found} -> gateway_rpc_error:raise(<<"guild_not_found">>); error -> @@ -219,7 +219,7 @@ parse_channel_id(CIB) -> validation:snowflake_or_throw(<<"channel_id">>, CIB). -spec permission_or_throw(term()) -> non_neg_integer(). permission_or_throw(Value) -> - try permission_bits:parse(Value) of + try bitset:parse(Value) of Permission when is_integer(Permission) -> Permission catch error:{invalid_bitset, _} -> gateway_rpc_error:raise(<<"invalid_params">>) @@ -249,15 +249,4 @@ get_user_permissions_unknown_guild_raises_guild_not_found_test() -> handle(<<"guild.get_user_permissions">>, Payload) ). -get_user_permissions_batch_rejects_oversized_test() -> - GuildIds = [integer_to_binary(N) || N <- lists:seq(1, 101)], - Payload = #{ - <<"guild_ids">> => GuildIds, - <<"user_id">> => <<"1">>, - <<"channel_id">> => <<"0">> - }, - ?assertError( - {gateway_rpc_error, <<"batch_too_large">>}, - handle(<<"guild.get_user_permissions_batch">>, Payload) - ). -endif. diff --git a/fluxer_gateway/src/gateway/gateway_rpc_guild_routing.erl b/fluxer_gateway/src/gateway/gateway_rpc_guild_routing.erl index 541710db7..1724051f7 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_guild_routing.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_guild_routing.erl @@ -2,6 +2,10 @@ -module(gateway_rpc_guild_routing). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -export([ @@ -15,10 +19,6 @@ validate_batch_size/1 ]). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -define(MAX_BATCH_SIZE, 100). -spec guild_owner_scope(integer()) -> @@ -211,17 +211,6 @@ collect_results(Ref, Remaining, Deadline, Acc) -> -ifdef(TEST). -resolve_owner_node_rejects_invalid_owner_atom_test() -> - LocalNode = 'gateway_a@127.0.0.1', - ?assertEqual( - unavailable, - resolve_owner_node(123, LocalNode, fun(_) -> bad_owner end) - ), - ?assertEqual( - unavailable, - resolve_owner_node(123, LocalNode, fun(_) -> {bad_owner} end) - ). - owner_groups_for_reload_all_empty_ids_uses_active_nodes_test() -> NodeA = 'gateway_a@127.0.0.1', NodeB = 'gateway_b@127.0.0.1', @@ -230,11 +219,6 @@ owner_groups_for_reload_all_empty_ids_uses_active_nodes_test() -> owner_groups_for_reload_all([], fun() -> [NodeB, NodeA, NodeB] end) ). -validate_batch_size_test() -> - ?assertEqual(ok, validate_batch_size(50)), - ?assertEqual(ok, validate_batch_size(100)), - ?assertError({gateway_rpc_error, <<"batch_too_large">>}, validate_batch_size(101)). - process_batch_collects_successful_results_test() -> Results = process_batch([1, 2, 3], fun(N) -> eqwalizer:dynamic_cast(N) * 2 end, 1000), ?assertEqual([2, 4, 6], lists:sort(Results)). diff --git a/fluxer_gateway/src/gateway/gateway_rpc_guild_voice.erl b/fluxer_gateway/src/gateway/gateway_rpc_guild_voice.erl index 7a4fd07f8..0a31a0b5b 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_guild_voice.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_guild_voice.erl @@ -2,40 +2,32 @@ -module(gateway_rpc_guild_voice). --typing([eqwalizer]). - --export([handle/2]). - -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -endif. +-typing([eqwalizer]). + +-export([handle/2]). + -define(GUILD_CALL_TIMEOUT, 4000). -define(BATCH_TIMEOUT_MS, 5000). -spec handle(binary(), map()) -> term(). handle(<<"guild.update_member_voice">>, P) -> handle_update_member_voice(P); -handle(<<"guild.disconnect_voice_user">>, P) -> - handle_disconnect_voice_user(P); handle(<<"guild.disconnect_voice_user_if_in_channel">>, P) -> handle_disconnect_if_in_channel(P); -handle(<<"guild.disconnect_all_voice_users_in_channel">>, P) -> - handle_disconnect_all_in_channel(P); handle(<<"guild.confirm_voice_connection_from_livekit">>, P) -> handle_confirm_connection(P); handle(<<"guild.get_voice_states_for_channel">>, P) -> handle_get_voice_states(P); -handle(<<"guild.get_pending_joins_for_channel">>, P) -> - handle_get_pending_joins(P); handle(<<"guild.move_member">>, P) -> handle_move_member(P); handle(<<"guild.get_voice_state">>, P) -> handle_get_voice_state(P); handle(<<"guild.switch_voice_region">>, P) -> - handle_switch_voice_region(P); -handle(<<"guild.batch_voice_state_update">>, P) -> - handle_batch_update(P). + handle_switch_voice_region(P). -spec handle_update_member_voice(map()) -> term(). handle_update_member_voice(#{ @@ -58,22 +50,6 @@ call_update_member_voice(VPid, Request) -> #{error := E} -> raise_voice_error(E) end. --spec handle_disconnect_voice_user(map()) -> term(). -handle_disconnect_voice_user(#{<<"guild_id">> := GIB, <<"user_id">> := UIB} = P) -> - GuildId = validation:snowflake_or_throw(<<"guild_id">>, GIB), - UserId = validation:snowflake_or_throw(<<"user_id">>, UIB), - Request = #{user_id => UserId, connection_id => maps:get(<<"connection_id">>, P, null)}, - gateway_rpc_guild_infra:with_voice_server(GuildId, fun(VPid, _) -> - call_disconnect_voice_user(VPid, Request) - end). - --spec call_disconnect_voice_user(pid(), map()) -> map(). -call_disconnect_voice_user(VPid, Request) -> - case gen_server:call(VPid, {disconnect_voice_user, Request}, ?GUILD_CALL_TIMEOUT) of - #{success := true} -> #{<<"success">> => true}; - #{error := E} -> raise_voice_error(E) - end. - -spec handle_disconnect_if_in_channel(map()) -> term(). handle_disconnect_if_in_channel( #{ @@ -117,24 +93,6 @@ build_disconnect_if_in_channel_req(UserId, ExpCId, undefined) -> build_disconnect_if_in_channel_req(UserId, ExpCId, ConnId) -> #{user_id => UserId, expected_channel_id => ExpCId, connection_id => ConnId}. --spec handle_disconnect_all_in_channel(map()) -> term(). -handle_disconnect_all_in_channel(#{<<"guild_id">> := GIB, <<"channel_id">> := CIB}) -> - GuildId = validation:snowflake_or_throw(<<"guild_id">>, GIB), - ChannelId = validation:snowflake_or_throw(<<"channel_id">>, CIB), - gateway_rpc_guild_infra:with_voice_server(GuildId, fun(VPid, _) -> - call_disconnect_all_in_channel(VPid, ChannelId) - end). - --spec call_disconnect_all_in_channel(pid(), integer()) -> map(). -call_disconnect_all_in_channel(VPid, ChannelId) -> - Request = {disconnect_all_voice_users_in_channel, #{channel_id => ChannelId}}, - case gen_server:call(VPid, Request, ?GUILD_CALL_TIMEOUT) of - #{success := true, disconnected_count := Cnt} -> - #{<<"success">> => true, <<"disconnected_count">> => Cnt}; - #{error := E} -> - raise_voice_error(E) - end. - -spec handle_confirm_connection(map()) -> term(). handle_confirm_connection(P) -> GuildId = validation:snowflake_or_throw(<<"guild_id">>, maps:get(<<"guild_id">>, P)), @@ -185,26 +143,6 @@ get_voice_states_for_channel(VPid, CIdBin) -> _ -> gateway_rpc_error:raise(<<"voice_states_error">>) end. --spec handle_get_pending_joins(map()) -> term(). -handle_get_pending_joins(P) -> - case validate_gc_params(P) of - {ok, GId, CIdBin} -> get_pending_joins_for_guild(GId, CIdBin); - error -> #{<<"pending_joins">> => []} - end. - --spec get_pending_joins_for_guild(integer(), binary()) -> term(). -get_pending_joins_for_guild(GuildId, CIdBin) -> - gateway_rpc_guild_infra:with_voice_server(GuildId, fun(VPid, _) -> - get_pending_joins_for_channel(VPid, CIdBin) - end). - --spec get_pending_joins_for_channel(pid(), binary()) -> map(). -get_pending_joins_for_channel(VPid, CIdBin) -> - case gen_server:call(VPid, {get_pending_joins_for_channel, CIdBin}, 10000) of - #{pending_joins := PJ} -> #{<<"pending_joins">> => PJ}; - _ -> gateway_rpc_error:raise(<<"pending_joins_error">>) - end. - -spec handle_move_member(map()) -> term(). handle_move_member( #{ @@ -272,20 +210,9 @@ switch_voice_region(VPid, GPid, GuildId, ChannelId) -> -spec spawn_switch_voice_region(integer(), integer(), pid()) -> ok. spawn_switch_voice_region(GuildId, ChannelId, GPid) -> - spawn(fun() -> guild_voice:switch_voice_region(GuildId, ChannelId, GPid) end), + spawn(fun() -> guild_voice_region:switch_voice_region(GuildId, ChannelId, GPid) end), ok. --spec handle_batch_update(map()) -> term(). -handle_batch_update(#{<<"updates">> := Updates}) -> - gateway_rpc_guild_routing:validate_batch_size(length(Updates)), - Parsed = lists:map(fun gateway_rpc_guild_voice_util:parse_voice_update/1, Updates), - Results = gateway_rpc_guild_routing:process_batch( - Parsed, - fun gateway_rpc_guild_voice_util:process_voice_update/1, - ?BATCH_TIMEOUT_MS - ), - #{<<"results">> => Results}. - -spec raise_voice_error(term()) -> no_return(). raise_voice_error(Error) -> gateway_rpc_error:raise(normalize_voice_rpc_error(Error)). @@ -317,13 +244,6 @@ guild_voice_channel_read_malformed_payloads_test() -> ?assertEqual( #{<<"voice_states">> => []}, handle(<<"guild.get_voice_states_for_channel">>, #{}) - ), - ?assertEqual( - #{<<"pending_joins">> => []}, - handle( - <<"guild.get_pending_joins_for_channel">>, - #{<<"guild_id">> => null, <<"channel_id">> => <<"2">>} - ) ). -endif. diff --git a/fluxer_gateway/src/gateway/gateway_rpc_guild_voice_util.erl b/fluxer_gateway/src/gateway/gateway_rpc_guild_voice_util.erl index 1848380f6..c0c7311e8 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_guild_voice_util.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_guild_voice_util.erl @@ -4,13 +4,7 @@ -typing([eqwalizer]). --export([ - handle_move_member_result/4, - normalize_voice_rpc_error/1, - parse_voice_update/1, - process_voice_update/1 -]). --export_type([voice_update_payload/0]). +-export([handle_move_member_result/4, normalize_voice_rpc_error/1]). -define(GUILD_CALL_TIMEOUT, 4000). @@ -46,7 +40,7 @@ handle_move_member_result(_, _, _, _) -> -spec send_voice_server_updates_for_move(integer(), integer(), [map()], pid()) -> ok. send_voice_server_updates_for_move(GuildId, ChannelId, VoiceSessionData, Pid) -> spawn(fun() -> - guild_voice:send_voice_server_updates_for_move( + guild_voice_move:send_voice_server_updates_for_move( GuildId, ChannelId, VoiceSessionData, Pid ) end), @@ -54,83 +48,13 @@ send_voice_server_updates_for_move(GuildId, ChannelId, VoiceSessionData, Pid) -> -spec cleanup_virtual_access_on_disconnect(integer(), pid()) -> ok. cleanup_virtual_access_on_disconnect(DisconnectedUserId, Pid) -> - spawn(fun() -> guild_voice:cleanup_virtual_access_on_disconnect(DisconnectedUserId, Pid) end), + spawn(fun() -> do_cleanup_virtual_access_on_disconnect(DisconnectedUserId, Pid) end), ok. -spec voice_session_data([term()]) -> [map()]. voice_session_data(SessionData) -> [Entry || Entry <- SessionData, is_map(Entry)]. --type voice_update_payload() :: #{binary() => term()}. - --spec parse_voice_update(voice_update_payload()) -> - {integer(), integer(), boolean(), boolean(), term()}. -parse_voice_update( - #{ - <<"guild_id">> := GuildIdBin, - <<"user_id">> := UserIdBin, - <<"mute">> := Mute, - <<"deaf">> := Deaf - } = Update -) when is_boolean(Mute), is_boolean(Deaf) -> - GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin), - UserId = validation:snowflake_or_throw(<<"user_id">>, UserIdBin), - {GuildId, UserId, Mute, Deaf, maps:get(<<"connection_id">>, Update, null)}. - --spec process_voice_update(term()) -> map(). -process_voice_update({GuildId, UserId, Mute, Deaf, ConnectionId}) when - is_integer(GuildId), is_integer(UserId), is_boolean(Mute), is_boolean(Deaf) --> - case gateway_rpc_guild_infra:ensure_guild_pid(GuildId) of - {ok, GuildPid} -> - process_voice_update_for_guild(GuildId, UserId, Mute, Deaf, ConnectionId, GuildPid); - _ -> - voice_update_result(GuildId, UserId, false, <<"guild_not_found">>) - end; -process_voice_update(_) -> - #{<<"success">> => false, <<"error">> => <<"voice_update_error">>}. - --spec process_voice_update_for_guild( - integer(), integer(), boolean(), boolean(), term(), pid() -) -> map(). -process_voice_update_for_guild(GuildId, UserId, Mute, Deaf, ConnectionId, GuildPid) -> - VoicePid = gateway_rpc_guild_infra:resolve_voice_pid(GuildId, GuildPid), - Request = #{ - user_id => UserId, - mute => Mute, - deaf => Deaf, - connection_id => ConnectionId - }, - Result = gateway_rpc_guild_infra:safe_gen_server_call( - VoicePid, - {update_member_voice, Request}, - ?GUILD_CALL_TIMEOUT - ), - normalize_voice_update_result(GuildId, UserId, Result). - --spec normalize_voice_update_result(integer(), integer(), term()) -> map(). -normalize_voice_update_result(GuildId, UserId, {ok, #{success := true}}) -> - voice_update_result(GuildId, UserId, true, undefined); -normalize_voice_update_result(GuildId, UserId, {ok, #{error := Error}}) -> - voice_update_result(GuildId, UserId, false, Error); -normalize_voice_update_result(GuildId, UserId, _Result) -> - voice_update_result(GuildId, UserId, false, <<"voice_update_error">>). - --spec voice_update_result(integer(), integer(), boolean(), term()) -> map(). -voice_update_result(GuildId, UserId, true, _Error) -> - #{ - <<"guild_id">> => integer_to_binary(GuildId), - <<"user_id">> => integer_to_binary(UserId), - <<"success">> => true - }; -voice_update_result(GuildId, UserId, false, Error) -> - #{ - <<"guild_id">> => integer_to_binary(GuildId), - <<"user_id">> => integer_to_binary(UserId), - <<"success">> => false, - <<"error">> => Error - }. - -spec normalize_voice_rpc_error(term()) -> binary(). normalize_voice_rpc_error(voice_user_not_in_voice) -> <<"user_not_in_voice">>; normalize_voice_rpc_error(voice_channel_not_found) -> <<"channel_not_found">>; @@ -152,3 +76,18 @@ normalize_binary(<<"voice_permission_denied">>) -> <<"target_missing_connect">>; normalize_binary(<<"voice_connection_not_found">>) -> <<"connection_not_found">>; normalize_binary(<<"voice_missing_connection_id">>) -> <<"connection_not_found">>; normalize_binary(Error) -> Error. + +do_cleanup_virtual_access_on_disconnect(UserId, GuildPid) -> + GuildId = resolve_guild_id_from_pid(GuildPid), + TargetPid = guild_voice_server:resolve(GuildId, GuildPid), + gen_server:cast(TargetPid, {cleanup_virtual_access_for_user, UserId}). + +resolve_guild_id_from_pid(GuildPid) -> + try guild_voice_server_state:guild_id_call(GuildPid, 5000) of + GuildId when is_integer(GuildId) -> + GuildId; + _ -> + undefined + catch + _:_ -> undefined + end. diff --git a/fluxer_gateway/src/gateway/gateway_rpc_misc_session.erl b/fluxer_gateway/src/gateway/gateway_rpc_misc_session.erl index 3c7143dca..9b0e14c57 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_misc_session.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_misc_session.erl @@ -115,15 +115,11 @@ node_id(TargetNode) -> -spec decode_integer(term()) -> non_neg_integer(). decode_integer(Value) -> - case maybe_integer(Value) of + case type_conv:to_integer(Value) of Integer when is_integer(Integer), Integer >= 0 -> Integer; _ -> 0 end. --spec maybe_integer(term()) -> integer() | undefined. -maybe_integer(Value) -> - type_conv:to_integer(Value). - -spec compare_node_ids(map(), map()) -> boolean(). compare_node_ids(Left, Right) -> maps:get(<<"node_id">>, Left, <<>>) =< maps:get(<<"node_id">>, Right, <<>>). @@ -143,13 +139,9 @@ local_hostname() -> trimmed_hostname(Hostname) -> case string:trim(Hostname) of "" -> undefined; - Trimmed -> hostname_binary(Trimmed) + Trimmed -> type_conv:unicode_to_binary(Trimmed) end. --spec hostname_binary(term()) -> binary() | undefined. -hostname_binary(Value) -> - type_conv:unicode_to_binary(Value). - -spec get_manager_count(atom()) -> non_neg_integer(). get_manager_count(Manager) -> try gen_server:call(Manager, get_global_count, ?MANAGER_COUNT_TIMEOUT) of @@ -224,7 +216,7 @@ aggregate_uptime(NodeStats) -> Uptimes = [ U || N <- NodeStats, - U <- [maybe_integer(maps:get(<<"uptime_seconds">>, N, undefined))], + U <- [type_conv:to_integer(maps:get(<<"uptime_seconds">>, N, undefined))], is_integer(U), U >= 0 ], diff --git a/fluxer_gateway/src/gateway/gateway_rpc_presence.erl b/fluxer_gateway/src/gateway/gateway_rpc_presence.erl index 19e2c8565..894549a23 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_presence.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_presence.erl @@ -1,14 +1,14 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(gateway_rpc_presence). --typing([eqwalizer]). - --export([execute_method/2]). - -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -endif. +-typing([eqwalizer]). + +-export([execute_method/2]). + -define(PRESENCE_LOOKUP_TIMEOUT, 2000). -spec execute_method(binary(), map()) -> term(). @@ -344,23 +344,6 @@ sync_blocked_ids_for_user(_UserId) -> -ifdef(TEST). -resolve_owner_node_uses_remote_owner_when_valid_test() -> - RemoteNode = 'gateway_b@127.0.0.1', - ?assertEqual( - {ok, RemoteNode}, - resolve_owner_node(123, fun(_UserId) -> {ok, RemoteNode} end) - ). - -resolve_owner_node_returns_unavailable_when_invalid_owner_test() -> - ?assertEqual( - unavailable, - resolve_owner_node(123, fun(_UserId) -> {ok, bad_owner} end) - ), - ?assertEqual( - unavailable, - resolve_owner_node(123, fun(_UserId) -> {bad_owner} end) - ). - dispatch_event_atom_accepts_thread_events_test() -> ?assertEqual( thread_member_update, dispatch_event_atom_or_error(<<"THREAD_MEMBER_UPDATE">>) diff --git a/fluxer_gateway/src/gateway/gateway_rpc_push.erl b/fluxer_gateway/src/gateway/gateway_rpc_push.erl index 6e9bc44bd..cc3bc4efe 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_push.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_push.erl @@ -8,16 +8,8 @@ -spec execute_method(binary(), map()) -> true. execute_method(<<"push.sync_user_guild_settings">>, P) -> do_sync_user_guild_settings(P); -execute_method(<<"push.sync_user_blocked_ids">>, P) -> - do_sync_user_blocked_ids(P); -execute_method(<<"push.invalidate_badge_count">>, P) -> - do_invalidate_badge_count(P); -execute_method(<<"push.invalidate_badge_counts">>, P) -> - do_invalidate_badge_counts(P); execute_method(<<"push.clear_channel_notifications">>, P) -> do_clear_channel_notifications(P); -execute_method(<<"push.invalidate_subscriptions">>, P) -> - do_invalidate_subscriptions(P); execute_method(Method, _Params) -> gateway_rpc_error:raise(<<"Unknown method: ", Method/binary>>). @@ -40,32 +32,6 @@ settings_guild_id(<<"0">>) -> settings_guild_id(GBin) -> validation:snowflake_or_throw(<<"guild_id">>, GBin). --spec do_sync_user_blocked_ids(map()) -> true. -do_sync_user_blocked_ids(#{<<"user_id">> := UBin, <<"blocked_user_ids">> := BlockedRaw}) -> - UserId = validation:snowflake_or_throw(<<"user_id">>, UBin), - BlockedIds = validation:snowflake_list_or_throw(<<"blocked_user_ids">>, BlockedRaw), - fanout_local_cache_mutation(push, sync_user_blocked_ids_local, [UserId, BlockedIds]), - true. - --spec do_invalidate_badge_count(map()) -> true. -do_invalidate_badge_count(#{<<"user_id">> := UBin}) -> - UserId = validation:snowflake_or_throw(<<"user_id">>, UBin), - fanout_local_cache_mutation(push, invalidate_user_badge_count_local, [UserId]), - true. - --spec do_invalidate_badge_counts(map()) -> true. -do_invalidate_badge_counts(#{<<"user_ids">> := UserIdsRaw}) -> - UserIds = lists:usort(validation:snowflake_list_or_throw(<<"user_ids">>, UserIdsRaw)), - gateway_rpc_guild_routing:validate_batch_size(length(UserIds)), - fanout_badge_count_invalidation(UserIds). - --spec fanout_badge_count_invalidation([pos_integer()]) -> true. -fanout_badge_count_invalidation([]) -> - true; -fanout_badge_count_invalidation(UserIds) -> - fanout_local_cache_mutation(push, invalidate_user_badge_counts_local, [UserIds]), - true. - -spec do_clear_channel_notifications(map()) -> true. do_clear_channel_notifications(#{ <<"user_id">> := UBin, @@ -81,12 +47,6 @@ do_clear_channel_notifications(#{ shard_utils:safe_apply(ClearFun, ok), true. --spec do_invalidate_subscriptions(map()) -> true. -do_invalidate_subscriptions(#{<<"user_id">> := UBin}) -> - UserId = validation:snowflake_or_throw(<<"user_id">>, UBin), - fanout_local_cache_mutation(push, invalidate_user_subscriptions_local, [UserId]), - true. - -spec fanout_local_cache_mutation(module(), atom(), [term()]) -> ok. fanout_local_cache_mutation(Module, Function, Args) -> Nodes = [node() | nodes()], @@ -106,43 +66,6 @@ fanout_local_cache_mutation(Module, Function, Args) -> -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -invalidate_badge_counts_rejects_oversized_batch_test() -> - UserIds = [integer_to_binary(UserId) || UserId <- lists:seq(1000, 1200)], - ?assertError( - {gateway_rpc_error, _}, - execute_method(<<"push.invalidate_badge_counts">>, #{<<"user_ids">> => UserIds}) - ). - -invalidate_badge_counts_rejects_invalid_snowflakes_test() -> - ?assertError( - {validation, _}, - execute_method(<<"push.invalidate_badge_counts">>, #{<<"user_ids">> => [<<"nope">>]}) - ). - -invalidate_badge_counts_accepts_an_empty_batch_test() -> - ?assert(execute_method(<<"push.invalidate_badge_counts">>, #{<<"user_ids">> => []})). - -invalidate_badge_counts_accepts_a_batch_test() -> - ?assert( - execute_method(<<"push.invalidate_badge_counts">>, #{ - <<"user_ids">> => [<<"1001">>, <<"1002">>] - }) - ). - -invalidate_badge_count_accepts_a_user_test() -> - ?assert(execute_method(<<"push.invalidate_badge_count">>, #{<<"user_id">> => <<"1001">>})). - -invalidate_badge_count_rejects_an_invalid_snowflake_test() -> - ?assertError( - {validation, _}, - execute_method(<<"push.invalidate_badge_count">>, #{<<"user_id">> => <<"nope">>}) - ). - -invalidate_subscriptions_accepts_a_user_test() -> - ?assert( - execute_method(<<"push.invalidate_subscriptions">>, #{<<"user_id">> => <<"1001">>}) - ). - sync_user_guild_settings_caches_the_private_scope_test() -> push_ets_cache:init(), Settings = #{<<"channel_overrides">> => #{<<"5">> => #{<<"muted">> => true}}}, @@ -184,10 +107,4 @@ with_registered_push(Fun) -> Fun() end. -invalidate_subscriptions_rejects_an_invalid_snowflake_test() -> - ?assertError( - {validation, _}, - execute_method(<<"push.invalidate_subscriptions">>, #{<<"user_id">> => <<"nope">>}) - ). - -endif. diff --git a/fluxer_gateway/src/gateway/gateway_rpc_router.erl b/fluxer_gateway/src/gateway/gateway_rpc_router.erl index 27450e75b..24ed9cc13 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_router.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_router.erl @@ -5,44 +5,10 @@ -export([execute/2]). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -spec execute(binary(), map()) -> term(). execute(Method, Params) -> - ensure_event_pause_allows(Method), route_method(Method, Params). --spec ensure_event_pause_allows(binary()) -> ok. -ensure_event_pause_allows(Method) -> - case gateway_event_pause:is_paused() andalso method_is_mutating(Method) of - true -> - gateway_rpc_error:raise(<<"event_mutations_paused">>); - false -> - ok - end. - --spec method_is_mutating(binary()) -> boolean(). -method_is_mutating(Method) -> - case binary:split(Method, <<".">>) of - [<<"process">>, _Operation] -> - false; - [_Namespace, Operation] -> - is_mutating_operation(Operation); - _ -> - true - end. - --spec is_mutating_operation(binary()) -> boolean(). -is_mutating_operation(<<"get", _/binary>>) -> false; -is_mutating_operation(<<"list", _/binary>>) -> false; -is_mutating_operation(<<"has", _/binary>>) -> false; -is_mutating_operation(<<"check", _/binary>>) -> false; -is_mutating_operation(<<"can", _/binary>>) -> false; -is_mutating_operation(<<"resolve", _/binary>>) -> false; -is_mutating_operation(_) -> true. - -spec route_method(binary(), map()) -> term(). route_method(<<"guild.", _/binary>> = Method, Params) -> gateway_rpc_guild:execute_method(Method, Params); @@ -58,32 +24,3 @@ route_method(<<"process.", _/binary>> = Method, Params) -> gateway_rpc_misc:execute_method(Method, Params); route_method(Method, _Params) -> gateway_rpc_error:raise(<<"Unknown method: ", Method/binary>>). - --ifdef(TEST). - -method_is_mutating_test() -> - ?assert(method_is_mutating(<<"guild.dispatch">>)), - ?assert(method_is_mutating(<<"call.create">>)), - ?assertNot(method_is_mutating(<<"guild.get_data">>)), - ?assertNot(method_is_mutating(<<"guild.list_members">>)), - ?assertNot(method_is_mutating(<<"guild.has_member">>)), - ?assertNot(method_is_mutating(<<"guild.can_manage_roles">>)), - ?assertNot(method_is_mutating(<<"guild.resolve_all_mentions">>)), - ?assertNot(method_is_mutating(<<"guild.resolve_mention_sources">>)), - ?assertNot(method_is_mutating(<<"guild.resolve_mention_sources_page">>)), - ?assertNot(method_is_mutating(<<"process.node_stats">>)). - -ensure_event_pause_allows_blocks_mutations_test() -> - Original = persistent_term:get({gateway_event_pause, paused}, false), - persistent_term:put({gateway_event_pause, paused}, true), - try - ?assertError( - {gateway_rpc_error, <<"event_mutations_paused">>}, - ensure_event_pause_allows(<<"guild.dispatch">>) - ), - ?assertEqual(ok, ensure_event_pause_allows(<<"guild.get_data">>)) - after - persistent_term:put({gateway_event_pause, paused}, Original) - end. - --endif. diff --git a/fluxer_gateway/src/gateway/gateway_rpc_voice.erl b/fluxer_gateway/src/gateway/gateway_rpc_voice.erl index 3b280911b..73dbf8040 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_voice.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_voice.erl @@ -4,11 +4,7 @@ -typing([eqwalizer]). -export([execute_method/2]). --export([ - normalize_pending_joins/1, - normalize_voice_states/1, - validate_voice_channel_read_params/1 -]). +-export([normalize_voice_states/1, validate_voice_channel_read_params/1]). -spec execute_method(binary(), map()) -> term(). execute_method(<<"voice.confirm_connection">>, P) -> @@ -17,8 +13,6 @@ execute_method(<<"voice.disconnect_user_if_in_channel">>, P) -> handle_disconnect_if_in_channel(P); execute_method(<<"voice.get_voice_states_for_channel">>, P) -> handle_get_voice_states(P); -execute_method(<<"voice.get_pending_joins_for_channel">>, P) -> - handle_get_pending_joins(P); execute_method(Method, _Params) -> gateway_rpc_error:raise(<<"Unknown method: ", Method/binary>>). @@ -85,28 +79,6 @@ handle_get_voice_states(Params) -> #{<<"voice_states">> => []} end. --spec handle_get_pending_joins(map()) -> map() | term(). -handle_get_pending_joins(Params) -> - case validate_voice_channel_read_params(Params) of - {ok, ChannelIdBin, undefined} -> - normalize_pending_joins_response( - gateway_rpc_call:execute_method( - <<"call.get_pending_joins">>, - #{<<"channel_id">> => ChannelIdBin} - ) - ); - {ok, ChannelIdBin, GuildId} -> - gateway_rpc_guild:execute_method( - <<"guild.get_pending_joins_for_channel">>, - #{ - <<"guild_id">> => integer_to_binary(GuildId), - <<"channel_id">> => ChannelIdBin - } - ); - error -> - #{<<"pending_joins">> => []} - end. - -spec parse_optional_guild_id(map()) -> integer() | undefined. parse_optional_guild_id(Params) -> case maps:get(<<"guild_id">>, Params, undefined) of @@ -234,41 +206,6 @@ maybe_put_normalized_id(Map, Key, Value) -> Normalized -> Map#{Key => Normalized} end. --spec normalize_pending_joins_response(term()) -> map(). -normalize_pending_joins_response(Response) when is_map(Response) -> - PendingJoins = get_map_value(Response, [<<"pending_joins">>, pending_joins]), - #{<<"pending_joins">> => normalize_pending_joins(PendingJoins)}; -normalize_pending_joins_response(_) -> - #{<<"pending_joins">> => []}. - --spec normalize_pending_joins(term()) -> [map()]. -normalize_pending_joins(PendingJoins) -> - normalize_entries(PendingJoins, fun normalize_pending_join_entry/2). - --spec normalize_pending_join_entry(term(), [map()]) -> [map()]. -normalize_pending_join_entry(PendingJoin, Acc) when is_map(PendingJoin) -> - {ConnectionId, UserId} = connection_user_ids(PendingJoin), - TokenNonce = normalize_token_nonce( - get_map_value(PendingJoin, [<<"token_nonce">>, token_nonce]) - ), - ExpiresAt = normalize_expiry(get_map_value(PendingJoin, [<<"expires_at">>, expires_at])), - case {ConnectionId, UserId} of - {undefined, _} -> - Acc; - {_, undefined} -> - Acc; - _ -> - NormalizedPendingJoin = #{ - <<"connection_id">> => ConnectionId, - <<"user_id">> => UserId, - <<"token_nonce">> => TokenNonce, - <<"expires_at">> => ExpiresAt - }, - [NormalizedPendingJoin | Acc] - end; -normalize_pending_join_entry(_, Acc) -> - Acc. - -spec connection_user_ids(map()) -> {binary() | undefined, binary() | undefined}. connection_user_ids(Map) -> ConnectionId = normalize_id(get_map_value(Map, [<<"connection_id">>, connection_id])), @@ -281,16 +218,6 @@ normalize_id(Value) when is_binary(Value) -> Value; normalize_id(Value) when is_integer(Value) -> integer_to_binary(Value); normalize_id(_) -> undefined. --spec normalize_token_nonce(term()) -> binary(). -normalize_token_nonce(undefined) -> <<>>; -normalize_token_nonce(Value) when is_binary(Value) -> Value; -normalize_token_nonce(Value) when is_integer(Value) -> integer_to_binary(Value); -normalize_token_nonce(_) -> <<>>. - --spec normalize_expiry(term()) -> integer(). -normalize_expiry(Value) when is_integer(Value) -> Value; -normalize_expiry(_) -> 0. - -spec get_map_value(map(), [term()]) -> term(). get_map_value(_Map, []) -> undefined; diff --git a/fluxer_gateway/src/gateway/gateway_timings_payload.erl b/fluxer_gateway/src/gateway/gateway_timings_payload.erl index be814ce16..b3bde40d8 100644 --- a/fluxer_gateway/src/gateway/gateway_timings_payload.erl +++ b/fluxer_gateway/src/gateway/gateway_timings_payload.erl @@ -9,13 +9,13 @@ -spec finalize(term()) -> map(). finalize(Recorder0) -> - Recorder = ensure_map(Recorder0), + Recorder = map_utils:ensure_map(Recorder0), Trace = sanitize_trace(lists:reverse(ensure_trace(maps:get(trace, Recorder, [])))), build_payload(Recorder, Trace, total_us(Recorder, Trace)). -spec sanitize(term()) -> map(). sanitize(Timings0) -> - Timings = ensure_map(Timings0), + Timings = map_utils:ensure_map(Timings0), Trace = sanitize_trace(ensure_trace(first_present([<<"trace">>, trace], Timings, []))), build_payload(Timings, Trace, sanitized_total_us(Timings, Trace)). @@ -40,12 +40,6 @@ build_payload(Source, Trace, TotalUs) -> <<"trace">> => Trace }. --spec ensure_map(term()) -> map(). -ensure_map(Value) when is_map(Value) -> - Value; -ensure_map(_) -> - #{}. - -spec ensure_trace(term()) -> [map()]. ensure_trace(Trace) when is_list(Trace) -> [Span || Span <- Trace, is_map(Span)]; diff --git a/fluxer_gateway/src/gateway/health_handler.erl b/fluxer_gateway/src/gateway/health_handler.erl index 1e38b68b5..5f17376f7 100644 --- a/fluxer_gateway/src/gateway/health_handler.erl +++ b/fluxer_gateway/src/gateway/health_handler.erl @@ -95,27 +95,3 @@ is_loopback_request(Req) -> _ -> false end. - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). - -readiness_status_test() -> - ?assertEqual({200, <<"OK">>}, readiness_status(true)), - ?assertEqual({503, <<"DRAINING">>}, readiness_status(false)). - -activate_drain_sets_draining_flag_test() -> - persistent_term:erase({fluxer_gateway, draining}), - ?assertEqual(ok, activate_drain()), - ?assert(gateway_node_router:is_draining()), - persistent_term:erase({fluxer_gateway, draining}). - -deactivate_drain_clears_draining_flag_test() -> - persistent_term:erase({fluxer_gateway, draining}), - ?assertEqual(ok, activate_drain()), - ?assert(gateway_node_router:is_draining()), - ?assertEqual(ok, deactivate_drain()), - ?assertNot(gateway_node_router:is_draining()), - ?assertEqual({200, <<"OK">>}, readiness_status(gateway_node_router:is_ready())), - persistent_term:erase({fluxer_gateway, draining}). - --endif. diff --git a/fluxer_gateway/src/gateway/rendezvous_router.erl b/fluxer_gateway/src/gateway/rendezvous_router.erl index 3db2e52c3..b05c09576 100644 --- a/fluxer_gateway/src/gateway/rendezvous_router.erl +++ b/fluxer_gateway/src/gateway/rendezvous_router.erl @@ -97,26 +97,6 @@ weight(Key, Index) -> -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -select_single_shard_test() -> - ?assertEqual(0, select(test_key, 1)), - ?assertEqual(0, select(any_key, 1)), - ?assertEqual(0, select(12345, 1)). - -select_valid_index_test_() -> - [?_test(assert_valid_index(N)) || N <- [2, 5, 10, 100]]. - -assert_valid_index(N) -> - Index = select(test_key, N), - ?assert(Index >= 0), - ?assert(Index < N). - -select_stability_test_() -> - [ - ?_assertEqual(select(<<"abc">>, 8), select(<<"abc">>, 8)), - ?_assertEqual(select(12345, 3), select(12345, 3)), - ?_assertEqual(select({user, 1}, 10), select({user, 1}, 10)) - ]. - select_distribution_test() -> Keys = lists:seq(1, 1000), ShardCount = 10, @@ -138,13 +118,6 @@ count_distribution_fold(Key, ShardCount, Acc) -> Index = select(Key, ShardCount), maps:update_with(Index, fun(V) -> V + 1 end, 1, Acc). -group_keys_empty_test() -> - ?assertEqual([], group_keys([], 4)). - -group_keys_single_test() -> - Groups = group_keys([key1], 4), - ?assertEqual(1, length(Groups)). - group_keys_deduplicates_test() -> Keys = [1, 2, 3, 1, 2], Groups = group_keys(Keys, 2), @@ -167,17 +140,6 @@ group_keys_all_keys_present_test() -> AllGroupedKeys = lists:flatten([K || {_, K} <- Groups]), ?assertEqual(lists:sort(Keys), lists:sort(AllGroupedKeys)). -select_node_empty_returns_undefined_test() -> - ?assertEqual(undefined, select_node(<<"k">>, [])). - -select_node_single_returns_only_node_test() -> - ?assertEqual('n@a', select_node(<<"k">>, ['n@a'])). - -select_node_deterministic_test() -> - Nodes = ['n@a', 'n@b', 'n@c'], - ?assertEqual(select_node(<<"guild-1">>, Nodes), select_node(<<"guild-1">>, Nodes)), - ?assertEqual(select_node(42, Nodes), select_node(42, Nodes)). - select_node_independent_of_order_test() -> Nodes1 = ['n@a', 'n@b', 'n@c'], Nodes2 = ['n@c', 'n@a', 'n@b'], diff --git a/fluxer_gateway/src/gateway/rpc_client.erl b/fluxer_gateway/src/gateway/rpc_client.erl index d448dac3b..b566b0a8b 100644 --- a/fluxer_gateway/src/gateway/rpc_client.erl +++ b/fluxer_gateway/src/gateway/rpc_client.erl @@ -4,8 +4,7 @@ -typing([eqwalizer]). -export([ - call/1, - call/2, + call/1, call/2, call_with_retry/2, handle_http_response/2, rpc_headers/1, @@ -349,7 +348,7 @@ normalize_optional_binary(Value) when is_binary(Value) -> normalize_optional_binary(Value) when is_integer(Value) -> integer_to_binary(Value); normalize_optional_binary(Value) when is_list(Value) -> - unicode_binary(Value); + type_conv:unicode_to_binary(Value); normalize_optional_binary(_) -> undefined. @@ -369,13 +368,9 @@ ensure_response_map(Response) when is_map(Response) -> ensure_response_map(_) -> erlang:error(badarg). --spec unicode_binary(term()) -> binary() | undefined. -unicode_binary(Value) -> - type_conv:unicode_to_binary(Value). - -spec unicode_binary_or_default(term(), binary()) -> binary(). unicode_binary_or_default(Value, Default) -> - case unicode_binary(Value) of + case type_conv:unicode_to_binary(Value) of Binary when is_binary(Binary) -> Binary; undefined -> Default end. diff --git a/fluxer_gateway/src/gateway/session_abuse_protection.erl b/fluxer_gateway/src/gateway/session_abuse_protection.erl index da15a6367..c94da1246 100644 --- a/fluxer_gateway/src/gateway/session_abuse_protection.erl +++ b/fluxer_gateway/src/gateway/session_abuse_protection.erl @@ -11,10 +11,6 @@ check_identify_rate/1 ]). --ifdef(TEST). --export([prune_old_identify_entries/1]). --endif. - -define(IDENTIFY_TABLE, gateway_identify_rate). -define(SESSION_USER_COUNTS, session_user_counts). -define(IDENTIFY_MAX_PER_IP, 300). diff --git a/fluxer_gateway/src/guild/guild.erl b/fluxer_gateway/src/guild/guild.erl index 007e894bd..ff0185946 100644 --- a/fluxer_gateway/src/guild/guild.erl +++ b/fluxer_gateway/src/guild/guild.erl @@ -120,18 +120,12 @@ route_call(Tag, Msg, From, State) -> call_handler(Tag) -> query_call_handler(Tag). -spec query_call_handler(atom()) -> query | voice | subscription | undefined. +query_call_handler(resolve_mention_sources_page) -> query; query_call_handler(get_counts) -> query; -query_call_handler(get_user_counts) -> query; query_call_handler(get_viewer_counts) -> query; query_call_handler(get_channel_member_counts) -> query; query_call_handler(get_forum_unread_scope) -> query; query_call_handler(get_large_guild_metadata) -> query; -query_call_handler(get_users_to_mention_by_roles) -> query; -query_call_handler(get_users_to_mention_by_user_ids) -> query; -query_call_handler(get_all_users_to_mention) -> query; -query_call_handler(resolve_all_mentions) -> query; -query_call_handler(resolve_mention_sources) -> query; -query_call_handler(resolve_mention_sources_page) -> query; query_call_handler(resolve_channel_mentions) -> query; query_call_handler(get_members_with_role) -> query; query_call_handler(check_permission) -> query; @@ -153,7 +147,6 @@ query_call_handler_more(has_member) -> query; query_call_handler_more(list_guild_members) -> query; query_call_handler_more(search_guild_members) -> query; query_call_handler_more(list_guild_members_cursor) -> query; -query_call_handler_more(get_vanity_url_channel) -> query; query_call_handler_more(get_first_viewable_text_channel) -> query; query_call_handler_more(get_category_channel_count) -> query; query_call_handler_more(get_channel_count) -> query; @@ -175,7 +168,6 @@ voice_call_handler(switch_voice_region) -> voice; voice_call_handler(add_virtual_channel_access) -> voice; voice_call_handler(store_pending_connection) -> voice; voice_call_handler(get_voice_states_for_channel) -> voice; -voice_call_handler(get_pending_joins_for_channel) -> voice; voice_call_handler(Tag) -> subscription_call_handler(Tag). -spec subscription_call_handler(atom()) -> subscription | undefined. @@ -213,7 +205,7 @@ handle_cast_internal({set_session_active, SessionId}, State) -> handle_cast_internal({set_session_passive, SessionId}, State) -> handle_set_session_passive_cast(SessionId, State); handle_cast_internal({drop_session_member_lists, SessionId}, State) when is_binary(SessionId) -> - {noreply, guild_member_list:unsubscribe_session(SessionId, State)}; + {noreply, guild_member_list_subscribe:unsubscribe_session(SessionId, State)}; handle_cast_internal({set_session_typing_override, SessionId, TypingFlag}, State) -> handle_set_session_typing_override_cast(SessionId, TypingFlag, State); handle_cast_internal({set_session_push_hold, SessionId, Hold}, State) when @@ -289,7 +281,7 @@ handle_info_internal({clear_stale_cached_voice_states, ConnectionIds}, State) -> handle_info_internal(flush_lazy_subscribe_buffer, State) -> guild_subscription_handler:handle_info(flush_lazy_subscribe_buffer, State); handle_info_internal(flush_member_list_sync_batch, State) -> - {noreply, guild_member_list:flush_pending_member_list_syncs(State)}; + {noreply, guild_member_list_write:flush_pending_member_list_syncs(State)}; handle_info_internal({check_auto_stop_empty, Token}, State) -> handle_auto_stop_info(Token, State); handle_info_internal(check_auto_stop_empty, State) -> @@ -329,7 +321,7 @@ handle_info_internal(_, State) -> -spec handle_session_connect_call(term(), pid(), guild_state()) -> call_reply(). handle_session_connect_call(Request, CallerPid, State) when is_map(Request) -> - guild_sessions:handle_session_connect( + guild_sessions_connect:handle_session_connect( Request, session_connect_pid(Request, CallerPid), State ). @@ -390,22 +382,27 @@ session_connect_result({error, _Reason} = Error) -> -spec handle_set_session_active_cast(term(), guild_state()) -> cast_reply(). handle_set_session_active_cast(SessionId, State) when is_binary(SessionId) -> - {noreply, guild_sessions:set_session_active_guild(SessionId, state_guild_id(State), State)}. + {noreply, + guild_sessions_passive:set_session_active_guild( + SessionId, state_guild_id(State), State + )}. -spec handle_set_session_passive_cast(term(), guild_state()) -> cast_reply(). handle_set_session_passive_cast(SessionId, State) when is_binary(SessionId) -> {noreply, - guild_sessions:set_session_passive_guild(SessionId, state_guild_id(State), State)}. + guild_sessions_passive:set_session_passive_guild( + SessionId, state_guild_id(State), State + )}. -spec handle_set_session_typing_override_cast(term(), term(), guild_state()) -> cast_reply(). handle_set_session_typing_override_cast(SessionId, TypingFlag, State) when is_binary(SessionId), is_boolean(TypingFlag) -> - {noreply, guild_sessions:handle_set_typing_override(SessionId, TypingFlag, State)}. + {noreply, guild_sessions_passive:handle_set_typing_override(SessionId, TypingFlag, State)}. -spec handle_send_guild_sync_cast(term(), guild_state()) -> cast_reply(). handle_send_guild_sync_cast(SessionId, State) when is_binary(SessionId) -> - {noreply, guild_sessions:handle_send_guild_sync(SessionId, State)}. + {noreply, guild_sessions_passive:handle_send_guild_sync(SessionId, State)}. -spec handle_presence_info(term(), term(), guild_state()) -> info_reply(). handle_presence_info(UserId, Payload, State) when is_integer(UserId), is_map(Payload) -> @@ -892,26 +889,26 @@ voice_guild_state_preserves_member_permissions_test() -> Projected = voice_guild_state(Full), Expected = guild_permissions:get_member_permissions(10, 500, Full), ?assert(Expected > 0), - ?assert(permission_bits:has(Expected, constants:speak_permission())), + ?assert(bitset:has(Expected, constants:speak_permission())), ?assertEqual(Expected, guild_permissions:get_member_permissions(10, 500, Projected)), ?assertEqual( voice_utils:compute_voice_permissions(10, 500, Full), voice_utils:compute_voice_permissions(10, 500, Projected) ), ?assertEqual( - guild_permissions:find_member_by_user_id(10, Full), - guild_permissions:find_member_by_user_id(10, Projected) + guild_permissions_check:find_member_by_user_id(10, Full), + guild_permissions_check:find_member_by_user_id(10, Projected) ), ?assertEqual( - guild_permissions:can_view_channel(10, 500, undefined, Full), - guild_permissions:can_view_channel(10, 500, undefined, Projected) + guild_permissions_check:can_view_channel(10, 500, undefined, Full), + guild_permissions_check:can_view_channel(10, 500, undefined, Projected) ) end). voice_guild_state_absent_member_still_resolves_to_zero_test() -> with_voice_projection_state(fun(Full) -> Projected = voice_guild_state(Full), - ?assertEqual(undefined, guild_permissions:find_member_by_user_id(11, Projected)), + ?assertEqual(undefined, guild_permissions_check:find_member_by_user_id(11, Projected)), ?assertEqual(0, guild_permissions:get_member_permissions(11, 500, Projected)), ?assertEqual( guild_permissions:get_member_permissions(11, 500, Full), @@ -923,9 +920,9 @@ voice_guild_state_put_member_keeps_permissions_resolvable_test() -> with_voice_projection_state(fun(Full) -> Projected = voice_guild_state(Full), Data = maps:get(data, Projected), - Member = guild_permissions:find_member_by_user_id(10, Projected), + Member = guild_permissions_check:find_member_by_user_id(10, Projected), ?assert(is_map(Member)), - Rebuilt = guild_data_index:put_member(Member#{<<"mute">> => true}, Data), + Rebuilt = guild_data_index_members:put_member(Member#{<<"mute">> => true}, Data), RebuiltState = Projected#{data => Rebuilt}, ?assertEqual(1, map_size(maps:get(<<"members">>, Rebuilt))), ?assertEqual(1, map_size(maps:get(members_normalized, Rebuilt))), @@ -936,11 +933,11 @@ voice_guild_state_put_member_keeps_permissions_resolvable_test() -> ), ?assertMatch( #{<<"mute">> := true}, - guild_permissions:find_member_by_user_id(10, RebuiltState) + guild_permissions_check:find_member_by_user_id(10, RebuiltState) ), ?assertEqual( - guild_permissions:can_view_channel(10, 500, undefined, Full), - guild_permissions:can_view_channel(10, 500, undefined, RebuiltState) + guild_permissions_check:can_view_channel(10, 500, undefined, Full), + guild_permissions_check:can_view_channel(10, 500, undefined, RebuiltState) ) end). @@ -954,22 +951,6 @@ voice_guild_state_without_members_table_returns_full_state_test() -> _ = erlang:erase(?VOICE_MEMBERS_TABLE_WARNED) end). -voice_members_table_warning_rearms_after_recovery_test() -> - with_voice_projection_state(fun(Full) -> - Data = maps:get(data, Full), - Broken = Full#{data => maps:remove(members_ets, Data)}, - _ = erlang:erase(?VOICE_MEMBERS_TABLE_WARNED), - _ = voice_guild_state(Broken), - ?assertEqual(true, erlang:get(?VOICE_MEMBERS_TABLE_WARNED)), - _ = voice_guild_state(Broken), - ?assertEqual(true, erlang:get(?VOICE_MEMBERS_TABLE_WARNED)), - _ = voice_guild_state(Full), - ?assertEqual(undefined, erlang:get(?VOICE_MEMBERS_TABLE_WARNED)), - _ = voice_guild_state(Broken), - ?assertEqual(true, erlang:get(?VOICE_MEMBERS_TABLE_WARNED)), - _ = erlang:erase(?VOICE_MEMBERS_TABLE_WARNED) - end). - voice_guild_state_session_readers_see_identical_results_test() -> with_voice_projection_state(fun(Base) -> Full = Base#{sessions => voice_projection_sessions()}, diff --git a/fluxer_gateway/src/guild/guild_availability.erl b/fluxer_gateway/src/guild/guild_availability.erl index 97b4cc10a..ef2e5bf9a 100644 --- a/fluxer_gateway/src/guild/guild_availability.erl +++ b/fluxer_gateway/src/guild/guild_availability.erl @@ -6,15 +6,12 @@ -export([ is_guild_unavailable_for_user/2, handle_unavailability_transition/2, - get_cached_unavailability_mode/1, - is_unavailable_hidden_enabled/1, is_unavailable_hidden_enabled_from_cache/1, is_guild_unavailable_for_user_from_cache/2, update_unavailability_cache_for_state/1, schedule_availability_recheck/1, handle_availability_recheck/1, - send_guild_create_to_session/4, - send_guild_create_to_session/5 + send_guild_create_to_session/4, send_guild_create_to_session/5 ]). -type guild_state() :: map(). @@ -40,14 +37,6 @@ is_guild_unavailable_for_user(UserId, State) -> false end. --spec get_cached_unavailability_mode(guild_id()) -> unavailability_mode(). -get_cached_unavailability_mode(GuildId) -> - guild_availability_cache:get_cached_unavailability_mode(GuildId). - --spec is_unavailable_hidden_enabled(guild_state()) -> boolean(). -is_unavailable_hidden_enabled(State) -> - guild_availability_check:is_unavailable_hidden_enabled(State). - -spec is_unavailable_hidden_enabled_from_cache(guild_id()) -> boolean(). is_unavailable_hidden_enabled_from_cache(GuildId) -> {Mode, UnavailableHidden} = guild_availability_cache:get_cached_unavailability_entry( @@ -185,7 +174,7 @@ do_disconnect( Pid = maps:get(pid, SessionData, undefined), maybe_send_guild_leave(Pid, GuildId, UnavailableHidden), {VoiceState, UpdatedUsers} = maybe_disconnect_voice(UserId, ProcessedUsers, State), - NewState = guild_sessions:remove_session(SessionId, VoiceState), + NewState = guild_sessions_connect:remove_session(SessionId, VoiceState), {NewState, UpdatedUsers}. -spec should_disconnect_user(user_id(), boolean(), guild_state()) -> boolean(). @@ -247,28 +236,6 @@ handle_recheck_transition(_OldMode, _CurrentMode, _GuildId, State) -> -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -schedule_availability_recheck_available_no_timer_test() -> - State = #{data => #{<<"guild">> => #{<<"features">> => []}}}, - ?assertEqual(State, schedule_availability_recheck(State)), - receive - availability_recheck -> ?assert(false, unexpected_timer) - after 50 -> - ok - end. - -schedule_availability_recheck_unavailable_sets_timer_test() -> - State = #{data => #{<<"guild">> => #{<<"features">> => [<<"UNAVAILABLE_FOR_EVERYONE">>]}}}, - Parent = self(), - {Pid, MonitorRef} = spawn_monitor(fun() -> - Parent ! {scheduled, self(), schedule_availability_recheck(State)} - end), - receive - {scheduled, Pid, Result} -> ?assertEqual(State, Result) - end, - receive - {'DOWN', MonitorRef, process, Pid, normal} -> ok - end. - handle_recheck_transition_clears_cache_on_recovery_test() -> GuildId = 99001, State = #{ diff --git a/fluxer_gateway/src/guild/guild_availability_check.erl b/fluxer_gateway/src/guild/guild_availability_check.erl index 6b2768125..ac2e83360 100644 --- a/fluxer_gateway/src/guild/guild_availability_check.erl +++ b/fluxer_gateway/src/guild/guild_availability_check.erl @@ -1,6 +1,10 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_availability_check). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -export([ @@ -23,10 +27,6 @@ -export_type([guild_state/0, user_id/0, unavailability_mode/0, transition_result/0]). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -spec get_unavailability_mode_from_state(guild_state()) -> unavailability_mode(). get_unavailability_mode_from_state(State) -> Data = maps:get(data, State, #{}), @@ -99,7 +99,7 @@ check_session_staff(UserId, SessionData) -> -spec is_user_staff_from_member(user_id(), guild_state()) -> boolean(). is_user_staff_from_member(UserId, State) -> - case guild_permissions:find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> false; Member -> diff --git a/fluxer_gateway/src/guild/guild_client.erl b/fluxer_gateway/src/guild/guild_client.erl index 9804d84a3..ca5332491 100644 --- a/fluxer_gateway/src/guild/guild_client.erl +++ b/fluxer_gateway/src/guild/guild_client.erl @@ -1,6 +1,10 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_client). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -export([voice_state_update/4]). @@ -40,10 +44,6 @@ | {error, too_many_requests} | {error, atom(), atom()}. --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -spec execute_with_slot(pid(), map(), timeout()) -> voice_state_update_result(). execute_with_slot(TargetPid, Request, Timeout) -> try diff --git a/fluxer_gateway/src/guild/guild_client_circuit_breaker.erl b/fluxer_gateway/src/guild/guild_client_circuit_breaker.erl index 936dc4e24..d1b4ffa38 100644 --- a/fluxer_gateway/src/guild/guild_client_circuit_breaker.erl +++ b/fluxer_gateway/src/guild/guild_client_circuit_breaker.erl @@ -1,6 +1,10 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_client_circuit_breaker). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -export([ @@ -20,10 +24,6 @@ -define(RECOVERY_TIMEOUT_MS, 30000). -define(MAX_CONCURRENT, 50). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -spec ensure_table() -> ok. ensure_table() -> guild_ets_utils:ensure_table(?CIRCUIT_BREAKER_TABLE, [ @@ -238,14 +238,14 @@ test_waiter() -> after 30000 -> ok end. -setup_test() -> +circuit_test_setup() -> ensure_table(), Pid = spawn_test_pid(), ets:delete_all_objects(?CIRCUIT_BREAKER_TABLE), Pid. get_circuit_state_open_test() -> - Pid = setup_test(), + Pid = circuit_test_setup(), Now = erlang:system_time(millisecond), ets:insert( ?CIRCUIT_BREAKER_TABLE, @@ -260,7 +260,7 @@ get_circuit_state_open_test() -> Pid ! done. get_circuit_state_half_open_test() -> - Pid = setup_test(), + Pid = circuit_test_setup(), OldTime = erlang:system_time(millisecond) - ?RECOVERY_TIMEOUT_MS - 1000, ets:insert( ?CIRCUIT_BREAKER_TABLE, @@ -275,7 +275,7 @@ get_circuit_state_half_open_test() -> Pid ! done. record_failure_opens_circuit_test() -> - Pid = setup_test(), + Pid = circuit_test_setup(), ets:insert( ?CIRCUIT_BREAKER_TABLE, {Pid, #{ diff --git a/fluxer_gateway/src/guild/guild_connect_async.erl b/fluxer_gateway/src/guild/guild_connect_async.erl index 9d5c25311..0b3388114 100644 --- a/fluxer_gateway/src/guild/guild_connect_async.erl +++ b/fluxer_gateway/src/guild/guild_connect_async.erl @@ -520,7 +520,7 @@ compute_session_connect(_GuildId, _Request, _State) -> -spec compute_available_connect(integer(), integer(), map(), map()) -> map(). compute_available_connect(GuildId, UserId, Request, State) -> - case guild_data_members:find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> #{not_member => true}; _Member -> @@ -531,10 +531,12 @@ compute_available_connect(GuildId, UserId, Request, State) -> #{ guild_state => GS, thread_viewer => maps:get(thread_viewer, ThreadFields), - initial_last_message_ids => guild_sessions:build_initial_last_message_ids(GS), + initial_last_message_ids => guild_sessions_connect:build_initial_last_message_ids( + GS + ), initial_channel_versions => build_channel_versions(GS), viewable_channels => build_viewable_map( - guild_visibility:get_user_viewable_channels(UserId, State) + guild_visibility_channels:get_user_viewable_channels(UserId, State) ), user_roles => session_passive:get_user_roles_for_guild(UserId, State), should_mark_guild_synced => @@ -696,7 +698,9 @@ update_presence_subscription(UserId, Existing, State, Joined) -> undefined -> subscribe_joined_user(UserId, State, Joined); ExistingUserId -> - State1 = guild_sessions:unsubscribe_from_user_presence(ExistingUserId, State), + State1 = guild_sessions_presence:unsubscribe_from_user_presence( + ExistingUserId, State + ), subscribe_joined_user(UserId, State1, Joined) end. diff --git a/fluxer_gateway/src/guild/guild_counts_cache.erl b/fluxer_gateway/src/guild/guild_counts_cache.erl index d44d646ba..c181ecdb8 100644 --- a/fluxer_gateway/src/guild/guild_counts_cache.erl +++ b/fluxer_gateway/src/guild/guild_counts_cache.erl @@ -9,14 +9,7 @@ -compile({no_auto_import, [get/1]}). -export([ - start_link/0, - update/3, - get/1, - bulk_get/1, - delete/1, - rebalance/0, - rebalance_async/0, - pending_handoff_count/0 + start_link/0, update/3, get/1, bulk_get/1, delete/1, rebalance/0, pending_handoff_count/0 ]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). @@ -70,11 +63,6 @@ delete(GuildId) when is_integer(GuildId) -> _ = guild_counts_cache_query:safe_owner_call(GuildId, {delete, GuildId}, ok), ok. --spec rebalance_async() -> ok. -rebalance_async() -> - _ = guild_counts_cache_query:safe_local_cast(rebalance), - ok. - -spec rebalance() -> ok. rebalance() -> _ = guild_counts_cache_query:safe_local_call(rebalance, ok), @@ -91,7 +79,9 @@ pending_handoff_count() -> init([]) -> process_flag(trap_exit, true), erlang:process_flag(fullsweep_after, 0), - {ShardCount, _Source} = guild_counts_cache_shard_mgmt:determine_shard_count(), + {ShardCount, _Source} = clustered_ets_cache:determine_shard_count([ + guild_counts_cache_shards, guild_shards + ]), Shards = guild_counts_cache_shard_mgmt:start_shards(ShardCount, #{}), {ok, #{ @@ -146,21 +136,19 @@ handle_cast({remote_handoff_result, GuildId, Op, Action, Result}, State) -> State ), {noreply, State1}; -handle_cast(rebalance, State) -> - {noreply, guild_counts_cache_remote:rebalance_ownership(State)}; handle_cast(_Msg, State) -> {noreply, State}. -spec handle_info(term(), state()) -> {noreply, state()}. handle_info({'DOWN', Ref, process, _Pid, _Reason}, State) when is_reference(Ref) -> Shards = maps:get(shards, State), - case guild_counts_cache_shard_mgmt:find_shard_by_ref(Ref, Shards) of + case shard_utils:find_shard_by_ref(Ref, Shards) of {ok, Index} -> restart_shard(Index, State); not_found -> {noreply, State} end; handle_info({'EXIT', Pid, _Reason}, State) when is_pid(Pid) -> Shards = maps:get(shards, State), - case guild_counts_cache_shard_mgmt:find_shard_by_pid(Pid, Shards) of + case shard_utils:find_shard_by_pid(Pid, Shards) of {ok, Index} -> restart_shard(Index, State); not_found -> {noreply, State} end; diff --git a/fluxer_gateway/src/guild/guild_counts_cache_shard_mgmt.erl b/fluxer_gateway/src/guild/guild_counts_cache_shard_mgmt.erl index a3a9fda29..a85899054 100644 --- a/fluxer_gateway/src/guild/guild_counts_cache_shard_mgmt.erl +++ b/fluxer_gateway/src/guild/guild_counts_cache_shard_mgmt.erl @@ -9,15 +9,12 @@ start_shards/2, start_shard/1, restart_shard/2, - find_shard_by_ref/2, - find_shard_by_pid/2, ensure_shard/2, ensure_shard_for_index/2, select_shard/2, forward_call/3, call_shard/3, - shard_pid/2, - determine_shard_count/0 + shard_pid/2 ]). -export([ @@ -35,10 +32,6 @@ -define(PENDING_HANDOFF_RETRY_MS, 1000). --spec determine_shard_count() -> {pos_integer(), atom() | auto}. -determine_shard_count() -> - clustered_ets_cache:determine_shard_count([guild_counts_cache_shards, guild_shards]). - -spec start_shards(pos_integer(), #{}) -> #{non_neg_integer() => shard()}. start_shards(Count, Acc) -> lists:foldl( @@ -150,16 +143,6 @@ handle_shard_liveness(Index, Pid, State) -> select_shard(Key, Count) when Count > 0 -> clustered_ets_cache:select_shard(Key, Count). --spec find_shard_by_ref(reference(), #{non_neg_integer() => shard()}) -> - {ok, non_neg_integer()} | not_found. -find_shard_by_ref(Ref, Shards) -> - shard_utils:find_shard_by_ref(Ref, Shards). - --spec find_shard_by_pid(pid(), #{non_neg_integer() => shard()}) -> - {ok, non_neg_integer()} | not_found. -find_shard_by_pid(Pid, Shards) -> - shard_utils:find_shard_by_pid(Pid, Shards). - -spec rebalance_retry_timer_ref(map()) -> reference() | undefined. rebalance_retry_timer_ref(State) -> case maps:get(rebalance_retry_timer, State, undefined) of diff --git a/fluxer_gateway/src/guild/guild_data.erl b/fluxer_gateway/src/guild/guild_data.erl index 887165a87..c4c31ac98 100644 --- a/fluxer_gateway/src/guild/guild_data.erl +++ b/fluxer_gateway/src/guild/guild_data.erl @@ -5,12 +5,7 @@ -export([get_guild_data/2]). -export([get_auth_context/2]). --export([get_guild_member/2]). --export([get_guild_members_batch/2]). --export([has_member/2]). --export([list_guild_members/2]). --export([search_guild_members/2]). --export([get_vanity_url_channel/1]). + -export([get_first_viewable_text_channel/1]). -export([get_guild_state/2, get_guild_state/3]). -export([build_connect_snapshot/2]). @@ -56,7 +51,7 @@ get_auth_context(#{user_id := UserId, channel_id := ChannelId}, State) -> -spec get_auth_context_for_member(user_id(), integer() | null, map(), guild_state()) -> guild_reply(map()). get_auth_context_for_member(UserId, ChannelId, Data, State) -> - case guild_data_index:get_member(UserId, Data) of + case guild_data_index_members:get_member(UserId, Data) of undefined -> {reply, #{auth_context => null, error_reason => <<"forbidden">>}, State}; _Member -> @@ -76,16 +71,16 @@ build_auth_guild(Data, State) -> GuildProperties#{ <<"roles">> => map_utils:ensure_list(maps:get(<<"roles">>, Data, [])), <<"member_count">> => maps:get( - member_count, State, guild_data_index:member_count(Data) + member_count, State, guild_data_index_members:member_count(Data) ), - <<"online_count">> => guild_member_list:get_online_count(State) + <<"online_count">> => guild_member_list_read:get_online_count(State) }. -spec find_channel(integer() | null, map()) -> map() | null. find_channel(null, _Data) -> null; find_channel(ChannelId, Data) -> - Index = guild_data_index:channel_index(Data), + Index = guild_data_index_channels:channel_index(Data), case maps:get(ChannelId, Index, null) of null -> find_thread_parent_channel(ChannelId, Index, Data); Channel -> Channel @@ -102,30 +97,6 @@ find_thread_parent_channel(ChannelId, Index, Data) when is_integer(ChannelId) -> find_thread_parent_channel(_ChannelId, _Index, _Data) -> null. --spec get_guild_member(map(), guild_state()) -> guild_reply(map()). -get_guild_member(Request, State) -> - guild_data_members:get_guild_member(Request, State). - --spec get_guild_members_batch(map(), guild_state()) -> guild_reply(map()). -get_guild_members_batch(Request, State) -> - guild_data_members:get_guild_members_batch(Request, State). - --spec has_member(map(), guild_state()) -> guild_reply(map()). -has_member(Request, State) -> - guild_data_members:has_member(Request, State). - --spec list_guild_members(map(), guild_state()) -> guild_reply(map()). -list_guild_members(Request, State) -> - guild_data_members:list_guild_members(Request, State). - --spec search_guild_members(map(), guild_state()) -> guild_reply(map()). -search_guild_members(Request, State) -> - guild_data_members:search_guild_members(Request, State). - --spec get_vanity_url_channel(guild_state()) -> guild_reply(map()). -get_vanity_url_channel(State) -> - get_everyone_viewable_text_channel(State). - -spec get_first_viewable_text_channel(guild_state()) -> guild_reply(map()). get_first_viewable_text_channel(State) -> get_everyone_viewable_text_channel(State). @@ -152,10 +123,10 @@ get_guild_state(UserId, State, Opts) -> -spec apply_thread_view(user_id(), map(), guild_state(), map()) -> map(). apply_thread_view(UserId, #{thread_viewer := true} = Opts, State, GuildState) -> - Member = guild_data_members:find_member_by_user_id(UserId, State), + Member = guild_permissions_check:find_member_by_user_id(UserId, State), GuildState#{<<"threads">> => guild_thread_view:guild_threads(UserId, Opts, Member, State)}; apply_thread_view(UserId, _Opts, State, GuildState) -> - Member = guild_data_members:find_member_by_user_id(UserId, State), + Member = guild_permissions_check:find_member_by_user_id(UserId, State), Channels = guild_thread_view:hide_for_non_viewer( UserId, Member, State, maps:get(<<"channels">>, GuildState, []) ), @@ -170,19 +141,19 @@ base_guild_state(UserId, State) -> Data = guild_data_index:ensure_data_map(State), GuildId = guild_id(State), AllChannels = guild_data_channels:channels_from_data(Data), - Member = guild_data_members:find_member_by_user_id(UserId, State), + Member = guild_permissions_check:find_member_by_user_id(UserId, State), {ViewableChannels, JoinedAt} = guild_data_channels:derive_member_view( UserId, Member, State, AllChannels ), - OnlineCount = guild_member_list:get_online_count(State), + OnlineCount = guild_member_list_read:get_online_count(State), OwnMemberList = own_member_list(Member), StateWithVoice = fetch_latest_voice_states(State), - AllVoiceStates = guild_voice:get_voice_states_list(StateWithVoice), + AllVoiceStates = guild_voice_state:get_voice_states_list(StateWithVoice), ViewableChannelIds = channel_id_set(ViewableChannels), VoiceStates = filter_voice_states(AllVoiceStates, ViewableChannelIds), VoiceMembers = resolve_voice_members(VoiceStates, Data), Members = guild_data_channels:merge_members(OwnMemberList, VoiceMembers), - MemberCount = maps:get(member_count, State, guild_data_index:member_count(Data)), + MemberCount = maps:get(member_count, State, guild_data_index_members:member_count(Data)), build_guild_state_map( GuildId, Data, @@ -352,7 +323,7 @@ fetch_latest_voice_states(State) -> -spec get_guild_data_for_user(user_id(), map(), guild_state()) -> guild_reply(map()). get_guild_data_for_user(UserId, Data, State) -> - case guild_data_index:get_member(UserId, Data) of + case guild_data_index_members:get_member(UserId, Data) of undefined -> {reply, #{guild_data => null, error_reason => <<"forbidden">>}, State}; Member -> @@ -386,9 +357,9 @@ build_guild_collection_data(Data, Channels, State) -> <<"emojis">> => map_utils:ensure_list(maps:get(<<"emojis">>, Data, [])), <<"stickers">> => map_utils:ensure_list(maps:get(<<"stickers">>, Data, [])), <<"member_count">> => maps:get( - member_count, State, guild_data_index:member_count(Data) + member_count, State, guild_data_index_members:member_count(Data) ), - <<"online_count">> => guild_member_list:get_online_count(State) + <<"online_count">> => guild_member_list_read:get_online_count(State) }. -spec own_member_list(map() | undefined) -> [map()]. @@ -398,7 +369,7 @@ own_member_list(M) -> [M]. -spec filter_voice_states([map()], sets:set(integer())) -> [map()]. filter_voice_states(AllVoiceStates, ViewableChannelIds) -> [ - guild_data_channels:sanitize_voice_state(VS) + voice_state_utils:sanitize_voice_state_for_broadcast(VS) || VS <- AllVoiceStates, voice_state_in_viewable_channel(VS, ViewableChannelIds) ]. diff --git a/fluxer_gateway/src/guild/guild_data_channels.erl b/fluxer_gateway/src/guild/guild_data_channels.erl index f31cde7dc..f42a5e5b8 100644 --- a/fluxer_gateway/src/guild/guild_data_channels.erl +++ b/fluxer_gateway/src/guild/guild_data_channels.erl @@ -10,7 +10,6 @@ sort_channels_for_ordering/1, derive_member_view/4, member_view/4, - sanitize_voice_state/1, merge_members/2 ]). @@ -38,7 +37,7 @@ channels_from_state(State) -> -spec channels_from_data(guild_data_map()) -> channel_list(). channels_from_data(Data) -> - guild_data_index:channel_list(Data). + guild_data_index_channels:channel_list(Data). -spec find_everyone_viewable_text_channel(channel_list(), guild_state()) -> integer() | null. find_everyone_viewable_text_channel(Channels, State) -> @@ -123,7 +122,7 @@ member_view_inputs(State) -> maps:get(role_perms_cache, Data, undefined), maps:get(overwrite_perms_cache, Data, undefined), maps:get(<<"channels">>, Data, undefined), - maps:map(fun channel_view_inputs/2, guild_data_index:channel_index(Data)) + maps:map(fun channel_view_inputs/2, guild_data_index_channels:channel_index(Data)) }. -spec channel_view_inputs(integer(), term()) -> term(). @@ -152,10 +151,6 @@ with_missing_parents(Filtered, Channels) -> MissingParentIds = find_missing_parent_ids(Filtered, FilteredIds), Filtered ++ collect_extra_categories(MissingParentIds, Channels). --spec sanitize_voice_state(map()) -> map(). -sanitize_voice_state(VS) -> - voice_state_utils:sanitize_voice_state_for_broadcast(VS). - -spec merge_members([guild_member()], [guild_member()]) -> [guild_member()]. merge_members(Primary, Secondary) -> {Merged, _} = lists:foldl( @@ -184,7 +179,7 @@ filter_viewable_channels(UserId, Member, State, Channels) -> fun(Channel) -> ChannelId = channel_id(Channel), ChannelId =/= undefined andalso - guild_permissions:can_view_channel(UserId, ChannelId, Member, State) + guild_permissions_check:can_view_channel(UserId, ChannelId, Member, State) end, Channels ). @@ -228,7 +223,7 @@ select_first_viewable(Channel, GuildId, BasePerms) -> check_viewable(ChannelType, ChannelId, Channel, GuildId, BasePerms) when is_integer(ChannelId), ChannelType =:= 0 orelse ChannelType =:= 2 orelse ChannelType =:= 5 -> - case permission_bits:has(BasePerms, constants:administrator_permission()) of + case bitset:has(BasePerms, constants:administrator_permission()) of true -> ChannelId; false -> check_view_permission(ChannelId, Channel, GuildId, BasePerms) end; @@ -237,10 +232,10 @@ check_viewable(_, _, _, _, _) -> -spec check_view_permission(integer(), map(), integer(), integer()) -> integer() | null. check_view_permission(ChannelId, Channel, GuildId, BasePerms) -> - FinalPerms = guild_permissions:apply_channel_overwrites( + FinalPerms = guild_permissions_overwrites:apply_channel_overwrites( BasePerms, undefined, [], Channel, GuildId ), - case permission_bits:has(FinalPerms, constants:view_channel_permission()) of + case bitset:has(FinalPerms, constants:view_channel_permission()) of true -> ChannelId; false -> null end. @@ -256,7 +251,7 @@ role_permissions_for_id(Roles, GuildId) -> -spec role_permissions_for_id_fold(map(), guild_id(), integer()) -> integer(). role_permissions_for_id_fold(Role, GuildId, Acc) -> case safe_snowflake_id(maps:get(<<"id">>, Role, undefined)) of - GuildId -> permission_bits:parse(maps:get(<<"permissions">>, Role, undefined)); + GuildId -> bitset:parse(maps:get(<<"permissions">>, Role, undefined)); _ -> Acc end. diff --git a/fluxer_gateway/src/guild/guild_data_index.erl b/fluxer_gateway/src/guild/guild_data_index.erl index 224208d04..dbc01f317 100644 --- a/fluxer_gateway/src/guild/guild_data_index.erl +++ b/fluxer_gateway/src/guild/guild_data_index.erl @@ -6,28 +6,9 @@ -export([ normalize_data/1, normalize_map/1, - member_map/1, - member_values/1, - member_list/1, - member_count/1, - member_ids/1, - member_role_index/1, - get_member/2, - put_member/2, - put_member_map/2, - put_member_list/2, - remove_member/2, - role_list/1, - role_index/1, put_roles/2, - channel_list/1, - channel_index/1, put_channels/2, build_id_index/1, - build_role_perms_cache/1, - build_overwrite_perms_cache/1, - ensure_list/1, - extract_integer_list/1, ensure_data_map/1 ]). @@ -71,66 +52,14 @@ normalize_map(Data) -> guild_data_index_channels:build_overwrite_perms_cache(Channels) }. --spec member_map(term()) -> map(). -member_map(Data) -> guild_data_index_members:member_map(Data). - --spec member_values(term()) -> [map()]. -member_values(Data) -> guild_data_index_members:member_values(Data). - --spec member_list(term()) -> [map()]. -member_list(Data) -> guild_data_index_members:member_list(Data). - --spec member_count(term()) -> non_neg_integer(). -member_count(Data) -> guild_data_index_members:member_count(Data). - --spec member_ids(term()) -> [integer()]. -member_ids(Data) -> guild_data_index_members:member_ids(Data). - --spec member_role_index(term()) -> map(). -member_role_index(Data) -> guild_data_index_members:member_role_index(Data). - --spec get_member(term(), term()) -> map() | undefined. -get_member(UserId, Data) -> guild_data_index_members:get_member(UserId, Data). - --spec put_member(term(), guild_data()) -> guild_data(). -put_member(Member, Data) -> guild_data_index_members:put_member(Member, Data). - --spec put_member_map(term(), guild_data()) -> guild_data(). -put_member_map(MemberMap, Data) -> guild_data_index_members:put_member_map(MemberMap, Data). - --spec put_member_list(term(), guild_data()) -> guild_data(). -put_member_list(Members, Data) -> guild_data_index_members:put_member_list(Members, Data). - --spec remove_member(term(), guild_data()) -> guild_data(). -remove_member(UserId, Data) -> guild_data_index_members:remove_member(UserId, Data). - --spec role_list(term()) -> [map()]. -role_list(Data) -> guild_data_index_roles:role_list(Data). - --spec role_index(term()) -> map(). -role_index(Data) -> guild_data_index_roles:role_index(Data). - -spec put_roles(term(), guild_data()) -> guild_data(). put_roles(Roles, Data) -> ensure_guild_data(guild_data_index_roles:put_roles(Roles, Data)). --spec channel_list(term()) -> [map()]. -channel_list(Data) -> guild_data_index_channels:channel_list(Data). - --spec channel_index(term()) -> map(). -channel_index(Data) -> guild_data_index_channels:channel_index(Data). - -spec put_channels(term(), guild_data()) -> guild_data(). put_channels(Channels, Data) -> ensure_guild_data(guild_data_index_channels:put_channels(Channels, Data)). --spec build_role_perms_cache([map()]) -> map(). -build_role_perms_cache(Roles) -> guild_data_index_roles:build_role_perms_cache(Roles). - --spec build_overwrite_perms_cache([map()]) -> map(). -build_overwrite_perms_cache(Channels) -> - guild_data_index_channels:build_overwrite_perms_cache(Channels). - -spec build_id_index([map()]) -> #{integer() => map()}. build_id_index(Items) -> lists:foldl(fun add_item_to_id_index/2, #{}, Items). @@ -142,19 +71,6 @@ add_item_to_id_index(Item, Acc) -> Id -> Acc#{Id => Item} end. --spec ensure_list(term()) -> list(). -ensure_list(Value) -> map_utils:ensure_list(Value). - --spec extract_integer_list(term()) -> [integer()]. -extract_integer_list(List) when is_list(List) -> - snowflake_id:parse_list(List); -extract_integer_list(undefined) -> - []; -extract_integer_list(null) -> - []; -extract_integer_list(Value) -> - erlang:error({invalid_snowflake_list, Value}). - -spec ensure_data_map(map()) -> map(). ensure_data_map(State) -> map_utils:ensure_map(map_utils:get_safe(State, data, #{})). @@ -170,10 +86,14 @@ member_list_revision_rotates_on_every_member_writer_test() -> Member = #{<<"user">> => #{<<"id">> => 1}}, Data0 = normalize_map(#{<<"members">> => [Member]}), Data1 = normalize_map(Data0), - Data2 = put_member(Member#{<<"nick">> => <<"renamed">>}, Data1), - Data3 = put_member_map(member_map(Data2), Data2), - Data4 = put_member_list(member_list(Data3), Data3), - Data5 = remove_member(1, Data4), + Data2 = guild_data_index_members:put_member(Member#{<<"nick">> => <<"renamed">>}, Data1), + Data3 = guild_data_index_members:put_member_map( + guild_data_index_members:member_map(Data2), Data2 + ), + Data4 = guild_data_index_members:put_member_list( + guild_data_index_members:member_list(Data3), Data3 + ), + Data5 = guild_data_index_members:remove_member(1, Data4), Revisions = [ maps:get(member_list_revision, D) || D <- [Data0, Data1, Data2, Data3, Data4, Data5] diff --git a/fluxer_gateway/src/guild/guild_data_index_channels.erl b/fluxer_gateway/src/guild/guild_data_index_channels.erl index 502384def..b4a0a0327 100644 --- a/fluxer_gateway/src/guild/guild_data_index_channels.erl +++ b/fluxer_gateway/src/guild/guild_data_index_channels.erl @@ -26,7 +26,7 @@ channel_list(Data) when is_map(Data) -> false -> lists:filtermap( fun normalize_channel_item/1, - guild_data_index:ensure_list(maps:get(<<"channels">>, Data, [])) + map_utils:ensure_list(maps:get(<<"channels">>, Data, [])) ) end; channel_list(_) -> @@ -45,7 +45,7 @@ channel_index(_) -> put_channels(Channels, Data) when is_map(Data) -> ChannelList = lists:filtermap( fun normalize_channel_item/1, - guild_data_index:ensure_list(Channels) + map_utils:ensure_list(Channels) ), put_forum_categories(ChannelList, Data#{ <<"channels">> => ChannelList, @@ -100,7 +100,7 @@ is_forum_type(16) -> true; is_forum_type(_) -> false. -spec build_overwrite_perms_cache([channel()]) -> - #{integer() => [{integer(), integer(), integer()}]}. + #{integer() => [{integer(), integer(), integer(), integer()}]}. build_overwrite_perms_cache(Channels) -> lists:foldl( fun @@ -152,8 +152,8 @@ valid_overwrite(Overwrite) when is_map(Overwrite) -> { snowflake_id:parse_maybe(maps:get(<<"id">>, Overwrite, undefined)), map_utils:get_integer(Overwrite, <<"type">>, undefined), - permission_bits:parse_maybe(maps:get(<<"allow">>, Overwrite, undefined)), - permission_bits:parse_maybe(maps:get(<<"deny">>, Overwrite, undefined)) + bitset:parse_maybe(maps:get(<<"allow">>, Overwrite, undefined)), + bitset:parse_maybe(maps:get(<<"deny">>, Overwrite, undefined)) } of {Id, Type, Allow, Deny} when @@ -191,8 +191,8 @@ channel_overwrites(Channel) -> -spec overwrite_tuple(term()) -> false | {true, {integer(), term(), integer(), integer()}}. overwrite_tuple(OW) when is_map(OW) -> OWId = snowflake_id:parse_maybe(maps:get(<<"id">>, OW, undefined)), - Allow = permission_bits:parse_optional(maps:get(<<"allow">>, OW, undefined)), - Deny = permission_bits:parse_optional(maps:get(<<"deny">>, OW, undefined)), + Allow = bitset:parse_optional(maps:get(<<"allow">>, OW, undefined)), + Deny = bitset:parse_optional(maps:get(<<"deny">>, OW, undefined)), Type = map_utils:get_integer(OW, <<"type">>, undefined), case {OWId, Allow, Deny, Type} of {Id, AllowBits, DenyBits, OverwriteType} when diff --git a/fluxer_gateway/src/guild/guild_data_index_members.erl b/fluxer_gateway/src/guild/guild_data_index_members.erl index 2a8e83635..d7134369a 100644 --- a/fluxer_gateway/src/guild/guild_data_index_members.erl +++ b/fluxer_gateway/src/guild/guild_data_index_members.erl @@ -276,7 +276,7 @@ member_user(Member) -> -spec member_role_ids(term()) -> [snowflake_id()]. member_role_ids(Member) when is_map(Member) -> - guild_data_index:extract_integer_list(maps:get(<<"roles">>, Member, [])); + snowflake_id:parse_list(maps:get(<<"roles">>, Member, [])); member_role_ids(_) -> []. diff --git a/fluxer_gateway/src/guild/guild_data_index_roles.erl b/fluxer_gateway/src/guild/guild_data_index_roles.erl index 6c728c443..bc03fdbda 100644 --- a/fluxer_gateway/src/guild/guild_data_index_roles.erl +++ b/fluxer_gateway/src/guild/guild_data_index_roles.erl @@ -20,7 +20,7 @@ role_list(Data) when is_map(Data) -> [ normalize_role(Role) - || Role <- guild_data_index:ensure_list( + || Role <- map_utils:ensure_list( maps:get(<<"roles">>, Data, []) ), is_map(Role) @@ -41,7 +41,7 @@ role_index(_) -> put_roles(Roles, Data) when is_map(Data) -> RoleList = [ normalize_role(Role) - || Role <- guild_data_index:ensure_list(Roles), + || Role <- map_utils:ensure_list(Roles), is_map(Role) ], Data#{ @@ -68,7 +68,7 @@ build_role_perms_cache(Roles) -> -spec cache_role_permissions(role(), map()) -> map(). cache_role_permissions(Role, Acc) -> RoleId = snowflake_id:parse_optional(maps:get(<<"id">>, Role, undefined)), - Perms = permission_bits:parse_optional(maps:get(<<"permissions">>, Role, undefined)), + Perms = bitset:parse_optional(maps:get(<<"permissions">>, Role, undefined)), cache_role_permissions(RoleId, Perms, Acc). -spec cache_role_permissions(term(), term(), map()) -> map(). diff --git a/fluxer_gateway/src/guild/guild_data_members.erl b/fluxer_gateway/src/guild/guild_data_members.erl index e1166d923..0bba0610e 100644 --- a/fluxer_gateway/src/guild/guild_data_members.erl +++ b/fluxer_gateway/src/guild/guild_data_members.erl @@ -9,7 +9,6 @@ has_member/2, list_guild_members/2, search_guild_members/2, - find_member_by_user_id/2, paginate_members/3 ]). @@ -21,7 +20,7 @@ -spec get_guild_member(map(), guild_state()) -> {reply, map(), guild_state()}. get_guild_member(#{user_id := UserId}, State) -> - case find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> {reply, #{success => false, member_data => null}, State}; Member -> @@ -39,14 +38,14 @@ get_guild_members_batch(#{user_ids := UserIds}, State) when is_list(UserIds) -> -spec add_member_to_batch(user_id(), guild_state(), map()) -> map(). add_member_to_batch(UserId, State, Acc) -> - case find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> Acc; Member -> Acc#{UserId => Member} end. -spec has_member(map(), guild_state()) -> {reply, map(), guild_state()}. has_member(#{user_id := UserId}, State) -> - case find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> {reply, #{has_member => false}, State}; _ -> @@ -56,7 +55,7 @@ has_member(#{user_id := UserId}, State) -> -spec list_guild_members(map(), guild_state()) -> {reply, map(), guild_state()}. list_guild_members(#{limit := Limit, offset := Offset}, State) -> Data = guild_data_index:ensure_data_map(State), - MemberMap = guild_data_index:member_map(Data), + MemberMap = guild_data_index_members:member_map(Data), SortedIds = guild_data_index_members:sorted_member_ids(Data, MemberMap), PageIds = paginate_members(SortedIds, Limit, Offset), PaginatedMembers = lookup_members(PageIds, MemberMap), @@ -71,7 +70,7 @@ search_guild_members(#{query := Query, limit := Limit}, State) when is_binary(Query), is_integer(Limit), Limit >= 0 -> Data = guild_data_index:ensure_data_map(State), - MemberMap = guild_data_index:member_map(Data), + MemberMap = guild_data_index_members:member_map(Data), Members = collect_query_matches(MemberMap, string:lowercase(Query), Limit), {reply, #{members => Members, total => map_size(MemberMap)}, State}; search_guild_members(_Request, State) -> @@ -114,10 +113,6 @@ collect_query_match(Member, NormalizedQuery, Remaining, Acc, NextIterator) -> collect_query_matches_iter(NextIterator, NormalizedQuery, Remaining, Acc) end. --spec find_member_by_user_id(user_id(), guild_state()) -> guild_member() | undefined. -find_member_by_user_id(UserId, State) -> - guild_permissions:find_member_by_user_id(UserId, State). - -spec paginate_members([term()], non_neg_integer(), non_neg_integer()) -> [term()]. paginate_members(Items, Limit, Offset) -> take_page(drop_items(Items, Offset), Limit, []). diff --git a/fluxer_gateway/src/guild/guild_data_normalize.erl b/fluxer_gateway/src/guild/guild_data_normalize.erl index 3c34303f2..302bb44df 100644 --- a/fluxer_gateway/src/guild/guild_data_normalize.erl +++ b/fluxer_gateway/src/guild/guild_data_normalize.erl @@ -195,8 +195,8 @@ add_snowflake_keyed_value(Key, Value, Acc) -> -spec normalize_overwrite(term()) -> false | {true, map()}. normalize_overwrite(Overwrite) when is_map(Overwrite) -> Id = snowflake_id:parse_optional(maps:get(<<"id">>, Overwrite, undefined)), - Allow = permission_bits:parse_optional(maps:get(<<"allow">>, Overwrite, undefined)), - Deny = permission_bits:parse_optional(maps:get(<<"deny">>, Overwrite, undefined)), + Allow = bitset:parse_optional(maps:get(<<"allow">>, Overwrite, undefined)), + Deny = bitset:parse_optional(maps:get(<<"deny">>, Overwrite, undefined)), Type = overwrite_type(maps:get(<<"type">>, Overwrite, undefined)), case {Id, Type, Allow, Deny} of {OverwriteId, OverwriteType, AllowBits, DenyBits} when @@ -285,7 +285,7 @@ normalize_permission_field(Key, Map) -> -spec put_normalized_permission(binary(), term(), map()) -> map(). put_normalized_permission(Key, Value, Map) -> - Map#{Key => permission_bits:parse(Value)}. + Map#{Key => bitset:parse(Value)}. -spec normalize_snowflake_list_field(binary(), map()) -> map(). normalize_snowflake_list_field(Key, Map) -> diff --git a/fluxer_gateway/src/guild/guild_dispatch.erl b/fluxer_gateway/src/guild/guild_dispatch.erl index 963c5cf0d..ba5b96b44 100644 --- a/fluxer_gateway/src/guild/guild_dispatch.erl +++ b/fluxer_gateway/src/guild/guild_dispatch.erl @@ -177,14 +177,6 @@ should_skip_dispatch_unavailable_for_everyone_but_staff_test() -> }, ?assertEqual(true, should_skip_dispatch(message_create, State)). -should_skip_dispatch_normal_guild_test() -> - State = #{ - data => #{ - <<"guild">> => #{<<"features">> => []} - } - }, - ?assertEqual(false, should_skip_dispatch(message_create, State)). - unknown_binary_event_is_dropped_without_crashing_test() -> State = #{id => 1, data => #{<<"guild">> => #{}}, sessions => #{}}, ?assertEqual( @@ -196,20 +188,20 @@ normalize_event_keeps_unknown_binaries_test() -> ?assertEqual(<<"FUTURE_EVENT_ZZZ">>, normalize_event(<<"FUTURE_EVENT_ZZZ">>)), ?assertEqual(thread_create, normalize_event(<<"THREAD_CREATE">>)). -should_skip_dispatch_no_features_test() -> - State = #{data => #{<<"guild">> => #{}}}, - ?assertEqual(false, should_skip_dispatch(message_create, State)). - channel_delete_dispatch_filters_by_pre_delete_visibility_test() -> Parent = self(), VisiblePid = start_dispatch_capture(visible, Parent), HiddenPid = start_dispatch_capture(hidden, Parent), try State = build_channel_delete_dispatch_state(VisiblePid, HiddenPid), - HiddenMember = guild_permissions:find_member_by_user_id(1001, State), - VisibleMember = guild_permissions:find_member_by_user_id(1002, State), - ?assertEqual(false, guild_permissions:can_view_channel(1001, 10, HiddenMember, State)), - ?assertEqual(true, guild_permissions:can_view_channel(1002, 10, VisibleMember, State)), + HiddenMember = guild_permissions_check:find_member_by_user_id(1001, State), + VisibleMember = guild_permissions_check:find_member_by_user_id(1002, State), + ?assertEqual( + false, guild_permissions_check:can_view_channel(1001, 10, HiddenMember, State) + ), + ?assertEqual( + true, guild_permissions_check:can_view_channel(1002, 10, VisibleMember, State) + ), {noreply, _} = handle_dispatch(channel_delete, #{<<"id">> => <<"10">>}, State), assert_visible_channel_delete_dispatched(), assert_hidden_channel_delete_not_dispatched() diff --git a/fluxer_gateway/src/guild/guild_dispatch_decorate.erl b/fluxer_gateway/src/guild/guild_dispatch_decorate.erl index 00b454567..3a890f524 100644 --- a/fluxer_gateway/src/guild/guild_dispatch_decorate.erl +++ b/fluxer_gateway/src/guild/guild_dispatch_decorate.erl @@ -8,7 +8,6 @@ extract_member_for_event/3, extract_and_remove_session_id/1, parse_snowflake/2, - require_snowflake/2, parse_snowflake_binary/2 ]). @@ -71,7 +70,7 @@ extract_member_for_event(Event, Data, State) -> UserId = extract_user_id_for_event(Event, Data), case UserId of undefined -> undefined; - Id -> guild_permissions:find_member_by_user_id(Id, State) + Id -> guild_permissions_check:find_member_by_user_id(Id, State) end. -spec extract_user_id_for_event(event(), event_data()) -> user_id() | undefined. @@ -90,18 +89,12 @@ extract_author_id(Data) -> undefined -> maps:get(<<"author_id">>, Data, undefined); _ -> AuthorId end, - validate_snowflake_or_undefined(<<"author_id">>, RawAuthorId). + parse_snowflake(<<"author_id">>, RawAuthorId). -spec extract_user_id_field(event_data()) -> user_id() | undefined. extract_user_id_field(Data) -> UserId = maps:get(<<"user_id">>, Data, undefined), - validate_snowflake_or_undefined(<<"user_id">>, UserId). - --spec validate_snowflake_or_undefined(binary(), term()) -> user_id() | undefined. -validate_snowflake_or_undefined(_FieldName, undefined) -> - undefined; -validate_snowflake_or_undefined(FieldName, Value) -> - parse_snowflake(FieldName, Value). + parse_snowflake(<<"user_id">>, UserId). -spec parse_snowflake(binary(), term()) -> integer() | undefined. parse_snowflake(FieldName, Value) -> @@ -110,13 +103,9 @@ parse_snowflake(FieldName, Value) -> {error, _, _} -> undefined end. --spec require_snowflake(binary(), term()) -> integer(). -require_snowflake(FieldName, Value) -> - validation:snowflake_or_throw(FieldName, Value). - -spec parse_snowflake_binary(binary(), term()) -> binary(). parse_snowflake_binary(FieldName, Value) -> - integer_to_binary(require_snowflake(FieldName, Value)). + integer_to_binary(validation:snowflake_or_throw(FieldName, Value)). -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). diff --git a/fluxer_gateway/src/guild/guild_dispatch_filter.erl b/fluxer_gateway/src/guild/guild_dispatch_filter.erl index 73f8644be..544cdbccf 100644 --- a/fluxer_gateway/src/guild/guild_dispatch_filter.erl +++ b/fluxer_gateway/src/guild/guild_dispatch_filter.erl @@ -171,7 +171,7 @@ has_view_audit_log_permission(SessionData, State) -> case maps:get(user_id, SessionData, undefined) of UserId when is_integer(UserId) -> Permissions = guild_permissions:get_member_permissions(UserId, undefined, State), - permission_bits:has(Permissions, constants:view_audit_log_permission()); + bitset:has(Permissions, constants:view_audit_log_permission()); _ -> false end. @@ -203,10 +203,10 @@ extract_channel_id(Event, FinalData) when Event =:= channel_create; Event =:= channel_update; Event =:= channel_delete -> ChannelIdBin = maps:get(<<"id">>, FinalData, undefined), - guild_dispatch_decorate:require_snowflake(<<"id">>, ChannelIdBin); + validation:snowflake_or_throw(<<"id">>, ChannelIdBin); extract_channel_id(_, FinalData) -> ChannelIdBin = maps:get(<<"channel_id">>, FinalData, undefined), - guild_dispatch_decorate:require_snowflake(<<"channel_id">>, ChannelIdBin). + validation:snowflake_or_throw(<<"channel_id">>, ChannelIdBin). -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). diff --git a/fluxer_gateway/src/guild/guild_dispatch_member_list.erl b/fluxer_gateway/src/guild/guild_dispatch_member_list.erl index 2e659945c..62deb149e 100644 --- a/fluxer_gateway/src/guild/guild_dispatch_member_list.erl +++ b/fluxer_gateway/src/guild/guild_dispatch_member_list.erl @@ -62,7 +62,7 @@ broadcast_member_update(EventData, OldState, UpdatedState) -> undefined -> UpdatedState; _ -> - {ok, NewState} = guild_member_list:broadcast_member_list_updates( + {ok, NewState} = guild_member_list_write:broadcast_member_list_updates( UserId, OldState, UpdatedState ), NewState @@ -70,7 +70,7 @@ broadcast_member_update(EventData, OldState, UpdatedState) -> -spec broadcast_all_updates(guild_state()) -> guild_state(). broadcast_all_updates(UpdatedState) -> - {ok, NewState} = guild_member_list:broadcast_all_member_list_updates(UpdatedState), + {ok, NewState} = guild_member_list_write:broadcast_all_member_list_updates(UpdatedState), NewState. -spec broadcast_channel_update(event_data(), guild_state()) -> guild_state(). @@ -95,7 +95,7 @@ broadcast_channel_update_for_id(ChannelId, UpdatedState) -> true -> sync_channel_list_without_rebuild(ChannelId, UpdatedState); false -> - {ok, NewState} = guild_member_list:broadcast_member_list_updates_for_channel( + {ok, NewState} = guild_member_list_write:broadcast_member_list_updates_for_channel( ChannelId, UpdatedState ), NewState diff --git a/fluxer_gateway/src/guild/guild_dispatch_push.erl b/fluxer_gateway/src/guild/guild_dispatch_push.erl index 2eb40f58e..635e3c0d7 100644 --- a/fluxer_gateway/src/guild/guild_dispatch_push.erl +++ b/fluxer_gateway/src/guild/guild_dispatch_push.erl @@ -480,6 +480,7 @@ send_forum_thread_created( MentionRoles = mention_role_id_set(maps:get(<<"mention_roles">>, MessageData, [])), Recipient = guild_thread_gate:recipient_active(ChannelId, State), HeldSessions = held_sessions(Hold), + CanView = fun guild_permissions_check:can_view_channel/4, Recipients = fold_members( fun(UserId, Member, Acc) -> Route = @@ -492,7 +493,7 @@ send_forum_thread_created( not member_has_mentioned_role(Member, MentionRoles) andalso not guild_members_common:is_member_bot(Member) andalso Recipient(UserId) andalso - guild_permissions:can_view_channel(UserId, ForumId, Member, State) + CanView(UserId, ForumId, Member, State) of true -> [{Route, UserId, extract_role_ids(Member)} | Acc]; false -> Acc @@ -709,7 +710,7 @@ thread_role_visible(ChannelId, State) -> Recipient = guild_thread_gate:recipient_active(ChannelId, State), fun(UserId, Member) -> Recipient(UserId) andalso - guild_permissions:can_view_channel(UserId, ChannelId, Member, State) + guild_permissions_check:can_view_channel(UserId, ChannelId, Member, State) end. -spec thread_role_adds( @@ -793,7 +794,7 @@ member_route(UserId, Member, Context, State) -> ThreadRecipient = maps:get(thread_recipient, Context, fun all_recipients/1), visible_member_route( Key, - guild_permissions:can_view_channel( + guild_permissions_check:can_view_channel( UserId, maps:get(channel_id, Context), Member, State ) andalso ThreadRecipient(UserId) ) @@ -946,7 +947,7 @@ collect_and_send_push_notifications(MessageData, GuildId, State) -> -spec send_push_notifications(event_data(), guild_id(), guild_state()) -> ok. send_push_notifications(MessageData, GuildId, State) -> Data = maps:get(data, State), - Members = guild_data_index:member_map(Data), + Members = guild_data_index_members:member_map(Data), Sessions = maps:get(sessions, State, #{}), {SessionEligibility, Hold} = build_push_session_eligibility(Sessions, State), CandidateUserIds = push_candidate_user_ids(Members, SessionEligibility, MessageData), @@ -1229,7 +1230,7 @@ is_push_eligible(UserId, Members, ChannelId, State) -> user_id(), integer(), map(), guild_state() ) -> {true, user_id()} | false. view_to_filtermap(UserId, ChannelId, Member, State) -> - case guild_permissions:can_view_channel(UserId, ChannelId, Member, State) of + case guild_permissions_check:can_view_channel(UserId, ChannelId, Member, State) of true -> {true, UserId}; false -> false end. @@ -1628,7 +1629,7 @@ find_channel_name(ChannelIdBin, Data) -> -spec lookup_channel_name(integer(), map()) -> binary(). lookup_channel_name(ChannelId, Data) -> - Channels = guild_data_index:channel_index(Data), + Channels = guild_data_index_channels:channel_index(Data), case maps:get(ChannelId, Channels, undefined) of undefined -> <<"unknown">>; Channel -> maps:get(<<"name">>, Channel, <<"unknown">>) @@ -1639,7 +1640,7 @@ build_role_names_map(Data) -> maps:fold( fun add_role_name/3, #{}, - guild_data_index:role_index(Data) + guild_data_index_roles:role_index(Data) ). -spec add_role_name(term(), term(), #{integer() => binary()}) -> #{integer() => binary()}. @@ -2001,57 +2002,6 @@ push_candidate_user_ids_deduplicates_mentions_test() -> CandidateUserIds = push_candidate_user_ids(Members, SessionEligibility, MessageData), ?assertEqual([1], CandidateUserIds). -send_push_to_eligible_users_uses_full_data_for_channel_name_test() -> - Self = self(), - ok = meck:new(push, [passthrough, no_link]), - try - ok = meck:expect(push, handle_message_create, fun(Params) -> - Self ! {push_params, Params}, - ok - end), - MessageData = #{ - <<"channel_id">> => <<"100">>, - <<"author">> => #{<<"id">> => <<"42">>} - }, - Data = #{ - <<"guild">> => #{ - <<"name">> => <<"Test Guild">>, - <<"default_message_notifications">> => 0 - }, - <<"channels">> => [ - #{<<"id">> => <<"100">>, <<"name">> => <<"general">>} - ], - <<"channel_index">> => #{ - 100 => #{<<"id">> => <<"100">>, <<"name">> => <<"general">>} - }, - <<"roles">> => [ - #{<<"id">> => <<"200">>, <<"name">> => <<"Alerts">>} - ], - <<"role_index">> => #{ - 200 => #{<<"id">> => <<"200">>, <<"name">> => <<"Alerts">>} - } - }, - ?assertEqual( - ok, - send_push_to_eligible_users( - MessageData, 10, [1], #{1 => []}, #{}, Data, undefined, {[], none, undefined} - ) - ), - receive - {push_params, Params} -> - ?assertEqual(<<"general">>, maps:get(channel_name, Params)), - ?assertEqual(<<"Test Guild">>, maps:get(guild_name, Params)), - ?assertEqual(#{200 => <<"Alerts">>}, maps:get(role_names, Params)), - ?assertEqual(undefined, maps:get(guild_member_count, Params)), - ?assertEqual(undefined, maps:get(guild_features, Params)) - after 1000 -> - ?assert(false) - end, - ?assert(meck:validate(push)) - after - meck:unload(push) - end. - compact_push_state_drops_members_and_keeps_member_count_test() -> Tab = ets:new(test_members, [set, public]), try @@ -2133,13 +2083,6 @@ collect_format_mention_ids_is_bounded_test() -> Result = collect_format_mention_ids(Mentions, ?MAX_FORMAT_MEMBERS, #{}), ?assertEqual(?MAX_FORMAT_MEMBERS, map_size(Result)). -format_member_id_set_includes_author_test() -> - MessageData = #{ - <<"author">> => #{<<"id">> => <<"99">>}, - <<"mentions">> => [#{<<"id">> => <<"1">>}, #{<<"id">> => <<"1">>}] - }, - ?assertEqual(#{1 => true, 99 => true}, format_member_id_set(MessageData)). - spawn_push_without_members_table_falls_back_to_legacy_push_test() -> reset_push_worker_state(), try @@ -2299,41 +2242,6 @@ thread_role_adds_skip_private_and_archived_threads_test() -> ) ). -thread_push_message_data_names_the_thread_test() -> - Tab = ets:new(test_members, [set, public]), - Store = guild_thread_store:new(), - try - guild_thread_store:load( - Store, - [#{<<"id">> => 500, <<"parent_id">> => 10, <<"name">> => <<"ideas">>}], - [#{<<"id">> => <<"500">>, <<"user_id">> => <<"1">>, <<"flags">> => 4}] - ), - Data = (compact_test_data(Tab))#{ - thread_gate => #{active => true, version => 1}, thread_store => Store - }, - Message = #{<<"channel_id">> => <<"500">>}, - {ThreadMessage, Name} = push_message_data(Message, <<"500">>, [1, 2], Data), - ?assertEqual(<<"ideas">>, Name), - ?assertEqual( - #{ - <<"parent_id">> => 10, - <<"parent_name">> => <<"general">>, - <<"category_id">> => undefined, - <<"members">> => #{1 => #{<<"flags">> => 4}} - }, - maps:get(<<"__thread_push">>, ThreadMessage) - ), - Plain = #{<<"channel_id">> => <<"10">>}, - ?assertEqual({Plain, <<"general">>}, push_message_data(Plain, <<"10">>, [1], Data)), - ?assertEqual( - {Message, <<"unknown">>}, - push_message_data(Message, <<"500">>, [1], compact_test_data(Tab)) - ) - after - guild_thread_store:destroy(Store), - ets:delete(Tab) - end. - compact_format_data_restricts_member_map_test() -> Data = #{ <<"guild">> => #{}, diff --git a/fluxer_gateway/src/guild/guild_dispatch_send.erl b/fluxer_gateway/src/guild/guild_dispatch_send.erl index 782c52aea..04a0a8e91 100644 --- a/fluxer_gateway/src/guild/guild_dispatch_send.erl +++ b/fluxer_gateway/src/guild/guild_dispatch_send.erl @@ -5,10 +5,7 @@ -include_lib("kernel/include/logger.hrl"). --export([ - dispatch_to_sessions/4, - filter_visible_channels/4 -]). +-export([dispatch_to_sessions/4]). -define(MAX_BULK_ENCODE_GROUPS, 1024). -define(BROADCASTER_CLAIM_DEADLINE_MS, 250). @@ -194,34 +191,15 @@ filter_permitted_for_session(SessionData, IndexedChannels, UpdatedState) -> ]; _ -> UserId = maps:get(user_id, SessionData), - Member = guild_permissions:find_member_by_user_id(UserId, UpdatedState), + Member = guild_permissions_check:find_member_by_user_id(UserId, UpdatedState), [ Ch || {ChId, Ch} <- IndexedChannels, is_integer(ChId), - guild_permissions:can_view_channel(UserId, ChId, Member, UpdatedState) + guild_permissions_check:can_view_channel(UserId, ChId, Member, UpdatedState) ] end. --spec filter_visible_channels([map()], user_id(), map() | undefined, guild_state()) -> [map()]. -filter_visible_channels(Channels, UserId, Member, State) -> - lists:filter( - fun(Channel) -> - is_channel_visible(Channel, UserId, Member, State) - end, - Channels - ). - --spec is_channel_visible(map(), user_id(), map() | undefined, guild_state()) -> boolean(). -is_channel_visible(_Channel, _UserId, undefined, _State) -> - false; -is_channel_visible(Channel, UserId, Member, State) -> - ChannelIdBin = maps:get(<<"id">>, Channel, undefined), - case guild_dispatch_decorate:parse_snowflake(<<"id">>, ChannelIdBin) of - undefined -> false; - ChannelId -> guild_permissions:can_view_channel(UserId, ChannelId, Member, State) - end. - -spec dispatch_bulk_to_pid( pid(), event(), event_data(), [map()], guild_id(), non_neg_integer() ) -> non_neg_integer(). @@ -351,67 +329,6 @@ normalize_dispatched([_ | _]) -> 1. -define(OVERFLOW_CHANNEL_BITS, 11). -normalize_success_test() -> - ?assertEqual(1, normalize_success(5)), - ?assertEqual(1, normalize_success(1)), - ?assertEqual(0, normalize_success(0)). - -normalize_dispatched_test() -> - ?assertEqual(0, normalize_dispatched([])), - ?assertEqual(1, normalize_dispatched([self()])), - ?assertEqual(1, normalize_dispatched([self(), self()])). - -filter_visible_channels_test() -> - {UserId, Member, State} = visibility_test_fixture(), - Channels = [#{<<"id">> => <<"100">>}, #{<<"id">> => <<"101">>}], - Result = filter_visible_channels(Channels, UserId, Member, State), - ?assertEqual(1, length(Result)), - ?assertEqual(<<"100">>, maps:get(<<"id">>, hd(Result))). - -visibility_test_fixture() -> - GuildId = 42, - UserId = 10, - VP = constants:view_channel_permission(), - GIdBin = integer_to_binary(GuildId), - VPBin = integer_to_binary(VP), - Member = #{<<"user">> => #{<<"id">> => integer_to_binary(UserId)}, <<"roles">> => []}, - DenyOW = #{ - <<"id">> => GIdBin, <<"type">> => 0, <<"allow">> => <<"0">>, <<"deny">> => VPBin - }, - State = #{ - id => GuildId, - data => #{ - <<"guild">> => #{<<"owner_id">> => <<"999">>}, - <<"roles">> => [#{<<"id">> => GIdBin, <<"permissions">> => VPBin}], - <<"members">> => [Member], - <<"channels">> => [ - #{<<"id">> => <<"100">>, <<"permission_overwrites">> => []}, - #{<<"id">> => <<"101">>, <<"permission_overwrites">> => [DenyOW]} - ] - } - }, - {UserId, Member, State}. - -filter_visible_channels_undefined_member_test() -> - State = #{data => #{<<"members">> => []}}, - Channels = [#{<<"id">> => <<"100">>}], - Result = filter_visible_channels(Channels, 10, undefined, State), - ?assertEqual([], Result). - -passive_standard_structural_updates_dispatch_test() -> - Events = [ - {guild_update, #{<<"name">> => <<"Updated">>}}, - {guild_role_update, #{<<"role">> => #{<<"id">> => <<"200">>, <<"name">> => <<"Role">>}}}, - {guild_role_update_bulk, #{ - <<"roles">> => [#{<<"id">> => <<"200">>, <<"name">> => <<"Role">>}] - }}, - {channel_create, #{<<"id">> => <<"100">>, <<"name">> => <<"general">>}}, - {channel_update, #{<<"id">> => <<"100">>, <<"name">> => <<"general">>}}, - {channel_delete, #{<<"id">> => <<"100">>}}, - {guild_member_update, #{<<"user">> => #{<<"id">> => <<"10">>}, <<"roles">> => []}} - ], - lists:foreach(fun assert_passive_standard_dispatch/1, Events). - passive_channel_update_bulk_dispatches_visible_channels_test() -> flush_dispatches(), Data = #{ @@ -562,20 +479,6 @@ assert_no_dispatch() -> ok end. -assert_passive_standard_dispatch({Event, Data}) -> - flush_dispatches(), - ?assertEqual( - 1, - dispatch_to_sessions( - [passive_session_pair()], - Event, - Data#{<<"guild_id">> => <<"42">>}, - passive_dispatch_state() - ) - ), - _Payload = receive_pre_encoded_payload(Event), - ok. - passive_session_pair() -> {<<"passive">>, passive_session_data()}. diff --git a/fluxer_gateway/src/guild/guild_dm_partners.erl b/fluxer_gateway/src/guild/guild_dm_partners.erl index 6579de407..3895e3711 100644 --- a/fluxer_gateway/src/guild/guild_dm_partners.erl +++ b/fluxer_gateway/src/guild/guild_dm_partners.erl @@ -59,8 +59,8 @@ visibility_inputs(State) -> Guild = map_utils:ensure_map(maps:get(<<"guild">>, Data, #{})), { maps:get(<<"owner_id">>, Guild, undefined), - role_inputs(guild_data_index:role_index(Data)), - channel_inputs(guild_data_index:channel_index(Data)), + role_inputs(guild_data_index_roles:role_index(Data)), + channel_inputs(guild_data_index_channels:channel_index(Data)), maps:get(virtual_channel_access, State, #{}) }. diff --git a/fluxer_gateway/src/guild/guild_ets_utils.erl b/fluxer_gateway/src/guild/guild_ets_utils.erl index 76cc7d53b..3727b3873 100644 --- a/fluxer_gateway/src/guild/guild_ets_utils.erl +++ b/fluxer_gateway/src/guild/guild_ets_utils.erl @@ -35,29 +35,3 @@ try_create_table(TableName, Options) -> catch error:badarg -> ok end. - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). - -safe_delete_table(TableName) -> - try ets:delete(TableName) of - _ -> ok - catch - error:badarg -> ok - end. - -test_table_opts() -> [named_table, public, set]. - -ensure_table_delegates_to_owner_when_running_test() -> - TableName = guild_ets_utils_test_owner, - safe_delete_table(TableName), - {ok, Pid} = guild_ets_owner:start_link(), - try - ok = ensure_table(TableName, test_table_opts()), - ?assertEqual(Pid, ets:info(TableName, owner)) - after - gen_server:stop(guild_ets_owner), - safe_delete_table(TableName) - end. - --endif. diff --git a/fluxer_gateway/src/guild/guild_init.erl b/fluxer_gateway/src/guild/guild_init.erl index fa3602f31..16a75f2f1 100644 --- a/fluxer_gateway/src/guild/guild_init.erl +++ b/fluxer_gateway/src/guild/guild_init.erl @@ -74,12 +74,12 @@ init_member_list(State) -> Data = maps:get(data, State, #{}), case guild_id(State) of GuildId when is_integer(GuildId), GuildId > 0 -> - NifRef = guild_member_list_store:new(GuildId), + NifRef = guild_member_list_engine:new(), Roles = map_utils:ensure_list(maps:get(<<"roles">>, Data, [])), - MemberMap = guild_data_index:member_map(Data), + MemberMap = guild_data_index_members:member_map(Data), MemberTuples = guild_member_list_store:prepare_member_tuples(MemberMap, State), HoistedRoleIds = guild_member_list_store:prepare_hoisted_role_ids(Roles, GuildId), - ok = guild_member_list_store:bulk_load(NifRef, MemberTuples, HoistedRoleIds), + ok = guild_member_list_engine:bulk_load(NifRef, MemberTuples, HoistedRoleIds), State#{member_list_engine => NifRef}; _ -> State @@ -91,10 +91,10 @@ init_counts(State) -> MemberCount = case maps:get(member_count, State, undefined) of N when is_integer(N), N >= 0 -> N; - _ -> guild_data_index:member_count(Data) + _ -> guild_data_index_members:member_count(Data) end, State1 = State#{member_count => MemberCount}, - OnlineCount = guild_member_list:get_online_count(State1), + OnlineCount = guild_member_list_read:get_online_count(State1), State2 = State1#{online_count => OnlineCount}, PublicOnlineCount = guild_public_online:compute_count(State2), State2#{public_online_count => PublicOnlineCount}. diff --git a/fluxer_gateway/src/guild/guild_maintenance.erl b/fluxer_gateway/src/guild/guild_maintenance.erl index 69f07182c..907f7e049 100644 --- a/fluxer_gateway/src/guild/guild_maintenance.erl +++ b/fluxer_gateway/src/guild/guild_maintenance.erl @@ -32,8 +32,8 @@ update_counts(State) -> State0 = guild_members_table_repair:maybe_repair(State), Data = maps:get(data, State0, #{}), - MemberCount = guild_data_index:member_count(Data), - OnlineCount = guild_member_list:get_online_count(State0), + MemberCount = guild_data_index_members:member_count(Data), + OnlineCount = guild_member_list_read:get_online_count(State0), PublicOnlineCount = guild_public_online:compute_count(State0), ok = maybe_put_guild_count_cache(State0, MemberCount, PublicOnlineCount), State0#{ @@ -115,8 +115,8 @@ maybe_prune_invalid_member_subscriptions(Event, State) -> -spec cleanup_removed_member_subscriptions(map(), map(), guild_state()) -> guild_state(). cleanup_removed_member_subscriptions(OldData, NewData, State) -> - OldMemberIds = sets:from_list(guild_data_index:member_ids(OldData)), - NewMemberIds = sets:from_list(guild_data_index:member_ids(NewData)), + OldMemberIds = sets:from_list(guild_data_index_members:member_ids(OldData)), + NewMemberIds = sets:from_list(guild_data_index_members:member_ids(NewData)), RemovedIds = sets:to_list(sets:subtract(OldMemberIds, NewMemberIds)), PresenceSubs = maps:get(presence_subscriptions, State, #{}), NewPresenceSubs = unsubscribe_removed_members(RemovedIds, PresenceSubs), @@ -126,7 +126,7 @@ cleanup_removed_member_subscriptions(OldData, NewData, State) -> apply_everyone_perm_bit(Bit, State) -> GuildId = maps:get(id, State), Data = maps:get(data, State, #{}), - Roles = guild_data_index:role_list(Data), + Roles = guild_data_index_roles:role_list(Data), {Updated, Changed} = update_everyone_role(Roles, GuildId, Bit), case Changed of false -> @@ -210,14 +210,14 @@ session_viewable_channels(SessionData, SessionUserId, State) -> ViewableChannels; _ -> guild_sessions:build_viewable_channel_map( - guild_visibility:get_user_viewable_channels(SessionUserId, State) + guild_visibility_channels:get_user_viewable_channels(SessionUserId, State) ) end. -spec member_viewable_channel_map(user_id(), guild_state()) -> map(). member_viewable_channel_map(MemberId, State) -> guild_sessions:build_viewable_channel_map( - guild_visibility:get_user_viewable_channels(MemberId, State) + guild_visibility_channels:get_user_viewable_channels(MemberId, State) ). %% One pass reads a fixed state, and get_user_viewable_channels/2 reads nothing from a @@ -256,8 +256,10 @@ cached_member_viewable_channel_map(MemberId, RawRoles, State, Cache, Memo) -> -spec memo_key(user_id(), sets:set(user_id()), guild_state()) -> {ok, term()} | bypass. memo_key(MemberId, Exceptions, State) -> case sets:is_element(MemberId, Exceptions) of - true -> bypass; - false -> member_roles_key(guild_permissions:find_member_by_user_id(MemberId, State)) + true -> + bypass; + false -> + member_roles_key(guild_permissions_check:find_member_by_user_id(MemberId, State)) end. -spec member_roles_key(map() | undefined) -> {ok, term()} | bypass. @@ -296,7 +298,7 @@ overwrite_target_ids(Data) -> maps:fold( fun(_ChannelId, Channel, Acc) -> channel_target_ids(Channel, Acc) end, Cached, - guild_data_index:channel_index(Data) + guild_data_index_channels:channel_index(Data) ). -spec cached_target_ids(term(), [user_id()]) -> [user_id()]. @@ -484,18 +486,18 @@ update_matching_everyone_role(Role, Bit, Acc, ChangedAcc) -> -spec update_everyone_role_permissions(map(), integer(), integer(), [map()], boolean()) -> {[map()], boolean()}. update_everyone_role_permissions(Role, Current, Bit, Acc, ChangedAcc) -> - case permission_bits:has(Current, Bit) of + case bitset:has(Current, Bit) of true -> {[Role | Acc], ChangedAcc}; false -> - New = permission_bits:add(Current, Bit), + New = bitset:add(Current, Bit), NewRole = Role#{<<"permissions">> => New}, {[NewRole | Acc], true} end. -spec role_permissions_int(map()) -> integer(). role_permissions_int(Role) -> - permission_bits:parse(maps:get(<<"permissions">>, Role, undefined)). + bitset:parse(maps:get(<<"permissions">>, Role, undefined)). -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). @@ -673,14 +675,6 @@ prune_keeps_exception_members_a_session_can_still_see_test() -> ), ?assertEqual(Presence, maps:get(presence_subscriptions, Result)). -memo_reuses_one_entry_per_roles_term_test() -> - State = memo_prune_state(), - {First, Memo1} = memoised_member_viewable_channel_map(20, State, new_viewable_memo(State)), - {Second, Memo2} = memoised_member_viewable_channel_map(21, State, Memo1), - ?assertEqual(member_viewable_channel_map(21, State), Second), - ?assertEqual(First, Second), - ?assertEqual([1, 1], [map_size(maps:get(cache, M)) || M <- [Memo1, Memo2]]). - memo_never_answers_owner_virtual_or_overwrite_targets_from_the_cache_test() -> State = memo_prune_state(), {_, Memo} = memoised_member_viewable_channel_map(30, State, new_viewable_memo(State)), @@ -707,8 +701,8 @@ permission_cache_skips_nick_only_member_update_test() -> EventData = permission_cache_member(UserId, [10], <<"new">>), NewState = permission_cache_apply(EventData, OldState), ?assertNotEqual( - guild_data_index:get_member(UserId, maps:get(data, OldState)), - guild_data_index:get_member(UserId, maps:get(data, NewState)) + guild_data_index_members:get_member(UserId, maps:get(data, OldState)), + guild_data_index_members:get_member(UserId, maps:get(data, NewState)) ), ?assertEqual( {error, not_found}, @@ -725,7 +719,7 @@ permission_cache_rebuilds_on_member_role_change_test() -> {ok, Snapshot} = permission_cache_refresh( guild_member_update, EventData, OldState, NewState ), - #{<<"roles">> := Roles} = guild_permissions:find_member_by_user_id(UserId, Snapshot), + #{<<"roles">> := Roles} = guild_permissions_check:find_member_by_user_id(UserId, Snapshot), ?assertEqual([10, 11], Roles). permission_cache_rebuilds_on_member_timeout_change_test() -> @@ -742,7 +736,7 @@ permission_cache_rebuilds_on_member_timeout_change_test() -> guild_member_update, EventData, OldState, NewState ), #{<<"communication_disabled_until">> := MemberUntil} = - guild_permissions:find_member_by_user_id(UserId, Snapshot), + guild_permissions_check:find_member_by_user_id(UserId, Snapshot), ?assertEqual(Until, MemberUntil). permission_cache_rebuilds_for_other_mutating_events_test() -> diff --git a/fluxer_gateway/src/guild/guild_manager.erl b/fluxer_gateway/src/guild/guild_manager.erl index cf83cb464..224e7cd57 100644 --- a/fluxer_gateway/src/guild/guild_manager.erl +++ b/fluxer_gateway/src/guild/guild_manager.erl @@ -75,7 +75,7 @@ init([]) -> erlang:process_flag(fullsweep_after, 0), process_registry:init(), guild_manager_cache:ensure_tables(), - {ShardCount, _Source} = guild_manager_shards:determine_shard_count(), + {ShardCount, _Source} = clustered_ets_cache:determine_shard_count([guild_shards]), ShardMap = guild_manager_shards:start_shards(ShardCount), State = #{shards => ShardMap, shard_count => ShardCount}, guild_manager_cache:sync_shard_table(State), @@ -242,7 +242,7 @@ adopt_worker_shard_for_pid(Index, WorkerShard, Acc, Pid) -> -spec handle_info(term(), state()) -> {noreply, state()}. handle_info({'DOWN', Ref, process, Pid, _Reason}, State) when is_reference(Ref), is_pid(Pid) -> Shards = maps:get(shards, State), - case guild_manager_shards:find_shard_by_ref(Ref, Shards) of + case shard_utils:find_shard_by_ref(Ref, Shards) of {ok, Index} -> restart_manager_shard(Index, State); not_found -> @@ -251,7 +251,7 @@ handle_info({'DOWN', Ref, process, Pid, _Reason}, State) when is_reference(Ref), end; handle_info({'EXIT', Pid, _Reason}, State) when is_pid(Pid) -> Shards = maps:get(shards, State), - case guild_manager_shards:find_shard_by_pid(Pid, Shards) of + case shard_utils:find_shard_by_pid(Pid, Shards) of {ok, Index} -> restart_manager_shard(Index, State); not_found -> diff --git a/fluxer_gateway/src/guild/guild_manager_shards.erl b/fluxer_gateway/src/guild/guild_manager_shards.erl index 1df94c541..b5044c0d6 100644 --- a/fluxer_gateway/src/guild/guild_manager_shards.erl +++ b/fluxer_gateway/src/guild/guild_manager_shards.erl @@ -4,17 +4,13 @@ -typing([eqwalizer]). -export([ - determine_shard_count/0, - default_shard_count/0, start_shards/1, start_shard/1, restart_shard/2, ensure_shard/2, ensure_shard_for_index/2, select_shard/2, - group_ids_by_shard/2, - find_shard_by_ref/2, - find_shard_by_pid/2 + group_ids_by_shard/2 ]). -type guild_id() :: integer(). @@ -23,14 +19,6 @@ -export_type([guild_id/0, shard_map/0, state/0]). --spec determine_shard_count() -> {pos_integer(), configured | auto}. -determine_shard_count() -> - shard_utils:determine_shard_count(guild_shards). - --spec default_shard_count() -> pos_integer(). -default_shard_count() -> - clustered_ets_cache:default_shard_count(). - -spec start_shards(pos_integer()) -> #{non_neg_integer() => shard_map()}. start_shards(Count) -> lists:foldl(fun start_shard_into_map/2, #{}, lists:seq(0, Count - 1)). @@ -98,16 +86,6 @@ group_ids_by_shard(GuildIds, ShardCount) -> normalize_grouped_ids(Groups) -> [{Index, [GuildId || GuildId <- Ids, is_integer(GuildId)]} || {Index, Ids} <- Groups]. --spec find_shard_by_ref(reference(), #{non_neg_integer() => shard_map()}) -> - {ok, non_neg_integer()} | not_found. -find_shard_by_ref(Ref, Shards) -> - shard_utils:find_shard_by_ref(Ref, Shards). - --spec find_shard_by_pid(pid(), #{non_neg_integer() => shard_map()}) -> - {ok, non_neg_integer()} | not_found. -find_shard_by_pid(Pid, Shards) -> - shard_utils:find_shard_by_pid(Pid, Shards). - -spec start_shard_into_map(non_neg_integer(), #{non_neg_integer() => shard_map()}) -> #{non_neg_integer() => shard_map()}. start_shard_into_map(Index, Acc) -> diff --git a/fluxer_gateway/src/guild/guild_member_list.erl b/fluxer_gateway/src/guild/guild_member_list.erl index b519f9bb2..29a6f0807 100644 --- a/fluxer_gateway/src/guild/guild_member_list.erl +++ b/fluxer_gateway/src/guild/guild_member_list.erl @@ -3,26 +3,7 @@ -module(guild_member_list). -typing([eqwalizer]). --export([ - calculate_list_id/2, - get_counts/2, - subscribe_ranges/4, - unsubscribe_session/2, - build_sync_response/4, - member_list_snapshot/2, - get_online_count/1, - broadcast_member_list_updates/3, - broadcast_member_list_updates/5, - broadcast_all_member_list_updates/1, - broadcast_member_list_updates_for_channel/2, - flush_pending_member_list_syncs/1, - normalize_ranges/1, - get_members_cursor/2 -]). - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. +-export([calculate_list_id/2, normalize_ranges/1]). -type guild_state() :: map(). -type list_id() :: binary(). @@ -37,7 +18,7 @@ -spec calculate_list_id(channel_id(), guild_state()) -> list_id() | undefined. calculate_list_id(ChannelId, State) when is_integer(ChannelId), ChannelId > 0 -> Data = maps:get(data, State, #{}), - ChannelIndex = guild_data_index:channel_index(Data), + ChannelIndex = guild_data_index_channels:channel_index(Data), case maps:is_key(ChannelId, ChannelIndex) of true -> integer_to_binary(ChannelId); false -> undefined @@ -61,76 +42,6 @@ valid_range_item(Range) -> Valid -> {true, Valid} end. --spec get_counts(list_id(), guild_state()) -> {non_neg_integer(), non_neg_integer()}. -get_counts(ListId, State) -> - guild_member_list_read:get_counts(ListId, State). - --spec subscribe_ranges(binary(), list_id(), [range()], guild_state()) -> - {guild_state(), boolean(), [range()]}. -subscribe_ranges(SessionId, ListId, Ranges, State) -> - guild_member_list_subscribe:subscribe_ranges(SessionId, ListId, Ranges, State). - --spec unsubscribe_session(binary(), guild_state()) -> guild_state(). -unsubscribe_session(SessionId, State) -> - guild_member_list_subscribe:unsubscribe_session(SessionId, State). - --spec build_sync_response(integer(), list_id(), [range()], guild_state()) -> map(). -build_sync_response(GuildId, ListId, Ranges, State) -> - guild_member_list_read:build_sync_response(GuildId, ListId, Ranges, State). - --spec member_list_snapshot(list_id(), guild_state()) -> - {non_neg_integer(), non_neg_integer(), [map()], [map()]}. -member_list_snapshot(ListId, State) -> - guild_member_list_read:member_list_snapshot(ListId, State). - --spec get_online_count(guild_state()) -> non_neg_integer(). -get_online_count(State) -> - guild_member_list_read:get_online_count(State). - --spec broadcast_member_list_updates(integer() | undefined, guild_state(), guild_state()) -> - {ok, guild_state()}. -broadcast_member_list_updates(UserId, OldState, UpdatedState) -> - guild_member_list_write:broadcast_member_list_updates(UserId, OldState, UpdatedState). - --spec broadcast_member_list_updates( - integer() | undefined, - guild_state(), - guild_state(), - map() | undefined, - map() | undefined -) -> {ok, guild_state()}. -broadcast_member_list_updates( - UserId, - OldState, - UpdatedState, - OldPresence, - NewPresence -) -> - guild_member_list_write:broadcast_member_list_updates( - UserId, - OldState, - UpdatedState, - OldPresence, - NewPresence - ). - --spec broadcast_all_member_list_updates(guild_state()) -> {ok, guild_state()}. -broadcast_all_member_list_updates(State) -> - guild_member_list_write:broadcast_all_member_list_updates(State). - --spec broadcast_member_list_updates_for_channel(channel_id(), guild_state()) -> - {ok, guild_state()}. -broadcast_member_list_updates_for_channel(ChannelId, State) -> - guild_member_list_write:broadcast_member_list_updates_for_channel(ChannelId, State). - --spec flush_pending_member_list_syncs(guild_state()) -> guild_state(). -flush_pending_member_list_syncs(State) -> - guild_member_list_write:flush_pending_member_list_syncs(State). - --spec get_members_cursor(map(), guild_state()) -> {reply, map(), guild_state()}. -get_members_cursor(Request, State) -> - guild_member_list_read:get_members_cursor(Request, State). - -spec validate_range(term()) -> range() | invalid. validate_range({Start, End}) when is_integer(Start), @@ -165,44 +76,3 @@ split_range_by_max_span({Start, End}) when End - Start =< ?MAX_RANGE_SPAN -> split_range_by_max_span({Start, End}) -> ChunkEnd = Start + ?MAX_RANGE_SPAN, [{Start, ChunkEnd} | split_range_by_max_span({ChunkEnd + 1, End})]. - --ifdef(TEST). - -normalize_ranges_empty_test() -> - ?assertEqual([], normalize_ranges([])). - -normalize_ranges_single_test() -> - ?assertEqual([{0, 99}], normalize_ranges([{0, 99}])). - -normalize_ranges_merges_overlapping_test() -> - ?assertEqual([{0, 99}], normalize_ranges([{0, 60}, {50, 99}])). - -normalize_ranges_merges_adjacent_test() -> - ?assertEqual([{0, 99}], normalize_ranges([{0, 49}, {50, 99}])). - -normalize_ranges_keeps_separate_test() -> - ?assertEqual([{0, 50}, {100, 150}], normalize_ranges([{0, 50}, {100, 150}])). - -normalize_ranges_filters_invalid_test() -> - ?assertEqual([{0, 50}], normalize_ranges([{100, 50}, {0, 50}, {-1, 10}])). - -normalize_ranges_sorts_and_merges_test() -> - ?assertEqual([{0, 99}], normalize_ranges([{50, 99}, {0, 49}, {30, 70}])). - -normalize_ranges_duplicate_ranges_test() -> - ?assertEqual([{0, 99}], normalize_ranges([{0, 99}, {0, 99}])). - -normalize_ranges_three_separate_ranges_test() -> - ThreeRanges = [{0, 10}, {50, 60}, {100, 110}], - ?assertEqual(ThreeRanges, normalize_ranges(ThreeRanges)). - -normalize_ranges_all_invalid_test() -> - ?assertEqual([], normalize_ranges([{10, 5}, {-1, -1}, {200000, 300000}])). - -normalize_ranges_touching_ranges_merge_test() -> - ?assertEqual([{0, 20}], normalize_ranges([{0, 10}, {11, 20}])). - -normalize_ranges_gap_of_one_no_merge_test() -> - ?assertEqual([{0, 10}, {12, 20}], normalize_ranges([{0, 10}, {12, 20}])). - --endif. diff --git a/fluxer_gateway/src/guild/guild_member_list_channel_engine.erl b/fluxer_gateway/src/guild/guild_member_list_channel_engine.erl index 2857f448d..44965cfa2 100644 --- a/fluxer_gateway/src/guild/guild_member_list_channel_engine.erl +++ b/fluxer_gateway/src/guild/guild_member_list_channel_engine.erl @@ -149,7 +149,7 @@ update_user_in_engine(UserId, ListId, Ref, State) -> -spec update_user_in_channel(integer(), pos_integer(), engine_ref(), guild_state()) -> ok. update_user_in_channel(UserId, ChannelId, Ref, State) -> Data = maps:get(data, State, #{}), - case guild_data_index:get_member(UserId, Data) of + case guild_data_index_members:get_member(UserId, Data) of Member when is_map(Member) -> upsert_visible_user(UserId, ChannelId, Member, Ref, State); _ -> @@ -241,7 +241,7 @@ load_engine(ChannelId, State) -> {[guild_member_list_store:member_tuple()], [integer()]}. build_inputs(ChannelId, State) -> Data = maps:get(data, State, #{}), - MemberMap = guild_data_index:member_map(Data), + MemberMap = guild_data_index_members:member_map(Data), ConnectedUserIds = guild_member_list_common:connected_session_user_ids(State), PresenceTab = maps:get(member_presence, State), {Tuples, _Memo, _RoleCache} = maps:fold( @@ -355,7 +355,7 @@ memoised_can_view(UserId, ChannelId, Member, SortedRoleIds, State, Memo) -> %% overwrites, so the exception set is not local to this channel. Do not memoise. -spec memoisable_channel(pos_integer(), map()) -> boolean(). memoisable_channel(ChannelId, Data) -> - case maps:get(ChannelId, guild_data_index:channel_index(Data), undefined) of + case maps:get(ChannelId, guild_data_index_channels:channel_index(Data), undefined) of Channel when is_map(Channel) -> maps:get(<<"type">>, Channel, undefined) =/= 4; _ -> false end. @@ -385,7 +385,7 @@ overwrite_target_ids(ChannelId, Data) -> -spec channel_overwrite_target_ids(pos_integer(), map()) -> [integer()]. channel_overwrite_target_ids(ChannelId, Data) -> - case maps:get(ChannelId, guild_data_index:channel_index(Data), undefined) of + case maps:get(ChannelId, guild_data_index_channels:channel_index(Data), undefined) of Channel when is_map(Channel) -> lists:filtermap( fun(Overwrite) -> overwrite_target_id(Overwrite) end, @@ -435,7 +435,7 @@ presence_status_is_online(UserId, PresenceTab) -> -spec can_view(integer(), pos_integer(), map(), guild_state()) -> boolean(). can_view(UserId, ChannelId, Member, State) when is_integer(UserId), UserId > 0 -> try - guild_permissions:can_view_channel(UserId, ChannelId, Member, State) + guild_permissions_check:can_view_channel(UserId, ChannelId, Member, State) catch _:_ -> false end; @@ -504,19 +504,6 @@ new_visibility_memo_is_disabled_for_categories_test() -> ?assertEqual(disabled, new_visibility_memo(600, maps:get(data, State), State)), ?assertNotEqual(disabled, new_visibility_memo(500, maps:get(data, State), State)). -memoised_can_view_caches_one_entry_per_role_set_test() -> - State = memo_channel_state(), - Memo0 = new_visibility_memo(500, maps:get(data, State), State), - {First, Memo1} = memoised_can_view( - 1000, 500, #{<<"roles">> => [2, 1]}, lists:sort([2, 1]), State, Memo0 - ), - ?assertEqual(1, map_size(maps:get(cache, Memo1))), - {Second, Memo2} = memoised_can_view( - 1001, 500, #{<<"roles">> => [1, 2]}, lists:sort([1, 2]), State, Memo1 - ), - ?assertEqual(First, Second), - ?assertEqual(1, map_size(maps:get(cache, Memo2))). - memoised_can_view_bypasses_exception_users_test() -> State = memo_channel_state(), Memo0 = new_visibility_memo(500, maps:get(data, State), State), @@ -532,18 +519,6 @@ memoised_can_view_disabled_never_caches_test() -> memoised_can_view(1000, 600, #{<<"roles">> => [1]}, [1], State, disabled) ). -member_role_ids_caches_and_sorts_test() -> - Member = #{<<"roles">> => [3, 1, 2]}, - {RoleIds, Sorted, Cache1} = member_role_ids(Member, #{}), - ?assertEqual([1, 2, 3], Sorted), - ?assertEqual(lists:sort(RoleIds), Sorted), - ?assertEqual(1, map_size(Cache1)), - {RoleIds2, Sorted2, Cache2} = member_role_ids(#{<<"roles">> => [3, 1, 2]}, Cache1), - ?assertEqual({RoleIds, Sorted}, {RoleIds2, Sorted2}), - ?assertEqual(1, map_size(Cache2)), - {_, _, Cache3} = member_role_ids(#{<<"roles">> => [9]}, Cache2), - ?assertEqual(2, map_size(Cache3)). - offline_member_skips_the_presence_lookup_test() -> Presences = #{7 => #{<<"status">> => <<"online">>}}, Connected = sets:from_list([7]), diff --git a/fluxer_gateway/src/guild/guild_member_list_connected.erl b/fluxer_gateway/src/guild/guild_member_list_connected.erl index f070343f3..dbad557a7 100644 --- a/fluxer_gateway/src/guild/guild_member_list_connected.erl +++ b/fluxer_gateway/src/guild/guild_member_list_connected.erl @@ -124,9 +124,9 @@ session_can_view_channel_by_permissions(SessionData, ChannelId, State) -> is_integer(UserId), UserId > 0, is_map(ViewableChannels) -> maps:is_key(ChannelId, ViewableChannels) orelse - guild_permissions:can_view_channel(UserId, ChannelId, undefined, State); + guild_permissions_check:can_view_channel(UserId, ChannelId, undefined, State); {UserId, _} when is_integer(UserId), UserId > 0 -> - guild_permissions:can_view_channel(UserId, ChannelId, undefined, State); + guild_permissions_check:can_view_channel(UserId, ChannelId, undefined, State); _ -> false end. @@ -144,7 +144,9 @@ session_can_view_channel_members(SessionData, ChannelId, State) -> session_user_can_view_channel_members(SessionData, ChannelId, State) -> case maps:get(user_id, SessionData, undefined) of UserId when is_integer(UserId), UserId > 0 -> - guild_permissions:can_view_channel_members(UserId, ChannelId, undefined, State); + guild_permissions_check:can_view_channel_members( + UserId, ChannelId, undefined, State + ); _ -> false end. diff --git a/fluxer_gateway/src/guild/guild_member_list_engine_inputs.erl b/fluxer_gateway/src/guild/guild_member_list_engine_inputs.erl index 134295b33..7e04cca3d 100644 --- a/fluxer_gateway/src/guild/guild_member_list_engine_inputs.erl +++ b/fluxer_gateway/src/guild/guild_member_list_engine_inputs.erl @@ -95,7 +95,10 @@ inputs(ChannelId, State) -> -spec inputs(channel_id(), guild_state(), #{term() => [term()]}) -> inputs(). inputs(ChannelId, State, AccessByChannel) -> - {guild_permissions:view_inputs(ChannelId, State), maps:get(ChannelId, AccessByChannel, [])}. + { + guild_permissions_check:view_inputs(ChannelId, State), + maps:get(ChannelId, AccessByChannel, []) + }. -spec virtual_access_by_channel(guild_state()) -> #{term() => [term()]}. virtual_access_by_channel(State) -> diff --git a/fluxer_gateway/src/guild/guild_member_list_read.erl b/fluxer_gateway/src/guild/guild_member_list_read.erl index cea8a3f9a..06839213f 100644 --- a/fluxer_gateway/src/guild/guild_member_list_read.erl +++ b/fluxer_gateway/src/guild/guild_member_list_read.erl @@ -97,7 +97,7 @@ with_member_item_memo(Fun) -> get_counts(ListId, State) -> case store_ref_for(ListId, State) of undefined -> {0, 0}; - Ref -> guild_member_list_store:get_counts(Ref) + Ref -> guild_member_list_engine:get_counts(Ref) end. -spec get_online_count(guild_state()) -> non_neg_integer(). @@ -106,7 +106,7 @@ get_online_count(State) -> undefined -> 0; Ref -> - {_Total, Online} = guild_member_list_store:get_counts(Ref), + {_Total, Online} = guild_member_list_engine:get_counts(Ref), Online end. @@ -145,8 +145,8 @@ build_normalized_sync_response_builder_for_store(GuildIdBin, ListId, undefined, } end; build_normalized_sync_response_builder_for_store(GuildIdBin, ListId, Ref, State) -> - {MemberCount, OnlineCount} = guild_member_list_store:get_counts(Ref), - StoreGroups = guild_member_list_store:get_groups(Ref), + {MemberCount, OnlineCount} = guild_member_list_engine:get_counts(Ref), + StoreGroups = guild_member_list_engine:get_groups(Ref), VisibleGroups = visible_groups(StoreGroups), ReadCtx = read_context(ListId, StoreGroups, State), fun(NormalizedRanges) -> @@ -169,7 +169,7 @@ read_context(ListId, StoreGroups, State) -> #{ list_id => ListId, hide_offline => offline_hidden(StoreGroups), - member_map => guild_data_index:member_map(Data), + member_map => guild_data_index_members:member_map(Data), member_revision => maps:get(member_list_revision, Data, undefined), presence_context => guild_member_list_connected:presence_context(State), state => State @@ -341,7 +341,7 @@ build_range_sync_op(Ref, {Start, End} = Range, StoreGroups, ReadCtx) -> read_context() ) -> [store_item()]. store_range_items_from_context(Ref, _StoreGroups, Start, End, #{hide_offline := false}) -> - guild_member_list_store:get_items(Ref, Start, End); + guild_member_list_engine:get_items(Ref, Start, End); store_range_items_from_context(Ref, StoreGroups, Start, End, #{hide_offline := true}) -> visible_range_items(Ref, StoreGroups, Start, End). @@ -361,9 +361,9 @@ snapshot(ListId, State) -> undefined -> {0, 0, [], []}; Ref -> - {Total, Online} = guild_member_list_store:get_counts(Ref), - StoreGroups = guild_member_list_store:get_groups(Ref), - StoreItems = guild_member_list_store:get_items(Ref, 0, Total * 2), + {Total, Online} = guild_member_list_engine:get_counts(Ref), + StoreGroups = guild_member_list_engine:get_groups(Ref), + StoreItems = guild_member_list_engine:get_items(Ref, 0, Total * 2), Items = hydrate_engine_items(ListId, StoreItems, StoreGroups, State), {Total, Online, visible_groups(StoreGroups), Items} end. @@ -374,7 +374,7 @@ snapshot(ListId, State) -> [list_item()]. hydrate_engine_items(ListId, StoreItems, StoreGroups, State) -> Data = maps:get(data, State, #{}), - MemberMap = guild_data_index:member_map(Data), + MemberMap = guild_data_index_members:member_map(Data), HideOffline = offline_hidden(StoreGroups), hydrate_engine_items_with_context(StoreItems, ListId, HideOffline, MemberMap, State). @@ -538,7 +538,7 @@ visible_range_items(Ref, StoreGroups, Start, End) -> VisibleEnd = visible_online_last_index(StoreGroups), case Start > VisibleEnd of true -> []; - false -> guild_member_list_store:get_items(Ref, Start, min(End, VisibleEnd)) + false -> guild_member_list_engine:get_items(Ref, Start, min(End, VisibleEnd)) end. -spec visible_online_last_index([{binary(), non_neg_integer()}]) -> integer(). @@ -622,30 +622,6 @@ with_memo_presence_tab(Fun) -> ets:delete(Tab) end. -member_item_memo_shares_items_within_scope_test() -> - with_memo_presence_tab(fun(Tab) -> - MemberMap = #{1 => memo_member(1, <<"one">>), 2 => memo_member(2, <<"two">>)}, - Ctx = memo_presence_ctx(Tab, [1, 2]), - Unscoped1 = hydrate_member_ref(1, MemberMap, Ctx), - Unscoped2 = hydrate_member_ref(1, MemberMap, Ctx), - ?assertNot(erts_debug:same(element(2, Unscoped1), element(2, Unscoped2))), - {Scoped1, Scoped2, Other} = with_member_item_memo(fun() -> - { - hydrate_member_ref(1, MemberMap, Ctx), - hydrate_member_ref(1, MemberMap, memo_presence_ctx(Tab, [1, 2])), - hydrate_member_ref(2, MemberMap, Ctx) - } - end), - ?assertEqual(Unscoped1, Scoped1), - ?assertEqual(Scoped1, Scoped2), - ?assert(erts_debug:same(element(2, Scoped1), element(2, Scoped2))), - ?assertEqual(hydrate_member_ref(2, MemberMap, Ctx), Other), - ?assertEqual( - false, with_member_item_memo(fun() -> hydrate_member_ref(3, MemberMap, Ctx) end) - ), - ?assertEqual(undefined, erlang:get(?MEMBER_ITEM_MEMO_KEY)) - end). - member_item_memo_rebuilds_changed_member_test() -> with_memo_presence_tab(fun(Tab) -> Ctx = memo_presence_ctx(Tab, [1]), @@ -796,16 +772,6 @@ range_items_by_user(Op) -> || #{<<"member">> := M} = I <- maps:get(<<"items">>, Op) ]). -range_cache_reuses_unchanged_range_test() -> - with_range_world(fun(State, _Tab, _Ref) -> - First = range_op(State), - Second = range_op(State), - ?assert(erts_debug:same(First, Second)), - ?assertEqual(uncached_range_op(State), Second), - ok = note_presence_write(77), - ?assert(erts_debug:same(First, range_op(State))) - end). - range_cache_patches_only_the_written_member_test() -> with_range_world(fun(State, Tab, _Ref) -> Before = range_items_by_user(range_op(State)), @@ -852,7 +818,7 @@ range_cache_rebuilds_on_member_change_test() -> _ = range_op(State), #{data := Data} = State, Renamed = State#{ - data => guild_data_index:put_member(memo_member(5, <<"renamed">>), Data) + data => guild_data_index_members:put_member(memo_member(5, <<"renamed">>), Data) }, ?assertEqual(uncached_range_op(Renamed), range_op(Renamed)) end). @@ -911,13 +877,15 @@ range_cache_requires_a_member_revision_test() -> range_cache_does_not_retain_unrendered_members_or_connections_test() -> with_range_world(fun(State, _Tab, _Ref) -> #{data := Data} = State, - Members = guild_data_index:member_map(Data), - SmallData = guild_data_index:put_member_map(Members, Data), + Members = guild_data_index_members:member_map(Data), + SmallData = guild_data_index_members:put_member_map(Members, Data), Small = State#{data => SmallData}, SmallOp = range_op(Small), SmallSize = erts_debug:flat_size(erlang:get(guild_member_list_sync_item_cache)), Extra = maps:from_list([{U, memo_member(U, <<"extra">>)} || U <- lists:seq(7, 5000)]), - LargeData = guild_data_index:put_member_map(maps:merge(Members, Extra), SmallData), + LargeData = guild_data_index_members:put_member_map( + maps:merge(Members, Extra), SmallData + ), Large = Small#{ data => LargeData, connected_user_ids => sets:from_list([1, 2, 3, 4] ++ lists:seq(7, 5000)) diff --git a/fluxer_gateway/src/guild/guild_member_list_read_cursor.erl b/fluxer_gateway/src/guild/guild_member_list_read_cursor.erl index 5f5076e66..12c243e98 100644 --- a/fluxer_gateway/src/guild/guild_member_list_read_cursor.erl +++ b/fluxer_gateway/src/guild/guild_member_list_read_cursor.erl @@ -19,11 +19,11 @@ get_members_cursor(Request, State) -> {reply, map(), guild_state()}. members_cursor(Limit, _AfterId, State) when Limit =< 0 -> Data = maps:get(data, State, #{}), - MemberMap = guild_data_index:member_map(Data), + MemberMap = guild_data_index_members:member_map(Data), {reply, #{members => [], total => map_size(MemberMap)}, State}; members_cursor(Limit, AfterId, State) -> Data = maps:get(data, State, #{}), - MemberMap = guild_data_index:member_map(Data), + MemberMap = guild_data_index_members:member_map(Data), Total = map_size(MemberMap), SortedIds = guild_data_index_members:sorted_member_ids(Data, MemberMap), FilteredIds = filter_ids_after(SortedIds, AfterId), diff --git a/fluxer_gateway/src/guild/guild_member_list_store.erl b/fluxer_gateway/src/guild/guild_member_list_store.erl index cf96f89bb..cdd1087ef 100644 --- a/fluxer_gateway/src/guild/guild_member_list_store.erl +++ b/fluxer_gateway/src/guild/guild_member_list_store.erl @@ -3,20 +3,7 @@ -module(guild_member_list_store). -typing([eqwalizer]). --export([ - new/1, - bulk_load/3, - update_member/5, - remove_member/2, - set_online/3, - set_hoisted_roles/2, - get_counts/1, - get_groups/1, - get_items/3, - prepare_member_tuples/2, - prepare_hoisted_role_ids/2, - extract_role_ids/1 -]). +-export([prepare_member_tuples/2, prepare_hoisted_role_ids/2, extract_role_ids/1]). -type store_ref() :: ets:table(). -type member_tuple() :: {integer(), binary(), [integer()], boolean()}. @@ -24,43 +11,6 @@ -export_type([store_ref/0, member_tuple/0, guild_state/0]). --spec new(integer()) -> store_ref(). -new(_GuildId) -> - guild_member_list_engine:new(). - --spec bulk_load(store_ref(), [member_tuple()], [integer()]) -> ok. -bulk_load(Ref, Members, HoistedRoleIds) -> - guild_member_list_engine:bulk_load(Ref, Members, HoistedRoleIds). - --spec update_member(store_ref(), integer(), binary(), [integer()], boolean()) -> ok. -update_member(Ref, UserId, SortKey, RoleIds, IsOnline) -> - guild_member_list_engine:update_member(Ref, UserId, SortKey, RoleIds, IsOnline). - --spec remove_member(store_ref(), integer()) -> ok. -remove_member(Ref, UserId) -> - guild_member_list_engine:remove_member(Ref, UserId). - --spec set_online(store_ref(), integer(), boolean()) -> ok. -set_online(Ref, UserId, IsOnline) -> - guild_member_list_engine:set_online(Ref, UserId, IsOnline). - --spec set_hoisted_roles(store_ref(), [integer()]) -> changed | unchanged. -set_hoisted_roles(Ref, HoistedRoleIds) -> - guild_member_list_engine:set_hoisted_roles(Ref, HoistedRoleIds). - --spec get_counts(store_ref()) -> {non_neg_integer(), non_neg_integer()}. -get_counts(Ref) -> - guild_member_list_engine:get_counts(Ref). - --spec get_groups(store_ref()) -> [{binary(), non_neg_integer()}]. -get_groups(Ref) -> - guild_member_list_engine:get_groups(Ref). - --spec get_items(store_ref(), non_neg_integer(), non_neg_integer()) -> - [{group, binary(), non_neg_integer()} | {member, integer()}]. -get_items(Ref, Start, End) -> - guild_member_list_engine:get_items(Ref, Start, End). - -spec prepare_member_tuples(map(), guild_state()) -> [member_tuple()]. prepare_member_tuples(MemberMap, State) -> ConnectedUserIds = guild_member_list_common:connected_session_user_ids(State), diff --git a/fluxer_gateway/src/guild/guild_member_list_subs.erl b/fluxer_gateway/src/guild/guild_member_list_subs.erl index 596e1a5e3..58165b4ef 100644 --- a/fluxer_gateway/src/guild/guild_member_list_subs.erl +++ b/fluxer_gateway/src/guild/guild_member_list_subs.erl @@ -1,12 +1,12 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_member_list_subs). --typing([eqwalizer]). - -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -endif. +-typing([eqwalizer]). + -export([ new/0, destroy/1, @@ -211,25 +211,6 @@ remove_list_from_session_index(IdxTab, SessionId, ListId, Ids) -> -ifdef(TEST). -retain_only_session_list_drops_other_lists_test() -> - Tab = new(), - subscribe(<<"s1">>, <<"100">>, [{0, 99}], Tab), - subscribe(<<"s1">>, <<"200">>, [{0, 99}], Tab), - subscribe(<<"s1">>, <<"300">>, [{0, 99}], Tab), - Removed = retain_only_session_list(<<"s1">>, <<"300">>, Tab), - ?assertEqual([<<"100">>, <<"200">>], lists:sort(Removed)), - ?assert(is_subscribed(<<"300">>, <<"s1">>, Tab)), - ?assertNot(is_subscribed(<<"100">>, <<"s1">>, Tab)), - ?assertNot(is_subscribed(<<"200">>, <<"s1">>, Tab)), - destroy(Tab). - -retain_only_session_list_noop_when_single_list_test() -> - Tab = new(), - subscribe(<<"s1">>, <<"100">>, [{0, 99}], Tab), - ?assertEqual([], retain_only_session_list(<<"s1">>, <<"100">>, Tab)), - ?assert(is_subscribed(<<"100">>, <<"s1">>, Tab)), - destroy(Tab). - retain_only_session_list_leaves_other_sessions_untouched_test() -> Tab = new(), subscribe(<<"s1">>, <<"100">>, [{0, 99}], Tab), diff --git a/fluxer_gateway/src/guild/guild_member_list_subscribe.erl b/fluxer_gateway/src/guild/guild_member_list_subscribe.erl index 2dd8320a4..dcff29c1a 100644 --- a/fluxer_gateway/src/guild/guild_member_list_subscribe.erl +++ b/fluxer_gateway/src/guild/guild_member_list_subscribe.erl @@ -348,7 +348,7 @@ sync_item_cache_enabled() -> session_can_view_list_members(SessionData, undefined, State) -> case maps:get(user_id, SessionData, undefined) of UserId when is_integer(UserId), UserId > 0 -> - guild_permissions:find_member_by_user_id(UserId, State) =/= undefined; + guild_permissions_check:find_member_by_user_id(UserId, State) =/= undefined; _ -> false end; @@ -455,24 +455,6 @@ sync_item_cache_released_after_two_idle_generations_test() -> end) end). -sync_item_cache_keeps_entries_touched_each_generation_test() -> - with_clean_cache(fun() -> - Payload = sync_payload(<<"500">>, [{0, 99}], [test_member(N) || N <- lists:seq(1, 8)]), - assert_matches_uncached(Payload), - rotate_now(), - ?assertEqual(#{}, current_entries()), - ?assertEqual(8, map_size(previous_entries())), - assert_matches_uncached(Payload), - ?assertEqual(8, map_size(current_entries())), - ?assertEqual(1, map_size(payload_entries())), - rotate_now(), - ?assertEqual(8, map_size(previous_entries())), - rotate_now(), - ?assertEqual(undefined, erlang:get(?SYNC_ITEM_CACHE_KEY)), - assert_matches_uncached(Payload), - ?assertEqual(8, map_size(current_entries())) - end). - sync_item_cache_previous_generation_changed_member_reencodes_test() -> with_clean_cache(fun() -> Members = [test_member(N) || N <- lists:seq(1, 4)], @@ -507,25 +489,6 @@ dispatch_sync_group_sends_uncached_bytes_test() -> ?assertEqual([Expected, Expected], received_dispatches()) end). -sync_payload_equal_content_reuses_encoded_bytes_test() -> - with_clean_cache(fun() -> - Members = [test_member(N) || N <- lists:seq(1, 30)], - Payload = sync_payload(<<"500">>, [{0, 99}], Members), - assert_matches_uncached(Payload), - [Key] = maps:keys(payload_entries()), - Sentinel = {pre_encoded, <<"sentinel">>}, - {TimerRef, Current, Previous} = erlang:get(?SYNC_ITEM_CACHE_KEY), - _ = erlang:put( - ?SYNC_ITEM_CACHE_KEY, {TimerRef, Current#{Key => {Payload, Sentinel}}, Previous} - ), - Copy = binary_to_term(term_to_binary(Payload)), - ?assertEqual(Sentinel, encode_sync_payload(Copy)), - [First | Rest] = Members, - Afk = put_in(First, [<<"member">>, <<"presence">>, <<"afk">>], true), - assert_matches_uncached(sync_payload(<<"500">>, [{0, 99}], [Afk | Rest])), - ?assertEqual([Key], maps:keys(payload_entries())) - end). - sync_payload_cache_is_keyed_by_list_and_ranges_test() -> with_clean_cache(fun() -> Members = [test_member(N) || N <- lists:seq(1, 40)], diff --git a/fluxer_gateway/src/guild/guild_member_list_write.erl b/fluxer_gateway/src/guild/guild_member_list_write.erl index 52d44a28c..b90b17ada 100644 --- a/fluxer_gateway/src/guild/guild_member_list_write.erl +++ b/fluxer_gateway/src/guild/guild_member_list_write.erl @@ -133,7 +133,7 @@ member_list_presence_fields(Presence) -> -spec find_member_in_state_data(user_id(), guild_state()) -> map() | undefined. find_member_in_state_data(UserId, State) -> - guild_data_index:get_member(UserId, maps:get(data, State, #{})). + guild_data_index_members:get_member(UserId, maps:get(data, State, #{})). -spec broadcast_all_member_list_updates(guild_state()) -> {ok, guild_state()}. broadcast_all_member_list_updates(State) -> diff --git a/fluxer_gateway/src/guild/guild_members.erl b/fluxer_gateway/src/guild/guild_members.erl deleted file mode 100644 index 239a8027a..000000000 --- a/fluxer_gateway/src/guild/guild_members.erl +++ /dev/null @@ -1,65 +0,0 @@ -%% SPDX-License-Identifier: AGPL-3.0-or-later - --module(guild_members). --typing([eqwalizer]). - --export([ - get_users_to_mention_by_roles/2, - get_users_to_mention_by_user_ids/2, - get_all_users_to_mention/2, - resolve_all_mentions/2, - resolve_mention_sources/2, - resolve_mention_sources_page/2, - resolve_channel_mentions/2, - get_members_with_role/2, - can_manage_roles/2, - can_manage_role/2, - get_assignable_roles/2, - check_target_member/2, - get_viewable_channels/2 -]). - --export_type([guild_state/0, guild_reply/1]). - --type guild_state() :: map(). --type guild_reply(T) :: {reply, T, guild_state()}. - --spec get_users_to_mention_by_roles(map(), guild_state()) -> guild_reply(map()). -get_users_to_mention_by_roles(R, S) -> guild_members_query:get_users_to_mention_by_roles(R, S). - --spec get_users_to_mention_by_user_ids(map(), guild_state()) -> guild_reply(map()). -get_users_to_mention_by_user_ids(R, S) -> - guild_members_query:get_users_to_mention_by_user_ids(R, S). - --spec get_all_users_to_mention(map(), guild_state()) -> guild_reply(map()). -get_all_users_to_mention(R, S) -> guild_members_query:get_all_users_to_mention(R, S). - --spec resolve_all_mentions(map(), guild_state()) -> guild_reply(map()). -resolve_all_mentions(R, S) -> guild_members_mutation:resolve_all_mentions(R, S). - --spec resolve_mention_sources(map(), guild_state()) -> guild_reply(map()). -resolve_mention_sources(R, S) -> guild_members_mutation:resolve_mention_sources(R, S). - --spec resolve_mention_sources_page(map(), guild_state()) -> guild_reply(map()). -resolve_mention_sources_page(R, S) -> guild_members_mutation:resolve_mention_sources_page(R, S). - --spec resolve_channel_mentions(map(), guild_state()) -> guild_reply(map()). -resolve_channel_mentions(R, S) -> guild_members_mutation:resolve_channel_mentions(R, S). - --spec get_members_with_role(map(), guild_state()) -> guild_reply(map()). -get_members_with_role(R, S) -> guild_members_query:get_members_with_role(R, S). - --spec can_manage_roles(map(), guild_state()) -> guild_reply(map()). -can_manage_roles(R, S) -> guild_members_roles:can_manage_roles(R, S). - --spec can_manage_role(map(), guild_state()) -> guild_reply(map()). -can_manage_role(R, S) -> guild_members_roles:can_manage_role(R, S). - --spec get_assignable_roles(map(), guild_state()) -> guild_reply(map()). -get_assignable_roles(R, S) -> guild_members_roles:get_assignable_roles(R, S). - --spec check_target_member(map(), guild_state()) -> guild_reply(map()). -check_target_member(R, S) -> guild_members_roles:check_target_member(R, S). - --spec get_viewable_channels(map(), guild_state()) -> guild_reply(map()). -get_viewable_channels(R, S) -> guild_members_query:get_viewable_channels(R, S). diff --git a/fluxer_gateway/src/guild/guild_members_common.erl b/fluxer_gateway/src/guild/guild_members_common.erl index 5f36031f9..19ebcef55 100644 --- a/fluxer_gateway/src/guild/guild_members_common.erl +++ b/fluxer_gateway/src/guild/guild_members_common.erl @@ -35,13 +35,13 @@ guild_data(State) -> map_utils:ensure_map(map_utils:get_safe(State, data, #{})). -spec guild_members(guild_state()) -> [member()]. -guild_members(State) -> guild_data_index:member_values(guild_data(State)). +guild_members(State) -> guild_data_index_members:member_values(guild_data(State)). -spec guild_roles(guild_state()) -> [map()]. -guild_roles(State) -> guild_data_index:role_list(guild_data(State)). +guild_roles(State) -> guild_data_index_roles:role_list(guild_data(State)). -spec guild_channels(guild_state()) -> [map()]. -guild_channels(State) -> guild_data_index:channel_list(guild_data(State)). +guild_channels(State) -> guild_data_index_channels:channel_list(guild_data(State)). -spec owner_id(guild_state()) -> user_id() | undefined. owner_id(State) -> @@ -68,7 +68,7 @@ is_member_bot(Member) -> -spec member_can_view_channel(user_id(), channel_id(), member(), guild_state()) -> boolean(). member_can_view_channel(UserId, ChannelId, Member, State) when is_integer(ChannelId) -> - guild_permissions:can_view_channel(UserId, ChannelId, Member, State); + guild_permissions_check:can_view_channel(UserId, ChannelId, Member, State); member_can_view_channel(_, _, _, _) -> false. @@ -111,7 +111,7 @@ role_id_from_role(Role) -> [user_id()], user_id(), channel_id(), guild_state(), fun((user_id(), member()) -> boolean()) ) -> [user_id()]. collect_mentions_for_user_ids(UserIds, AuthorId, ChannelId, State, Predicate) -> - MemberMap = guild_data_index:member_map(guild_data(State)), + MemberMap = guild_data_index_members:member_map(guild_data(State)), lists:filtermap( fun(UserId) -> collect_mention_for_user_id( diff --git a/fluxer_gateway/src/guild/guild_members_mutation.erl b/fluxer_gateway/src/guild/guild_members_mutation.erl index 4efa7713e..51ac68104 100644 --- a/fluxer_gateway/src/guild/guild_members_mutation.erl +++ b/fluxer_gateway/src/guild/guild_members_mutation.erl @@ -3,97 +3,21 @@ -module(guild_members_mutation). -typing([eqwalizer]). --export([ - resolve_all_mentions/2, - resolve_mention_sources/2, - resolve_mention_sources_page/2, - resolve_channel_mentions/2 -]). +-export([resolve_mention_sources_page/2, resolve_channel_mentions/2]). -export_type([guild_state/0, guild_reply/1]). -type guild_state() :: map(). -type guild_reply(T) :: {reply, T, guild_state()}. --type member() :: map(). -type user_id() :: integer(). -type channel_id() :: integer(). -type role_id() :: integer(). --spec resolve_all_mentions(map(), guild_state()) -> guild_reply(map()). -resolve_all_mentions(Request, State) -> - #{mention_everyone := MentionEveryone} = Request, - Members = guild_members_common:guild_members(State), - MemberMap = guild_data_index:member_map(guild_members_common:guild_data(State)), - Context = request_mention_context(Request, State), - UserIds = - case MentionEveryone of - true -> - resolve_mentions(thread_universe_members(Members, Context), Context); - false -> - CandidateUserIds = candidate_user_ids(Context), - resolve_mentions_for_ids(CandidateUserIds, MemberMap, Context) - end, - {reply, #{user_ids => UserIds}, State}. - --spec resolve_mention_sources(map(), guild_state()) -> guild_reply(map()). -resolve_mention_sources(Request, State) -> - #{ - channel_id := ChannelId, - author_id := AuthorId, - mention_everyone := MentionEveryone, - mention_here := MentionHere, - role_ids := RoleIds, - user_ids := DirectUserIds - } = Request, - RoleUserIds = collect_role_user_ids(RoleIds, AuthorId, ChannelId, State), - DirectMentionedUserIds = collect_direct_user_ids(DirectUserIds, AuthorId, ChannelId, State), - EveryoneUserIds = - case MentionEveryone orelse MentionHere of - true -> - MassMentionRequest = Request#{role_ids => [], user_ids => []}, - {reply, #{user_ids := MassUserIds}, _} = - resolve_all_mentions(MassMentionRequest, State), - MassUserIds; - false -> - [] - end, - {reply, - #{ - direct_user_ids => DirectMentionedUserIds, - role_user_ids => RoleUserIds, - everyone_user_ids => EveryoneUserIds - }, - State}. - --spec collect_role_user_ids(term(), user_id(), channel_id(), guild_state()) -> [user_id()]. -collect_role_user_ids(RoleIds, AuthorId, ChannelId, State) -> - case guild_members_common:normalize_int_list(RoleIds) of - [] -> - []; - RoleIdList -> - Candidates = guild_members_roles:user_ids_for_any_role(RoleIdList, State), - collect_user_mentions(Candidates, AuthorId, ChannelId, State) - end. - --spec collect_direct_user_ids(term(), user_id(), channel_id(), guild_state()) -> [user_id()]. -collect_direct_user_ids(DirectUserIds, AuthorId, ChannelId, State) -> - case guild_members_common:normalize_int_list(DirectUserIds) of - [] -> []; - TargetIds -> collect_user_mentions(TargetIds, AuthorId, ChannelId, State) - end. - --spec collect_user_mentions([user_id()], user_id(), channel_id(), guild_state()) -> [user_id()]. -collect_user_mentions(UserIds, AuthorId, ChannelId, State) -> - ThreadRecipient = guild_thread_gate:recipient_active(ChannelId, State), - guild_members_common:collect_mentions_for_user_ids( - UserIds, AuthorId, ChannelId, State, fun(UserId, _Member) -> ThreadRecipient(UserId) end - ). - -spec resolve_mention_sources_page(map(), guild_state()) -> guild_reply(map()). resolve_mention_sources_page(Request, State) -> #{limit := Limit, cursor := Cursor} = Request, Context = request_mention_context(Request, State), - MemberMap = guild_data_index:member_map(guild_members_common:guild_data(State)), + MemberMap = guild_data_index_members:member_map(guild_members_common:guild_data(State)), CandidateUserIds = paged_candidates(Context, MemberMap), CandidatesAfterCursor = drop_candidates_after_cursor(Cursor, CandidateUserIds), {Entries, NextCursor} = guild_members_search:take_mention_source_page( @@ -156,16 +80,6 @@ build_mention_context( state => State }. --spec thread_universe_members([member()], map()) -> [member()]. -thread_universe_members(Members, #{thread_universe := Universe}) when is_map(Universe) -> - [ - Member - || Member <- Members, - maps:is_key(guild_members_common:member_user_id(Member), Universe) - ]; -thread_universe_members(Members, _Context) -> - Members. - -spec thread_here_set(gb_sets:set(user_id()), map()) -> gb_sets:set(user_id()). thread_here_set(HereSet, #{thread_universe := Universe}) when is_map(Universe) -> gb_sets:filter(fun(UserId) -> maps:is_key(UserId, Universe) end, HereSet); @@ -191,66 +105,6 @@ thread_recipient_set(UserIdSet, #{thread_universe := Universe} = Context) when thread_recipient_set(UserIdSet, _Context) -> UserIdSet. --spec resolve_mentions([member()], map()) -> [user_id()]. -resolve_mentions(Members, Context) -> - #{author_id := AuthorId} = Context, - lists:filtermap( - fun(Member) -> - resolve_member_mention(Member, AuthorId, Context) - end, - Members - ). - --spec resolve_member_mention(member(), user_id(), map()) -> {true, user_id()} | false. -resolve_member_mention(Member, AuthorId, Context) -> - case guild_members_common:member_user_id(Member) of - undefined -> false; - AuthorId -> false; - UserId -> check_member_mention(UserId, Member, Context) - end. - --spec check_member_mention(user_id(), member(), map()) -> {true, user_id()} | false. -check_member_mention(UserId, Member, Context) -> - case guild_members_common:is_member_bot(Member) of - true -> false; - false -> maybe_visible_member_mention(UserId, Member, Context) - end. - --spec maybe_visible_member_mention(user_id(), member(), map()) -> {true, user_id()} | false. -maybe_visible_member_mention(UserId, Member, Context) -> - #{channel_id := ChannelId, state := State} = Context, - ShouldMention = guild_members_common:check_should_mention(UserId, Member, Context), - CanView = guild_members_common:member_can_view_channel(UserId, ChannelId, Member, State), - case ShouldMention andalso CanView of - true -> {true, UserId}; - false -> false - end. - --spec resolve_mentions_for_ids([user_id()], #{user_id() => member()}, map()) -> [user_id()]. -resolve_mentions_for_ids(CandidateUserIds, MemberMap, Context) -> - #{author_id := AuthorId} = Context, - lists:filtermap( - fun(UserId) -> - resolve_candidate_mention(UserId, AuthorId, MemberMap, Context) - end, - lists:usort(CandidateUserIds) - ). - --spec resolve_candidate_mention(user_id(), user_id(), #{user_id() => member()}, map()) -> - {true, user_id()} | false. -resolve_candidate_mention(UserId, UserId, _MemberMap, _Context) -> - false; -resolve_candidate_mention(UserId, _AuthorId, MemberMap, Context) -> - check_candidate_mention(UserId, MemberMap, Context). - --spec check_candidate_mention(user_id(), #{user_id() => member()}, map()) -> - {true, user_id()} | false. -check_candidate_mention(UserId, MemberMap, Context) -> - case maps:get(UserId, MemberMap, undefined) of - undefined -> false; - Member -> check_member_mention(UserId, Member, Context) - end. - -spec candidate_user_ids(map()) -> [user_id()]. candidate_user_ids(Context) -> #{ @@ -300,12 +154,12 @@ paged_candidates(Context, _MemberMap) -> -spec everyone_can_view(map(), role_id(), integer()) -> boolean(). everyone_can_view(Channel, GuildId, BasePerms) -> - case permission_bits:has(BasePerms, constants:administrator_permission()) of + case bitset:has(BasePerms, constants:administrator_permission()) of true -> true; false -> Perms = apply_everyone_overwrites(BasePerms, Channel, GuildId), - permission_bits:has(Perms, constants:view_channel_permission()) + bitset:has(Perms, constants:view_channel_permission()) end. -spec build_channel_mention(map()) -> {true, map()} | false. @@ -322,9 +176,9 @@ build_channel_mention(Channel) -> -spec resolve_channel_mentions_for_guild(term(), role_id(), map()) -> [map()]. resolve_channel_mentions_for_guild(ChannelIds, GuildId, Data) -> - Roles = guild_data_index:role_index(Data), + Roles = guild_data_index_roles:role_index(Data), BasePerms = role_permissions_for_id(Roles, GuildId), - ChannelIndex = guild_data_index:channel_index(Data), + ChannelIndex = guild_data_index_channels:channel_index(Data), lists:filtermap( fun(ChannelId) -> maybe_channel_mention(ChannelId, ChannelIndex, GuildId, BasePerms) @@ -360,9 +214,9 @@ maybe_channel_mention_for_channel(Channel, GuildId, BasePerms) -> -spec role_permissions_for_id(map(), role_id()) -> integer(). role_permissions_for_id(Roles, GuildId) -> - case guild_permissions:find_role_by_id(GuildId, Roles) of + case guild_permissions_check:find_role_by_id(GuildId, Roles) of undefined -> 0; - Role -> permission_bits:parse(maps:get(<<"permissions">>, Role, undefined)) + Role -> bitset:parse(maps:get(<<"permissions">>, Role, undefined)) end. -spec apply_everyone_overwrites(integer(), map(), role_id()) -> integer(). @@ -385,7 +239,7 @@ apply_everyone_overwrite(Overwrite, GuildId, Acc) when is_map(Overwrite) -> } of {GuildId, 0, Allow, Deny} when is_integer(Allow), is_integer(Deny) -> - permission_bits:apply_allow_deny(Acc, Allow, Deny); + bitset:apply_allow_deny(Acc, Allow, Deny); _ -> Acc end; @@ -411,8 +265,8 @@ overwrite_type(Overwrite) -> -spec overwrite_allow(map()) -> integer() | undefined. overwrite_allow(Overwrite) -> - permission_bits:parse_optional(maps:get(<<"allow">>, Overwrite, undefined)). + bitset:parse_optional(maps:get(<<"allow">>, Overwrite, undefined)). -spec overwrite_deny(map()) -> integer() | undefined. overwrite_deny(Overwrite) -> - permission_bits:parse_optional(maps:get(<<"deny">>, Overwrite, undefined)). + bitset:parse_optional(maps:get(<<"deny">>, Overwrite, undefined)). diff --git a/fluxer_gateway/src/guild/guild_members_query.erl b/fluxer_gateway/src/guild/guild_members_query.erl index 1ae87b724..4be1f177b 100644 --- a/fluxer_gateway/src/guild/guild_members_query.erl +++ b/fluxer_gateway/src/guild/guild_members_query.erl @@ -3,54 +3,17 @@ -module(guild_members_query). -typing([eqwalizer]). --export([ - get_users_to_mention_by_roles/2, - get_users_to_mention_by_user_ids/2, - get_all_users_to_mention/2, - get_members_with_role/2, - get_viewable_channels/2 -]). +-export([get_members_with_role/2, get_viewable_channels/2]). -export_type([guild_state/0, guild_reply/1]). -type guild_state() :: map(). -type guild_reply(T) :: {reply, T, guild_state()}. --spec get_users_to_mention_by_roles(map(), guild_state()) -> guild_reply(map()). -get_users_to_mention_by_roles( - #{channel_id := ChannelId, role_ids := RoleIds, author_id := AuthorId}, State -) -> - RoleIdList = guild_members_common:normalize_int_list(RoleIds), - CandidateUserIds = guild_members_roles:user_ids_for_any_role(RoleIdList, State), - UserIds = collect_user_mentions(CandidateUserIds, AuthorId, ChannelId, State), - {reply, #{user_ids => UserIds}, State}. - --spec get_users_to_mention_by_user_ids(map(), guild_state()) -> guild_reply(map()). -get_users_to_mention_by_user_ids( - #{channel_id := ChannelId, user_ids := UserIdsReq, author_id := AuthorId}, State -) -> - TargetIds = guild_members_common:normalize_int_list(UserIdsReq), - UserIds = collect_user_mentions(TargetIds, AuthorId, ChannelId, State), - {reply, #{user_ids => UserIds}, State}. - --spec get_all_users_to_mention(map(), guild_state()) -> guild_reply(map()). -get_all_users_to_mention(#{channel_id := ChannelId, author_id := AuthorId}, State) -> - Members = guild_members_common:guild_members(State), - UserIds = guild_members_common:collect_mentions( - Members, AuthorId, ChannelId, State, fun(_) -> true end - ), - {reply, #{user_ids => UserIds}, State}. - --spec collect_user_mentions([integer()], integer(), integer(), guild_state()) -> [integer()]. -collect_user_mentions(UserIds, AuthorId, ChannelId, State) -> - guild_members_common:collect_mentions_for_user_ids( - UserIds, AuthorId, ChannelId, State, fun(_UserId, _Member) -> true end - ). - -spec get_members_with_role(map(), guild_state()) -> guild_reply(map()). get_members_with_role(#{role_id := RoleId}, State) -> Data = guild_members_common:guild_data(State), - MemberRoleIndex = guild_data_index:member_role_index(Data), + MemberRoleIndex = guild_data_index_members:member_role_index(Data), UserMap = user_map_for_role(RoleId, MemberRoleIndex), {reply, #{user_ids => lists:sort(maps:keys(UserMap))}, State}. @@ -64,7 +27,7 @@ user_map_for_role(RoleId, MemberRoleIndex) -> -spec get_viewable_channels(map(), guild_state()) -> guild_reply(map()). get_viewable_channels(#{user_id := UserId}, State) -> Channels = guild_members_common:guild_channels(State), - case guild_permissions:find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> {reply, #{channel_ids => []}, State}; Member -> @@ -97,7 +60,7 @@ channel_id(Channel) -> -spec viewable_channel_id(integer(), integer(), map(), guild_state()) -> false | {true, integer()}. viewable_channel_id(UserId, ChannelId, Member, State) -> - case guild_permissions:can_view_channel(UserId, ChannelId, Member, State) of + case guild_permissions_check:can_view_channel(UserId, ChannelId, Member, State) of true -> {true, ChannelId}; false -> false end. diff --git a/fluxer_gateway/src/guild/guild_members_roles.erl b/fluxer_gateway/src/guild/guild_members_roles.erl index 49032572b..635a56574 100644 --- a/fluxer_gateway/src/guild/guild_members_roles.erl +++ b/fluxer_gateway/src/guild/guild_members_roles.erl @@ -23,7 +23,7 @@ -spec user_ids_for_any_role([role_id()], guild_state()) -> [user_id()]. user_ids_for_any_role(RoleIds, State) -> Data = guild_members_common:guild_data(State), - MemberRoleIndex = guild_data_index:member_role_index(Data), + MemberRoleIndex = guild_data_index_members:member_role_index(Data), gb_sets:to_list( lists:foldl( fun(RoleId, AccSet) -> @@ -58,13 +58,13 @@ can_manage_roles(#{user_id := UserId, role_id := RoleId}, State) -> -spec can_manage_role(map(), guild_state()) -> guild_reply(map()). can_manage_role(#{user_id := UserId, role_id := RoleId}, State) -> Data = guild_members_common:guild_data(State), - Roles = guild_data_index:role_index(Data), + Roles = guild_data_index_roles:role_index(Data), Reply = - case guild_permissions:find_role_by_id(RoleId, Roles) of + case guild_permissions_check:find_role_by_id(RoleId, Roles) of undefined -> false; Role -> - UserMax = guild_permissions:get_max_role_position(UserId, State), + UserMax = guild_permissions_check:get_max_role_position(UserId, State), RolePos = maps:get(<<"position">>, Role, 0), can_rank_manage_role(UserMax, RolePos, UserId, RoleId, State) end, @@ -111,25 +111,25 @@ check_can_manage(UserId, _RoleId, UserId, _State) -> true; check_can_manage(UserId, RoleId, _OwnerId, State) -> UserPerms = guild_permissions:get_member_permissions(UserId, undefined, State), - case permission_bits:has(UserPerms, constants:manage_roles_permission()) of + case bitset:has(UserPerms, constants:manage_roles_permission()) of false -> false; true -> Data = guild_members_common:guild_data(State), - Roles = guild_data_index:role_index(Data), + Roles = guild_data_index_roles:role_index(Data), check_role_position(UserId, RoleId, Roles, State) end. -spec check_role_position(user_id(), role_id(), map(), guild_state()) -> boolean(). check_role_position(UserId, RoleId, Roles, State) -> - case guild_permissions:find_role_by_id(RoleId, Roles) of + case guild_permissions_check:find_role_by_id(RoleId, Roles) of undefined -> false; Role -> check_role_position_for_role(UserId, Role, State) end. -spec check_role_position_for_role(user_id(), role(), guild_state()) -> boolean(). check_role_position_for_role(UserId, Role, State) -> - UserMax = guild_permissions:get_max_role_position(UserId, State), + UserMax = guild_permissions_check:get_max_role_position(UserId, State), RolePos = maps:get(<<"position">>, Role, 0), case role_id(Role) of undefined -> false; @@ -144,13 +144,13 @@ can_rank_manage_role(UserMax, RolePos, UserId, RoleId, State) -> -spec compare_role_ids_eq_pos(user_id(), role_id(), guild_state()) -> boolean(). compare_role_ids_eq_pos(UserId, TargetRoleId, State) -> - case guild_permissions:find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> false; Member -> MemberRoles = guild_members_common:member_roles(Member), Data = guild_members_common:guild_data(State), - Roles = guild_data_index:role_index(Data), + Roles = guild_data_index_roles:role_index(Data), highest_role_below(get_highest_role(MemberRoles, Roles), TargetRoleId) end. @@ -175,7 +175,7 @@ get_highest_role(MemberRoleIds, Roles) -> -spec maybe_highest_role(role_id(), [role()] | map(), role() | undefined) -> role() | undefined. maybe_highest_role(RoleId, Roles, Acc) -> - case guild_permissions:find_role_by_id(RoleId, Roles) of + case guild_permissions_check:find_role_by_id(RoleId, Roles) of undefined -> Acc; Role -> compare_roles(Role, Acc) end. @@ -209,7 +209,7 @@ check_can_manage_target(UserId, TargetUserId, _OwnerId, State) -> -spec get_member_role_rank(user_id(), guild_state()) -> {integer(), integer()}. get_member_role_rank(UserId, State) -> - case guild_permissions:find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> {-1, 0}; Member -> member_role_rank(Member, State) end. @@ -217,7 +217,7 @@ get_member_role_rank(UserId, State) -> -spec member_role_rank(map(), guild_state()) -> {integer(), integer()}. member_role_rank(Member, State) -> Data = guild_members_common:guild_data(State), - Roles = guild_data_index:role_index(Data), + Roles = guild_data_index_roles:role_index(Data), case get_highest_role(guild_members_common:member_roles(Member), Roles) of undefined -> {-1, 0}; HighestRole -> role_rank(HighestRole) diff --git a/fluxer_gateway/src/guild/guild_mutual_online.erl b/fluxer_gateway/src/guild/guild_mutual_online.erl index 4193fe5d3..633f459e4 100644 --- a/fluxer_gateway/src/guild/guild_mutual_online.erl +++ b/fluxer_gateway/src/guild/guild_mutual_online.erl @@ -1,14 +1,14 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_mutual_online). --typing([eqwalizer]). - --export([compute_count/2]). - -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -endif. +-typing([eqwalizer]). + +-export([compute_count/2]). + -export_type([user_id/0, guild_state/0]). -type user_id() :: integer(). @@ -21,7 +21,7 @@ compute_count(UserId, State) when is_integer(UserId), UserId > 0 -> case viewer_sees_everything(UserId, State) of true -> - guild_member_list:get_online_count(State); + guild_member_list_read:get_online_count(State); false -> slow_count(UserId, State) end; @@ -31,11 +31,11 @@ compute_count(_, _) -> -spec viewer_sees_everything(user_id(), guild_state()) -> boolean(). viewer_sees_everything(UserId, State) -> Perms = guild_permissions:get_member_permissions(UserId, undefined, State), - permission_bits:has(Perms, constants:administrator_permission()). + bitset:has(Perms, constants:administrator_permission()). -spec slow_count(user_id(), guild_state()) -> non_neg_integer(). slow_count(UserId, State) -> - ViewerSet = guild_visibility:viewable_channel_set(UserId, State), + ViewerSet = guild_visibility_channels:viewable_channel_set(UserId, State), case sets:is_empty(ViewerSet) of true -> self_online_count(UserId, State); @@ -115,7 +115,7 @@ viewer_channel_map(ViewerSet, State) -> false -> sets:fold(fun(ChannelId, Acc) -> Acc#{ChannelId => true} end, #{}, ViewerSet); true -> - Index = guild_data_index:channel_index(maps:get(data, State, #{})), + Index = guild_data_index_channels:channel_index(maps:get(data, State, #{})), sets:fold( fun(ChannelId, Acc) -> put_shared(ChannelId, Index, Acc) end, #{}, ViewerSet ) @@ -373,7 +373,7 @@ disconnected_online_row_is_not_counted_test() -> State = Base#{connected_user_ids => sets:from_list([10, 20, 40])}, ?assertNot(guild_member_list_connected:user_is_online(30, State)), ?assertEqual(1, compute_count(10, State)), - ViewerSet = guild_visibility:viewable_channel_set(10, State), + ViewerSet = guild_visibility_channels:viewable_channel_set(10, State), ?assertEqual( reference_count_mutually_visible(10, ViewerSet, State), compute_count(10, State) ). @@ -388,7 +388,7 @@ counts_only_members_the_member_list_shows_online_test() -> lists:foreach( fun(Connected) -> State = Base#{connected_user_ids => sets:from_list(Connected)}, - ViewerSet = guild_visibility:viewable_channel_set(10, State), + ViewerSet = guild_visibility_channels:viewable_channel_set(10, State), Expected = length([ U || U <- [10, 20, 30, 40], @@ -396,7 +396,7 @@ counts_only_members_the_member_list_shows_online_test() -> U =:= 10 orelse not sets:is_empty( sets:intersection( - ViewerSet, guild_visibility:viewable_channel_set(U, State) + ViewerSet, guild_visibility_channels:viewable_channel_set(U, State) ) ) ]), @@ -443,7 +443,7 @@ reference_count_online_member(_UserId, _ViewerSet, _State, _OtherUserId, _Presen user_id(), sets:set(), guild_state(), non_neg_integer() ) -> non_neg_integer(). reference_count_if_mutually_visible(OtherUserId, ViewerSet, State, Acc) -> - OtherSet = guild_visibility:viewable_channel_set(OtherUserId, State), + OtherSet = guild_visibility_channels:viewable_channel_set(OtherUserId, State), case sets:is_empty(sets:intersection(ViewerSet, OtherSet)) of true -> Acc; false -> Acc + 1 @@ -543,7 +543,7 @@ overwrite(Id, Type, Allow, Deny) -> }. reference_slow_count(UserId, State) -> - ViewerSet = guild_visibility:viewable_channel_set(UserId, State), + ViewerSet = guild_visibility_channels:viewable_channel_set(UserId, State), case sets:is_empty(ViewerSet) of true -> self_online_count(UserId, State); false -> reference_count_mutually_visible(UserId, ViewerSet, State) diff --git a/fluxer_gateway/src/guild/guild_passive_sync.erl b/fluxer_gateway/src/guild/guild_passive_sync.erl index 16ce2eec7..a32a38294 100644 --- a/fluxer_gateway/src/guild/guild_passive_sync.erl +++ b/fluxer_gateway/src/guild/guild_passive_sync.erl @@ -123,7 +123,7 @@ is_large_guild(MemberCount) -> -spec send_passive_updates(integer(), map(), guild_state()) -> ok. send_passive_updates(GuildId, PassiveSessions, SyncState) -> - Channels = guild_data_index:channel_list(maps:get(data, SyncState)), + Channels = guild_data_index_channels:channel_list(maps:get(data, SyncState)), send_passive_session_updates(PassiveSessions, GuildId, Channels, SyncState). -spec send_passive_session_updates(map(), integer(), [map()], guild_state()) -> ok. @@ -140,7 +140,7 @@ send_passive_session_updates(PassiveSessions, GuildId, Channels, SyncState) -> process_single_passive_session(SessionId, SessionData, GuildId, Channels, State) -> Pid = maps:get(pid, SessionData), UserId = maps:get(user_id, SessionData), - Member = guild_permissions:find_member_by_user_id(UserId, State), + Member = guild_permissions_check:find_member_by_user_id(UserId, State), RegState = passive_sync_registry:lookup(SessionId, GuildId), Visible = session_channels(SessionData, Channels, State) ++ @@ -184,7 +184,7 @@ compute_passive_msg_diffs(Channels, UserId, Member, State, RegState) -> -spec compute_passive_voice_diffs(integer(), integer(), guild_state(), map()) -> map(). compute_passive_voice_diffs(UserId, GuildId, State, RegState) -> - ViewableChannels = guild_visibility:viewable_channel_set(UserId, State), + ViewableChannels = guild_visibility_channels:viewable_channel_set(UserId, State), StateWithLatestVoice = guild_data:fetch_latest_voice_states(State), LatestVoiceStates = maps:get(voice_states, StateWithLatestVoice, #{}), Current = guild_passive_sync_voice:build_current_voice_state_map( @@ -334,7 +334,7 @@ maybe_add_last_message_for_channel(IdBin, MsgIdBin, RawId, UserId, Member, State binary(), term(), integer(), integer(), map(), guild_state(), map() ) -> map(). maybe_add_visible_last_message(IdBin, MsgId, UserId, ChId, Member, State, Acc) -> - case guild_permissions:can_view_channel(UserId, ChId, Member, State) of + case guild_permissions_check:can_view_channel(UserId, ChId, Member, State) of true -> Acc#{IdBin => MsgId}; false -> Acc end. @@ -355,18 +355,6 @@ snowflake_binary(FieldName, Value) -> -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -skipped_spawn_drops_no_session_the_old_filter_kept_test() -> - GuildId = 4242, - lists:foreach( - fun({MemberCount, Sessions}) -> - Kept = reference_passive_sessions(GuildId, Sessions, MemberCount), - ?assert(is_large_guild(MemberCount) orelse map_size(Kept) =:= 0) - end, - passive_gate_cases() - ), - Large = reference_passive_sessions(GuildId, passive_test_sessions(), 251), - ?assertEqual(1, map_size(Large)). - reference_passive_sessions(GuildId, Sessions, MemberCount) -> IsLargeGuild = is_integer(MemberCount) andalso MemberCount > 250, maps:filter( @@ -376,32 +364,6 @@ reference_passive_sessions(GuildId, Sessions, MemberCount) -> Sessions ). -passive_gate_cases() -> - Sessions = passive_test_sessions(), - [ - {undefined, #{}}, - {undefined, Sessions}, - {0, Sessions}, - {250, Sessions}, - {251, Sessions}, - {251, #{}}, - {not_a_count, Sessions} - ]. - -passive_test_sessions() -> - passive_test_sessions(<<"passive-session">>). - -passive_test_sessions(SessionId) -> - #{ - SessionId => #{ - session_id => SessionId, - user_id => 1130650140672000000, - pid => self(), - active_guilds => sets:new(), - bot => false - } - }. - handle_passive_sync_stays_silent_below_threshold_test() -> flush_passive_dispatches(), ok = passive_sync_registry:init(), @@ -547,7 +509,7 @@ reference_send_passive_updates(State) -> GuildId, maps:get(sessions, State), maps:get(member_count, State) ), Data = maps:get(data, State), - Channels = guild_data_index:channel_list(Data), + Channels = guild_data_index_channels:channel_list(Data), SyncState = State#{data => Data, voice_states => maps:get(voice_states, State, #{})}, ok = send_passive_session_updates(PassiveSessions, GuildId, Channels, SyncState), State. @@ -743,4 +705,14 @@ diff_channel(N) -> 1428100000000000000 + N. diff_message(N, Round) -> 1500000000000000000 + N * 10 + Round. +passive_test_sessions(SessionId) -> + #{ + SessionId => #{ + session_id => SessionId, + user_id => 1130650140672000000, + pid => self(), + active_guilds => sets:new(), + bot => false + } + }. -endif. diff --git a/fluxer_gateway/src/guild/guild_passive_sync_voice.erl b/fluxer_gateway/src/guild/guild_passive_sync_voice.erl index 7db3543e0..c69b71c05 100644 --- a/fluxer_gateway/src/guild/guild_passive_sync_voice.erl +++ b/fluxer_gateway/src/guild/guild_passive_sync_voice.erl @@ -3,10 +3,7 @@ -module(guild_passive_sync_voice). -typing([eqwalizer]). --export([ - build_current_voice_state_map/2, - compute_voice_state_updates/3 -]). +-export([build_current_voice_state_map/2, compute_voice_state_updates/3]). -export_type([voice_state/0]). @@ -35,7 +32,7 @@ maybe_include_voice_state(ConnectionId, VoiceState, ViewableChannels, Acc) -> -spec maybe_include_if_viewable(binary(), voice_state(), binary(), sets:set(), map()) -> map(). maybe_include_if_viewable(ConnectionId, VoiceState, ChannelIdBin, ViewableChannels, Acc) -> - case parse_snowflake(ChannelIdBin) of + case snowflake_id:parse_maybe(ChannelIdBin) of undefined -> Acc; ChannelId -> @@ -128,10 +125,3 @@ build_removed(PrevState, GuildIdBin) -> voice_state_utils:sanitize_voice_state_for_broadcast( (ensure_guild(PrevState, GuildIdBin))#{<<"channel_id">> => null} ). - --spec parse_snowflake(term()) -> integer() | undefined. -parse_snowflake(Value) -> - case validation:validate_snowflake(<<"channel_id">>, Value) of - {ok, Id} -> Id; - {error, _, _} -> undefined - end. diff --git a/fluxer_gateway/src/guild/guild_permission_cache.erl b/fluxer_gateway/src/guild/guild_permission_cache.erl index 9cad0f23e..21075221f 100644 --- a/fluxer_gateway/src/guild/guild_permission_cache.erl +++ b/fluxer_gateway/src/guild/guild_permission_cache.erl @@ -1,12 +1,12 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_permission_cache). --typing([eqwalizer]). - -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -endif. +-typing([eqwalizer]). + -export([ put_state/1, put_data/2, @@ -109,7 +109,9 @@ has_member(GuildId, UserId) when is_integer(GuildId), is_integer(UserId) -> case get_snapshot(GuildId) of {ok, Snapshot} -> safe_member_read(fun() -> - {ok, guild_permissions:find_member_by_user_id(UserId, Snapshot) =/= undefined} + {ok, + guild_permissions_check:find_member_by_user_id(UserId, Snapshot) =/= + undefined} end); {error, not_found} -> {error, not_found} @@ -122,7 +124,7 @@ get_member(GuildId, UserId) when is_integer(GuildId), is_integer(UserId) -> case get_snapshot(GuildId) of {ok, Snapshot} -> safe_member_read(fun() -> - Member = guild_permissions:find_member_by_user_id(UserId, Snapshot), + Member = guild_permissions_check:find_member_by_user_id(UserId, Snapshot), {ok, project_member(Snapshot, Member)} end); {error, not_found} -> @@ -262,8 +264,8 @@ members_ets_enabled() -> -spec member_projection_changed(user_id() | undefined, guild_data(), guild_data()) -> boolean(). member_projection_changed(UserId, OldData, NewData) when is_integer(UserId) -> - strip_member(guild_data_index:get_member(UserId, OldData)) =/= - strip_member(guild_data_index:get_member(UserId, NewData)); + strip_member(guild_data_index_members:get_member(UserId, OldData)) =/= + strip_member(guild_data_index_members:get_member(UserId, NewData)); member_projection_changed(_UserId, _OldData, _NewData) -> true. @@ -433,11 +435,7 @@ normalize_channel(Channel) -> -spec get_member_user_id(map()) -> integer() | undefined. get_member_user_id(Member) when is_map(Member) -> User = maps:get(<<"user">>, Member, #{}), - parse_user_id(maps:get(<<"id">>, User, undefined)). - --spec parse_user_id(term()) -> integer() | undefined. -parse_user_id(Id) -> - snowflake_id:parse_optional(Id). + snowflake_id:parse_maybe(maps:get(<<"id">>, User, undefined)). -ifdef(TEST). @@ -589,33 +587,6 @@ memoised_strip_members_matches_reference_test() -> memo_reset() end. -memo_reuses_the_stripped_term_for_an_identical_members_term_test() -> - memo_reset(), - try - Members = memo_members([1, 2]), - First = memoised_strip_members(Members), - Second = memoised_strip_members(Members), - ?assertEqual(reference_strip_members(Members), First), - ?assert(erts_debug:same(First, Second)), - ?assertEqual({Members, First}, erlang:get(?STRIPPED_MEMBERS_MEMO)) - after - memo_reset() - end. - -memo_recomputes_for_an_equal_but_distinct_members_term_test() -> - memo_reset(), - try - Members = memo_members([1, 2]), - Copy = binary_to_term(term_to_binary(Members)), - Stripped = memoised_strip_members(Members), - ?assertNot(erts_debug:same(Members, Copy)), - ?assertEqual(Stripped, memoised_strip_members(Copy)), - {Source, _Stripped} = erlang:get(?STRIPPED_MEMBERS_MEMO), - ?assert(erts_debug:same(Copy, Source)) - after - memo_reset() - end. - memo_changed_member_maps(Members) -> [ Members#{2 => memo_member(2, [<<"7">>])}, diff --git a/fluxer_gateway/src/guild/guild_permissions.erl b/fluxer_gateway/src/guild/guild_permissions.erl index 92922eca7..1c7b5b8a9 100644 --- a/fluxer_gateway/src/guild/guild_permissions.erl +++ b/fluxer_gateway/src/guild/guild_permissions.erl @@ -8,18 +8,6 @@ compute_member_permissions/4, member_base_permissions/3, channel_permissions/4, - can_view_channel/4, - viewable_channel_ids/4, - can_view_channel_by_permissions/4, - can_view_channel_members/4, - can_manage_channel/3, - can_access_message_by_permissions/3, - apply_channel_overwrites/5, - get_max_role_position/2, - find_member_by_user_id/2, - find_role_by_id/2, - find_channel_by_id/2, - view_inputs/2, aggregate_role_permissions_cached/4 ]). @@ -83,7 +71,10 @@ channel_permissions(Base, UserId, ChannelId, State) -> overwrite_permissions(Base, UserId, ChannelId, State); Thread -> guild_thread_permissions:resolve( - UserId, Thread, find_member_by_user_id(UserId, State), State + UserId, + Thread, + guild_permissions_check:find_member_by_user_id(UserId, State), + State ) end. @@ -120,61 +111,6 @@ base_permissions_for_data(UserId, ProvidedMember, State, Data) -> false -> non_owner_base_permissions(UserId, ProvidedMember, State, Data) end. --spec can_view_channel(user_id(), integer(), maybe_member(), guild_state()) -> boolean(). -can_view_channel(UserId, ChannelId, Member, State) -> - guild_permissions_check:can_view_channel(UserId, ChannelId, Member, State). - --spec viewable_channel_ids(user_id(), base_permissions(), [map()], guild_state()) -> - #{integer() => true}. -viewable_channel_ids(UserId, Base, Channels, State) -> - guild_permissions_check:viewable_channel_ids(UserId, Base, Channels, State). - --spec can_view_channel_by_permissions(user_id(), integer(), maybe_member(), guild_state()) -> - boolean(). -can_view_channel_by_permissions(UserId, ChannelId, Member, State) -> - guild_permissions_check:can_view_channel_by_permissions(UserId, ChannelId, Member, State). - --spec can_view_channel_members(user_id(), integer(), maybe_member(), guild_state()) -> - boolean(). -can_view_channel_members(UserId, ChannelId, Member, State) -> - guild_permissions_check:can_view_channel_members(UserId, ChannelId, Member, State). - --spec can_manage_channel(user_id(), maybe_channel_id(), guild_state()) -> boolean(). -can_manage_channel(UserId, ChannelId, State) -> - guild_permissions_check:can_manage_channel(UserId, ChannelId, State). - --spec can_access_message_by_permissions(permission(), binary(), guild_state()) -> boolean(). -can_access_message_by_permissions(Permissions, MessageId, State) -> - guild_permissions_check:can_access_message_by_permissions(Permissions, MessageId, State). - --spec apply_channel_overwrites( - permission(), user_id() | undefined, member_roles(), map(), role_id() -) -> permission(). -apply_channel_overwrites(BasePerms, UserId, MemberRoles, Channel, EveryoneRoleId) -> - guild_permissions_overwrites:apply_channel_overwrites( - BasePerms, UserId, MemberRoles, Channel, EveryoneRoleId - ). - --spec get_max_role_position(user_id(), guild_state()) -> integer(). -get_max_role_position(UserId, State) -> - guild_permissions_check:get_max_role_position(UserId, State). - --spec find_member_by_user_id(user_id(), guild_state()) -> member() | undefined. -find_member_by_user_id(UserId, State) -> - guild_permissions_check:find_member_by_user_id(UserId, State). - --spec find_role_by_id(role_id(), [map()] | map()) -> map() | undefined. -find_role_by_id(RoleId, Roles) -> - guild_permissions_check:find_role_by_id(RoleId, Roles). - --spec find_channel_by_id(integer(), guild_state()) -> map() | undefined. -find_channel_by_id(ChannelId, State) -> - guild_permissions_check:find_channel_by_id(ChannelId, State). - --spec view_inputs(integer(), guild_state()) -> term(). -view_inputs(ChannelId, State) -> - guild_permissions_check:view_inputs(ChannelId, State). - -spec non_owner_base_permissions(user_id(), maybe_member(), guild_state(), guild_data()) -> base_permissions(). non_owner_base_permissions(UserId, ProvidedMember, State, Data) -> @@ -191,14 +127,14 @@ member_role_base_permissions(Member, State, Data) -> undefined -> 0; GuildId -> - Roles = guild_data_index:role_index(Data), + Roles = guild_data_index_roles:role_index(Data), RolePermsCache = maps:get(role_perms_cache, Data, #{}), BasePermissions = cached_role_permissions(GuildId, RolePermsCache, Roles), MemberRoles = guild_permissions_common:member_role_ids(Member), Permissions = aggregate_role_permissions_cached( MemberRoles, RolePermsCache, Roles, BasePermissions ), - case permission_bits:has(Permissions, constants:administrator_permission()) of + case bitset:has(Permissions, constants:administrator_permission()) of true -> ?ALL_PERMISSIONS; false -> {Permissions, MemberRoles, GuildId} end @@ -254,7 +190,7 @@ aggregate_role_permissions_cached(MemberRoles, Cache, Roles, BasePermissions) -> add_cached_role_permissions(RoleId, Cache, Roles, Acc) -> case maps:get(RoleId, Cache, undefined) of V when is_integer(V) -> - permission_bits:add(Acc, V); + bitset:add(Acc, V); _ -> add_role_permissions(RoleId, Roles, Acc) end. @@ -263,5 +199,5 @@ add_cached_role_permissions(RoleId, Cache, Roles, Acc) -> add_role_permissions(RoleId, Roles, Acc) -> case guild_permissions_check:find_role_by_id(RoleId, Roles) of undefined -> Acc; - Role -> permission_bits:add(Acc, guild_permissions_common:role_permissions(Role)) + Role -> bitset:add(Acc, guild_permissions_common:role_permissions(Role)) end. diff --git a/fluxer_gateway/src/guild/guild_permissions_check.erl b/fluxer_gateway/src/guild/guild_permissions_check.erl index 665b47614..2334e4451 100644 --- a/fluxer_gateway/src/guild/guild_permissions_check.erl +++ b/fluxer_gateway/src/guild/guild_permissions_check.erl @@ -115,28 +115,28 @@ is_category(ChannelId, State) -> boolean(). base_has_view(Base, UserId, ChannelId, State) -> Perms = guild_permissions:channel_permissions(Base, UserId, ChannelId, State), - permission_bits:has(Perms, constants:view_channel_permission()). + bitset:has(Perms, constants:view_channel_permission()). -spec can_view_channel_by_permissions(user_id(), channel_id(), maybe_member(), guild_state()) -> boolean(). can_view_channel_by_permissions(UserId, ChannelId, Member, State) -> Perms = guild_permissions:compute_member_permissions(UserId, ChannelId, Member, State), - permission_bits:has(Perms, constants:view_channel_permission()). + bitset:has(Perms, constants:view_channel_permission()). -spec can_view_channel_members(user_id(), channel_id(), maybe_member(), guild_state()) -> boolean(). can_view_channel_members(UserId, ChannelId, Member, State) -> Perms = guild_permissions:compute_member_permissions(UserId, ChannelId, Member, State), - permission_bits:has(Perms, constants:view_channel_members_permission()). + bitset:has(Perms, constants:view_channel_members_permission()). -spec can_manage_channel(user_id(), maybe_channel_id(), guild_state()) -> boolean(). can_manage_channel(UserId, ChannelId, State) -> Perms = guild_permissions:get_member_permissions(UserId, ChannelId, State), - permission_bits:has(Perms, constants:manage_channels_permission()). + bitset:has(Perms, constants:manage_channels_permission()). -spec can_access_message_by_permissions(permission(), binary(), guild_state()) -> boolean(). can_access_message_by_permissions(Permissions, MessageId, State) -> - HasReadHistory = permission_bits:has( + HasReadHistory = bitset:has( Permissions, constants:read_message_history_permission() ), case HasReadHistory of @@ -152,7 +152,7 @@ check_message_history_cutoff(MessageId, State) -> null -> false; CutoffMs -> - MessageMs = snowflake_util:extract_timestamp(MessageId), + MessageMs = snowflake_id:extract_timestamp(MessageId), MessageMs >= CutoffMs end. @@ -185,7 +185,7 @@ get_max_role_position(UserId, State) -> {_, undefined} -> -1; {Member, Data} -> - Roles = guild_data_index:role_index(Data), + Roles = guild_data_index_roles:role_index(Data), compute_max_position(Member, Roles) end. @@ -287,7 +287,7 @@ find_role_by_id(RoleId, Roles) -> undefined -> undefined; TargetId -> - find_role_in_list(TargetId, guild_permissions_common:ensure_list(Roles)) + find_role_in_list(TargetId, map_utils:ensure_list(Roles)) end. -spec find_role_in_list(role_id(), [term()]) -> role() | undefined. @@ -311,7 +311,9 @@ find_channel_by_id(ChannelId, State) -> {snowflake_id:parse_maybe(ChannelId), guild_permissions_common:resolve_data_map(State)} of {ResolvedChannelId, Data} when is_integer(ResolvedChannelId), is_map(Data) -> - maps:get(ResolvedChannelId, guild_data_index:channel_index(Data), undefined); + maps:get( + ResolvedChannelId, guild_data_index_channels:channel_index(Data), undefined + ); _ -> undefined end. @@ -355,7 +357,7 @@ is_viewable_child(Channel, UserId, CategoryId, Base, State) -> view_inputs(ChannelId, State) -> Data = map_utils:ensure_map(guild_permissions_common:resolve_data_map(State)), Guild = map_utils:ensure_map(maps:get(<<"guild">>, Data, #{})), - Index = guild_data_index:channel_index(Data), + Index = guild_data_index_channels:channel_index(Data), Cache = map_utils:ensure_map(maps:get(overwrite_perms_cache, Data, #{})), { maps:get(<<"owner_id">>, Guild, undefined), diff --git a/fluxer_gateway/src/guild/guild_permissions_common.erl b/fluxer_gateway/src/guild/guild_permissions_common.erl index 306bf2322..1b7a02499 100644 --- a/fluxer_gateway/src/guild/guild_permissions_common.erl +++ b/fluxer_gateway/src/guild/guild_permissions_common.erl @@ -3,13 +3,7 @@ -module(guild_permissions_common). -typing([eqwalizer]). --export([ - ensure_list/1, - extract_integer_list/1, - resolve_data_map/1, - member_role_ids/1, - role_permissions/1 -]). +-export([resolve_data_map/1, member_role_ids/1, role_permissions/1]). -export_type([permission/0, member/0, role/0, guild_state/0, guild_data/0]). @@ -19,19 +13,6 @@ -type guild_state() :: map(). -type guild_data() :: map(). --spec ensure_list(term()) -> list(). -ensure_list(Value) -> map_utils:ensure_list(Value). - --spec extract_integer_list(term()) -> [integer()]. -extract_integer_list(List) when is_list(List) -> - snowflake_id:parse_list(List); -extract_integer_list(undefined) -> - []; -extract_integer_list(null) -> - []; -extract_integer_list(Value) -> - erlang:error({invalid_snowflake_list, Value}). - -spec resolve_data_map(guild_state() | map()) -> guild_data() | undefined. resolve_data_map(State) when is_map(State) -> case maps:find(data, State) of @@ -52,11 +33,11 @@ state_as_data_map(State) -> -spec member_role_ids(member()) -> [integer()]. member_role_ids(Member) -> RoleIds = maps:get(<<"roles">>, Member, []), - extract_integer_list(RoleIds). + snowflake_id:parse_list(RoleIds). -spec role_permissions(role()) -> permission(). role_permissions(Role) -> - case permission_bits:parse_optional(maps:get(<<"permissions">>, Role, undefined)) of + case bitset:parse_optional(maps:get(<<"permissions">>, Role, undefined)) of undefined -> 0; Permissions -> Permissions end. diff --git a/fluxer_gateway/src/guild/guild_permissions_overwrites.erl b/fluxer_gateway/src/guild/guild_permissions_overwrites.erl index f90d311bb..b00d857bc 100644 --- a/fluxer_gateway/src/guild/guild_permissions_overwrites.erl +++ b/fluxer_gateway/src/guild/guild_permissions_overwrites.erl @@ -4,9 +4,7 @@ -typing([eqwalizer]). -export([ - apply_channel_overwrites/5, - apply_cached_overwrites/5, - maybe_apply_channel_overwrites/6 + apply_channel_overwrites/5, apply_cached_overwrites/5, maybe_apply_channel_overwrites/6 ]). -export_type([ @@ -35,7 +33,7 @@ apply_channel_overwrites(BasePerms, UserId, MemberRoles, Channel, EveryoneRoleId Overwrites = channel_overwrites(Channel), EveryonePerms = apply_everyone_overwrites(BasePerms, Overwrites, EveryoneRoleId), {RoleAllow, RoleDeny} = accumulate_role_overwrites(MemberRoles, Overwrites), - RolePerms = permission_bits:apply_allow_deny(EveryonePerms, RoleAllow, RoleDeny), + RolePerms = bitset:apply_allow_deny(EveryonePerms, RoleAllow, RoleDeny), apply_user_overwrites(RolePerms, Overwrites, UserId). -spec apply_cached_overwrites( @@ -53,7 +51,7 @@ apply_cached_overwrites(BasePerms, UserId, MemberRoles, CachedOWs, EveryoneRoleI {BasePerms, 0, 0}, CachedOWs ), - RolePerms = permission_bits:apply_allow_deny(EveryonePerms, RoleAllow, RoleDeny), + RolePerms = bitset:apply_allow_deny(EveryonePerms, RoleAllow, RoleDeny), apply_cached_user(RolePerms, CachedOWs, UserId). -spec maybe_apply_channel_overwrites( @@ -179,11 +177,11 @@ apply_user_overwrite(_Overwrite, _UserId, Acc) -> collect_cached_role_overwrite({OWId, 0, Allow, Deny}, MemberRoles, EveryoneRoleId, {E, A, D}) -> E1 = case OWId =:= EveryoneRoleId of - true -> apply_allow_deny(E, Allow, Deny); + true -> bitset:apply_allow_deny(E, Allow, Deny); false -> E end, case lists:member(OWId, MemberRoles) of - true -> {E1, permission_bits:add(A, Allow), permission_bits:add(D, Deny)}; + true -> {E1, bitset:add(A, Allow), bitset:add(D, Deny)}; false -> {E1, A, D} end; collect_cached_role_overwrite(_, _MemberRoles, _EveryoneRoleId, Acc) -> @@ -197,7 +195,7 @@ apply_cached_user(RolePerms, CachedOWs, UserId) -> lists:foldl( fun ({OWId, 1, Allow, Deny}, Acc) when OWId =:= UserId -> - apply_allow_deny(Acc, Allow, Deny); + bitset:apply_allow_deny(Acc, Allow, Deny); (_, Acc) -> Acc end, @@ -230,17 +228,17 @@ overwrite_type(Overwrite) -> -spec overwrite_allow(overwrite()) -> permission() | undefined. overwrite_allow(Overwrite) -> - permission_bits:parse(maps:get(<<"allow">>, Overwrite, undefined)). + bitset:parse(maps:get(<<"allow">>, Overwrite, undefined)). -spec overwrite_deny(overwrite()) -> permission() | undefined. overwrite_deny(Overwrite) -> - permission_bits:parse(maps:get(<<"deny">>, Overwrite, undefined)). + bitset:parse(maps:get(<<"deny">>, Overwrite, undefined)). -spec apply_overwrite_allow_deny(permission(), overwrite()) -> permission(). apply_overwrite_allow_deny(Acc, Overwrite) -> case {overwrite_allow(Overwrite), overwrite_deny(Overwrite)} of {Allow, Deny} when is_integer(Allow), is_integer(Deny) -> - apply_allow_deny(Acc, Allow, Deny); + bitset:apply_allow_deny(Acc, Allow, Deny); _ -> Acc end. @@ -250,15 +248,11 @@ apply_overwrite_allow_deny(Acc, Overwrite) -> accumulate_overwrite_allow_deny(AllowAcc, DenyAcc, Overwrite) -> case {overwrite_allow(Overwrite), overwrite_deny(Overwrite)} of {Allow, Deny} when is_integer(Allow), is_integer(Deny) -> - {permission_bits:add(AllowAcc, Allow), permission_bits:add(DenyAcc, Deny)}; + {bitset:add(AllowAcc, Allow), bitset:add(DenyAcc, Deny)}; _ -> {AllowAcc, DenyAcc} end. --spec apply_allow_deny(permission(), permission(), permission()) -> permission(). -apply_allow_deny(Acc, Allow, Deny) -> - permission_bits:apply_allow_deny(Acc, Allow, Deny). - -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). @@ -321,10 +315,10 @@ cached_overwrites_matches_uncached_test() -> ] }, MemberRoles = [9], - BasePerms = permission_bits:add(View, 16), + BasePerms = bitset:add(View, 16), Uncached = apply_channel_overwrites(BasePerms, UserId, MemberRoles, Channel, GuildId), Channels = [Channel], - OverwriteCache = guild_data_index:build_overwrite_perms_cache(Channels), + OverwriteCache = guild_data_index_channels:build_overwrite_perms_cache(Channels), CachedOWs = maps:get(10, OverwriteCache), Cached = apply_cached_overwrites(BasePerms, UserId, MemberRoles, CachedOWs, GuildId), ?assertEqual(Uncached, Cached). diff --git a/fluxer_gateway/src/guild/guild_presence.erl b/fluxer_gateway/src/guild/guild_presence.erl index 5d513b534..3fbcaf421 100644 --- a/fluxer_gateway/src/guild/guild_presence.erl +++ b/fluxer_gateway/src/guild/guild_presence.erl @@ -1,6 +1,10 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_presence). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -export([handle_bus_presence/3, send_cached_presence_to_session/3]). @@ -22,10 +26,6 @@ ]). -define(PRESENCE_SNAPSHOT_TRIM_MEMBER_THRESHOLD_DEFAULT, 5000). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -spec handle_bus_presence(user_id(), map(), guild_state()) -> {noreply, guild_state()}. handle_bus_presence(UserId, Payload, State) -> case maps:get(<<"user_update">>, Payload, false) of @@ -37,7 +37,7 @@ handle_bus_presence(UserId, Payload, State) -> handle_user_update(UserId, Payload, State) -> UserData = maps:get(<<"user">>, Payload, #{}), UpdatedState = handle_user_data_update(UserId, UserData, State), - {ok, NewState} = guild_member_list:broadcast_member_list_updates( + {ok, NewState} = guild_member_list_write:broadcast_member_list_updates( UserId, State, UpdatedState ), {noreply, NewState}. @@ -74,7 +74,7 @@ handle_presence_update(UserId, Payload, State) -> -spec handle_presence_update(user_id(), map(), list_sync(), guild_state()) -> {noreply, guild_state()}. handle_presence_update(UserId, Payload, ListSync, State) -> - case find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> {noreply, State}; Member -> process_presence(UserId, Payload, Member, ListSync, State) end. @@ -125,7 +125,7 @@ build_presence_map(Payload, Member) -> -spec maybe_handle_offline(atom(), user_id(), guild_state()) -> guild_state(). maybe_handle_offline(offline, UserId, State) -> - guild_sessions:handle_user_offline(UserId, State); + guild_sessions_presence:handle_user_offline(UserId, State); maybe_handle_offline(_, _UserId, State) -> State. @@ -153,7 +153,7 @@ spawn_presence_broadcast(UserId, OldPresence, PresenceMap, OldState, NewState, L list_sync(), user_id(), guild_state(), guild_state(), map(), map() ) -> {ok, guild_state()}. member_list_presence_update(immediate, UserId, OldState, NewState, OldPresence, PresenceMap) -> - guild_member_list:broadcast_member_list_updates( + guild_member_list_write:broadcast_member_list_updates( UserId, OldState, NewState, OldPresence, PresenceMap ); member_list_presence_update(deferred, UserId, OldState, NewState, OldPresence, PresenceMap) -> @@ -191,7 +191,7 @@ safe_presence_update_recipients(UserId, View) -> -spec presence_update_recipients(user_id(), guild_state()) -> {integer(), [pid()]} | none. presence_update_recipients(UserId, View) -> - case {find_member_by_user_id(UserId, View), guild_id(View)} of + case {guild_permissions_check:find_member_by_user_id(UserId, View), guild_id(View)} of {undefined, _} -> none; {_Member, GuildId} when is_integer(GuildId), GuildId > 0 -> @@ -330,7 +330,7 @@ send_cached_presence_to_known_session(UserId, SessionId, Payload, GuildId, Sessi -spec dispatch_cached_presence(user_id(), pid(), map(), integer(), guild_state()) -> guild_state(). dispatch_cached_presence(UserId, SessionPid, Payload, GuildId, State) -> - case find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> State; Member -> @@ -374,7 +374,7 @@ guild_id(State) -> -spec handle_user_data_update(user_id(), map(), guild_state()) -> guild_state(). handle_user_data_update(UserId, UserData, State) -> - case find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> State; Member -> apply_user_data_update(UserId, UserData, Member, State) end. @@ -389,7 +389,7 @@ apply_user_data_update(UserId, UserData, Member, State) -> true -> UpdatedMember = Member#{<<"user">> => NormalizedUserData}, Data = map_utils:ensure_map(map_utils:get_safe(State, data, #{})), - UpdatedData = guild_data_index:put_member(UpdatedMember, Data), + UpdatedData = guild_data_index_members:put_member(UpdatedMember, Data), UpdatedState = State#{data => UpdatedData}, guild_presence_sync:sync_member_data(UserId, UpdatedState), maybe_dispatch_member_update(UserId, UpdatedState), @@ -398,7 +398,7 @@ apply_user_data_update(UserId, UserData, Member, State) -> -spec maybe_dispatch_member_update(user_id(), guild_state()) -> ok. maybe_dispatch_member_update(UserId, State) -> - case find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> ok; Member -> dispatch_member_update(Member, State) end. @@ -415,10 +415,6 @@ dispatch_member_update(Member, State) -> ok end. --spec find_member_by_user_id(user_id(), guild_state()) -> member() | undefined. -find_member_by_user_id(UserId, State) -> - guild_permissions:find_member_by_user_id(UserId, State). - -spec store_member_presence(user_id(), map(), guild_state()) -> guild_state(). store_member_presence(UserId, PresenceMap, State) -> Tab = maps:get(member_presence, State), @@ -442,7 +438,7 @@ handle_bus_presence_unchanged_payload_is_noop_test() -> <<"afk">> => false, <<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"Alpha">>} }, - Member = #{} = guild_permissions:find_member_by_user_id(1, State), + Member = #{} = guild_permissions_check:find_member_by_user_id(1, State), PresenceMap = build_presence_map(Payload, Member), ets:insert(maps:get(member_presence, State), {1, PresenceMap}), {noreply, NewState} = handle_bus_presence(1, Payload, State), diff --git a/fluxer_gateway/src/guild/guild_presence_sync.erl b/fluxer_gateway/src/guild/guild_presence_sync.erl index 2c353e3db..d75097708 100644 --- a/fluxer_gateway/src/guild/guild_presence_sync.erl +++ b/fluxer_gateway/src/guild/guild_presence_sync.erl @@ -1,6 +1,10 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_presence_sync). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -export([ @@ -16,10 +20,6 @@ -type guild_state() :: map(). -type user_id() :: integer(). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -spec sync_online_status(user_id(), guild_state()) -> ok. sync_online_status(UserId, State) -> IsOnline = compute_is_online(UserId, State), @@ -27,14 +27,14 @@ sync_online_status(UserId, State) -> error -> ok; {ok, Ref} -> - guild_member_list_store:set_online(Ref, UserId, IsOnline) + guild_member_list_engine:set_online(Ref, UserId, IsOnline) end, guild_member_list_channel_engine:sync_online(UserId, IsOnline, State). -spec sync_member_data(user_id(), guild_state()) -> ok. sync_member_data(UserId, State) -> Data = maps:get(data, State, #{}), - case guild_data_index:get_member(UserId, Data) of + case guild_data_index_members:get_member(UserId, Data) of undefined -> ok; Member -> sync_local_nif_member_data(UserId, Member, State) end. @@ -54,7 +54,7 @@ sync_default_member_list_data(UserId, Member, State) -> SortKey = guild_member_list_common:casefold_binary(DisplayName), RoleIds = guild_member_list_store:extract_role_ids(Member), IsOnline = compute_is_online(UserId, State), - guild_member_list_store:update_member(Ref, UserId, SortKey, RoleIds, IsOnline) + guild_member_list_engine:update_member(Ref, UserId, SortKey, RoleIds, IsOnline) end. -spec compute_is_online(user_id(), guild_state()) -> boolean(). @@ -125,7 +125,9 @@ session_shares_channels(SessionData, SessionUserId, TargetChannelMap, State) -> -spec session_channel_list_shares(user_id(), map(), guild_state()) -> boolean(). session_channel_list_shares(SessionUserId, TargetChannelMap, State) -> - SessionChannelList = guild_visibility:get_user_viewable_channels(SessionUserId, State), + SessionChannelList = guild_visibility_channels:get_user_viewable_channels( + SessionUserId, State + ), lists:any(fun(Ch) -> maps:is_key(Ch, TargetChannelMap) end, SessionChannelList). -spec maps_share_any_key(map(), map()) -> boolean(). @@ -155,7 +157,7 @@ key_matches_or_continue(Key, NextIterator, LargerMap) -> get_user_viewable_channel_map(UserId, Sessions, State) -> case find_session_viewable_channels_for_user(UserId, Sessions) of undefined -> - ChannelList = guild_visibility:get_user_viewable_channels(UserId, State), + ChannelList = guild_visibility_channels:get_user_viewable_channels(UserId, State), maps:from_list([{Ch, true} || Ch <- ChannelList]); ViewableMap -> ViewableMap diff --git a/fluxer_gateway/src/guild/guild_public_online.erl b/fluxer_gateway/src/guild/guild_public_online.erl index 4af9d2b55..ee8308604 100644 --- a/fluxer_gateway/src/guild/guild_public_online.erl +++ b/fluxer_gateway/src/guild/guild_public_online.erl @@ -1,14 +1,14 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_public_online). --typing([eqwalizer]). - --export([compute_count/1]). - -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -endif. +-typing([eqwalizer]). + +-export([compute_count/1]). + -export_type([guild_state/0]). -type guild_state() :: map(). @@ -35,7 +35,7 @@ compute_count_for_data(Data, State) -> not guild_thread_gate:is_thread_only_type(maps:get(<<"type">>, C, undefined)) ], case classify_everyone_viewable(Channels, State) of - {open, _ChannelIds} -> guild_member_list:get_online_count(State); + {open, _ChannelIds} -> guild_member_list_read:get_online_count(State); {restricted, ChannelIdSet} -> count_online_with_access(ChannelIdSet, State); empty -> 0 end. @@ -93,7 +93,9 @@ classify_channel_visibility(Channel, EveryoneMember, ViewBit, State, {OpenIds, R ) -> {[integer()], [integer()]}. classify_valid_channel(ChannelId, Channel, EveryoneMember, ViewBit, State, Acc) -> case - guild_permissions:can_view_channel_by_permissions(0, ChannelId, EveryoneMember, State) + guild_permissions_check:can_view_channel_by_permissions( + 0, ChannelId, EveryoneMember, State + ) of true -> classify_visible_channel(ChannelId, Channel, ViewBit, Acc); false -> Acc @@ -117,9 +119,9 @@ channel_has_view_restricting_overrides(Channel, ViewBit) -> lists:any( fun (OW) when is_map(OW) -> - Deny = permission_bits:parse(maps:get(<<"deny">>, OW, undefined)), + Deny = bitset:parse(maps:get(<<"deny">>, OW, undefined)), Type = map_utils:get_integer(OW, <<"type">>, undefined), - is_integer(Deny) andalso permission_bits:has(Deny, ViewBit) andalso + is_integer(Deny) andalso bitset:has(Deny, ViewBit) andalso (Type =:= 0 orelse Type =:= 1); (_) -> false @@ -162,7 +164,7 @@ count_user_with_access(UserId, TargetMap, State, Acc) -> -spec user_has_access(user_id(), map(), guild_state()) -> boolean(). user_has_access(UserId, TargetMap, State) -> - UserChannels = guild_visibility:get_user_viewable_channels(UserId, State), + UserChannels = guild_visibility_channels:get_user_viewable_channels(UserId, State), lists:any(fun(Ch) -> maps:is_key(Ch, TargetMap) end, UserChannels). -spec is_online(map()) -> boolean(). diff --git a/fluxer_gateway/src/guild/guild_query_handler.erl b/fluxer_gateway/src/guild/guild_query_handler.erl index f4e546419..1c6713e02 100644 --- a/fluxer_gateway/src/guild/guild_query_handler.erl +++ b/fluxer_gateway/src/guild/guild_query_handler.erl @@ -6,10 +6,6 @@ -export([handle_call/3, call/3]). -export_type([guild_state/0]). --ifdef(TEST). --export([strip_member_alias/1, restore_member_alias/2]). --endif. - -define(INLINE_MEMBER_QUERY_MAX_IDS, 100). -define(INFERRED_DEADLINE_MARGIN_MS, 1000). @@ -77,7 +73,6 @@ inferred_deadlines_enabled() -> -spec caller_timeout_ms(term()) -> pos_integer() | undefined. caller_timeout_ms(get_large_guild_metadata) -> 200; -caller_timeout_ms(get_user_counts) -> 2000; caller_timeout_ms(get_viewer_counts) -> 2000; caller_timeout_ms(get_channel_member_counts) -> 2000; caller_timeout_ms(get_forum_unread_scope) -> 2000; @@ -88,6 +83,7 @@ caller_timeout_ms(search_guild_members) -> 10000; caller_timeout_ms(Tag) -> rpc_caller_timeout_ms(Tag). -spec rpc_caller_timeout_ms(term()) -> pos_integer() | undefined. +rpc_caller_timeout_ms(resolve_mention_sources_page) -> 4000; rpc_caller_timeout_ms(get_user_permissions) -> 4000; rpc_caller_timeout_ms(can_manage_roles) -> 4000; rpc_caller_timeout_ms(can_manage_role) -> 4000; @@ -102,16 +98,9 @@ rpc_caller_timeout_ms(check_target_member) -> 4000; rpc_caller_timeout_ms(list_guild_members_cursor) -> 4000; rpc_caller_timeout_ms(get_viewable_channels) -> 4000; rpc_caller_timeout_ms(resolve_channel_mentions) -> 4000; -rpc_caller_timeout_ms(get_vanity_url_channel) -> 4000; rpc_caller_timeout_ms(get_first_viewable_text_channel) -> 4000; rpc_caller_timeout_ms(get_category_channel_count) -> 4000; rpc_caller_timeout_ms(get_channel_count) -> 4000; -rpc_caller_timeout_ms(get_users_to_mention_by_roles) -> 4000; -rpc_caller_timeout_ms(get_users_to_mention_by_user_ids) -> 4000; -rpc_caller_timeout_ms(get_all_users_to_mention) -> 4000; -rpc_caller_timeout_ms(resolve_all_mentions) -> 4000; -rpc_caller_timeout_ms(resolve_mention_sources) -> 4000; -rpc_caller_timeout_ms(resolve_mention_sources_page) -> 4000; rpc_caller_timeout_ms(_Tag) -> undefined. -spec handle_query(term(), gen_server:from(), guild_state()) -> @@ -119,8 +108,6 @@ rpc_caller_timeout_ms(_Tag) -> undefined. | {noreply, guild_state()}. handle_query({get_counts}, _From, State) -> handle_get_counts(State); -handle_query({get_user_counts, UserId}, _From, State) when is_integer(UserId) -> - handle_get_user_counts(UserId, State); handle_query({get_viewer_counts, #{user_id := UserId}}, _From, State) when is_integer(UserId) -> handle_get_user_counts(UserId, State); handle_query({get_channel_member_counts, Request}, _From, State) when is_map(Request) -> @@ -217,7 +204,7 @@ channel_member_count_entry_for_visible_channel(ChannelId, State) -> false; ListId -> NewState = guild_member_list_channel_engine:ensure(ListId, State), - {MemberCount, OnlineCount} = guild_member_list:get_counts(ListId, NewState), + {MemberCount, OnlineCount} = guild_member_list_read:get_counts(ListId, NewState), {true, #{ channel_id => ChannelId, @@ -255,24 +242,14 @@ handle_async_member_query(Msg, _From, State) -> handle_call_sync(Msg, State). -spec member_query_fun(atom()) -> query_fun() | undefined. -member_query_fun(get_users_to_mention_by_roles) -> - fun guild_members:get_users_to_mention_by_roles/2; -member_query_fun(get_users_to_mention_by_user_ids) -> - fun guild_members:get_users_to_mention_by_user_ids/2; -member_query_fun(get_all_users_to_mention) -> - fun guild_members:get_all_users_to_mention/2; -member_query_fun(resolve_all_mentions) -> - fun guild_members:resolve_all_mentions/2; -member_query_fun(resolve_mention_sources) -> - fun guild_members:resolve_mention_sources/2; member_query_fun(resolve_mention_sources_page) -> - fun guild_members:resolve_mention_sources_page/2; + fun guild_members_mutation:resolve_mention_sources_page/2; member_query_fun(resolve_channel_mentions) -> - fun guild_members:resolve_channel_mentions/2; + fun guild_members_mutation:resolve_channel_mentions/2; member_query_fun(get_members_with_role) -> - fun guild_members:get_members_with_role/2; + fun guild_members_query:get_members_with_role/2; member_query_fun(get_viewable_channels) -> - fun guild_members:get_viewable_channels/2; + fun guild_members_query:get_viewable_channels/2; member_query_fun(_Tag) -> undefined. @@ -285,12 +262,6 @@ member_query(Tag, From, State, Request, QueryFun) -> end. -spec bounded_member_query(atom(), map()) -> boolean(). -bounded_member_query(get_users_to_mention_by_user_ids, Request) -> - few_ids(maps:get(user_ids, Request, undefined)); -bounded_member_query(resolve_all_mentions, Request) -> - direct_mentions_only(Request); -bounded_member_query(resolve_mention_sources, Request) -> - direct_mentions_only(Request); bounded_member_query(resolve_mention_sources_page, Request) -> direct_mentions_only(Request); bounded_member_query(resolve_channel_mentions, Request) -> @@ -363,7 +334,7 @@ check_user_permission(UserId, Permission, ChannelId, State) -> true; false -> Perms = guild_permissions:get_member_permissions(UserId, ChannelId, State), - permission_bits:has(Perms, Permission) + bitset:has(Perms, Permission) end. -spec handle_get_user_permissions(map(), guild_state()) -> {reply, map(), guild_state()}. @@ -376,21 +347,21 @@ handle_get_user_permissions(Request, State) -> -spec handle_call_sync(term(), guild_state()) -> {reply, term(), guild_state()}. handle_call_sync({can_manage_roles, Req}, State) -> - guild_members:can_manage_roles(request_map(Req), State); + guild_members_roles:can_manage_roles(request_map(Req), State); handle_call_sync({can_manage_role, Req}, State) -> - guild_members:can_manage_role(request_map(Req), State); + guild_members_roles:can_manage_role(request_map(Req), State); handle_call_sync({get_assignable_roles, Req}, State) -> - guild_members:get_assignable_roles(request_map(Req), State); + guild_members_roles:get_assignable_roles(request_map(Req), State); handle_call_sync({get_user_max_role_position, Req}, State) -> handle_max_role_position(request_map(Req), State); handle_call_sync({check_target_member, Req}, State) -> - guild_members:check_target_member(request_map(Req), State); + guild_members_roles:check_target_member(request_map(Req), State); handle_call_sync(Msg, State) -> handle_call_data(Msg, State). -spec handle_max_role_position(map(), guild_state()) -> {reply, map(), guild_state()}. handle_max_role_position(#{user_id := UserId}, State) -> - Position = guild_permissions:get_max_role_position(UserId, State), + Position = guild_permissions_check:get_max_role_position(UserId, State), {reply, #{position => Position}, State}. -spec handle_call_data(term(), guild_state()) -> {reply, term(), guild_state()}. @@ -399,19 +370,17 @@ handle_call_data({get_guild_data, Req}, State) -> handle_call_data({get_guild_auth_context, Req}, State) -> guild_data:get_auth_context(request_map(Req), State); handle_call_data({get_guild_member, Req}, State) -> - guild_data:get_guild_member(request_map(Req), State); + guild_data_members:get_guild_member(request_map(Req), State); handle_call_data({get_guild_members_batch, Req}, State) -> - guild_data:get_guild_members_batch(request_map(Req), State); + guild_data_members:get_guild_members_batch(request_map(Req), State); handle_call_data({has_member, Req}, State) -> - guild_data:has_member(request_map(Req), State); + guild_data_members:has_member(request_map(Req), State); handle_call_data({list_guild_members, Req}, State) -> - guild_data:list_guild_members(request_map(Req), State); + guild_data_members:list_guild_members(request_map(Req), State); handle_call_data({search_guild_members, Req}, State) -> - guild_data:search_guild_members(request_map(Req), State); + guild_data_members:search_guild_members(request_map(Req), State); handle_call_data({list_guild_members_cursor, Req}, State) -> - guild_member_list:get_members_cursor(request_map(Req), State); -handle_call_data({get_vanity_url_channel}, State) -> - guild_data:get_vanity_url_channel(State); + guild_member_list_read:get_members_cursor(request_map(Req), State); handle_call_data({get_first_viewable_text_channel}, State) -> guild_data:get_first_viewable_text_channel(State); handle_call_data({get_category_channel_count, Req}, State) -> diff --git a/fluxer_gateway/src/guild/guild_read_model.erl b/fluxer_gateway/src/guild/guild_read_model.erl index 9165f73e7..3c59a6535 100644 --- a/fluxer_gateway/src/guild/guild_read_model.erl +++ b/fluxer_gateway/src/guild/guild_read_model.erl @@ -42,7 +42,7 @@ store(GuildId, State, Data) -> Members = case maps:get(members_ets, Data, undefined) of Tab when is_reference(Tab) -> #{}; - _ -> guild_data_index:member_map(Data) + _ -> guild_data_index_members:member_map(Data) end, Snapshot = (maps:with(?STATE_KEYS, State))#{ data => ReadData#{<<"members">> => Members, members_normalized => Members} @@ -102,8 +102,8 @@ read({Tag, Request}, Snapshot) -> case Tag of get_guild_data -> reply(guild_data:get_guild_data(Request, State)); get_guild_auth_context -> reply(guild_data:get_auth_context(Request, State)); - get_guild_member -> reply(guild_data:get_guild_member(Request, State)); - has_member -> reply(guild_data:has_member(Request, State)); + get_guild_member -> reply(guild_data_members:get_guild_member(Request, State)); + has_member -> reply(guild_data_members:has_member(Request, State)); _ -> miss end. diff --git a/fluxer_gateway/src/guild/guild_request_channel_member_counts.erl b/fluxer_gateway/src/guild/guild_request_channel_member_counts.erl index c05616327..e662216de 100644 --- a/fluxer_gateway/src/guild/guild_request_channel_member_counts.erl +++ b/fluxer_gateway/src/guild/guild_request_channel_member_counts.erl @@ -1,14 +1,14 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_request_channel_member_counts). --typing([eqwalizer]). - --export([handle_request/3]). - -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -endif. +-typing([eqwalizer]). + +-export([handle_request/3]). + -export_type([session_state/0]). -define(MAX_CHANNEL_IDS, 25). @@ -20,7 +20,7 @@ -spec handle_request(map(), pid(), session_state()) -> ok. handle_request(Data, _SocketPid, SessionState) when is_map(Data) -> SessionPid = maps:get(session_pid, SessionState, undefined), - UserId = parse_user_id(maps:get(user_id, SessionState, undefined)), + UserId = snowflake_id:parse_maybe(maps:get(user_id, SessionState, undefined)), SessionId = parse_session_id(maps:get(session_id, SessionState, undefined)), case is_pid(SessionPid) andalso is_integer(UserId) andalso is_binary(SessionId) of false -> @@ -33,7 +33,7 @@ handle_request(_, _, _) -> -spec handle_valid_request(map(), pid(), binary(), integer(), session_state()) -> ok. handle_valid_request(Data, SessionPid, SessionId, UserId, SessionState) -> - GuildId = parse_guild_id(maps:get(<<"guild_id">>, Data, undefined)), + GuildId = snowflake_id:parse_maybe(maps:get(<<"guild_id">>, Data, undefined)), ChannelIds = parse_channel_ids(Data), Nonce = parse_nonce(maps:get(<<"nonce">>, Data, undefined)), Entries = fetch_counts(GuildId, ChannelIds, SessionId, UserId, SessionState), @@ -118,16 +118,9 @@ parse_channel_ids(Data) -> Ids when is_list(Ids) -> Ids; _ -> [maps:get(<<"channel_id">>, Data, undefined)] end, - Parsed = lists:filtermap(fun parse_channel_id_filter/1, RawIds), + Parsed = lists:filtermap(fun snowflake_id:filter/1, RawIds), lists:sublist(lists:usort(Parsed), ?MAX_CHANNEL_IDS). --spec parse_channel_id_filter(term()) -> {true, integer()} | false. -parse_channel_id_filter(Value) -> - case to_int(Value) of - N when is_integer(N), N > 0 -> {true, N}; - _ -> false - end. - -spec lookup_guild_pid(integer(), map()) -> {ok, pid()} | error. lookup_guild_pid(GuildId, Guilds) -> case maps:get(GuildId, Guilds, undefined) of @@ -135,14 +128,6 @@ lookup_guild_pid(GuildId, Guilds) -> _ -> error end. --spec parse_guild_id(term()) -> integer() | undefined. -parse_guild_id(Value) -> - to_int(Value). - --spec parse_user_id(term()) -> integer() | undefined. -parse_user_id(Value) -> - to_int(Value). - -spec parse_session_id(term()) -> binary() | undefined. parse_session_id(SessionId) when is_binary(SessionId), byte_size(SessionId) > 0 -> SessionId; @@ -157,10 +142,6 @@ parse_nonce(Nonce) when parse_nonce(_) -> undefined. --spec to_int(term()) -> integer() | undefined. -to_int(Value) -> - snowflake_id:parse_maybe(Value). - -ifdef(TEST). parse_channel_ids_accepts_single_channel_id_test() -> diff --git a/fluxer_gateway/src/guild/guild_request_counts.erl b/fluxer_gateway/src/guild/guild_request_counts.erl index dff2ba517..c139efb8a 100644 --- a/fluxer_gateway/src/guild/guild_request_counts.erl +++ b/fluxer_gateway/src/guild/guild_request_counts.erl @@ -1,28 +1,27 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_request_counts). --typing([eqwalizer]). - --export([handle_request/3]). - -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -endif. +-typing([eqwalizer]). + +-export([handle_request/3]). + -export_type([session_state/0]). -define(MAX_GUILD_IDS, 100). -define(GUILD_CALL_TIMEOUT_MS, 2000). -define(BATCH_OVERALL_TIMEOUT_MS, 3000). -define(MAX_NONCE_BYTES, 64). --define(LEGACY_NODE_KEY(Node), {?MODULE, legacy_node, Node}). -type session_state() :: map(). -spec handle_request(map(), pid(), session_state()) -> ok. handle_request(Data, _SocketPid, SessionState) when is_map(Data) -> SessionPid = maps:get(session_pid, SessionState, undefined), - UserId = parse_user_id(maps:get(user_id, SessionState, undefined)), + UserId = snowflake_id:parse_maybe(maps:get(user_id, SessionState, undefined)), case is_pid(SessionPid) andalso is_integer(UserId) of false -> ok; @@ -41,7 +40,7 @@ handle_request(_, _, _) -> -spec parse_guild_ids(term()) -> [integer()]. parse_guild_ids(GuildIds) when is_list(GuildIds) -> Parsed = lists:filtermap( - fun parse_guild_id_filter/1, + fun snowflake_id:filter/1, GuildIds ), lists:sublist(lists:usort(Parsed), ?MAX_GUILD_IDS); @@ -64,13 +63,6 @@ target_guild(GuildId, Guilds) -> error -> false end. --spec parse_guild_id_filter(term()) -> {true, integer()} | false. -parse_guild_id_filter(Id) -> - case to_int(Id) of - N when is_integer(N), N > 0 -> {true, N}; - _ -> false - end. - -spec lookup_guild_pid(integer(), map()) -> {ok, pid()} | error. lookup_guild_pid(GuildId, Guilds) -> case maps:get(GuildId, Guilds, undefined) of @@ -106,42 +98,19 @@ worker(Parent, Tag, GuildId, GuildPid, UserId) -> -spec fetch_counts(pid(), integer()) -> {ok, non_neg_integer(), non_neg_integer()} | error. fetch_counts(GuildPid, UserId) -> - case is_legacy_node(node(GuildPid)) of - true -> fetch_legacy_counts(GuildPid, UserId); - false -> fetch_viewer_counts(GuildPid, UserId) - end. + fetch_viewer_counts(GuildPid, UserId). -spec fetch_viewer_counts(pid(), integer()) -> {ok, non_neg_integer(), non_neg_integer()} | error. fetch_viewer_counts(GuildPid, UserId) -> Request = {get_viewer_counts, #{user_id => UserId}}, try guild_query_handler:call(GuildPid, Request, ?GUILD_CALL_TIMEOUT_MS) of - ok -> - ok = remember_legacy_node(node(GuildPid)), - fetch_legacy_counts(GuildPid, UserId); Reply -> counts_result(Reply) catch _:_ -> error end. --spec is_legacy_node(node()) -> boolean(). -is_legacy_node(Node) -> - persistent_term:get(?LEGACY_NODE_KEY(Node), false) =:= true. - --spec remember_legacy_node(node()) -> ok. -remember_legacy_node(Node) -> - persistent_term:put(?LEGACY_NODE_KEY(Node), true). - --spec fetch_legacy_counts(pid(), integer()) -> - {ok, non_neg_integer(), non_neg_integer()} | error. -fetch_legacy_counts(GuildPid, UserId) -> - try gen_server:call(GuildPid, {get_user_counts, UserId}, ?GUILD_CALL_TIMEOUT_MS) of - Reply -> counts_result(Reply) - catch - _:_ -> error - end. - -spec counts_result(term()) -> {ok, non_neg_integer(), non_neg_integer()} | error. counts_result(#{member_count := MemberCount, online_count := OnlineCount}) -> {ok, MemberCount, OnlineCount}; @@ -191,14 +160,6 @@ parse_nonce(Nonce) when parse_nonce(_) -> undefined. --spec parse_user_id(term()) -> integer() | undefined. -parse_user_id(Value) -> - snowflake_id:parse_maybe(Value). - --spec to_int(term()) -> integer() | undefined. -to_int(Value) -> - snowflake_id:parse_maybe(Value). - -ifdef(TEST). parse_guild_ids_caps_at_max_test() -> @@ -232,12 +193,7 @@ handle_request_echoes_nonce_test() -> ?assert(false) end. -forget_legacy_nodes() -> - _ = persistent_term:erase(?LEGACY_NODE_KEY(node())), - ok. - handle_request_fetches_viewer_counts_with_deadline_test() -> - ok = forget_legacy_nodes(), Self = self(), Guild = spawn(fun() -> receive @@ -258,66 +214,7 @@ handle_request_fetches_viewer_counts_with_deadline_test() -> ?assert(false) end. -legacy_guild(Replies) -> - spawn(fun() -> legacy_guild_loop(Replies) end). - -legacy_guild_loop(Replies) -> - receive - {'$gen_call', From, {get_viewer_counts, #{user_id := 100, deadline := D}}} when - is_integer(D) - -> - gen_server:reply(From, ok), - legacy_guild_loop(Replies); - {'$gen_call', From, {get_user_counts, 100}} -> - [Reply | Rest] = Replies, - gen_server:reply(From, Reply), - legacy_guild_loop(Rest) - after 5000 -> - ok - end. - -request_counts_payload(Guilds) -> - Self = self(), - SessionState = #{session_pid => Self, user_id => <<"100">>, guilds => Guilds}, - GuildIds = [integer_to_binary(Id) || Id <- maps:keys(Guilds)], - ok = handle_request(#{<<"guild_ids">> => GuildIds}, Self, SessionState), - receive - {'$gen_cast', {dispatch, guild_counts_update, Payload}} -> Payload - after 1000 -> - error(no_dispatch) - end. - -handle_request_falls_back_to_user_counts_on_legacy_guild_test() -> - ok = forget_legacy_nodes(), - Guild = legacy_guild([#{member_count => 50, online_count => 10}]), - Payload = request_counts_payload(#{7 => {Guild, make_ref()}}), - ?assertEqual([build_entry(7, 50, 10)], maps:get(<<"counts">>, Payload)). - -handle_request_omits_guild_when_legacy_fallback_fails_test() -> - ok = forget_legacy_nodes(), - Guild = legacy_guild([ok]), - Payload = request_counts_payload(#{7 => {Guild, make_ref()}}), - ?assertEqual([], maps:get(<<"counts">>, Payload)). - -handle_request_mixes_legacy_and_current_guilds_test() -> - ok = forget_legacy_nodes(), - Legacy = legacy_guild([#{member_count => 50, online_count => 10}]), - Current = spawn(fun() -> - receive - {'$gen_call', From, {get_viewer_counts, #{user_id := 100}}} -> - gen_server:reply(From, #{member_count => 80, online_count => 20}); - {'$gen_call', From, {get_user_counts, 100}} -> - gen_server:reply(From, #{member_count => 80, online_count => 20}) - end - end), - Payload = request_counts_payload(#{7 => {Legacy, make_ref()}, 9 => {Current, make_ref()}}), - ?assertEqual( - [build_entry(7, 50, 10), build_entry(9, 80, 20)], - lists:sort(maps:get(<<"counts">>, Payload)) - ). - fetch_counts_does_not_fall_back_on_current_guild_test() -> - ok = forget_legacy_nodes(), Self = self(), Guild = spawn(fun() -> receive @@ -337,39 +234,7 @@ fetch_counts_does_not_fall_back_on_current_guild_test() -> after 400 -> ok end. -fetch_counts_goes_straight_to_user_counts_after_a_legacy_answer_test() -> - ok = forget_legacy_nodes(), - Self = self(), - Guild = spawn(fun() -> recording_legacy_guild(Self, 3) end), - ?assertEqual({ok, 50, 10}, fetch_counts(Guild, 100)), - ?assertEqual({ok, 50, 10}, fetch_counts(Guild, 100)), - Calls = [ - receive - {legacy_call, Tag} -> Tag - after 1000 -> none - end - || _ <- [1, 2, 3] - ], - ok = forget_legacy_nodes(), - ?assertEqual([get_viewer_counts, get_user_counts, get_user_counts], Calls). - -recording_legacy_guild(_Parent, 0) -> - ok; -recording_legacy_guild(Parent, N) -> - receive - {'$gen_call', From, {get_viewer_counts, _}} -> - Parent ! {legacy_call, get_viewer_counts}, - gen_server:reply(From, ok); - {'$gen_call', From, {get_user_counts, 100}} -> - Parent ! {legacy_call, get_user_counts}, - gen_server:reply(From, #{member_count => 50, online_count => 10}) - after 1000 -> - ok - end, - recording_legacy_guild(Parent, N - 1). - fetch_counts_errors_on_malformed_reply_test() -> - ok = forget_legacy_nodes(), Guild = spawn(fun() -> receive {'$gen_call', From, {get_viewer_counts, _}} -> gen_server:reply(From, #{}) diff --git a/fluxer_gateway/src/guild/guild_request_forum_unreads.erl b/fluxer_gateway/src/guild/guild_request_forum_unreads.erl index d8e3174f4..bb0141921 100644 --- a/fluxer_gateway/src/guild/guild_request_forum_unreads.erl +++ b/fluxer_gateway/src/guild/guild_request_forum_unreads.erl @@ -122,7 +122,9 @@ scope(_Session, _Request, _State) -> is_forum(ChannelId, State) -> case maps:get( - ChannelId, guild_data_index:channel_index(maps:get(data, State, #{})), undefined + ChannelId, + guild_data_index_channels:channel_index(maps:get(data, State, #{})), + undefined ) of #{<<"type">> := Type} -> guild_thread_gate:is_thread_only_type(Type); diff --git a/fluxer_gateway/src/guild/guild_request_members.erl b/fluxer_gateway/src/guild/guild_request_members.erl index 737f6d44e..399d7e943 100644 --- a/fluxer_gateway/src/guild/guild_request_members.erl +++ b/fluxer_gateway/src/guild/guild_request_members.erl @@ -9,7 +9,6 @@ validate_guild_id/1, normalize_nonce/1, validate_user_ids/1, - parse_user_id/1, ensure_binary/1, ensure_limit/1 ]). @@ -140,28 +139,10 @@ has_too_many_user_ids(UserIds) -> -spec parse_user_ids([term()]) -> [integer()]. parse_user_ids(UserIds) -> lists:filtermap( - fun parse_user_id_filter/1, + fun snowflake_id:filter/1, UserIds ). --spec parse_user_id_filter(term()) -> {true, integer()} | false. -parse_user_id_filter(Id) -> - case parse_user_id(Id) of - {ok, ParsedId} -> {true, ParsedId}; - error -> false - end. - --spec parse_user_id(term()) -> {ok, integer()} | error. -parse_user_id(Id) when is_integer(Id), Id > 0 -> - {ok, Id}; -parse_user_id(Id) when is_binary(Id) -> - case validation:validate_snowflake(<<"user_id">>, Id) of - {ok, ParsedId} -> {ok, ParsedId}; - {error, _, _} -> error - end; -parse_user_id(_) -> - error. - -spec ensure_binary(term()) -> binary(). ensure_binary(Value) when is_binary(Value) -> Value; ensure_binary(_) -> <<>>. diff --git a/fluxer_gateway/src/guild/guild_sessions.erl b/fluxer_gateway/src/guild/guild_sessions.erl index 3cd012b1b..546a89def 100644 --- a/fluxer_gateway/src/guild/guild_sessions.erl +++ b/fluxer_gateway/src/guild/guild_sessions.erl @@ -4,26 +4,16 @@ -typing([eqwalizer]). -export([ - handle_session_connect/3, handle_session_down/2, - remove_session/2, filter_sessions_for_channel/4, filter_sessions_for_message/5, filter_sessions_for_manage_channels/4, filter_sessions_exclude_session/2, - handle_user_offline/2, - set_session_active_guild/3, - set_session_passive_guild/3, - build_initial_last_message_ids/1, - subscribe_to_user_presence/2, - unsubscribe_from_user_presence/2, set_session_viewable_channels/3, refresh_user_session_cache/2, refresh_all_viewable_channels/1, - handle_set_typing_override/3, set_session_push_hold/3, released_push_holds/2, - handle_send_guild_sync/2, build_viewable_channel_map/1 ]). @@ -51,16 +41,6 @@ session_pair/0 ]). --spec handle_session_connect(map(), pid(), guild_state()) -> - {reply, - {ok, map()} - | {ok, unavailable, map()} - | {error, too_many_sessions} - | {error, not_member}, - guild_state()}. -handle_session_connect(Request, Pid, State) -> - guild_sessions_connect:handle_session_connect(Request, Pid, State). - -spec handle_session_down(reference(), guild_state()) -> {noreply, guild_state()} | {stop, normal, guild_state()}. handle_session_down(Ref, State) -> @@ -101,38 +81,6 @@ finish_pending_session_down(NewSessions, State) -> {noreply, guild_sessions_connect_cleanup:clear_auto_stop_pending(State)} end. --spec remove_session(session_id(), guild_state()) -> guild_state(). -remove_session(SessionId, State) -> - guild_sessions_connect:remove_session(SessionId, State). - --spec build_initial_last_message_ids(map()) -> #{binary() => binary()}. -build_initial_last_message_ids(GuildState) -> - guild_sessions_connect:build_initial_last_message_ids(GuildState). - --spec subscribe_to_user_presence(user_id(), guild_state()) -> guild_state(). -subscribe_to_user_presence(UserId, State) -> - guild_sessions_presence:subscribe_to_user_presence(UserId, State). - --spec unsubscribe_from_user_presence(user_id(), guild_state()) -> guild_state(). -unsubscribe_from_user_presence(UserId, State) -> - guild_sessions_presence:unsubscribe_from_user_presence(UserId, State). - --spec handle_user_offline(user_id(), guild_state()) -> guild_state(). -handle_user_offline(UserId, State) -> - guild_sessions_presence:handle_user_offline(UserId, State). - --spec set_session_active_guild(session_id(), guild_id(), guild_state()) -> guild_state(). -set_session_active_guild(SessionId, GuildId, State) -> - guild_sessions_passive:set_session_active_guild(SessionId, GuildId, State). - --spec set_session_passive_guild(session_id(), guild_id(), guild_state()) -> guild_state(). -set_session_passive_guild(SessionId, GuildId, State) -> - guild_sessions_passive:set_session_passive_guild(SessionId, GuildId, State). - --spec handle_set_typing_override(session_id(), boolean(), guild_state()) -> guild_state(). -handle_set_typing_override(SessionId, TypingFlag, State) -> - guild_sessions_passive:handle_set_typing_override(SessionId, TypingFlag, State). - -spec set_session_push_hold(session_id(), boolean(), guild_state()) -> guild_state(). set_session_push_hold(SessionId, Hold, State) -> Sessions = maps:get(sessions, State, #{}), @@ -152,10 +100,6 @@ released_push_holds(SessionIds, State) -> maps:get(push_hold, maps:get(SessionId, Sessions, #{}), undefined) =:= false ]. --spec handle_send_guild_sync(session_id(), guild_state()) -> guild_state(). -handle_send_guild_sync(SessionId, State) -> - guild_sessions_passive:handle_send_guild_sync(SessionId, State). - -spec filter_sessions_for_channel( sessions_map(), channel_id(), session_id() | undefined, guild_state() ) -> [session_pair()]. @@ -246,7 +190,9 @@ memo_message_session(Sid, S, {ChannelId, MessageId, _SessionIdOpt, _Gated, State case maps:get(user_id, S, undefined) of UserId when is_integer(UserId) -> {Perms, Memo1} = memo_member_permissions(UserId, ChannelId, State, Memo), - Ok = guild_permissions:can_access_message_by_permissions(Perms, MessageId, State), + Ok = guild_permissions_check:can_access_message_by_permissions( + Perms, MessageId, State + ), {prepend_session(Ok, Sid, S, Acc), Memo1}; _ -> {Acc, Memo} @@ -280,7 +226,7 @@ prepend_session(false, _Sid, _S, Acc) -> Acc. filter_sessions_for_manage_channels(Sessions, ChannelId, SessionIdOpt, State) -> filter_active_sessions(Sessions, SessionIdOpt, fun(S, _Sid) -> UserId = maps:get(user_id, S), - guild_permissions:can_manage_channel(UserId, ChannelId, State) + guild_permissions_check:can_manage_channel(UserId, ChannelId, State) end). -spec filter_active_sessions( @@ -358,7 +304,7 @@ refresh_user_session_cache(UserId, State) when is_integer(UserId), UserId > 0 -> Sessions = maps:get(sessions, State, #{}), UserRoles = session_passive:get_user_roles_for_guild(UserId, State), ViewableChannels = build_viewable_channel_map( - guild_visibility:get_user_viewable_channels(UserId, State) + guild_visibility_channels:get_user_viewable_channels(UserId, State) ), NewSessions = maps:map( fun(_SessionId, SessionData) -> @@ -399,7 +345,7 @@ refresh_session_viewable(SessionId, SessionData, AccState) -> case is_integer(UserId) of true -> ViewableChannels = build_viewable_channel_map( - guild_visibility:get_user_viewable_channels(UserId, AccState) + guild_visibility_channels:get_user_viewable_channels(UserId, AccState) ), set_session_viewable_channels(SessionId, ViewableChannels, AccState); false -> @@ -451,10 +397,10 @@ memo_member_channel_access(UserId, ChannelId, State, Memo) -> -spec check_member_channel_access(user_id(), channel_id(), guild_state()) -> boolean(). check_member_channel_access(UserId, ChannelId, State) -> - Member = guild_permissions:find_member_by_user_id(UserId, State), + Member = guild_permissions_check:find_member_by_user_id(UserId, State), case Member of undefined -> false; - _ -> guild_permissions:can_view_channel(UserId, ChannelId, Member, State) + _ -> guild_permissions_check:can_view_channel(UserId, ChannelId, Member, State) end. -spec build_viewable_channel_map([channel_id()]) -> #{channel_id() => true}. @@ -498,7 +444,7 @@ reference_session_can_access_message(SessionData, ChannelId, MessageId, State) - case maps:get(user_id, SessionData, undefined) of UserId when is_integer(UserId) -> Perms = guild_permissions:get_member_permissions(UserId, ChannelId, State), - guild_permissions:can_access_message_by_permissions(Perms, MessageId, State); + guild_permissions_check:can_access_message_by_permissions(Perms, MessageId, State); _ -> false end. diff --git a/fluxer_gateway/src/guild/guild_sessions_connect.erl b/fluxer_gateway/src/guild/guild_sessions_connect.erl index 0752703b9..c8cf93106 100644 --- a/fluxer_gateway/src/guild/guild_sessions_connect.erl +++ b/fluxer_gateway/src/guild/guild_sessions_connect.erl @@ -98,7 +98,7 @@ admit_session_connect(UserId, State) -> -spec admit_existing_member_connect(user_id(), guild_state()) -> admit | reject_not_member. admit_existing_member_connect(UserId, State) -> - case guild_data_members:find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> reject_not_member; _Member -> admit end. @@ -205,7 +205,7 @@ finalize_connect(SessionId, UserId, GuildId, InitialGuildId, State, CachedGuildS build_unavailable_reply(GuildId, State) -> Base = #{<<"id">> => integer_to_binary(GuildId), <<"unavailable">> => true}, Response = - case guild_availability:is_unavailable_hidden_enabled(State) of + case guild_availability_check:is_unavailable_hidden_enabled(State) of true -> Base#{<<"unavailable_hidden">> => true}; false -> Base end, @@ -424,7 +424,7 @@ cleanup_session_subscriptions(Session, State) -> SessionId = maps:get(session_id, Session), GuildId = require_guild_id(maps:get(id, State)), passive_sync_registry:delete(SessionId, GuildId), - State1 = guild_member_list:unsubscribe_session(SessionId, State), + State1 = guild_member_list_subscribe:unsubscribe_session(SessionId, State), MemberSubs = maps:get(member_subscriptions, State1, guild_subscriptions:init_state()), NewMemberSubs = guild_subscriptions:unsubscribe_session(SessionId, MemberSubs), State2 = State1#{member_subscriptions => NewMemberSubs}, @@ -566,7 +566,7 @@ lookup_or_compute_viewable(UserId, RoleKey, CacheTab, State) -> -spec compute_viewable_channel_map(user_id(), guild_state()) -> map(). compute_viewable_channel_map(UserId, State) -> guild_sessions:build_viewable_channel_map( - guild_visibility:get_user_viewable_channels(UserId, State) + guild_visibility_channels:get_user_viewable_channels(UserId, State) ). -spec invalidate_viewable_channels_cache(guild_state()) -> ok. diff --git a/fluxer_gateway/src/guild/guild_state.erl b/fluxer_gateway/src/guild/guild_state.erl index 8f9b8b867..3b1d8316d 100644 --- a/fluxer_gateway/src/guild/guild_state.erl +++ b/fluxer_gateway/src/guild/guild_state.erl @@ -185,7 +185,7 @@ resync_channels_after_permission_change(ChanIds, OldState, NewState) -> ], NewState ), - Dispatched = guild_visibility:compute_and_dispatch_visibility_changes_for_channels( + Dispatched = guild_visibility_overwrites:compute_and_dispatch_visibility_changes_for_channels( ChanIds, OldState, Rebuilt ), ok = guild_voice_permission_sync:sync_all_voice_permissions_for_channels( @@ -205,7 +205,9 @@ post_update_member_remove(EventData, NewState) -> post_update_fallback(Event, OldState, NewState) -> case guild_state_utils:needs_visibility_check(Event) of true -> - guild_visibility:compute_and_dispatch_visibility_changes(OldState, NewState); + guild_visibility_overwrites:compute_and_dispatch_visibility_changes( + OldState, NewState + ); false -> NewState end. @@ -215,11 +217,11 @@ post_update_fallback(Event, OldState, NewState) -> dispatch_member_update_visibility(UserId, StateWithUpdatedUser, UpdatedState) when is_integer(UserId), UserId > 0 -> - guild_visibility:compute_and_dispatch_visibility_changes_for_users( + guild_visibility_overwrites:compute_and_dispatch_visibility_changes_for_users( [UserId], StateWithUpdatedUser, UpdatedState ); dispatch_member_update_visibility(_UserId, StateWithUpdatedUser, UpdatedState) -> - guild_visibility:compute_and_dispatch_visibility_changes( + guild_visibility_overwrites:compute_and_dispatch_visibility_changes( StateWithUpdatedUser, UpdatedState ). @@ -308,10 +310,12 @@ guild_update_syncs_unavailability_cache_test() -> ), ?assertEqual( unavailable_for_everyone, - guild_availability:get_cached_unavailability_mode(GuildId) + guild_availability_cache:get_cached_unavailability_mode(GuildId) ), _State2 = update_state(guild_update, #{<<"features">> => []}, State1), - ?assertEqual(available, guild_availability:get_cached_unavailability_mode(GuildId)) + ?assertEqual( + available, guild_availability_cache:get_cached_unavailability_mode(GuildId) + ) after _ = guild_availability:update_unavailability_cache_for_state(CleanupState) end. diff --git a/fluxer_gateway/src/guild/guild_state_channels.erl b/fluxer_gateway/src/guild/guild_state_channels.erl index 983467254..9aafc498a 100644 --- a/fluxer_gateway/src/guild/guild_state_channels.erl +++ b/fluxer_gateway/src/guild/guild_state_channels.erl @@ -30,26 +30,26 @@ handle_guild_update(EventData, Data) -> -spec handle_channel_create(event_data(), guild_data()) -> guild_data(). handle_channel_create(EventData, Data) -> - Channels = guild_data_index:channel_list(Data), + Channels = guild_data_index_channels:channel_list(Data), guild_data_index:put_channels([EventData | Channels], Data). -spec handle_channel_update(event_data(), guild_data()) -> guild_data(). handle_channel_update(EventData, Data) -> - Channels = guild_data_index:channel_list(Data), + Channels = guild_data_index_channels:channel_list(Data), ChannelId = maps:get(<<"id">>, EventData), UpdatedChannels = guild_state_utils:replace_item_by_id(Channels, ChannelId, EventData), guild_data_index:put_channels(UpdatedChannels, Data). -spec handle_channel_update_bulk(event_data(), guild_data()) -> guild_data(). handle_channel_update_bulk(EventData, Data) -> - Channels = guild_data_index:channel_list(Data), + Channels = guild_data_index_channels:channel_list(Data), BulkChannels = maps:get(<<"channels">>, EventData, []), UpdatedChannels = guild_state_utils:bulk_update_items(Channels, BulkChannels), guild_data_index:put_channels(UpdatedChannels, Data). -spec handle_channel_delete(event_data(), guild_data()) -> guild_data(). handle_channel_delete(EventData, Data) -> - Channels = guild_data_index:channel_list(Data), + Channels = guild_data_index_channels:channel_list(Data), ChannelId = maps:get(<<"id">>, EventData), FilteredChannels = guild_state_utils:remove_item_by_id(Channels, ChannelId), guild_data_index:put_channels(FilteredChannels, Data). @@ -69,7 +69,7 @@ handle_message_create(EventData, Data) -> advance_last_message_id(Data, _ChannelId, undefined) -> Data; advance_last_message_id(Data, ChannelId, MessageId) -> - Index = guild_data_index:channel_index(Data), + Index = guild_data_index_channels:channel_index(Data), case maps:find(ChannelId, Index) of {ok, Channel} -> Current = snowflake_id:parse_maybe( @@ -98,7 +98,7 @@ handle_channel_pins_update(EventData, Data) -> update_channel_field_fast(Data, undefined, _, _) -> Data; update_channel_field_fast(Data, ChannelId, Field, Value) -> - Index = guild_data_index:channel_index(Data), + Index = guild_data_index_channels:channel_index(Data), case maps:find(ChannelId, Index) of {ok, Channel} -> Updated = Channel#{Field => Value}, @@ -149,7 +149,7 @@ handle_message_create_updates_last_message_id_test() -> }, EventData = #{<<"channel_id">> => <<"100">>, <<"id">> => <<"700">>}, Result = handle_message_create(EventData, Data), - Channels = guild_data_index:channel_list(Result), + Channels = guild_data_index_channels:channel_list(Result), [C1, C2] = Channels, ?assertEqual(700, maps:get(<<"last_message_id">>, C1)), ?assertEqual(600, maps:get(<<"last_message_id">>, C2)). @@ -162,7 +162,7 @@ handle_message_create_keeps_newer_last_message_id_test() -> }, EventData = #{<<"channel_id">> => <<"100">>, <<"id">> => <<"700">>}, Result = handle_message_create(EventData, Data), - [C1] = guild_data_index:channel_list(Result), + [C1] = guild_data_index_channels:channel_list(Result), ?assertEqual(900, maps:get(<<"last_message_id">>, C1)). handle_message_create_sets_last_message_id_when_missing_test() -> @@ -173,7 +173,7 @@ handle_message_create_sets_last_message_id_when_missing_test() -> }, EventData = #{<<"channel_id">> => <<"100">>, <<"id">> => <<"700">>}, Result = handle_message_create(EventData, Data), - [C1] = guild_data_index:channel_list(Result), + [C1] = guild_data_index_channels:channel_list(Result), ?assertEqual(700, maps:get(<<"last_message_id">>, C1)). -endif. diff --git a/fluxer_gateway/src/guild/guild_state_member.erl b/fluxer_gateway/src/guild/guild_state_member.erl index 7d1ffd659..54885ca69 100644 --- a/fluxer_gateway/src/guild/guild_state_member.erl +++ b/fluxer_gateway/src/guild/guild_state_member.erl @@ -25,15 +25,15 @@ -spec handle_member_add(event_data(), guild_data()) -> guild_data(). handle_member_add(EventData, Data) -> - guild_data_index:put_member(EventData, Data). + guild_data_index_members:put_member(EventData, Data). -spec handle_member_update(event_data(), guild_data()) -> guild_data(). handle_member_update(EventData, Data) -> UserId = extract_user_id(EventData), - Members = guild_data_index:member_map(Data), + Members = guild_data_index_members:member_map(Data), case maps:is_key(UserId, Members) of true -> - guild_data_index:put_member(EventData, Data); + guild_data_index_members:put_member(EventData, Data); false -> Data end. @@ -41,7 +41,7 @@ handle_member_update(EventData, Data) -> -spec handle_member_remove(event_data(), guild_data(), guild_state()) -> guild_data(). handle_member_remove(EventData, Data, _State) -> UserId = extract_user_id(EventData), - guild_data_index:remove_member(UserId, Data). + guild_data_index_members:remove_member(UserId, Data). -spec sync_member(event_data(), guild_state()) -> ok. sync_member(EventData, State) -> @@ -54,7 +54,7 @@ sync_member(EventData, State) -> -spec sync_valid_member(user_id(), guild_state()) -> ok. sync_valid_member(UserId, State) -> Data = maps:get(data, State, #{}), - case guild_data_index:get_member(UserId, Data) of + case guild_data_index_members:get_member(UserId, Data) of undefined -> ok; Member -> sync_local_nif_member(UserId, Member, State) end. @@ -79,7 +79,7 @@ sync_default_member_list_member(UserId, Member, State) -> IsConnected = sets:is_element(UserId, ConnectedUserIds), NotOffline = Status =/= <<"offline">> andalso Status =/= <<"invisible">>, IsOnline = IsConnected andalso NotOffline, - guild_member_list_store:update_member(Ref, UserId, SortKey, RoleIds, IsOnline) + guild_member_list_engine:update_member(Ref, UserId, SortKey, RoleIds, IsOnline) end. -spec sync_member_remove(user_id() | undefined, guild_state()) -> ok. @@ -100,13 +100,13 @@ sync_local_nif_member_remove(UserId, State) -> sync_default_member_list_remove(UserId, State) -> case member_list_engine_ref(State) of undefined -> ok; - Ref -> guild_member_list_store:remove_member(Ref, UserId) + Ref -> guild_member_list_engine:remove_member(Ref, UserId) end. -spec cleanup_removed_member_sessions(guild_state()) -> guild_state(). cleanup_removed_member_sessions(State) -> Data = state_data(State), - MemberUserIds = sets:from_list(guild_data_index:member_ids(Data)), + MemberUserIds = sets:from_list(guild_data_index_members:member_ids(Data)), Sessions = state_sessions(State), FilteredSessions = maps:filter( fun(_K, S) -> @@ -201,7 +201,7 @@ handle_member_update_ignores_non_member_test() -> EventData = #{<<"user">> => #{<<"id">> => <<"999">>}, <<"nick">> => <<"new">>}, Result = handle_member_update(EventData, Data), ?assertEqual(1, map_size(maps:get(<<"members">>, Result))), - ?assertEqual(undefined, guild_data_index:get_member(999, Result)). + ?assertEqual(undefined, guild_data_index_members:get_member(999, Result)). cleanup_removed_member_sessions_removes_non_members_test() -> Data = #{ diff --git a/fluxer_gateway/src/guild/guild_state_roles.erl b/fluxer_gateway/src/guild/guild_state_roles.erl index 56867593a..2ca8e9377 100644 --- a/fluxer_gateway/src/guild/guild_state_roles.erl +++ b/fluxer_gateway/src/guild/guild_state_roles.erl @@ -29,7 +29,7 @@ handle_role_create(EventData, Data) -> undefined -> Data; RoleData -> - Roles = guild_data_index:role_list(Data), + Roles = guild_data_index_roles:role_list(Data), guild_data_index:put_roles([RoleData | Roles], Data) end. @@ -39,7 +39,7 @@ handle_role_update(EventData, Data) -> undefined -> Data; RoleData -> - Roles = guild_data_index:role_list(Data), + Roles = guild_data_index_roles:role_list(Data), RoleId = maps:get(<<"id">>, RoleData), UpdatedRoles = guild_state_utils:replace_item_by_id(Roles, RoleId, RoleData), guild_data_index:put_roles(UpdatedRoles, Data) @@ -47,7 +47,7 @@ handle_role_update(EventData, Data) -> -spec handle_role_update_bulk(event_data(), guild_data()) -> guild_data(). handle_role_update_bulk(EventData, Data) -> - Roles = guild_data_index:role_list(Data), + Roles = guild_data_index_roles:role_list(Data), BulkRoles = maps:get(<<"roles">>, EventData, []), UpdatedRoles = guild_state_utils:bulk_update_items(Roles, BulkRoles), guild_data_index:put_roles(UpdatedRoles, Data). @@ -58,7 +58,7 @@ handle_role_delete(EventData, Data) -> undefined -> Data; RoleIdInt -> - Roles = guild_data_index:role_list(Data), + Roles = guild_data_index_roles:role_list(Data), FilteredRoles = guild_state_utils:remove_item_by_id(Roles, RoleIdInt), Data1 = guild_data_index:put_roles(FilteredRoles, Data), Data2 = guild_state_roles_delete:strip_role_from_members(RoleIdInt, Data1), @@ -85,7 +85,7 @@ sync_zero_engine_hoisted_roles(HoistedRoleIds, State) -> undefined -> false; Ref -> - guild_member_list_store:set_hoisted_roles(Ref, HoistedRoleIds) =:= changed + guild_member_list_engine:set_hoisted_roles(Ref, HoistedRoleIds) =:= changed end. -spec recompute_visibility_for_roles([integer()], guild_state(), guild_state()) -> @@ -107,7 +107,7 @@ recompute_visibility_for_roles(RoleIds, StateWithUpdatedUser, UpdatedState) -> recompute_visibility_for_valid_roles(GuildId, ValidRoleIds, StateWithUpdatedUser, UpdatedState) -> case lists:member(GuildId, ValidRoleIds) of true -> - guild_visibility:compute_and_dispatch_visibility_changes( + guild_visibility_overwrites:compute_and_dispatch_visibility_changes( StateWithUpdatedUser, UpdatedState ); @@ -122,7 +122,7 @@ recompute_visibility_for_valid_roles(GuildId, ValidRoleIds, StateWithUpdatedUser recompute_for_user_ids([], _StateWithUpdatedUser, UpdatedState) -> UpdatedState; recompute_for_user_ids(UserIds, StateWithUpdatedUser, UpdatedState) -> - guild_visibility:compute_and_dispatch_visibility_changes_for_users( + guild_visibility_overwrites:compute_and_dispatch_visibility_changes_for_users( UserIds, StateWithUpdatedUser, UpdatedState @@ -132,8 +132,8 @@ recompute_for_user_ids(UserIds, StateWithUpdatedUser, UpdatedState) -> affected_user_ids_for_roles(RoleIds, StateWithUpdatedUser, UpdatedState) -> OldData = maps:get(data, StateWithUpdatedUser, #{}), NewData = maps:get(data, UpdatedState, #{}), - OldMemberRoleIndex = guild_data_index:member_role_index(OldData), - NewMemberRoleIndex = guild_data_index:member_role_index(NewData), + OldMemberRoleIndex = guild_data_index_members:member_role_index(OldData), + NewMemberRoleIndex = guild_data_index_members:member_role_index(NewData), UserIdSet = lists:foldl( fun(RoleId, AccSet) -> collect_users_for_role(RoleId, OldMemberRoleIndex, NewMemberRoleIndex, AccSet) diff --git a/fluxer_gateway/src/guild/guild_state_roles_delete.erl b/fluxer_gateway/src/guild/guild_state_roles_delete.erl index f60951d11..db4974525 100644 --- a/fluxer_gateway/src/guild/guild_state_roles_delete.erl +++ b/fluxer_gateway/src/guild/guild_state_roles_delete.erl @@ -12,7 +12,7 @@ -spec strip_role_from_members(term(), guild_data()) -> guild_data(). strip_role_from_members(RoleId, Data) when is_integer(RoleId) -> - MemberRoleIndex = guild_data_index:member_role_index(Data), + MemberRoleIndex = guild_data_index_members:member_role_index(Data), AffectedUsers = maps:keys(maps:get(RoleId, MemberRoleIndex, #{})), lists:foldl( fun(UserId, AccData) -> @@ -26,7 +26,7 @@ strip_role_from_members(_RoleId, Data) -> -spec strip_role_from_single_member(user_id(), integer(), guild_data()) -> guild_data(). strip_role_from_single_member(UserId, RoleIdInt, AccData) -> - case guild_data_index:get_member(UserId, AccData) of + case guild_data_index_members:get_member(UserId, AccData) of undefined -> AccData; Member -> @@ -42,11 +42,11 @@ remove_role_from_member(Member, RoleIdInt, AccData) -> end, MemberRoles ), - guild_data_index:put_member(Member#{<<"roles">> => FilteredRoles}, AccData). + guild_data_index_members:put_member(Member#{<<"roles">> => FilteredRoles}, AccData). -spec strip_role_from_channel_overwrites(term(), guild_data()) -> guild_data(). strip_role_from_channel_overwrites(RoleId, Data) when is_integer(RoleId) -> - Channels = guild_data_index:channel_list(Data), + Channels = guild_data_index_channels:channel_list(Data), UpdatedChannels = lists:map( fun(Channel) -> strip_role_overwrite_from_channel(Channel, RoleId) end, Channels diff --git a/fluxer_gateway/src/guild/guild_state_threads.erl b/fluxer_gateway/src/guild/guild_state_threads.erl index 733c847f7..e07f8769b 100644 --- a/fluxer_gateway/src/guild/guild_state_threads.erl +++ b/fluxer_gateway/src/guild/guild_state_threads.erl @@ -185,7 +185,7 @@ archived(_) -> false. bump_forum_parent(Thread, State) -> Data = maps:get(data, State), ParentId = guild_thread_permissions:parent_id(Thread), - Index = guild_data_index:channel_index(Data), + Index = guild_data_index_channels:channel_index(Data), case maps:get(ParentId, Index, undefined) of #{<<"type">> := Type} = Parent when Type =:= 15; Type =:= 16 -> Updated = Parent#{<<"last_message_id">> => maps:get(<<"id">>, Thread)}, @@ -242,7 +242,9 @@ regular_channel_event(Event, Data, State) when -> case snowflake_id:parse_maybe(maps:get(<<"channel_id">>, Data, undefined)) of ChannelId when is_integer(ChannelId) -> - maps:is_key(ChannelId, guild_data_index:channel_index(maps:get(data, State, #{}))); + maps:is_key( + ChannelId, guild_data_index_channels:channel_index(maps:get(data, State, #{})) + ); _ -> true end; @@ -478,7 +480,7 @@ out_of_order_live_messages_still_count_test() -> forum_parent_last_message_id_follows_new_posts_test() -> State0 = state(), State1 = apply_event(thread_create, thread_payload(300, 200), State0), - Index = guild_data_index:channel_index(maps:get(data, State1)), + Index = guild_data_index_channels:channel_index(maps:get(data, State1)), ?assertEqual(300, maps:get(<<"last_message_id">>, maps:get(200, Index))), guild_thread_store:destroy(guild_thread_gate:store(State0)). diff --git a/fluxer_gateway/src/guild/guild_subscription_handler.erl b/fluxer_gateway/src/guild/guild_subscription_handler.erl index 44fd5cd07..f94f29e0c 100644 --- a/fluxer_gateway/src/guild/guild_subscription_handler.erl +++ b/fluxer_gateway/src/guild/guild_subscription_handler.erl @@ -1,6 +1,10 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_subscription_handler). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -export([ @@ -9,10 +13,6 @@ handle_info/2 ]). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -type guild_state() :: map(). -type user_id() :: integer(). -type session_id() :: binary(). @@ -267,10 +267,10 @@ process_lazy_subscribe_for_guild( ) -> CanView = is_integer(SessionUserId) andalso - guild_permissions:can_view_channel( + guild_permissions_check:can_view_channel( SessionUserId, ChannelId, undefined, State ) andalso - guild_permissions:can_view_channel_members( + guild_permissions_check:can_view_channel_members( SessionUserId, ChannelId, undefined, State ) andalso member_list_channel_allowed(SessionId, ChannelId, State), @@ -307,7 +307,7 @@ subscribe_member_list_ranges(undefined, _GuildId, _ChannelId, _SessionId, _Range State; subscribe_member_list_ranges(ListId, GuildId, ChannelId, SessionId, Ranges, State) -> {NewState, ShouldSendSync, NormalizedRanges} = - guild_member_list:subscribe_ranges(SessionId, ListId, Ranges, State), + guild_member_list_subscribe:subscribe_ranges(SessionId, ListId, Ranges, State), process_lazy_subscribe_sync( ShouldSendSync, NormalizedRanges, GuildId, ListId, ChannelId, SessionId, NewState ). @@ -331,7 +331,9 @@ should_ignore_member_list_subscribe(_Ranges, State) -> process_lazy_subscribe_sync(true, [], _GuildId, _ListId, _ChannelId, _SessionId, State) -> State; process_lazy_subscribe_sync(true, RangesToSend, GuildId, ListId, ChannelId, SessionId, State) -> - SyncResponse = guild_member_list:build_sync_response(GuildId, ListId, RangesToSend, State), + SyncResponse = guild_member_list_read:build_sync_response( + GuildId, ListId, RangesToSend, State + ), dispatch_lazy_subscribe_sync(SyncResponse, ChannelId, GuildId, SessionId, State); process_lazy_subscribe_sync(_, _, _GuildId, _ListId, _ChannelId, _SessionId, State) -> State. @@ -418,7 +420,7 @@ handle_added_subscriptions(Added, SessionId, State) -> -spec subscribe_added_member(user_id(), session_id(), map(), guild_state()) -> guild_state(). subscribe_added_member(UserId, SessionId, Prefetched, State) -> Watched = presence_watched(UserId, State), - StateWithPresence = guild_sessions:subscribe_to_user_presence(UserId, State), + StateWithPresence = guild_sessions_presence:subscribe_to_user_presence(UserId, State), case {Watched, maps:find(UserId, Prefetched)} of {true, {ok, Lookup}} -> guild_presence:send_presence_lookup_to_session( @@ -435,7 +437,7 @@ presence_watched(UserId, State) -> -spec handle_removed_subscriptions([user_id()], guild_state()) -> guild_state(). handle_removed_subscriptions(Removed, State) -> lists:foldl( - fun guild_sessions:unsubscribe_from_user_presence/2, + fun guild_sessions_presence:unsubscribe_from_user_presence/2, State, Removed ). @@ -737,7 +739,7 @@ reference_update_member_subscriptions(SessionId, MemberIds, State) -> MemberId =/= Viewer, lists:any( fun(ChannelId) -> maps:is_key(ChannelId, SessionMap) end, - guild_visibility:get_user_viewable_channels(MemberId, State) + guild_visibility_channels:get_user_viewable_channels(MemberId, State) ) ], {NewSubs, Added, Removed} = guild_subscriptions:update_subscriptions_with_delta( @@ -745,7 +747,7 @@ reference_update_member_subscriptions(SessionId, MemberIds, State) -> ), State1 = lists:foldl( fun(UserId, Acc) -> - StateWithPresence = guild_sessions:subscribe_to_user_presence(UserId, Acc), + StateWithPresence = guild_sessions_presence:subscribe_to_user_presence(UserId, Acc), guild_presence:send_cached_presence_to_session(UserId, SessionId, StateWithPresence) end, State#{member_subscriptions => NewSubs}, diff --git a/fluxer_gateway/src/guild/guild_subscription_mutual_channels.erl b/fluxer_gateway/src/guild/guild_subscription_mutual_channels.erl index ab6180b48..3b4c725b9 100644 --- a/fluxer_gateway/src/guild/guild_subscription_mutual_channels.erl +++ b/fluxer_gateway/src/guild/guild_subscription_mutual_channels.erl @@ -1,14 +1,14 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_subscription_mutual_channels). --typing([eqwalizer]). - --export([filter_member_ids/4, filter_session_member_ids/2, filter_session_member_ids/3]). - -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -endif. +-typing([eqwalizer]). + +-export([filter_member_ids/4, filter_session_member_ids/2, filter_session_member_ids/3]). + -type guild_state() :: map(). -type user_id() :: integer(). -type memo() :: #{exceptions := sets:set(user_id()), cache := #{term() => boolean()}}. @@ -154,7 +154,9 @@ member_channel_key(MemberId, Exceptions, State, Cache) -> true -> {Key, Cache}; false -> - {Key, Cache#{Key => guild_visibility:get_user_viewable_channels(MemberId, State)}} + {Key, Cache#{ + Key => guild_visibility_channels:get_user_viewable_channels(MemberId, State) + }} end. -spec session_channel_map(user_id(), guild_state()) -> map(). @@ -162,7 +164,7 @@ session_channel_map(SessionUserId, State) -> case guild_visibility_channels:get_cached_viewable_channel_map(SessionUserId, State) of undefined -> guild_sessions:build_viewable_channel_map( - guild_visibility:get_user_viewable_channels(SessionUserId, State) + guild_visibility_channels:get_user_viewable_channels(SessionUserId, State) ); Map -> Map @@ -216,8 +218,10 @@ cached_has_mutual_channel(MemberId, RawRoles, SessionMap, State, Cache, Memo) -> -spec memo_key(user_id(), sets:set(user_id()), guild_state()) -> {ok, term()} | bypass. memo_key(MemberId, Exceptions, State) -> case sets:is_element(MemberId, Exceptions) of - true -> bypass; - false -> member_roles_key(guild_permissions:find_member_by_user_id(MemberId, State)) + true -> + bypass; + false -> + member_roles_key(guild_permissions_check:find_member_by_user_id(MemberId, State)) end. -spec member_roles_key(map() | undefined) -> {ok, term()} | bypass. @@ -257,7 +261,7 @@ overwrite_target_ids(Data) -> maps:fold( fun(_ChannelId, Channel, Acc) -> channel_target_ids(Channel, Acc) end, Cached, - guild_data_index:channel_index(Data) + guild_data_index_channels:channel_index(Data) ). -spec cached_target_ids(term(), [user_id()]) -> [user_id()]. @@ -362,7 +366,7 @@ reference_keep(MemberId, SessionUserId, _SessionMap, _State) when -> false; reference_keep(MemberId, _SessionUserId, SessionMap, State) -> - MemberChannels = guild_visibility:get_user_viewable_channels(MemberId, State), + MemberChannels = guild_visibility_channels:get_user_viewable_channels(MemberId, State), case has_shared_channel(MemberChannels, SessionMap) of true -> {true, MemberId}; false -> false @@ -388,15 +392,6 @@ filter_member_ids_matches_unmemoised_test() -> [filter_member_ids(#{}, Viewer, Ids, State) || Viewer <- [10, 30, 7, 99]] ). -memo_holds_one_entry_per_roles_term_test() -> - State = test_state(), - SessionMap = session_channel_map(10, State), - {First, Memo1} = memoised_has_mutual_channel(20, SessionMap, State, new_memo(State)), - {Second, Memo2} = memoised_has_mutual_channel(21, SessionMap, State, Memo1), - ?assert(First), - ?assertEqual(First, Second), - ?assertEqual([1, 1], [map_size(maps:get(cache, M)) || M <- [Memo1, Memo2]]). - exception_user_is_not_answered_from_its_role_set_test() -> State = test_state(), SessionMap = session_channel_map(10, State), diff --git a/fluxer_gateway/src/guild/guild_subscriptions.erl b/fluxer_gateway/src/guild/guild_subscriptions.erl index 34eae79f5..c2c138230 100644 --- a/fluxer_gateway/src/guild/guild_subscriptions.erl +++ b/fluxer_gateway/src/guild/guild_subscriptions.erl @@ -1,6 +1,10 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_subscriptions). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -export([ @@ -15,10 +19,6 @@ get_user_ids_for_session/2 ]). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -type session_id() :: binary(). -type user_id() :: integer(). -type subscription_state() :: #{user_id() => sets:set(session_id())}. @@ -146,20 +146,6 @@ collect_user_for_session(UserId, SessionId, Subscribers, Acc) -> -ifdef(TEST). -unsubscribe_test() -> - State0 = init_state(), - State1 = subscribe(<<"session1">>, 123, State0), - State2 = unsubscribe(<<"session1">>, 123, State1), - ?assertNot(is_subscribed(<<"session1">>, 123, State2)). - -unsubscribe_one_of_many_test() -> - State0 = init_state(), - State1 = subscribe(<<"session1">>, 123, State0), - State2 = subscribe(<<"session2">>, 123, State1), - State3 = unsubscribe(<<"session1">>, 123, State2), - ?assertNot(is_subscribed(<<"session1">>, 123, State3)), - ?assert(is_subscribed(<<"session2">>, 123, State3)). - unsubscribe_session_test() -> State0 = init_state(), State1 = subscribe(<<"session1">>, 123, State0), @@ -170,15 +156,6 @@ unsubscribe_session_test() -> ?assertNot(is_subscribed(<<"session1">>, 456, State4)), ?assert(is_subscribed(<<"session2">>, 123, State4)). -update_subscriptions_test() -> - State0 = init_state(), - State1 = subscribe(<<"session1">>, 100, State0), - State2 = subscribe(<<"session1">>, 200, State1), - State3 = update_subscriptions(<<"session1">>, [200, 300], State2), - ?assertNot(is_subscribed(<<"session1">>, 100, State3)), - ?assert(is_subscribed(<<"session1">>, 200, State3)), - ?assert(is_subscribed(<<"session1">>, 300, State3)). - %% The subscription map as unsubscribe_session/2 built it before it stopped %% rebuilding untouched entries. %% The state and delta handle_update_member_subscriptions_local/4 derived before it diff --git a/fluxer_gateway/src/guild/guild_thread_dispatch.erl b/fluxer_gateway/src/guild/guild_thread_dispatch.erl index 1e2d9bfed..66707e226 100644 --- a/fluxer_gateway/src/guild/guild_thread_dispatch.erl +++ b/fluxer_gateway/src/guild/guild_thread_dispatch.erl @@ -352,7 +352,7 @@ decorate_added(Member, PresenceCtx, State) -> Stripped = maps:without([<<"muted">>, <<"mute_config">>], Member), case snowflake_id:parse_maybe(maps:get(<<"user_id">>, Member, undefined)) of UserId when is_integer(UserId) -> - GuildMember = guild_data_members:find_member_by_user_id(UserId, State), + GuildMember = guild_permissions_check:find_member_by_user_id(UserId, State), Stripped#{ <<"member">> => null_if_undefined(GuildMember), <<"presence">> => presence(UserId, PresenceCtx) @@ -433,7 +433,7 @@ is_bot(Session) -> -spec can_view(map(), map(), guild_state()) -> boolean(). can_view(#{user_id := UserId}, Thread, State) when is_integer(UserId) -> - Member = guild_permissions:find_member_by_user_id(UserId, State), + Member = guild_permissions_check:find_member_by_user_id(UserId, State), Member =/= undefined andalso guild_thread_view:can_view(UserId, Thread, Member, State); can_view(_Session, _Thread, _State) -> false. diff --git a/fluxer_gateway/src/guild/guild_thread_flip.erl b/fluxer_gateway/src/guild/guild_thread_flip.erl index 20dabd508..9dd98f3b6 100644 --- a/fluxer_gateway/src/guild/guild_thread_flip.erl +++ b/fluxer_gateway/src/guild/guild_thread_flip.erl @@ -228,7 +228,10 @@ apply_flip(FlipData, Held, OldState) -> merge_channels(FlipData, Held, Data) -> case maps:get(<<"channels">>, FlipData, undefined) of Channels when is_list(Channels) -> - Live = [C || C <- guild_data_index:channel_list(Data), not thread_only_channel(C)], + Live = [ + C + || C <- guild_data_index_channels:channel_list(Data), not thread_only_channel(C) + ], Gated = lists:foldl( fun replay_channel/2, [C || C <- Channels, thread_only_channel(C)], @@ -243,7 +246,7 @@ merge_channels(FlipData, Held, Data) -> thread_only_ids(Data) -> lists:sort([ snowflake_id:parse_optional(maps:get(<<"id">>, C, undefined)) - || C <- guild_data_index:channel_list(Data), thread_only_channel(C) + || C <- guild_data_index_channels:channel_list(Data), thread_only_channel(C) ]). -spec replay_channel({atom(), map()}, [map()]) -> [map()]. @@ -762,7 +765,7 @@ merge_channels_keeps_live_channels_and_replays_forum_events_test() -> Merged = merge_channels(Snapshot, Held, Live), Names = maps:from_list([ {maps:get(<<"id">>, C), maps:get(<<"name">>, C)} - || C <- guild_data_index:channel_list(Merged) + || C <- guild_data_index_channels:channel_list(Merged) ]), ?assertEqual( #{1 => <<"live">>, 2 => <<"renamed-forum">>, 5 => <<"new-forum">>}, diff --git a/fluxer_gateway/src/guild/guild_thread_gate.erl b/fluxer_gateway/src/guild/guild_thread_gate.erl index cc1b51ae4..7d08829da 100644 --- a/fluxer_gateway/src/guild/guild_thread_gate.erl +++ b/fluxer_gateway/src/guild/guild_thread_gate.erl @@ -161,7 +161,7 @@ is_gated_channel_type(Type) -> -spec channel_type(integer(), guild_state()) -> integer() | undefined. channel_type(ChannelId, State) -> - case maps:get(ChannelId, guild_data_index:channel_index(data(State)), undefined) of + case maps:get(ChannelId, guild_data_index_channels:channel_index(data(State)), undefined) of #{<<"type">> := Type} when is_integer(Type) -> Type; _ -> undefined end. @@ -232,12 +232,14 @@ category_visible(Session, CategoryId, State) -> plain_category_visible(#{user_id := UserId}, CategoryId, PlainChildren, State) when is_integer(UserId) -> - Member = guild_permissions:find_member_by_user_id(UserId, State), + Member = guild_permissions_check:find_member_by_user_id(UserId, State), Member =/= undefined andalso - (guild_permissions:can_view_channel_by_permissions(UserId, CategoryId, Member, State) orelse + (guild_permissions_check:can_view_channel_by_permissions( + UserId, CategoryId, Member, State + ) orelse lists:any( fun(ChildId) -> - guild_permissions:can_view_channel(UserId, ChildId, Member, State) + guild_permissions_check:can_view_channel(UserId, ChildId, Member, State) end, PlainChildren )); @@ -391,14 +393,14 @@ store_access_memo(_UserId, _Ok, Memo) -> -spec resolve_thread_access(integer(), map(), binary() | undefined, guild_state()) -> boolean(). resolve_thread_access(UserId, Thread, MessageId, State) -> - case guild_permissions:find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> false; Member -> Perms = guild_thread_permissions:resolve(UserId, Thread, Member, State), - permission_bits:has(Perms, constants:view_channel_permission()) andalso + bitset:has(Perms, constants:view_channel_permission()) andalso (MessageId =:= undefined orelse - guild_permissions:can_access_message_by_permissions( + guild_permissions_check:can_access_message_by_permissions( Perms, MessageId, State )) end. @@ -554,7 +556,7 @@ mask_overwrite(Overwrite) -> mask_bits(Value) when is_integer(Value) -> Value band bnot ?THREAD_PERMISSION_BITS; mask_bits(Value) when is_binary(Value) -> - case permission_bits:parse_optional(Value) of + case bitset:parse_optional(Value) of Bits when is_integer(Bits) -> integer_to_binary(Bits band bnot ?THREAD_PERMISSION_BITS); _ -> Value end; @@ -586,7 +588,7 @@ recipient_active(ChannelId, State) -> -spec is_bot_member(integer(), guild_state()) -> boolean(). is_bot_member(UserId, State) -> - case guild_permissions:find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of #{<<"user">> := #{<<"bot">> := true}} -> true; _ -> false end. @@ -713,8 +715,8 @@ thread_sessions_skip_permission_resolve_for_viewable_public_parent_test() -> meck:expect(guild_thread_permissions, resolve, fun(_UserId, _Thread, _Member, _State) -> 0 end), - meck:new(guild_permissions, [passthrough, no_link]), - meck:expect(guild_permissions, find_member_by_user_id, fun(_UserId, _State) -> #{} end), + meck:new(guild_permissions_check, [passthrough, no_link]), + meck:expect(guild_permissions_check, find_member_by_user_id, fun(_UserId, _State) -> #{} end), try Open = thread_sessions(typing_start, #{}, 20, Public, undefined, State), ?assertEqual([<<"a">>, <<"b">>], lists:sort([Sid || {Sid, _} <- Open])), @@ -724,7 +726,7 @@ thread_sessions_skip_permission_resolve_for_viewable_public_parent_test() -> ?assertEqual([], thread_sessions(typing_start, #{}, 20, Private, undefined, State)), ?assertEqual(2, meck:num_calls(guild_thread_permissions, resolve, '_')) after - meck:unload(guild_permissions), + meck:unload(guild_permissions_check), meck:unload(guild_thread_permissions) end. diff --git a/fluxer_gateway/src/guild/guild_thread_load.erl b/fluxer_gateway/src/guild/guild_thread_load.erl index c2ec0a9f3..29a6db753 100644 --- a/fluxer_gateway/src/guild/guild_thread_load.erl +++ b/fluxer_gateway/src/guild/guild_thread_load.erl @@ -334,7 +334,10 @@ merge_parent_settings(Settings, State) -> } || S <- Settings, is_map(S) ]), - Channels = [merge_parent_setting(C, ById) || C <- guild_data_index:channel_list(Data)], + Channels = [ + merge_parent_setting(C, ById) + || C <- guild_data_index_channels:channel_list(Data) + ], State#{data => guild_data_index:put_channels(Channels, Data)}. -spec merge_parent_setting(map(), map()) -> map(). @@ -749,7 +752,7 @@ loaded_forum_without_a_config_row_gets_default_surface_fields_test() -> }, #{id => 5, data => Data, sessions => #{}} ), - [Loaded] = guild_data_index:channel_list(maps:get(data, State)), + [Loaded] = guild_data_index_channels:channel_list(maps:get(data, State)), ?assertEqual([], maps:get(<<"available_tags">>, Loaded)), ?assertEqual(<<"match_some">>, maps:get(<<"default_tag_setting">>, Loaded)), ?assertEqual(0, maps:get(<<"default_forum_layout">>, Loaded)), diff --git a/fluxer_gateway/src/guild/guild_thread_permissions.erl b/fluxer_gateway/src/guild/guild_thread_permissions.erl index f019b142d..3cd1392f3 100644 --- a/fluxer_gateway/src/guild/guild_thread_permissions.erl +++ b/fluxer_gateway/src/guild/guild_thread_permissions.erl @@ -38,7 +38,7 @@ resolve(UserId, Thread, Member, State) -> alias_permissions(?ALL_PERMISSIONS) -> ?ALL_PERMISSIONS; alias_permissions(Perms) -> - case permission_bits:has(Perms, constants:send_messages_in_threads_permission()) of + case bitset:has(Perms, constants:send_messages_in_threads_permission()) of true -> Perms bor ?SEND_MESSAGES; false -> Perms band bnot ?SEND_MESSAGES end. @@ -47,8 +47,8 @@ alias_permissions(Perms) -> is_moderator(?ALL_PERMISSIONS, _Member) -> true; is_moderator(Perms, Member) -> - permission_bits:has(Perms, constants:administrator_permission()) orelse - (permission_bits:has(Perms, constants:manage_threads_permission()) andalso + bitset:has(Perms, constants:administrator_permission()) orelse + (bitset:has(Perms, constants:manage_threads_permission()) andalso not timed_out(Member)). -spec can_view_thread_from_parent( @@ -56,7 +56,7 @@ is_moderator(Perms, Member) -> ) -> boolean(). can_view_thread_from_parent(UserId, Thread, Perms, Member, State) -> - permission_bits:has(Perms, constants:view_channel_permission()) andalso + bitset:has(Perms, constants:view_channel_permission()) andalso (not is_private(Thread) orelse is_moderator(Perms, Member) orelse is_thread_member(UserId, Thread, State)). @@ -72,7 +72,7 @@ parent_id(Thread) -> parent_channel(Thread, State) -> case parent_id(Thread) of ParentId when is_integer(ParentId) -> - case guild_permissions:find_channel_by_id(ParentId, State) of + case guild_permissions_check:find_channel_by_id(ParentId, State) of undefined -> undefined; _Channel -> ParentId end; diff --git a/fluxer_gateway/src/guild/guild_thread_push.erl b/fluxer_gateway/src/guild/guild_thread_push.erl index 26bd9f6a3..70e20a565 100644 --- a/fluxer_gateway/src/guild/guild_thread_push.erl +++ b/fluxer_gateway/src/guild/guild_thread_push.erl @@ -114,7 +114,7 @@ forum_starter_scope(undefined, _Data) -> -spec parent_channel(integer() | undefined, map()) -> map(). parent_channel(ParentId, Data) when is_integer(ParentId) -> - case maps:get(ParentId, guild_data_index:channel_index(Data), undefined) of + case maps:get(ParentId, guild_data_index_channels:channel_index(Data), undefined) of Parent when is_map(Parent) -> Parent; _ -> #{} end; diff --git a/fluxer_gateway/src/guild/guild_thread_subscriptions.erl b/fluxer_gateway/src/guild/guild_thread_subscriptions.erl index a5c4d2000..c5fcc1e98 100644 --- a/fluxer_gateway/src/guild/guild_thread_subscriptions.erl +++ b/fluxer_gateway/src/guild/guild_thread_subscriptions.erl @@ -96,7 +96,7 @@ viewable_thread(Session, ThreadId, State) -> -spec session_can_view(map(), map(), guild_state()) -> boolean(). session_can_view(#{user_id := UserId}, Thread, State) when is_integer(UserId) -> - case guild_permissions:find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> false; Member -> guild_thread_view:can_view(UserId, Thread, Member, State) end; @@ -261,7 +261,7 @@ needs_resync(_Session, _Tab) -> -spec send_list_sync(pid(), integer(), [integer()] | all | joined, guild_state()) -> ok. send_list_sync(Pid, UserId, Parents, #{id := GuildId} = State) -> - Member = guild_permissions:find_member_by_user_id(UserId, State), + Member = guild_permissions_check:find_member_by_user_id(UserId, State), Payload = guild_thread_view:list_sync(GuildId, UserId, Parents, Member, State), gateway_dispatch_relay:dispatch(Pid, thread_list_sync, Payload, GuildId). @@ -527,7 +527,7 @@ list_member(UserId, ThreadMember, Ctx, State) -> #{}, UserId, Ctx ), GuildMember = - case guild_data_members:find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> null; M -> M end, diff --git a/fluxer_gateway/src/guild/guild_thread_view.erl b/fluxer_gateway/src/guild/guild_thread_view.erl index 9d8482601..7bdb3011c 100644 --- a/fluxer_gateway/src/guild/guild_thread_view.erl +++ b/fluxer_gateway/src/guild/guild_thread_view.erl @@ -72,7 +72,7 @@ thread_id(Thread) -> -spec can_view(integer(), map(), map() | undefined, guild_state()) -> boolean(). can_view(UserId, Thread, Member, State) -> Perms = guild_thread_permissions:resolve(UserId, Thread, Member, State), - permission_bits:has(Perms, constants:view_channel_permission()). + bitset:has(Perms, constants:view_channel_permission()). -spec viewable_thread_ids( integer(), [integer()] | all | joined, map() | undefined, guild_state() @@ -171,7 +171,7 @@ drop_forum_only_categories(UserId, Member, State, Channels) -> of {4, Id} when is_integer(Id) -> sets:is_element(Id, Parents) orelse - guild_permissions:can_view_channel_by_permissions( + guild_permissions_check:can_view_channel_by_permissions( UserId, Id, Member, State ) orelse guild_virtual_channel_access:has_virtual_access(UserId, Id, State); diff --git a/fluxer_gateway/src/guild/guild_unified_subscriptions.erl b/fluxer_gateway/src/guild/guild_unified_subscriptions.erl index 93d02d129..da3f35f5e 100644 --- a/fluxer_gateway/src/guild/guild_unified_subscriptions.erl +++ b/fluxer_gateway/src/guild/guild_unified_subscriptions.erl @@ -1,14 +1,14 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_unified_subscriptions). --typing([eqwalizer]). - --export([handle_subscriptions/3, replay_guild_subscription/6]). - -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -endif. +-typing([eqwalizer]). + +-export([handle_subscriptions/3, replay_guild_subscription/6]). + -type session_state() :: map(). -type session_id() :: binary() | undefined. -export_type([session_state/0, session_id/0]). diff --git a/fluxer_gateway/src/guild/guild_user_data.erl b/fluxer_gateway/src/guild/guild_user_data.erl index eae6a3bb1..782e8b9c2 100644 --- a/fluxer_gateway/src/guild/guild_user_data.erl +++ b/fluxer_gateway/src/guild/guild_user_data.erl @@ -1,13 +1,13 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_user_data). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). --export([ - maybe_update_cached_user_data/3, - handle_user_data_update/3, - check_user_data_differs/2 -]). +-export([maybe_update_cached_user_data/3, handle_user_data_update/3]). -export_type([guild_state/0, user_id/0]). @@ -15,10 +15,6 @@ -type user_id() :: integer(). -type member() :: map(). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -spec update_members_user_data(map(), user_id(), map()) -> map(). update_members_user_data(Members, UserId, UserData) -> maps:map( @@ -43,7 +39,7 @@ maybe_update_member_user(Member, UserId, EventData) -> -spec handle_user_data_update(user_id(), map(), guild_state()) -> guild_state(). handle_user_data_update(UserId, UserData, State) -> - case guild_permissions:find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> State; Member -> @@ -53,7 +49,7 @@ handle_user_data_update(UserId, UserData, State) -> -spec maybe_apply_changed_user_data(user_id(), map(), member(), guild_state()) -> guild_state(). maybe_apply_changed_user_data(UserId, UserData, Member, State) -> CurrentUserData = maps:get(<<"user">>, Member, #{}), - case check_user_data_differs(CurrentUserData, UserData) of + case utils:check_user_data_differs(CurrentUserData, UserData) of false -> State; true -> apply_user_data_update(UserId, UserData, State) end. @@ -61,18 +57,22 @@ maybe_apply_changed_user_data(UserId, UserData, Member, State) -> -spec apply_user_data_update(user_id(), map(), guild_state()) -> guild_state(). apply_user_data_update(UserId, UserData, State) -> Data = maps:get(data, State), - Members = guild_data_index:member_map(Data), - NormalizedUserData = ensure_map(guild_data_normalize:member(#{<<"user">> => UserData})), + Members = guild_data_index_members:member_map(Data), + NormalizedUserData = map_utils:ensure_map( + guild_data_normalize:member(#{<<"user">> => UserData}) + ), #{<<"user">> := UpdatedUserData} = NormalizedUserData, - UpdatedMembers = update_members_user_data(Members, UserId, ensure_map(UpdatedUserData)), - UpdatedData = guild_data_index:put_member_map(UpdatedMembers, Data), + UpdatedMembers = update_members_user_data( + Members, UserId, map_utils:ensure_map(UpdatedUserData) + ), + UpdatedData = guild_data_index_members:put_member_map(UpdatedMembers, Data), UpdatedState = State#{data => UpdatedData}, dispatch_guild_member_update(UserId, UpdatedState), UpdatedState. -spec dispatch_guild_member_update(user_id(), guild_state()) -> ok. dispatch_guild_member_update(UserId, State) -> - case guild_permissions:find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> ok; M -> @@ -84,10 +84,6 @@ dispatch_guild_member_update(UserId, State) -> ) end. --spec check_user_data_differs(map(), map()) -> boolean(). -check_user_data_differs(CurrentUserData, NewUserData) -> - utils:check_user_data_differs(CurrentUserData, NewUserData). - -spec maybe_update_cached_user_data(atom(), map(), guild_state()) -> guild_state(). maybe_update_cached_user_data(Event, EventData, State) when Event =:= message_create; Event =:= message_update @@ -112,7 +108,7 @@ maybe_update_author_data(AuthorData, State) -> -spec maybe_update_known_member(user_id(), map(), guild_state()) -> guild_state(). maybe_update_known_member(UserId, AuthorData, State) -> - case guild_permissions:find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> State; Member -> @@ -123,15 +119,11 @@ maybe_update_known_member(UserId, AuthorData, State) -> guild_state(). maybe_handle_changed_author_data(UserId, AuthorData, Member, State) -> CurrentUserData = maps:get(<<"user">>, Member, #{}), - case check_user_data_differs(CurrentUserData, AuthorData) of + case utils:check_user_data_differs(CurrentUserData, AuthorData) of true -> handle_user_data_update(UserId, AuthorData, State); false -> State end. --spec ensure_map(term()) -> map(). -ensure_map(M) when is_map(M) -> M; -ensure_map(_) -> #{}. - -ifdef(TEST). handle_user_data_update_no_change_test() -> @@ -169,7 +161,7 @@ test_state() -> normalized_test_state() -> #{ id => 42, - data => guild_data_index:put_member( + data => guild_data_index_members:put_member( #{ <<"user">> => #{ <<"id">> => <<"100">>, diff --git a/fluxer_gateway/src/guild/guild_virtual_channel_access.erl b/fluxer_gateway/src/guild/guild_virtual_channel_access.erl index f513462a8..e1e0c9fe6 100644 --- a/fluxer_gateway/src/guild/guild_virtual_channel_access.erl +++ b/fluxer_gateway/src/guild/guild_virtual_channel_access.erl @@ -233,7 +233,7 @@ is_move_pending(UserId, ChannelId, State) -> -spec dispatch_channel_visibility_change(user_id(), channel_id(), add | remove, guild_state()) -> ok. dispatch_channel_visibility_change(UserId, ChannelId, Action, State) -> - case guild_permissions:find_channel_by_id(ChannelId, State) of + case guild_permissions_check:find_channel_by_id(ChannelId, State) of undefined -> ok; Channel -> diff --git a/fluxer_gateway/src/guild/guild_visibility.erl b/fluxer_gateway/src/guild/guild_visibility.erl deleted file mode 100644 index 18f31abc9..000000000 --- a/fluxer_gateway/src/guild/guild_visibility.erl +++ /dev/null @@ -1,64 +0,0 @@ -%% SPDX-License-Identifier: AGPL-3.0-or-later - --module(guild_visibility). --typing([eqwalizer]). - --export([ - get_user_viewable_channels/2, - compute_and_dispatch_visibility_changes/2, - compute_and_dispatch_visibility_changes_for_users/3, - compute_and_dispatch_visibility_changes_for_channels/3, - viewable_channel_set/2 -]). - --export_type([guild_state/0, user_id/0, channel_id/0]). - --type guild_state() :: map(). --type user_id() :: integer(). --type channel_id() :: integer(). - --spec get_user_viewable_channels(user_id(), guild_state()) -> [channel_id()]. -get_user_viewable_channels(UserId, State) -> - guild_visibility_channels:get_user_viewable_channels(UserId, State). - --spec viewable_channel_set(user_id(), guild_state()) -> sets:set(channel_id()). -viewable_channel_set(UserId, State) -> - guild_visibility_channels:viewable_channel_set(UserId, State). - --spec compute_and_dispatch_visibility_changes(guild_state(), guild_state()) -> guild_state(). -compute_and_dispatch_visibility_changes(OldState, NewState) -> - guild_visibility_overwrites:compute_and_dispatch_visibility_changes(OldState, NewState). - --spec compute_and_dispatch_visibility_changes_for_users( - [user_id()], guild_state(), guild_state() -) -> guild_state(). -compute_and_dispatch_visibility_changes_for_users(UserIds, OldState, NewState) -> - guild_visibility_overwrites:compute_and_dispatch_visibility_changes_for_users( - UserIds, OldState, NewState - ). - --spec compute_and_dispatch_visibility_changes_for_channels( - [channel_id()], guild_state(), guild_state() -) -> guild_state(). -compute_and_dispatch_visibility_changes_for_channels(ChannelIds, OldState, NewState) -> - guild_visibility_overwrites:compute_and_dispatch_visibility_changes_for_channels( - ChannelIds, OldState, NewState - ). - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). - -get_user_viewable_channels_returns_empty_for_non_member_test() -> - State = #{ - data => #{ - <<"channels">> => [#{<<"id">> => <<"100">>, <<"type">> => 0}], - <<"members">> => [] - } - }, - ?assertEqual([], get_user_viewable_channels(999, State)). - -viewable_channel_set_returns_empty_for_invalid_user_test() -> - State = #{data => #{}}, - ?assertEqual(sets:new(), viewable_channel_set(0, State)). - --endif. diff --git a/fluxer_gateway/src/guild/guild_visibility_channels.erl b/fluxer_gateway/src/guild/guild_visibility_channels.erl index 54a15ee73..15cbdbcdf 100644 --- a/fluxer_gateway/src/guild/guild_visibility_channels.erl +++ b/fluxer_gateway/src/guild/guild_visibility_channels.erl @@ -14,7 +14,6 @@ preserve_connected_channels/4, filter_connected_session_entries/1, ensure_viewable_channel_map/3, - channel_is_visible/4, ensure_new_channel_visibility/5, update_viewable_map_for_channel/3 ]). @@ -29,7 +28,7 @@ get_user_viewable_channels(UserId, State) -> Data = map_utils:ensure_map(map_utils:get_safe(State, data, #{})), Channels = map_utils:ensure_list(maps:get(<<"channels">>, Data, [])), - Member = guild_permissions:find_member_by_user_id(UserId, State), + Member = guild_permissions_check:find_member_by_user_id(UserId, State), case Member of undefined -> []; @@ -42,7 +41,7 @@ get_user_viewable_channels(UserId, State) -> user_id(), guild_permissions:base_permissions(), [map()], guild_state() ) -> [channel_id()]. compute_viewable_with_categories(UserId, Base, Channels, State) -> - Viewable = guild_permissions:viewable_channel_ids(UserId, Base, Channels, State), + Viewable = guild_permissions_check:viewable_channel_ids(UserId, Base, Channels, State), {ViewableIds, ViewableIdSet, NeededParentIds} = lists:foldl( fun(Channel, {Ids, IdSet, Parents}) -> collect_viewable_channel(Channel, Viewable, {Ids, IdSet, Parents}) @@ -63,7 +62,7 @@ compute_viewable_with_categories(UserId, Base, Channels, State) -> shares_viewable_channel(UserId, ChannelMap, State) -> Data = map_utils:ensure_map(map_utils:get_safe(State, data, #{})), Channels = map_utils:ensure_list(maps:get(<<"channels">>, Data, [])), - case guild_permissions:find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> false; Member -> @@ -268,16 +267,11 @@ ensure_viewable_channel_map(SessionData, UserId, State) -> viewable_channel_map(sets:from_list(get_user_viewable_channels(UserId, State))) end. --spec channel_is_visible(user_id(), channel_id(), map() | undefined, guild_state()) -> - boolean(). -channel_is_visible(UserId, ChannelId, Member, State) -> - guild_permissions:can_view_channel(UserId, ChannelId, Member, State). - -spec ensure_new_channel_visibility( user_id(), channel_id(), sets:set(channel_id()), map() | undefined, guild_state() ) -> {guild_state(), boolean()}. ensure_new_channel_visibility(UserId, ChannelId, ConnectedSet, NewMember, State) -> - NewVisible0 = channel_is_visible(UserId, ChannelId, NewMember, State), + NewVisible0 = guild_permissions_check:can_view_channel(UserId, ChannelId, NewMember, State), case {NewVisible0, sets:is_element(ChannelId, ConnectedSet)} of {true, _} -> {State, true}; @@ -311,7 +305,7 @@ update_viewable_map_for_channel(ViewableMap, ChannelId, false) -> channel_viewable(UserId, Member, Channel, State) -> case channel_id(Channel) of ChannelId when is_integer(ChannelId) -> - guild_permissions:can_view_channel(UserId, ChannelId, Member, State); + guild_permissions_check:can_view_channel(UserId, ChannelId, Member, State); undefined -> false end. @@ -323,3 +317,21 @@ channel_id(Channel) -> -spec channel_parent_id(map()) -> channel_id() | undefined. channel_parent_id(Channel) -> snowflake_id:parse_maybe(maps:get(<<"parent_id">>, Channel, undefined)). + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). + +get_user_viewable_channels_returns_empty_for_non_member_test() -> + State = #{ + data => #{ + <<"channels">> => [#{<<"id">> => <<"100">>, <<"type">> => 0}], + <<"members">> => [] + } + }, + ?assertEqual([], guild_visibility_channels:get_user_viewable_channels(999, State)). + +viewable_channel_set_returns_empty_for_invalid_user_test() -> + State = #{data => #{}}, + ?assertEqual(sets:new(), guild_visibility_channels:viewable_channel_set(0, State)). + +-endif. diff --git a/fluxer_gateway/src/guild/guild_visibility_memo.erl b/fluxer_gateway/src/guild/guild_visibility_memo.erl index 4d52a68fe..356133da8 100644 --- a/fluxer_gateway/src/guild/guild_visibility_memo.erl +++ b/fluxer_gateway/src/guild/guild_visibility_memo.erl @@ -32,8 +32,8 @@ new(ChannelIds, OldState, NewState) -> [ Id || Id <- ChannelIds, - guild_permissions:view_inputs(Id, OldState) =:= - guild_permissions:view_inputs(Id, NewState) + guild_permissions_check:view_inputs(Id, OldState) =:= + guild_permissions_check:view_inputs(Id, NewState) ], true ), @@ -79,7 +79,7 @@ compute(ChannelIds, OldState, NewState, Reusable, ChangeContext) -> {boolean(), boolean()}. visibility(ChannelId, OldState, NewState, Reusable, ChangeContext) -> #{user_id := UserId, old_member := OldMember, new_member := NewMember} = ChangeContext, - OldVisible = guild_visibility_channels:channel_is_visible( + OldVisible = guild_permissions_check:can_view_channel( UserId, ChannelId, OldMember, OldState ), case maps:is_key(ChannelId, Reusable) of @@ -87,7 +87,7 @@ visibility(ChannelId, OldState, NewState, Reusable, ChangeContext) -> {OldVisible, OldVisible}; false -> {OldVisible, - guild_visibility_channels:channel_is_visible( + guild_permissions_check:can_view_channel( UserId, ChannelId, NewMember, NewState )} end. diff --git a/fluxer_gateway/src/guild/guild_visibility_overwrites.erl b/fluxer_gateway/src/guild/guild_visibility_overwrites.erl index 1d04d0d30..0cc404024 100644 --- a/fluxer_gateway/src/guild/guild_visibility_overwrites.erl +++ b/fluxer_gateway/src/guild/guild_visibility_overwrites.erl @@ -289,8 +289,8 @@ dispatch_per_channel_changes( {NewState, Memo}; true -> Pid = maps:get(pid, SessionData, undefined), - OldMember = guild_permissions:find_member_by_user_id(UserId, OldState), - NewMember = guild_permissions:find_member_by_user_id(UserId, NewState), + OldMember = guild_permissions_check:find_member_by_user_id(UserId, OldState), + NewMember = guild_permissions_check:find_member_by_user_id(UserId, NewState), ConnectedSet = maps:get(UserId, ConnectedVoiceByUser, sets:new()), InitialViewableMap = guild_visibility_channels:ensure_viewable_channel_map( SessionData, UserId, OldState @@ -370,7 +370,7 @@ apply_channel_change(ChannelId, {ViewableMapAcc, StateAcc}, ChangeContext) -> connected_set := ConnectedSet, old_state := OldState } = ChangeContext, - OldVisible = guild_visibility_channels:channel_is_visible( + OldVisible = guild_permissions_check:can_view_channel( UserId, ChannelId, OldMember, OldState ), {StateAfterPreserve, NewVisible} = diff --git a/fluxer_gateway/src/guild/guild_visibility_roles.erl b/fluxer_gateway/src/guild/guild_visibility_roles.erl index 8a3db2039..86c2a4c6e 100644 --- a/fluxer_gateway/src/guild/guild_visibility_roles.erl +++ b/fluxer_gateway/src/guild/guild_visibility_roles.erl @@ -21,7 +21,9 @@ channel_id(), map(), guild_state(), map(), integer() ) -> map(). maybe_ensure_parent_category_visible(ChannelId, ViewableMap, State, SessionData, GuildId) -> - case {snowflake_id(GuildId), guild_permissions:find_channel_by_id(ChannelId, State)} of + case + {snowflake_id(GuildId), guild_permissions_check:find_channel_by_id(ChannelId, State)} + of {undefined, _} -> ViewableMap; {_, undefined} -> @@ -129,7 +131,7 @@ send_sync_for_list(SessionId, SessionData, SessionPid, ChannelId, GuildId, ListI maybe_dispatch_sync(SessionUserId, SessionPid, ChannelId, GuildId, ListId, Ranges, State) -> case can_send_member_list(SessionUserId, ChannelId, State) of true -> - SyncResponse = guild_member_list:build_sync_response( + SyncResponse = guild_member_list_read:build_sync_response( GuildId, ListId, Ranges, State ), SyncResponseWithChannel = SyncResponse#{ @@ -146,14 +148,16 @@ maybe_dispatch_sync(SessionUserId, SessionPid, ChannelId, GuildId, ListId, Range can_send_member_list(UserId, ChannelId, State) -> case snowflake_id(ChannelId) of ResolvedChannelId when is_integer(UserId), is_integer(ResolvedChannelId) -> - guild_permissions:can_view_channel(UserId, ResolvedChannelId, undefined, State); + guild_permissions_check:can_view_channel( + UserId, ResolvedChannelId, undefined, State + ); undefined -> false end. -spec maybe_dispatch_channel_delete(channel_id(), pid(), guild_state(), integer()) -> ok. maybe_dispatch_channel_delete(ChannelId, SessionPid, OldState, GuildId) -> - case guild_permissions:find_channel_by_id(ChannelId, OldState) of + case guild_permissions_check:find_channel_by_id(ChannelId, OldState) of undefined -> ok; _Channel -> @@ -166,7 +170,7 @@ maybe_dispatch_channel_delete(ChannelId, SessionPid, OldState, GuildId) -> -spec maybe_dispatch_channel_create(channel_id(), map(), guild_state(), integer()) -> ok. maybe_dispatch_channel_create(ChannelId, #{pid := SessionPid} = SessionData, NewState, GuildId) -> - case guild_permissions:find_channel_by_id(ChannelId, NewState) of + case guild_permissions_check:find_channel_by_id(ChannelId, NewState) of undefined -> ok; Channel -> diff --git a/fluxer_gateway/src/guild/guild_voice_handler.erl b/fluxer_gateway/src/guild/guild_voice_handler.erl index 5177843fa..c3e164399 100644 --- a/fluxer_gateway/src/guild/guild_voice_handler.erl +++ b/fluxer_gateway/src/guild/guild_voice_handler.erl @@ -3,10 +3,7 @@ -module(guild_voice_handler). -typing([eqwalizer]). --export([ - handle_call/3, - handle_cast/2 -]). +-export([handle_call/3, handle_cast/2]). -type guild_state() :: map(). @@ -27,33 +24,28 @@ handle_call({get_voice_states_for_channel, ChIdBin}, _From, State) when is_binary(ChIdBin) -> VS = filter_voice_states_for_channel(ChIdBin, State), - {reply, #{voice_states => VS}, State}; -handle_call({get_pending_joins_for_channel, ChIdBin}, _From, State) when - is_binary(ChIdBin) --> - PJ = filter_pending_joins(ChIdBin, State), - {reply, #{pending_joins => PJ}, State}. + {reply, #{voice_states => VS}, State}. -spec handle_voice_call(atom(), map(), gen_server:from(), guild_state()) -> {reply, term(), guild_state()} | {noreply, guild_state()}. handle_voice_call(voice_state_update, Req, _From, State) -> - guild_voice:voice_state_update(Req, State); + guild_voice_connection:voice_state_update(Req, State); handle_voice_call(get_voice_state, Req, _From, State) -> - guild_voice:get_voice_state(Req, State); + guild_voice_state:get_voice_state(Req, State); handle_voice_call(update_member_voice, Req, _From, State) -> - guild_voice:update_member_voice(Req, State); + guild_voice_member:update_member_voice(Req, State); handle_voice_call(disconnect_voice_user, Req, _From, State) -> - guild_voice:disconnect_voice_user(Req, State); + guild_voice_disconnect_user:disconnect_voice_user(Req, State); handle_voice_call(disconnect_voice_user_if_in_channel, Req, _From, State) -> - guild_voice:disconnect_voice_user_if_in_channel(Req, State); + guild_voice_disconnect_user:disconnect_voice_user_if_in_channel(Req, State); handle_voice_call(disconnect_all_voice_users_in_channel, Req, _From, State) -> - guild_voice:disconnect_all_voice_users_in_channel(Req, State); + guild_voice_disconnect_channel:disconnect_all_voice_users_in_channel(Req, State); handle_voice_call(confirm_voice_connection_from_livekit, Req, _From, State) -> - guild_voice:confirm_voice_connection_from_livekit(Req, State); + guild_voice_connection_confirm:confirm_voice_connection_from_livekit(Req, State); handle_voice_call(move_member, Req, _From, State) -> - guild_voice:move_member(Req, State); + guild_voice_move:move_member(Req, State); handle_voice_call(switch_voice_region, Req, _From, State) -> - guild_voice:switch_voice_region_handler(Req, State). + guild_voice_region:switch_voice_region_handler(Req, State). -spec handle_add_virtual_access(integer(), integer(), guild_state()) -> {reply, ok, guild_state()}. @@ -95,79 +87,6 @@ append_matching_voice_state(ChannelIdBin, ChannelIdBin, ConnId, VS, Acc) -> append_matching_voice_state(_OtherChannelId, _ChannelIdBin, _ConnId, _VS, Acc) -> Acc. --spec filter_pending_joins(binary(), guild_state()) -> [map()]. -filter_pending_joins(ChannelIdBin, State) -> - PendingConnections = maps:get(pending_voice_connections, State, #{}), - case snowflake_id:parse_optional(ChannelIdBin) of - undefined -> - []; - ChannelIdInt when is_integer(ChannelIdInt) -> - collect_pending_joins(ChannelIdInt, PendingConnections) - end. - --spec collect_pending_joins(integer(), map()) -> [map()]. -collect_pending_joins(ChannelIdInt, PendingConnections) -> - maps:fold( - fun(ConnId, Meta, Acc) -> - append_pending_join(ChannelIdInt, ConnId, Meta, Acc) - end, - [], - PendingConnections - ). - --spec append_pending_join(integer(), term(), term(), [map()]) -> [map()]. -append_pending_join(ChannelIdInt, ConnId, Metadata, Acc) when is_map(Metadata) -> - MetaChId = metadata_integer(Metadata, channel_id), - MetaUserId = metadata_integer(Metadata, user_id), - append_matching_pending_join( - MetaChId, ChannelIdInt, MetaUserId, ConnId, Metadata, Acc - ); -append_pending_join(_ChannelIdInt, _ConnId, _Metadata, Acc) -> - Acc. - --spec append_matching_pending_join( - integer() | undefined, - integer(), - integer() | undefined, - term(), - map(), - [map()] -) -> [map()]. -append_matching_pending_join(ChannelIdInt, ChannelIdInt, UserId, ConnId, Metadata, Acc) when - ChannelIdInt > 0, is_integer(UserId), UserId > 0 --> - [ - #{ - connection_id => ConnId, - user_id => integer_to_binary(UserId), - token_nonce => maps:get(token_nonce, Metadata, null), - expires_at => maps:get(expires_at, Metadata, 0) - } - | Acc - ]; -append_matching_pending_join( - _MetadataChannelId, _ChannelIdInt, _UserId, _ConnId, _Metadata, Acc -) -> - Acc. - --spec metadata_integer(map(), atom()) -> integer() | undefined. -metadata_integer(Metadata, Key) -> - metadata_integer_or_fallback(map_integer(Metadata, Key), Metadata, Key). - --spec metadata_integer_or_fallback(integer() | undefined, map(), atom()) -> - integer() | undefined. -metadata_integer_or_fallback(undefined, Metadata, Key) -> - map_integer(Metadata, atom_to_binary(Key, utf8)); -metadata_integer_or_fallback(Value, _Metadata, _Key) -> - Value. - --spec map_integer(map(), atom() | binary()) -> integer() | undefined. -map_integer(Metadata, Key) -> - case map_utils:get_integer(Metadata, Key, undefined) of - Value when is_integer(Value) -> Value; - _ -> undefined - end. - -spec handle_cast(term(), guild_state()) -> {noreply, guild_state()}. handle_cast({relay_voice_state_update, VS, OldChId}, State) when is_map(VS), is_binary(OldChId) @@ -197,7 +116,7 @@ handle_cast({remove_virtual_channel_access, UId, ChId}, State) when handle_cast({cleanup_virtual_access_for_user, UId}, State) when is_integer(UId) -> - NewState = guild_voice_disconnect:cleanup_virtual_channel_access_for_user(UId, State), + NewState = guild_voice_disconnect_user:cleanup_virtual_channel_access_for_user(UId, State), {noreply, NewState}; handle_cast({voice_signal, Request}, State) when is_map(Request) -> ok = relay_voice_signal(Request, State), diff --git a/fluxer_gateway/src/guild/voice/dm_voice.erl b/fluxer_gateway/src/guild/voice/dm_voice.erl index b5984cbdb..6e2648de0 100644 --- a/fluxer_gateway/src/guild/voice/dm_voice.erl +++ b/fluxer_gateway/src/guild/voice/dm_voice.erl @@ -6,7 +6,6 @@ -export([voice_state_update/2]). -export([get_voice_token/6]). -export([disconnect_voice_user/2]). --export([]). -export_type([ dm_state/0, diff --git a/fluxer_gateway/src/guild/voice/dm_voice_connect.erl b/fluxer_gateway/src/guild/voice/dm_voice_connect.erl index 9027a1121..b0905fbb9 100644 --- a/fluxer_gateway/src/guild/voice/dm_voice_connect.erl +++ b/fluxer_gateway/src/guild/voice/dm_voice_connect.erl @@ -1,15 +1,15 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(dm_voice_connect). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -export([handle_dm_voice_with_channel/5]). -export([handle_dm_connect_or_update/1]). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -export_type([ dm_state/0, voice_state/0, diff --git a/fluxer_gateway/src/guild/voice/dm_voice_ring.erl b/fluxer_gateway/src/guild/voice/dm_voice_ring.erl index e6c5ce8ce..a79a1e9e6 100644 --- a/fluxer_gateway/src/guild/voice/dm_voice_ring.erl +++ b/fluxer_gateway/src/guild/voice/dm_voice_ring.erl @@ -1,6 +1,10 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(dm_voice_ring). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -export([fetch_dm_channel_via_rpc/2]). @@ -9,10 +13,6 @@ -export([is_dm_channel_type/1]). -export([broadcast_voice_state_update/3]). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -export_type([ dm_state/0, voice_state/0 @@ -129,14 +129,10 @@ channel_recipient_ids(Channel) -> Raw = case maps:get(<<"recipient_ids">>, Channel, undefined) of Ids when is_list(Ids) -> Ids; - _ -> ensure_list(maps:get(<<"recipients">>, Channel, [])) + _ -> map_utils:ensure_list(maps:get(<<"recipients">>, Channel, [])) end, [Id || Entry <- Raw, Id <- [recipient_entry_id(Entry)], is_integer(Id)]. --spec ensure_list(term()) -> [term()]. -ensure_list(Value) when is_list(Value) -> Value; -ensure_list(_) -> []. - -spec dispatch_voice_state_update([integer()], atom(), voice_state()) -> ok. dispatch_voice_state_update(AllRecipients, Event, VoiceState) -> lists:foreach( diff --git a/fluxer_gateway/src/guild/voice/dm_voice_state.erl b/fluxer_gateway/src/guild/voice/dm_voice_state.erl index abdc5c2f8..bf97c8ee8 100644 --- a/fluxer_gateway/src/guild/voice/dm_voice_state.erl +++ b/fluxer_gateway/src/guild/voice/dm_voice_state.erl @@ -1,21 +1,21 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(dm_voice_state). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -export([handle_dm_disconnect/4, disconnect_voice_user/2]). -export([resolve_call_region/1, resolve_call_region/2, voice_region_for_rpc/1]). -export([call_voice_states/1]). --export([normalize_session_id/1, resolve_effective_session_id/2]). +-export([resolve_effective_session_id/2]). -export([validate_dm_viewer_stream_keys/3]). -export([maybe_attach_voice_routing_metadata/3]). -export([clear_dm_e2ee_room_key_if_channel_empty/3]). -export([clear_dm_e2ee_room_keys_for_removed_voice_states/3]). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -export_type([ dm_state/0, voice_state/0, @@ -56,7 +56,7 @@ leave_call_if_needed(null, _SessionId, ConnectionId, _State) -> _ = voice_state_counts_cache:remove_connection(ConnectionId), ok; leave_call_if_needed(ChannelId, SessionId, ConnectionId, State) -> - case guild_voice_connection_normalize:normalize_positive_snowflake(ChannelId) of + case snowflake_id:parse_maybe(ChannelId) of undefined -> _ = voice_state_counts_cache:remove_connection(ConnectionId), ok; @@ -98,7 +98,9 @@ broadcast_disconnect_if_needed(OldChannelId, DisconnectVS, State) -> -spec disconnect_voice_user(integer(), dm_state()) -> {reply, map(), dm_state()}. disconnect_voice_user(UserId, State) -> VoiceStates = maps:get(dm_voice_states, State, #{}), - SessionId = normalize_session_id(maps:get(id, State, undefined)), + SessionId = guild_voice_connection_normalize:normalize_session_id( + maps:get(id, State, undefined) + ), UserVoiceStates = maps:filter( fun(_ConnId, VS) -> snowflake_id:equal(UserId, maps:get(<<"user_id">>, VS, undefined)) andalso @@ -115,7 +117,11 @@ disconnect_voice_user(UserId, State) -> voice_state_session_matches(_VoiceState, undefined) -> true; voice_state_session_matches(VoiceState, SessionId) -> - case normalize_session_id(maps:get(<<"session_id">>, VoiceState, undefined)) of + case + guild_voice_connection_normalize:normalize_session_id( + maps:get(<<"session_id">>, VoiceState, undefined) + ) + of undefined -> true; SessionId -> true; _ -> false @@ -164,7 +170,7 @@ broadcast_user_disconnect(ConnId, VoiceState, NewState) -> -spec clear_dm_e2ee_room_key_if_channel_empty(term(), voice_state_map(), dm_state()) -> dm_state(). clear_dm_e2ee_room_key_if_channel_empty(ChannelId, VoiceStates, State) -> - case guild_voice_connection_normalize:normalize_positive_snowflake(ChannelId) of + case snowflake_id:parse_maybe(ChannelId) of undefined -> State; ChannelIdInt -> @@ -232,14 +238,12 @@ voice_region_for_rpc(<<"automatic">>) -> null; voice_region_for_rpc(Region) when is_binary(Region) -> Region; voice_region_for_rpc(_) -> null. --spec normalize_session_id(term()) -> binary() | undefined. -normalize_session_id(Value) -> - guild_voice_connection_normalize:normalize_session_id(Value). - -spec resolve_effective_session_id(term(), term()) -> binary() | undefined. resolve_effective_session_id(ExistingSessionId, RequestSessionId) -> - ExistingNormalized = normalize_session_id(ExistingSessionId), - RequestNormalized = normalize_session_id(RequestSessionId), + ExistingNormalized = guild_voice_connection_normalize:normalize_session_id( + ExistingSessionId + ), + RequestNormalized = guild_voice_connection_normalize:normalize_session_id(RequestSessionId), case ExistingNormalized of undefined -> RequestNormalized; RequestNormalized -> RequestNormalized; diff --git a/fluxer_gateway/src/guild/voice/guild_voice.erl b/fluxer_gateway/src/guild/voice/guild_voice.erl deleted file mode 100644 index 34dcaf190..000000000 --- a/fluxer_gateway/src/guild/voice/guild_voice.erl +++ /dev/null @@ -1,100 +0,0 @@ -%% SPDX-License-Identifier: AGPL-3.0-or-later - --module(guild_voice). --typing([eqwalizer]). - --export([voice_state_update/2]). --export([get_voice_state/2]). --export([update_member_voice/2]). --export([disconnect_voice_user/2]). --export([disconnect_voice_user_if_in_channel/2]). --export([disconnect_all_voice_users_in_channel/2]). --export([confirm_voice_connection_from_livekit/2]). --export([move_member/2]). --export([]). --export([send_voice_server_updates_for_move/4]). --export([switch_voice_region_handler/2]). --export([switch_voice_region/3]). --export([get_voice_states_list/1]). --export([cleanup_virtual_access_on_disconnect/2]). - --export_type([ - guild_state/0, - voice_state/0, - voice_reply/0 -]). - --type guild_state() :: map(). --type voice_state() :: map(). --type voice_reply() :: {reply, map() | {error, atom(), atom()}, guild_state()}. - --spec voice_state_update(map(), guild_state()) -> voice_reply(). -voice_state_update(Request, State) -> - guild_voice_connection:voice_state_update(Request, State). - --spec get_voice_state(map(), guild_state()) -> {reply, map(), guild_state()}. -get_voice_state(Request, State) -> - guild_voice_state:get_voice_state(Request, State). - --spec get_voice_states_list(guild_state()) -> [voice_state()]. -get_voice_states_list(State) -> - guild_voice_state:get_voice_states_list(State). - --spec update_member_voice(map(), guild_state()) -> voice_reply(). -update_member_voice(Request, State) -> - guild_voice_member:update_member_voice(Request, State). - --spec disconnect_voice_user(map(), guild_state()) -> voice_reply(). -disconnect_voice_user(Request, State) -> - guild_voice_disconnect:disconnect_voice_user(Request, State). - --spec disconnect_voice_user_if_in_channel(map(), guild_state()) -> voice_reply(). -disconnect_voice_user_if_in_channel(Request, State) -> - guild_voice_disconnect:disconnect_voice_user_if_in_channel(Request, State). - --spec disconnect_all_voice_users_in_channel(map(), guild_state()) -> voice_reply(). -disconnect_all_voice_users_in_channel(Request, State) -> - guild_voice_disconnect:disconnect_all_voice_users_in_channel(Request, State). - --spec confirm_voice_connection_from_livekit(map(), guild_state()) -> voice_reply(). -confirm_voice_connection_from_livekit(Request, State) -> - guild_voice_connection:confirm_voice_connection_from_livekit(Request, State). - --spec move_member(map(), guild_state()) -> voice_reply(). -move_member(Request, State) -> - guild_voice_move:move_member(Request, State). - --spec send_voice_server_updates_for_move(integer(), integer(), [map()], pid()) -> ok. -send_voice_server_updates_for_move(GuildId, ChannelId, SessionDataList, GuildPid) -> - guild_voice_move:send_voice_server_updates_for_move( - GuildId, ChannelId, SessionDataList, GuildPid - ). - --spec switch_voice_region_handler(map(), guild_state()) -> voice_reply(). -switch_voice_region_handler(Request, State) -> - guild_voice_region:switch_voice_region_handler(Request, State). - --spec switch_voice_region(integer(), integer(), pid()) -> ok | {error, term()}. -switch_voice_region(GuildId, ChannelId, GuildPid) -> - guild_voice_region:switch_voice_region(GuildId, ChannelId, GuildPid). - --spec cleanup_virtual_access_on_disconnect(integer(), pid()) -> ok. -cleanup_virtual_access_on_disconnect(UserId, GuildPid) -> - GuildId = resolve_guild_id_from_pid(GuildPid), - TargetPid = resolve_voice_server(GuildId, GuildPid), - gen_server:cast(TargetPid, {cleanup_virtual_access_for_user, UserId}). - --spec resolve_guild_id_from_pid(pid()) -> integer() | undefined. -resolve_guild_id_from_pid(GuildPid) -> - try guild_voice_server_state:guild_id_call(GuildPid, 5000) of - GuildId when is_integer(GuildId) -> - GuildId; - _ -> - undefined - catch - _:_ -> undefined - end. - --spec resolve_voice_server(integer() | undefined, pid()) -> pid(). -resolve_voice_server(GuildId, FallbackPid) -> - guild_voice_server:resolve(GuildId, FallbackPid). diff --git a/fluxer_gateway/src/guild/voice/guild_voice_broadcast.erl b/fluxer_gateway/src/guild/voice/guild_voice_broadcast.erl index 20bc3b15e..8aae9b50e 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_broadcast.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_broadcast.erl @@ -30,7 +30,9 @@ do_broadcast_voice_state_update(VoiceState, State, OldChannelIdBin) -> FilterChannelId = utils:binary_to_integer_safe(FilterChannelIdBin), FilteredSessions = filter_sessions_for_voice_channel(Sessions, FilterChannelId, State), Pids = [maps:get(pid, S) || {_Sid, S} <- FilteredSessions], - maybe_dispatch_voice_state_update(Pids, sanitize_voice_state(VoiceState), State), + maybe_dispatch_voice_state_update( + Pids, voice_state_utils:sanitize_voice_state_for_broadcast(VoiceState), State + ), maybe_persist_voice_state_update(VoiceState, State), maybe_sync_guild_voice_state(VoiceState, OldChannelIdBin, State), ok. @@ -42,10 +44,6 @@ filter_channel_id_bin(VoiceState, OldChannelIdBin) -> ChannelIdBin -> ChannelIdBin end. --spec sanitize_voice_state(voice_state()) -> voice_state(). -sanitize_voice_state(VoiceState) -> - voice_state_utils:sanitize_voice_state_for_broadcast(VoiceState). - -spec filter_sessions_for_voice_channel(map(), integer() | undefined, guild_state()) -> [{term(), map()}]. filter_sessions_for_voice_channel(Sessions, FilterChannelId, State) when diff --git a/fluxer_gateway/src/guild/voice/guild_voice_connection.erl b/fluxer_gateway/src/guild/voice/guild_voice_connection.erl index 1f8060251..14f1aaad1 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_connection.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_connection.erl @@ -5,9 +5,6 @@ -typing([eqwalizer]). -export([voice_state_update/2]). --export([confirm_voice_connection_from_livekit/2]). --export([request_voice_token/8]). --export([sweep_expired_pending_joins/1]). -export_type([ guild_state/0, @@ -29,29 +26,6 @@ voice_state_update(Request, State) -> handle_with_member(Context, UserId, State) end. --spec confirm_voice_connection_from_livekit(map(), guild_state()) -> voice_reply(). -confirm_voice_connection_from_livekit(Request, State) -> - guild_voice_connection_confirm:confirm_voice_connection_from_livekit(Request, State). - --spec request_voice_token( - integer(), - integer(), - integer(), - binary() | null, - map(), - binary() | null, - binary() | undefined | null, - binary() | undefined | null -) -> {ok, map()} | {error, term()}. -request_voice_token(GuildId, ChannelId, UserId, ConnId, Perms, Nonce, Lat, Long) -> - guild_voice_connection_token:request_voice_token( - GuildId, ChannelId, UserId, ConnId, Perms, Nonce, Lat, Long - ). - --spec sweep_expired_pending_joins(guild_state()) -> guild_state(). -sweep_expired_pending_joins(State) -> - guild_voice_connection_pending:sweep_expired_pending_joins(State). - -spec handle_with_member(map(), integer(), guild_state()) -> voice_reply(). handle_with_member(Context, UserId, State) -> @@ -66,7 +40,7 @@ handle_with_member(Context, UserId, State) -> -spec handle_member_lookup(map(), integer(), voice_state_map(), guild_state()) -> voice_reply(). handle_member_lookup(Context, UserId, VoiceStates, State) -> - case guild_voice_member:find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> {reply, gateway_errors:error(voice_member_not_found), State}; Member -> @@ -77,7 +51,9 @@ handle_member_lookup(Context, UserId, VoiceStates, State) -> is_targeted_disconnect(Context) -> maps:get(channel_id, Context, undefined) =:= null andalso (maps:get(connection_id, Context, undefined) =/= undefined orelse - voice_state_utils:normalize_session_id(maps:get(session_id, Context, undefined)) =/= + guild_voice_connection_normalize:normalize_session_id( + maps:get(session_id, Context, undefined) + ) =/= undefined). -spec handle_member_voice(map(), map(), voice_state_map(), guild_state()) -> @@ -95,7 +71,7 @@ handle_member_voice(Context, Member, VoiceStates, State) -> -spec handle_disconnect(map(), voice_state_map(), guild_state()) -> voice_reply(). handle_disconnect(Context, VoiceStates, State) -> - guild_voice_disconnect:handle_voice_disconnect( + guild_voice_disconnect_user:handle_voice_disconnect( maps:get(connection_id, Context), maps:get(session_id, Context), maps:get(user_id, Context), @@ -106,7 +82,7 @@ handle_disconnect(Context, VoiceStates, State) -> -spec handle_connect_or_update(map(), integer(), map(), voice_state_map(), guild_state()) -> voice_reply(). handle_connect_or_update(Context, ChannelIdValue, Member, VoiceStates, State) -> - Channel = guild_voice_member:find_channel_by_id(ChannelIdValue, State), + Channel = guild_permissions_check:find_channel_by_id(ChannelIdValue, State), case Channel of undefined -> {reply, gateway_errors:error(voice_channel_not_found), State}; diff --git a/fluxer_gateway/src/guild/voice/guild_voice_connection_confirm.erl b/fluxer_gateway/src/guild/voice/guild_voice_connection_confirm.erl index 61c1522ee..47959d5b2 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_connection_confirm.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_connection_confirm.erl @@ -129,7 +129,7 @@ channel_invariant_violation(VoiceState, VoiceStates) -> -spec try_restore_from_recently_disconnected(binary(), guild_state()) -> {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}. try_restore_from_recently_disconnected(ConnectionId, State) -> - Cache = guild_voice_disconnect:recently_disconnected_voice_states(State), + Cache = guild_voice_disconnect_broadcast:recently_disconnected_voice_states(State), Now = erlang:system_time(millisecond), case maps:get(ConnectionId, Cache, undefined) of #{voice_state := VoiceState, disconnected_at := DisconnectedAt} when diff --git a/fluxer_gateway/src/guild/voice/guild_voice_connection_join.erl b/fluxer_gateway/src/guild/voice/guild_voice_connection_join.erl index 7745ba308..eadd23d84 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_connection_join.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_connection_join.erl @@ -22,7 +22,7 @@ {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}. handle_new_connection(Context, Member, Channel, VoiceStates, State) -> ChannelIdValue = maps:get(channel_id, Context), - GuildId = guild_voice_connection_normalize:normalize_positive_snowflake( + GuildId = snowflake_id:parse_maybe( maps:get(id, State, undefined) ), ViewerKeyResult = guild_voice_connection_util:resolve_viewer_stream_keys( @@ -296,7 +296,9 @@ e2ee_capable(Context, State) -> -spec normalize_context_session_id(context()) -> binary() | undefined. normalize_context_session_id(Context) -> - guild_voice_connection_util:normalize_session_id(maps:get(session_id, Context, undefined)). + guild_voice_connection_normalize:normalize_session_id( + maps:get(session_id, Context, undefined) + ). -spec create_and_decorate_voice_state(map()) -> voice_state(). create_and_decorate_voice_state(Build) -> diff --git a/fluxer_gateway/src/guild/voice/guild_voice_connection_move.erl b/fluxer_gateway/src/guild/voice/guild_voice_connection_move.erl index a6aa466ca..bfaa653cd 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_connection_move.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_connection_move.erl @@ -207,7 +207,7 @@ move_build_fields(Build) -> channel_id => ChannelIdValue, channel_id_bin => integer_to_binary(ChannelIdValue), user_id_bin => integer_to_binary(UserId), - session_id => guild_voice_connection_util:normalize_session_id( + session_id => guild_voice_connection_normalize:normalize_session_id( maps:get(session_id, Build) ), flags => guild_voice_connection_util:voice_flags_for_permissions( diff --git a/fluxer_gateway/src/guild/voice/guild_voice_connection_normalize.erl b/fluxer_gateway/src/guild/voice/guild_voice_connection_normalize.erl index 385e2f318..af773e133 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_connection_normalize.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_connection_normalize.erl @@ -12,7 +12,6 @@ -export([normalize_session_id/1]). -export([normalize_optional_binary/1]). -export([normalize_guild_id/1]). --export([normalize_positive_snowflake/1]). -spec normalize_connection_id(term()) -> binary() | undefined. normalize_connection_id(undefined) -> undefined; @@ -21,11 +20,11 @@ normalize_connection_id(ConnectionId) -> normalize_optional_binary(ConnectionId) -spec normalize_user_id(term()) -> integer() | undefined. normalize_user_id(Value) -> - normalize_positive_snowflake(Value). + snowflake_id:parse_maybe(Value). -spec normalize_channel_id_value(term()) -> integer() | null | undefined. normalize_channel_id_value(null) -> null; -normalize_channel_id_value(Value) -> normalize_positive_snowflake(Value). +normalize_channel_id_value(Value) -> snowflake_id:parse_maybe(Value). -spec normalize_boolean(term()) -> boolean(). normalize_boolean(true) -> true; @@ -62,7 +61,7 @@ normalize_optional_binary(_Value) -> undefined. -spec normalize_guild_id(term()) -> {ok, integer(), binary()} | {error, atom()}. normalize_guild_id(Value) -> - case normalize_positive_snowflake(Value) of + case snowflake_id:parse_maybe(Value) of undefined -> {error, voice_invalid_guild_id}; Int -> {ok, Int, guild_id_binary(Value, Int)} end. @@ -74,10 +73,6 @@ guild_id_binary(Value, Int) -> Bin -> Bin end. --spec normalize_positive_snowflake(term()) -> integer() | undefined. -normalize_positive_snowflake(Value) -> - snowflake_id:parse_maybe(Value). - -spec safe_iolist_to_binary(term()) -> binary() | undefined. safe_iolist_to_binary(Value) -> case safe_iolist_to_binary_parts(Value) of diff --git a/fluxer_gateway/src/guild/voice/guild_voice_connection_pending.erl b/fluxer_gateway/src/guild/voice/guild_voice_connection_pending.erl index 4e614c193..ba698ac38 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_connection_pending.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_connection_pending.erl @@ -249,7 +249,7 @@ build_voice_state_from_pending(PendingData, ConnectionId, State) -> resolve_pending_guild_id(PendingData, State) -> case pending_get_snowflake(PendingData, guild_id) of undefined -> - guild_voice_connection_normalize:normalize_positive_snowflake( + snowflake_id:parse_maybe( maps:get(id, State, undefined) ); GuildId -> @@ -363,7 +363,9 @@ force_disconnect_with_retry(GId, CId, UId, ConnId, 0) -> ok; force_disconnect_with_retry(GId, CId, UId, ConnId, Retries) -> Result = shard_utils:safe_apply( - fun() -> guild_voice_disconnect:force_disconnect_participant(GId, CId, UId, ConnId) end, + fun() -> + guild_voice_disconnect_user:force_disconnect_participant(GId, CId, UId, ConnId) + end, {error, crashed} ), case Result of @@ -436,7 +438,7 @@ pending_get_value(PendingData, Key) -> -spec pending_get_snowflake(map(), atom()) -> integer() | undefined. pending_get_snowflake(PendingData, Key) -> - guild_voice_connection_normalize:normalize_positive_snowflake( + snowflake_id:parse_maybe( pending_get_value(PendingData, Key) ). diff --git a/fluxer_gateway/src/guild/voice/guild_voice_connection_token.erl b/fluxer_gateway/src/guild/voice/guild_voice_connection_token.erl index 4c921200f..9cef8e839 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_connection_token.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_connection_token.erl @@ -130,10 +130,10 @@ handle_sfu_token_success(GuildId, ChannelId, UserId, Data) -> token => maps:get(<<"token">>, Data), endpoint => maps:get(<<"endpoint">>, Data), connection_id => maps:get(<<"connectionId">>, Data), - region_id => guild_voice_connection_util:normalize_optional_binary( + region_id => guild_voice_connection_normalize:normalize_optional_binary( maps:get(<<"regionId">>, Data, undefined) ), - server_id => guild_voice_connection_util:normalize_optional_binary( + server_id => guild_voice_connection_normalize:normalize_optional_binary( maps:get(<<"serverId">>, Data, undefined) ) }}. diff --git a/fluxer_gateway/src/guild/voice/guild_voice_connection_update.erl b/fluxer_gateway/src/guild/voice/guild_voice_connection_update.erl index a8d8cb2a9..69eaf3242 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_connection_update.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_connection_update.erl @@ -102,7 +102,7 @@ proceed_after_restore( ExistingChannelIdBin = maps:get(<<"channel_id">>, ExistingVS, null), NewChannelIdBin = integer_to_binary(ChannelIdValue), IsChannelChange = ExistingChannelIdBin =/= NewChannelIdBin, - GuildId = guild_voice_connection_normalize:normalize_positive_snowflake( + GuildId = snowflake_id:parse_maybe( maps:get(id, UpdatedState, undefined) ), ViewerKeyResult = guild_voice_connection_util:resolve_viewer_stream_keys( @@ -139,7 +139,7 @@ handle_existing_connection( ExistingChannelIdBin = maps:get(<<"channel_id">>, ExistingVoiceState, null), NewChannelIdBin = integer_to_binary(ChannelIdValue), IsChannelChange = ExistingChannelIdBin =/= NewChannelIdBin, - GuildId = guild_voice_connection_normalize:normalize_positive_snowflake( + GuildId = snowflake_id:parse_maybe( maps:get(id, State, undefined) ), ViewerKeyResult = guild_voice_connection_util:resolve_viewer_stream_keys( diff --git a/fluxer_gateway/src/guild/voice/guild_voice_connection_util.erl b/fluxer_gateway/src/guild/voice/guild_voice_connection_util.erl index d25f1e3ad..76c2215cc 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_connection_util.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_connection_util.erl @@ -17,8 +17,6 @@ maybe_attach_geolocation/3, maybe_attach_voice_routing_metadata/3, maybe_attach_e2ee_key_to_reply/2, - normalize_session_id/1, - normalize_optional_binary/1, maybe_error_reply/5, p2p_agreed/1, clear_virtual_access_flags/2 @@ -82,7 +80,7 @@ voice_flags_for_permissions(Context, VoicePermissions) -> resolve_guild_identity(State) -> Data = guild_data(State), DataGuildIdBin = maps:get(<<"id">>, Data, undefined), - StateGuildId = guild_voice_connection_normalize:normalize_positive_snowflake( + StateGuildId = snowflake_id:parse_maybe( maps:get(id, State, undefined) ), GuildMeta = map_utils:ensure_map(maps:get(<<"guild">>, Data, #{})), @@ -160,14 +158,6 @@ maybe_attach_e2ee_key_to_reply(Reply, undefined) -> maybe_attach_e2ee_key_to_reply(Reply, Key) when is_binary(Key) -> Reply#{e2ee_key => Key}. --spec normalize_session_id(term()) -> binary() | undefined. -normalize_session_id(Value) -> - guild_voice_connection_normalize:normalize_session_id(Value). - --spec normalize_optional_binary(term()) -> binary() | undefined. -normalize_optional_binary(Value) -> - guild_voice_connection_normalize:normalize_optional_binary(Value). - -spec maybe_error_reply(context(), voice_state(), guild_state(), integer(), atom()) -> {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}. maybe_error_reply(_Context, _ExistingVoiceState, State, _ChannelIdValue, ErrorAtom) -> diff --git a/fluxer_gateway/src/guild/voice/guild_voice_disconnect.erl b/fluxer_gateway/src/guild/voice/guild_voice_disconnect.erl deleted file mode 100644 index 5a9d6127f..000000000 --- a/fluxer_gateway/src/guild/voice/guild_voice_disconnect.erl +++ /dev/null @@ -1,207 +0,0 @@ -%% SPDX-License-Identifier: AGPL-3.0-or-later - --module(guild_voice_disconnect). --typing([eqwalizer]). - --export([handle_voice_disconnect/5]). --export([force_disconnect_participant/4]). --export([disconnect_voice_user/2]). --export([disconnect_voice_user_if_in_channel/2]). --export([disconnect_all_voice_users_in_channel/2]). --export([cleanup_virtual_channel_access_for_user/2]). --export([recently_disconnected_voice_states/1]). --export([clear_recently_disconnected/2]). --export([clear_recently_disconnected_for_channel/2]). - --export_type([ - guild_state/0, - voice_state_map/0, - voice_reply/0 -]). - --type guild_state() :: map(). --type voice_state_map() :: #{binary() => map()}. --type voice_reply() :: {reply, map() | {error, atom(), atom()}, guild_state()}. - --spec handle_voice_disconnect( - binary() | undefined, term(), integer(), voice_state_map() | term(), guild_state() -) -> voice_reply(). -handle_voice_disconnect(ConnId, SessionId, UserId, VoiceStates, State) -> - guild_voice_disconnect_user:handle_voice_disconnect( - ConnId, SessionId, UserId, VoiceStates, State - ). - --spec force_disconnect_participant(integer(), integer(), integer(), binary()) -> - {ok, map()} | {error, term()}. -force_disconnect_participant(GuildId, ChannelId, UserId, ConnectionId) -> - guild_voice_disconnect_user:force_disconnect_participant( - GuildId, ChannelId, UserId, ConnectionId - ). - --spec disconnect_voice_user(map(), guild_state()) -> voice_reply(). -disconnect_voice_user(Request, State) -> - guild_voice_disconnect_user:disconnect_voice_user(Request, State). - --spec disconnect_voice_user_if_in_channel(map(), guild_state()) -> voice_reply(). -disconnect_voice_user_if_in_channel(Request, State) -> - guild_voice_disconnect_user:disconnect_voice_user_if_in_channel(Request, State). - --spec disconnect_all_voice_users_in_channel(map(), guild_state()) -> - voice_reply(). -disconnect_all_voice_users_in_channel(Request, State) -> - guild_voice_disconnect_channel:disconnect_all_voice_users_in_channel(Request, State). - --spec cleanup_virtual_channel_access_for_user(integer(), guild_state()) -> guild_state(). -cleanup_virtual_channel_access_for_user(UserId, State) -> - guild_voice_disconnect_user:cleanup_virtual_channel_access_for_user(UserId, State). - --spec recently_disconnected_voice_states(guild_state()) -> map(). -recently_disconnected_voice_states(State) -> - guild_voice_disconnect_broadcast:recently_disconnected_voice_states(State). - --spec clear_recently_disconnected(binary(), guild_state()) -> guild_state(). -clear_recently_disconnected(ConnectionId, State) -> - guild_voice_disconnect_broadcast:clear_recently_disconnected(ConnectionId, State). - --spec clear_recently_disconnected_for_channel(integer(), guild_state()) -> guild_state(). -clear_recently_disconnected_for_channel(ChannelId, State) -> - guild_voice_disconnect_broadcast:clear_recently_disconnected_for_channel(ChannelId, State). - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). - -voice_state_fixture(UserId, GuildId, ChannelId) -> - #{ - <<"user_id">> => integer_to_binary(UserId), - <<"guild_id">> => integer_to_binary(GuildId), - <<"channel_id">> => integer_to_binary(ChannelId) - }. - -collect_force_disconnect_messages(0, Acc) -> - lists:reverse(Acc); -collect_force_disconnect_messages(Count, Acc) when Count > 0 -> - receive - {force_disconnect, _, _, _, _} = Msg -> - collect_force_disconnect_messages(Count - 1, [Msg | Acc]) - after 200 -> lists:reverse(Acc) - end. -collect_force_disconnect_messages(Count) -> collect_force_disconnect_messages(Count, []). - -test_force_disconnect_fun() -> - Self = self(), - fun(GId, CId, UId, ConnId) -> - Self ! {force_disconnect, GId, CId, UId, ConnId}, - {ok, #{success => true}} - end. - -assert_disconnect_clears_voice_states(UserId, State) -> - {reply, #{success := true}, #{voice_states := #{}}} = - disconnect_voice_user(#{user_id => UserId, connection_id => null}, State). - -disconnect_voice_user_removes_all_connections_test() -> - VoiceStates = #{ - <<"a">> => voice_state_fixture(5, 10, 20), - <<"b">> => voice_state_fixture(5, 10, 21) - }, - State = #{ - voice_states => VoiceStates, - test_force_disconnect_fun => fun(_, _, _, _) -> {ok, #{success => true}} end - }, - assert_disconnect_clears_voice_states(5, State). - -disconnect_voice_user_with_session_id_test() -> - TestFun = test_force_disconnect_fun(), - VoiceStates = #{ - <<"a">> => (voice_state_fixture(5, 10, 20))#{<<"session_id">> => <<"sess-a">>}, - <<"b">> => (voice_state_fixture(5, 10, 21))#{<<"session_id">> => <<"sess-b">>}, - <<"c">> => (voice_state_fixture(6, 10, 22))#{<<"session_id">> => <<"sess-a">>} - }, - State = #{id => 10, voice_states => VoiceStates, test_force_disconnect_fun => TestFun}, - {reply, #{success := true}, NewState} = - disconnect_voice_user( - #{user_id => 5, connection_id => null, session_id => <<"sess-a">>}, State - ), - ?assertEqual( - [{force_disconnect, 10, 20, 5, <<"a">>}], collect_force_disconnect_messages(1) - ), - Remaining = maps:get(voice_states, NewState), - ?assertNot(maps:is_key(<<"a">>, Remaining)), - ?assert(maps:is_key(<<"b">>, Remaining)), - ?assert(maps:is_key(<<"c">>, Remaining)). - -handle_voice_disconnect_invalid_state_test() -> - VoiceState = #{<<"user_id">> => <<"5">>}, - State = #{voice_states => #{<<"conn">> => VoiceState}}, - {reply, {error, validation_error, _}, _} = - handle_voice_disconnect(<<"conn">>, undefined, 5, #{<<"conn">> => VoiceState}, State). - -disconnect_voice_user_if_in_channel_ignored_test() -> - State = #{voice_states => #{}}, - {reply, #{ignored := true}, _} = - disconnect_voice_user_if_in_channel(#{user_id => 5, expected_channel_id => 99}, State). - -cache_and_clear_recently_disconnected_test() -> - VS = voice_state_fixture(5, 10, 20), - S1 = guild_voice_disconnect_broadcast:cache_recently_disconnected(#{<<"conn">> => VS}, #{}), - ?assert(maps:is_key(<<"conn">>, recently_disconnected_voice_states(S1))), - S2 = clear_recently_disconnected(<<"conn">>, S1), - ?assertEqual(#{}, recently_disconnected_voice_states(S2)). - -clear_recently_disconnected_for_channel_test() -> - VS1 = voice_state_fixture(5, 10, 20), - VS2 = voice_state_fixture(6, 10, 30), - S0 = guild_voice_disconnect_broadcast:cache_recently_disconnected( - #{<<"a">> => VS1, <<"b">> => VS2}, #{} - ), - S1 = clear_recently_disconnected_for_channel(20, S0), - Cache = recently_disconnected_voice_states(S1), - ?assertNot(maps:is_key(<<"a">>, Cache)), - ?assert(maps:is_key(<<"b">>, Cache)). - -disconnect_voice_user_if_in_channel_caches_test() -> - VS = voice_state_fixture(5, 10, 20), - State = #{voice_states => #{<<"conn">> => VS}}, - {reply, #{success := true}, NewState} = - disconnect_voice_user_if_in_channel( - #{user_id => 5, expected_channel_id => 20, connection_id => <<"conn">>}, State - ), - ?assert(maps:is_key(<<"conn">>, recently_disconnected_voice_states(NewState))). - -disconnect_voice_user_force_disconnect_all_test() -> - TestFun = test_force_disconnect_fun(), - VoiceStates = #{ - <<"a">> => voice_state_fixture(5, 10, 20), - <<"b">> => voice_state_fixture(5, 10, 21) - }, - State = #{id => 10, voice_states => VoiceStates, test_force_disconnect_fun => TestFun}, - assert_disconnect_clears_voice_states(5, State), - Msgs = collect_force_disconnect_messages(2), - ?assertEqual(2, length(Msgs)). - -disconnect_all_voice_users_in_channel_test() -> - TestFun = test_force_disconnect_fun(), - VoiceStates = #{ - <<"a">> => voice_state_fixture(5, 10, 20), - <<"b">> => voice_state_fixture(6, 10, 20), - <<"c">> => voice_state_fixture(7, 10, 30) - }, - State = #{id => 10, voice_states => VoiceStates, test_force_disconnect_fun => TestFun}, - {reply, #{success := true, disconnected_count := 2}, NewState} = - disconnect_all_voice_users_in_channel(#{channel_id => 20}, State), - _ = collect_force_disconnect_messages(2), - Remaining = maps:get(voice_states, NewState), - ?assert(maps:is_key(<<"c">>, Remaining)), - ?assertNot(maps:is_key(<<"a">>, Remaining)). - -disconnect_voice_user_cleans_pending_test() -> - Pending = #{ - <<"conn1">> => #{user_id => 5, channel_id => 100}, - <<"conn2">> => #{user_id => 6, channel_id => 100} - }, - State = #{id => 10, voice_states => #{}, pending_voice_connections => Pending}, - {reply, #{success := true}, NewState} = disconnect_voice_user(#{user_id => 5}, State), - NewPending = maps:get(pending_voice_connections, NewState), - ?assertNot(maps:is_key(<<"conn1">>, NewPending)), - ?assert(maps:is_key(<<"conn2">>, NewPending)). - --endif. diff --git a/fluxer_gateway/src/guild/voice/guild_voice_disconnect_broadcast.erl b/fluxer_gateway/src/guild/voice/guild_voice_disconnect_broadcast.erl index 26f01652f..24dd52d0d 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_disconnect_broadcast.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_disconnect_broadcast.erl @@ -125,10 +125,9 @@ clear_pending_voice_connections_for_user(UserId, RequestSessionId, State) -> pending_session_matches(_PendingData, undefined) -> true; pending_session_matches(PendingData, RequestSessionId) -> - normalize_session_id(maps:get(session_id, PendingData, undefined)) =:= RequestSessionId. - --spec normalize_session_id(term()) -> binary() | undefined. -normalize_session_id(Value) -> voice_state_utils:normalize_session_id(Value). + guild_voice_connection_normalize:normalize_session_id( + maps:get(session_id, PendingData, undefined) + ) =:= RequestSessionId. -spec clear_pending_voice_connections_for_user_channel(integer(), integer(), guild_state()) -> guild_state(). diff --git a/fluxer_gateway/src/guild/voice/guild_voice_disconnect_user.erl b/fluxer_gateway/src/guild/voice/guild_voice_disconnect_user.erl index 1e2fc65fb..b77558496 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_disconnect_user.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_disconnect_user.erl @@ -27,7 +27,7 @@ binary() | undefined, term(), integer(), voice_state_map() | term(), guild_state() ) -> voice_reply(). handle_voice_disconnect(undefined, SessionId, UserId, VoiceStates0, State) -> - case normalize_session_id(SessionId) of + case guild_voice_connection_normalize:normalize_session_id(SessionId) of undefined -> {reply, gateway_errors:error(voice_missing_connection_id), State}; RequestSessionId -> @@ -104,7 +104,9 @@ do_voice_disconnect( -spec disconnect_voice_user(map(), guild_state()) -> voice_reply(). disconnect_voice_user(#{user_id := UserId} = Request, State) -> ConnectionId = maps:get(connection_id, Request, null), - RequestSessionId = normalize_session_id(maps:get(session_id, Request, undefined)), + RequestSessionId = guild_voice_connection_normalize:normalize_session_id( + maps:get(session_id, Request, undefined) + ), VoiceStates = voice_state_utils:voice_states(State), case ConnectionId of null -> @@ -266,12 +268,11 @@ matching_user_voice_states(UserId, RequestSessionId, VoiceStates) -> voice_state_session_matches(_VoiceState, undefined) -> true; voice_state_session_matches(VoiceState, RequestSessionId) -> - normalize_session_id(maps:get(<<"session_id">>, VoiceState, undefined)) =:= + guild_voice_connection_normalize:normalize_session_id( + maps:get(<<"session_id">>, VoiceState, undefined) + ) =:= RequestSessionId. --spec normalize_session_id(term()) -> binary() | undefined. -normalize_session_id(Value) -> voice_state_utils:normalize_session_id(Value). - -spec maybe_cleanup_after_disconnect(integer(), integer(), guild_state()) -> guild_state(). maybe_cleanup_after_disconnect(UserId, ChannelId, State) -> case has_pending_or_preserve(UserId, ChannelId, State) of @@ -360,9 +361,159 @@ maybe_force_disconnect_voice_state(ConnectionId, VoiceState, State) -> resolve_guild_id(VoiceState, State) -> case voice_state_utils:voice_state_guild_id(VoiceState) of undefined -> - guild_voice_connection_normalize:normalize_positive_snowflake( + snowflake_id:parse_maybe( maps:get(id, State, undefined) ); GuildId -> GuildId end. + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). + +voice_state_fixture(UserId, GuildId, ChannelId) -> + #{ + <<"user_id">> => integer_to_binary(UserId), + <<"guild_id">> => integer_to_binary(GuildId), + <<"channel_id">> => integer_to_binary(ChannelId) + }. + +collect_force_disconnect_messages(0, Acc) -> + lists:reverse(Acc); +collect_force_disconnect_messages(Count, Acc) when Count > 0 -> + receive + {force_disconnect, _, _, _, _} = Msg -> + collect_force_disconnect_messages(Count - 1, [Msg | Acc]) + after 200 -> lists:reverse(Acc) + end. +collect_force_disconnect_messages(Count) -> collect_force_disconnect_messages(Count, []). + +test_force_disconnect_fun() -> + Self = self(), + fun(GId, CId, UId, ConnId) -> + Self ! {force_disconnect, GId, CId, UId, ConnId}, + {ok, #{success => true}} + end. + +assert_disconnect_clears_voice_states(UserId, State) -> + {reply, #{success := true}, #{voice_states := #{}}} = + disconnect_voice_user(#{user_id => UserId, connection_id => null}, State). + +disconnect_voice_user_removes_all_connections_test() -> + VoiceStates = #{ + <<"a">> => voice_state_fixture(5, 10, 20), + <<"b">> => voice_state_fixture(5, 10, 21) + }, + State = #{ + voice_states => VoiceStates, + test_force_disconnect_fun => fun(_, _, _, _) -> {ok, #{success => true}} end + }, + assert_disconnect_clears_voice_states(5, State). + +disconnect_voice_user_with_session_id_test() -> + TestFun = test_force_disconnect_fun(), + VoiceStates = #{ + <<"a">> => (voice_state_fixture(5, 10, 20))#{<<"session_id">> => <<"sess-a">>}, + <<"b">> => (voice_state_fixture(5, 10, 21))#{<<"session_id">> => <<"sess-b">>}, + <<"c">> => (voice_state_fixture(6, 10, 22))#{<<"session_id">> => <<"sess-a">>} + }, + State = #{id => 10, voice_states => VoiceStates, test_force_disconnect_fun => TestFun}, + {reply, #{success := true}, NewState} = + disconnect_voice_user( + #{user_id => 5, connection_id => null, session_id => <<"sess-a">>}, State + ), + ?assertEqual( + [{force_disconnect, 10, 20, 5, <<"a">>}], collect_force_disconnect_messages(1) + ), + Remaining = maps:get(voice_states, NewState), + ?assertNot(maps:is_key(<<"a">>, Remaining)), + ?assert(maps:is_key(<<"b">>, Remaining)), + ?assert(maps:is_key(<<"c">>, Remaining)). + +handle_voice_disconnect_invalid_state_test() -> + VoiceState = #{<<"user_id">> => <<"5">>}, + State = #{voice_states => #{<<"conn">> => VoiceState}}, + {reply, {error, validation_error, _}, _} = + handle_voice_disconnect(<<"conn">>, undefined, 5, #{<<"conn">> => VoiceState}, State). + +disconnect_voice_user_if_in_channel_ignored_test() -> + State = #{voice_states => #{}}, + {reply, #{ignored := true}, _} = + disconnect_voice_user_if_in_channel(#{user_id => 5, expected_channel_id => 99}, State). + +cache_and_clear_recently_disconnected_test() -> + VS = voice_state_fixture(5, 10, 20), + S1 = guild_voice_disconnect_broadcast:cache_recently_disconnected(#{<<"conn">> => VS}, #{}), + ?assert( + maps:is_key( + <<"conn">>, guild_voice_disconnect_broadcast:recently_disconnected_voice_states(S1) + ) + ), + S2 = guild_voice_disconnect_broadcast:clear_recently_disconnected(<<"conn">>, S1), + ?assertEqual(#{}, guild_voice_disconnect_broadcast:recently_disconnected_voice_states(S2)). + +clear_recently_disconnected_for_channel_test() -> + VS1 = voice_state_fixture(5, 10, 20), + VS2 = voice_state_fixture(6, 10, 30), + S0 = guild_voice_disconnect_broadcast:cache_recently_disconnected( + #{<<"a">> => VS1, <<"b">> => VS2}, #{} + ), + S1 = guild_voice_disconnect_broadcast:clear_recently_disconnected_for_channel(20, S0), + Cache = guild_voice_disconnect_broadcast:recently_disconnected_voice_states(S1), + ?assertNot(maps:is_key(<<"a">>, Cache)), + ?assert(maps:is_key(<<"b">>, Cache)). + +disconnect_voice_user_if_in_channel_caches_test() -> + VS = voice_state_fixture(5, 10, 20), + State = #{voice_states => #{<<"conn">> => VS}}, + {reply, #{success := true}, NewState} = + disconnect_voice_user_if_in_channel( + #{user_id => 5, expected_channel_id => 20, connection_id => <<"conn">>}, State + ), + ?assert( + maps:is_key( + <<"conn">>, + guild_voice_disconnect_broadcast:recently_disconnected_voice_states(NewState) + ) + ). + +disconnect_voice_user_force_disconnect_all_test() -> + TestFun = test_force_disconnect_fun(), + VoiceStates = #{ + <<"a">> => voice_state_fixture(5, 10, 20), + <<"b">> => voice_state_fixture(5, 10, 21) + }, + State = #{id => 10, voice_states => VoiceStates, test_force_disconnect_fun => TestFun}, + assert_disconnect_clears_voice_states(5, State), + Msgs = collect_force_disconnect_messages(2), + ?assertEqual(2, length(Msgs)). + +disconnect_all_voice_users_in_channel_test() -> + TestFun = test_force_disconnect_fun(), + VoiceStates = #{ + <<"a">> => voice_state_fixture(5, 10, 20), + <<"b">> => voice_state_fixture(6, 10, 20), + <<"c">> => voice_state_fixture(7, 10, 30) + }, + State = #{id => 10, voice_states => VoiceStates, test_force_disconnect_fun => TestFun}, + {reply, #{success := true, disconnected_count := 2}, NewState} = + guild_voice_disconnect_channel:disconnect_all_voice_users_in_channel( + #{channel_id => 20}, State + ), + _ = collect_force_disconnect_messages(2), + Remaining = maps:get(voice_states, NewState), + ?assert(maps:is_key(<<"c">>, Remaining)), + ?assertNot(maps:is_key(<<"a">>, Remaining)). + +disconnect_voice_user_cleans_pending_test() -> + Pending = #{ + <<"conn1">> => #{user_id => 5, channel_id => 100}, + <<"conn2">> => #{user_id => 6, channel_id => 100} + }, + State = #{id => 10, voice_states => #{}, pending_voice_connections => Pending}, + {reply, #{success := true}, NewState} = disconnect_voice_user(#{user_id => 5}, State), + NewPending = maps:get(pending_voice_connections, NewState), + ?assertNot(maps:is_key(<<"conn1">>, NewPending)), + ?assert(maps:is_key(<<"conn2">>, NewPending)). + +-endif. diff --git a/fluxer_gateway/src/guild/voice/guild_voice_member.erl b/fluxer_gateway/src/guild/voice/guild_voice_member.erl index cee0264ba..0f322764b 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_member.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_member.erl @@ -1,11 +1,13 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_voice_member). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -export([update_member_voice/2]). --export([find_member_by_user_id/2]). --export([find_channel_by_id/2]). -export_type([ guild_state/0, @@ -25,16 +27,12 @@ deaf := boolean() }. --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -spec update_member_voice(request(), guild_state()) -> guild_reply(map()). update_member_voice(Request, State) -> #{user_id := UserId, mute := Mute, deaf := Deaf} = Request, VoiceStates = voice_state_utils:voice_states(State), GuildId = state_guild_id(State), - case find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> {reply, gateway_errors:error(voice_member_not_found), State}; Member -> @@ -90,14 +88,6 @@ apply_member_voice_update( broadcast_voice_state_updates(Updated, FinalState), {reply, #{success => true}, FinalState}. --spec find_member_by_user_id(integer(), guild_state()) -> member() | undefined. -find_member_by_user_id(UserId, State) -> - guild_permissions:find_member_by_user_id(UserId, State). - --spec find_channel_by_id(integer(), guild_state()) -> map() | undefined. -find_channel_by_id(ChannelId, State) -> - guild_permissions:find_channel_by_id(ChannelId, State). - -spec enforce_participant_state_in_livekit( integer(), integer(), integer(), boolean(), boolean(), voice_utils:voice_permissions() ) -> @@ -120,7 +110,7 @@ guild_data(State) -> -spec member_user_id(member()) -> integer() | undefined. member_user_id(Member) when is_map(Member) -> User = map_utils:ensure_map(maps:get(<<"user">>, Member, #{})), - guild_voice_connection_normalize:normalize_positive_snowflake( + snowflake_id:parse_maybe( maps:get(<<"id">>, User, undefined) ). @@ -135,7 +125,7 @@ store_member(Member, State) -> State; _TargetId -> Data = guild_data(State), - UpdatedData = guild_data_index:put_member(Member, Data), + UpdatedData = guild_data_index_members:put_member(Member, Data), State#{data => UpdatedData} end. @@ -247,7 +237,7 @@ spawn_livekit_enforcement(GuildId, ChannelId, UserId, Mute, Deaf, VoicePermissio -spec state_guild_id(guild_state()) -> integer() | undefined. state_guild_id(State) -> - guild_voice_connection_normalize:normalize_positive_snowflake( + snowflake_id:parse_maybe( maps:get(id, State, undefined) ). @@ -271,7 +261,9 @@ update_member_voice_updates_member_flags_test() -> State = voice_member_test_state(#{}), Request = #{user_id => 10, mute => true, deaf => false}, {reply, #{success := true}, UpdatedState} = update_member_voice(Request, State), - #{<<"mute">> := true, <<"deaf">> := false} = find_member_by_user_id(10, UpdatedState). + #{<<"mute">> := true, <<"deaf">> := false} = guild_permissions_check:find_member_by_user_id( + 10, UpdatedState + ). update_member_voice_updates_voice_states_test() -> VoiceState = voice_state_fixture(10, 500), diff --git a/fluxer_gateway/src/guild/voice/guild_voice_move.erl b/fluxer_gateway/src/guild/voice/guild_voice_move.erl index 46e376df4..6edd1ff27 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_move.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_move.erl @@ -1,15 +1,15 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_voice_move). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -export([move_member/2]). -export([send_voice_server_updates_for_move/4]). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -export_type([ guild_state/0, voice_state/0, diff --git a/fluxer_gateway/src/guild/voice/guild_voice_move_execute.erl b/fluxer_gateway/src/guild/voice/guild_voice_move_execute.erl index e7311e0e0..fcd70e36a 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_move_execute.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_move_execute.erl @@ -116,7 +116,7 @@ broadcast_disconnects(ConnectionsToMove, NewState) -> voice_state_map(), integer(), integer(), integer(), voice_state_map(), guild_state() ) -> voice_reply(). handle_channel_move(Conns, ChannelIdValue, UserId, ModeratorId, VoiceStates, State) -> - Channel = guild_voice_member:find_channel_by_id(ChannelIdValue, State), + Channel = guild_permissions_check:find_channel_by_id(ChannelIdValue, State), case Channel of undefined -> {reply, gateway_errors:error(voice_channel_not_found), State}; @@ -158,8 +158,8 @@ check_perms_and_execute(Conns, ChannelId, UserId, ModId, VoiceStates, State) -> ViewPerm = constants:view_channel_permission(), ConnectPerm = constants:connect_permission(), ModPerms = guild_permissions:get_member_permissions(ModId, ChannelId, State), - ModHasConnect = permission_bits:has(ModPerms, ConnectPerm), - ModHasView = permission_bits:has(ModPerms, ViewPerm), + ModHasConnect = bitset:has(ModPerms, ConnectPerm), + ModHasView = bitset:has(ModPerms, ViewPerm), case { ModHasConnect andalso ModHasView, @@ -216,7 +216,7 @@ extract_session_data(ConnectionsToMove) -> -spec maybe_add_virtual_access(integer(), integer(), guild_state()) -> guild_state(). maybe_add_virtual_access(UserId, ChannelId, State) -> - Member = guild_permissions:find_member_by_user_id(UserId, State), + Member = guild_permissions_check:find_member_by_user_id(UserId, State), case Member of undefined -> State; _ -> maybe_grant_virtual_access(UserId, ChannelId, State) @@ -227,8 +227,8 @@ maybe_grant_virtual_access(UserId, ChannelId, State) -> Permissions = guild_permissions:get_member_permissions(UserId, ChannelId, State), ViewPerm = constants:view_channel_permission(), ConnectPerm = constants:connect_permission(), - HasView = permission_bits:has(Permissions, ViewPerm), - HasConnect = permission_bits:has(Permissions, ConnectPerm), + HasView = bitset:has(Permissions, ViewPerm), + HasConnect = bitset:has(Permissions, ConnectPerm), case HasView andalso HasConnect of true -> State; @@ -257,7 +257,7 @@ member_user_id(Member) -> User = map_utils:ensure_map( maps:get(<<"user">>, map_utils:ensure_map(Member), #{}) ), - guild_voice_connection_normalize:normalize_positive_snowflake( + snowflake_id:parse_maybe( maps:get(<<"id">>, User, undefined) ). @@ -285,7 +285,7 @@ request_and_broadcast(GuildId, ChannelId, SessionInfo, GuildPid, UserId, Member, Latitude = maps:get(latitude, SessionInfo, undefined), Longitude = maps:get(longitude, SessionInfo, undefined), case - guild_voice_connection:request_voice_token( + guild_voice_connection_token:request_voice_token( GuildId, ChannelId, UserId, diff --git a/fluxer_gateway/src/guild/voice/guild_voice_permission_sync.erl b/fluxer_gateway/src/guild/voice/guild_voice_permission_sync.erl index c302d5948..e5c472a20 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_permission_sync.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_permission_sync.erl @@ -1,6 +1,10 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_voice_permission_sync). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -export([ @@ -22,10 +26,6 @@ -type user_id() :: integer(). -type channel_id() :: integer(). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -spec sync_user_voice_permissions(user_id(), guild_state()) -> ok. sync_user_voice_permissions(UserId, State) -> VoiceStates = guild_voice_lifecycle:authoritative_voice_states(State), @@ -166,8 +166,8 @@ user_has_base_voice_access(UserId, ChannelId, State) -> Permissions = guild_permissions:get_member_permissions(UserId, ChannelId, State), ViewPerm = constants:view_channel_permission(), ConnectPerm = constants:connect_permission(), - HasView = permission_bits:has(Permissions, ViewPerm), - HasConnect = permission_bits:has(Permissions, ConnectPerm), + HasView = bitset:has(Permissions, ViewPerm), + HasConnect = bitset:has(Permissions, ConnectPerm), HasView andalso HasConnect end. @@ -187,7 +187,7 @@ dispatch_force_disconnect(GuildId, ChannelId, UserId, ConnectionId, State) -> -spec spawn_force_disconnect(integer(), channel_id(), user_id(), binary()) -> pid(). spawn_force_disconnect(GuildId, ChannelId, UserId, ConnectionId) -> spawn(fun() -> - guild_voice_disconnect:force_disconnect_participant( + guild_voice_disconnect_user:force_disconnect_participant( GuildId, ChannelId, UserId, ConnectionId ) end). @@ -260,8 +260,10 @@ affected_role_users(GuildId, RoleIds, OldState, State) -> true -> all; false -> - OldIndex = guild_data_index:member_role_index(maps:get(data, OldState, #{})), - NewIndex = guild_data_index:member_role_index(maps:get(data, State, #{})), + OldIndex = guild_data_index_members:member_role_index( + maps:get(data, OldState, #{}) + ), + NewIndex = guild_data_index_members:member_role_index(maps:get(data, State, #{})), sets:from_list( lists:append([ maps:keys(maps:get(RoleId, OldIndex, #{})) ++ @@ -296,13 +298,13 @@ is_affected(UserId, Affected) -> sets:is_element(UserId, Affected). -spec get_member_user_id(map()) -> user_id() | undefined. get_member_user_id(MemberUpdate) -> User = map_utils:ensure_map(maps:get(<<"user">>, MemberUpdate, #{})), - guild_voice_connection_normalize:normalize_positive_snowflake( + snowflake_id:parse_maybe( maps:get(<<"id">>, User, undefined) ). -spec state_guild_id(guild_state()) -> integer() | undefined. state_guild_id(State) -> - guild_voice_connection_normalize:normalize_positive_snowflake( + snowflake_id:parse_maybe( maps:get(id, State, undefined) ). diff --git a/fluxer_gateway/src/guild/voice/guild_voice_permissions.erl b/fluxer_gateway/src/guild/voice/guild_voice_permissions.erl index 1de065ed6..c02b7e933 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_permissions.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_permissions.erl @@ -96,8 +96,8 @@ has_resolved_view_and_connect_perms(UserId, ChannelIdValue, State) -> Permissions = resolve_permissions(UserId, ChannelIdValue, State), ViewPerm = constants:view_channel_permission(), ConnectPerm = constants:connect_permission(), - HasView = permission_bits:has(Permissions, ViewPerm), - HasConnect = permission_bits:has(Permissions, ConnectPerm), + HasView = bitset:has(Permissions, ViewPerm), + HasConnect = bitset:has(Permissions, ConnectPerm), HasView andalso HasConnect. -spec channel_has_capacity(integer(), channel(), channel_voice_stats(), boolean()) -> @@ -253,7 +253,7 @@ pending_connection_matches(UserId, ChannelIdValue, PendingData, Now) -> -spec is_member_timed_out(integer(), guild_state()) -> boolean(). is_member_timed_out(UserId, State) -> - case guild_permissions:find_member_by_user_id(UserId, State) of + case guild_permissions_check:find_member_by_user_id(UserId, State) of undefined -> false; Member -> diff --git a/fluxer_gateway/src/guild/voice/guild_voice_persistence.erl b/fluxer_gateway/src/guild/voice/guild_voice_persistence.erl index 465b774c5..56b52b82e 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_persistence.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_persistence.erl @@ -30,12 +30,12 @@ spawn_persist_voice_state_update(GuildId, VoiceState) -> -spec resolve_guild_id(voice_state(), guild_state()) -> integer() | undefined. resolve_guild_id(VoiceState, State) -> case - guild_voice_connection_normalize:normalize_positive_snowflake( + snowflake_id:parse_maybe( maps:get(<<"guild_id">>, VoiceState, undefined) ) of undefined -> - guild_voice_connection_normalize:normalize_positive_snowflake( + snowflake_id:parse_maybe( maps:get(id, State, undefined) ); GuildId -> diff --git a/fluxer_gateway/src/guild/voice/guild_voice_region.erl b/fluxer_gateway/src/guild/voice/guild_voice_region.erl index 705f41948..cb6f02712 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_region.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_region.erl @@ -1,6 +1,10 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_voice_region). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -export([switch_voice_region_handler/2]). @@ -17,14 +21,10 @@ -type guild_reply(T) :: {reply, T | {error, atom(), atom()}, guild_state()}. -type voice_state() :: map(). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -spec switch_voice_region_handler(map(), guild_state()) -> guild_reply(map()). switch_voice_region_handler(Request, State) -> #{channel_id := ChannelId} = Request, - Channel = guild_voice_member:find_channel_by_id(ChannelId, State), + Channel = guild_permissions_check:find_channel_by_id(ChannelId, State), case Channel of undefined -> {reply, gateway_errors:error(voice_channel_not_found), State}; @@ -41,7 +41,7 @@ switch_voice_region_channel_reply(Channel, ChannelId, State) -> -spec switch_to_sfu(integer(), guild_state()) -> guild_state(). switch_to_sfu(ChannelId, State) -> - Channel = guild_voice_member:find_channel_by_id(ChannelId, State), + Channel = guild_permissions_check:find_channel_by_id(ChannelId, State), NewState = convert_to_sfu(Channel, ChannelId, State), ok = spawn_switch_if_converted(ChannelId, State, NewState), NewState. @@ -182,7 +182,7 @@ request_and_broadcast_region_switch( TokenNonce = voice_utils:generate_token_nonce(), Lat = maps:get(<<"latitude">>, ExistingVoiceState, undefined), Lng = maps:get(<<"longitude">>, ExistingVoiceState, undefined), - TokenResult = guild_voice_connection:request_voice_token( + TokenResult = guild_voice_connection_token:request_voice_token( GuildId, ChannelId, UserId, @@ -277,17 +277,13 @@ build_pending_metadata( -spec store_pending_connection(integer(), pid(), binary(), map()) -> ok. store_pending_connection(GuildId, GuildPid, ConnectionId, Metadata) -> - TargetPid = resolve_voice_server(GuildId, GuildPid), + TargetPid = guild_voice_server:resolve(GuildId, GuildPid), gen_server:call( TargetPid, {store_pending_connection, ConnectionId, Metadata}, 10000 ). --spec resolve_voice_server(integer(), pid()) -> pid(). -resolve_voice_server(GuildId, FallbackPid) -> - guild_voice_server:resolve(GuildId, FallbackPid). - -ifdef(TEST). switch_voice_region_reads_guild_state_once_test() -> diff --git a/fluxer_gateway/src/guild/voice/guild_voice_server.erl b/fluxer_gateway/src/guild/voice/guild_voice_server.erl index 9f8cfc2f0..34124abec 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_server.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_server.erl @@ -163,23 +163,23 @@ handle_voice_request_call(error, _Req, Msg, S) -> -spec voice_request_handler(term()) -> {ok, fun((map(), map()) -> {reply, term(), map()})} | error. voice_request_handler(voice_state_update) -> - {ok, fun guild_voice:voice_state_update/2}; + {ok, fun guild_voice_connection:voice_state_update/2}; voice_request_handler(get_voice_state) -> - {ok, fun guild_voice:get_voice_state/2}; + {ok, fun guild_voice_state:get_voice_state/2}; voice_request_handler(update_member_voice) -> - {ok, fun guild_voice:update_member_voice/2}; + {ok, fun guild_voice_member:update_member_voice/2}; voice_request_handler(disconnect_voice_user) -> - {ok, fun guild_voice:disconnect_voice_user/2}; + {ok, fun guild_voice_disconnect_user:disconnect_voice_user/2}; voice_request_handler(disconnect_voice_user_if_in_channel) -> - {ok, fun guild_voice:disconnect_voice_user_if_in_channel/2}; + {ok, fun guild_voice_disconnect_user:disconnect_voice_user_if_in_channel/2}; voice_request_handler(disconnect_all_voice_users_in_channel) -> - {ok, fun guild_voice:disconnect_all_voice_users_in_channel/2}; + {ok, fun guild_voice_disconnect_channel:disconnect_all_voice_users_in_channel/2}; voice_request_handler(confirm_voice_connection_from_livekit) -> - {ok, fun guild_voice:confirm_voice_connection_from_livekit/2}; + {ok, fun guild_voice_connection_confirm:confirm_voice_connection_from_livekit/2}; voice_request_handler(move_member) -> - {ok, fun guild_voice:move_member/2}; + {ok, fun guild_voice_move:move_member/2}; voice_request_handler(switch_voice_region) -> - {ok, fun guild_voice:switch_voice_region_handler/2}; + {ok, fun guild_voice_region:switch_voice_region_handler/2}; voice_request_handler(_) -> error. @@ -190,8 +190,6 @@ handle_call_local({store_pending_connection, ConnectionId, Metadata}, State) -> {reply, ok, State#{pending_voice_connections => NewPending}}; handle_call_local({get_voice_states_for_channel, ChIdBin}, State) -> {reply, channel_query(voice_states, ChIdBin, State), State}; -handle_call_local({get_pending_joins_for_channel, ChIdBin}, State) -> - {reply, channel_query(pending_joins, ChIdBin, State), State}; handle_call_local({get_voice_states_map}, State) -> {reply, maps:get(voice_states, State, #{}), State}; handle_call_local(get_voice_server_pid, State) -> @@ -199,16 +197,11 @@ handle_call_local(get_voice_server_pid, State) -> handle_call_local(_, State) -> {reply, ok, State}. --spec channel_query(voice_states | pending_joins, term(), server_state()) -> map(). -channel_query(Kind, ChIdBin, State) -> +-spec channel_query(voice_states, term(), server_state()) -> map(). +channel_query(voice_states, ChIdBin, State) -> case guild_voice_server_state:parse_voice_channel_id(ChIdBin) of - {ok, _, Bin} when Kind =:= voice_states -> - guild_voice_server_state:local_voice_states_for_channel(Bin, State); - {ok, _, Bin} when Kind =:= pending_joins -> - guild_voice_server_state:local_pending_joins_for_channel(Bin, State); - error when Kind =:= voice_states -> #{voice_states => []}; - error -> - #{pending_joins => []} + {ok, _, Bin} -> guild_voice_server_state:local_voice_states_for_channel(Bin, State); + _ -> #{voice_states => []} end. -spec handle_cast(term(), server_state()) -> {noreply, server_state()}. @@ -217,15 +210,20 @@ handle_cast({store_pending_connection, ConnId, Meta}, State) -> NewPending = bounded_put(ConnId, Meta, Pending, ?MAX_PENDING_CONNECTIONS), {noreply, State#{pending_voice_connections => NewPending}}; handle_cast({disconnect_voice_user, Request}, State) when is_map(Request) -> - {noreply, delegate_voice_cast(fun guild_voice:disconnect_voice_user/2, Request, State)}; + {noreply, + delegate_voice_cast( + fun guild_voice_disconnect_user:disconnect_voice_user/2, Request, State + )}; handle_cast({disconnect_all_voice_users_in_channel, Request}, State) when is_map(Request) -> {noreply, delegate_voice_cast( - fun guild_voice:disconnect_all_voice_users_in_channel/2, Request, State + fun guild_voice_disconnect_channel:disconnect_all_voice_users_in_channel/2, + Request, + State )}; handle_cast({cleanup_virtual_access_for_user, UserId}, State) when is_integer(UserId) -> GS = guild_voice_server_state:build_guild_state(State), - NewGS = guild_voice_disconnect:cleanup_virtual_channel_access_for_user(UserId, GS), + NewGS = guild_voice_disconnect_user:cleanup_virtual_channel_access_for_user(UserId, GS), {noreply, guild_voice_server_state:apply_guild_state(NewGS, State)}; handle_cast(_, State) -> {noreply, State}. @@ -241,7 +239,9 @@ handle_info(sweep_pending_joins, State) -> {noreply, State2}; _ -> GuildState = guild_voice_server_state:build_guild_state(State2), - NewGuildState = guild_voice_connection:sweep_expired_pending_joins(GuildState), + NewGuildState = guild_voice_connection_pending:sweep_expired_pending_joins( + GuildState + ), {noreply, guild_voice_server_state:apply_guild_state(NewGuildState, State2)} end; handle_info(check_p2p_sessions, State) -> @@ -329,7 +329,7 @@ seeded_sessions(ConnectionIds, State) -> UserId <- [voice_state_utils:voice_state_user_id(VoiceState)], is_integer(UserId), SessionId <- [ - voice_state_utils:normalize_session_id( + guild_voice_connection_normalize:normalize_session_id( maps:get(<<"session_id">>, VoiceState, undefined) ) ], @@ -399,7 +399,7 @@ remove_gone_seeded_connection(ConnectionId, UserId, SessionId, State) -> [maps:get(guild_id, State, undefined), ConnectionId, UserId, SessionId] ), delegate_voice_cast( - fun guild_voice:disconnect_voice_user/2, + fun guild_voice_disconnect_user:disconnect_voice_user/2, #{user_id => UserId, connection_id => ConnectionId}, State ); diff --git a/fluxer_gateway/src/guild/voice/guild_voice_server_state.erl b/fluxer_gateway/src/guild/voice/guild_voice_server_state.erl index c8d334676..276ddcd23 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_server_state.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_server_state.erl @@ -8,7 +8,6 @@ apply_guild_state/2, merge_guild_state/2, local_voice_states_for_channel/2, - local_pending_joins_for_channel/2, parse_voice_channel_id/1, fetch_guild_data/1, guild_state_call/2, @@ -77,34 +76,6 @@ local_voice_states_for_channel(ChannelIdBin, State) -> ), #{voice_states => Filtered}. --spec local_pending_joins_for_channel(binary(), server_state()) -> map(). -local_pending_joins_for_channel(ChannelIdBin, State) -> - case parse_voice_channel_id(ChannelIdBin) of - {ok, ChannelIdInt, _NormalizedBin} -> - do_local_pending_joins(ChannelIdInt, State); - error -> - #{pending_joins => []} - end. - --spec do_local_pending_joins(integer(), server_state()) -> map(). -do_local_pending_joins(ChannelIdInt, State) -> - PendingConns = ensure_map(maps:get(pending_voice_connections, State, #{})), - Filtered = maps:fold( - fun(ConnId, Metadata, Acc) -> - prepend_pending_join_for_channel(ConnId, Metadata, ChannelIdInt, Acc) - end, - [], - PendingConns - ), - #{pending_joins => Filtered}. - --spec prepend_pending_join_for_channel(term(), term(), integer(), [map()]) -> [map()]. -prepend_pending_join_for_channel(ConnId, Metadata, ChannelIdInt, Acc) -> - case pending_join_channel_id(Metadata) of - ChannelIdInt -> prepend_defined(pending_join_rpc_entry(ConnId, Metadata), Acc); - _ -> Acc - end. - -spec parse_voice_channel_id(term()) -> {ok, integer(), binary()} | error. parse_voice_channel_id(ChannelId) -> case validation:validate_snowflake(<<"channel_id">>, ChannelId) of @@ -166,40 +137,6 @@ voice_state_rpc_entry_or_undefined(VS) -> false -> undefined end. --spec pending_join_rpc_entry(term(), term()) -> map() | undefined. -pending_join_rpc_entry(ConnId, Metadata) when is_map(Metadata) -> - NormalizedConnId = normalize_rpc_id(ConnId), - UserId = normalize_rpc_id( - maps:get(user_id, Metadata, maps:get(<<"user_id">>, Metadata, null)) - ), - case {NormalizedConnId, UserId} of - {null, _} -> - undefined; - {_, null} -> - undefined; - _ -> - #{ - connection_id => NormalizedConnId, - user_id => UserId, - token_nonce => normalize_rpc_binary( - maps:get(token_nonce, Metadata, maps:get(<<"token_nonce">>, Metadata, null)) - ), - expires_at => normalize_rpc_millisecond( - maps:get(expires_at, Metadata, maps:get(<<"expires_at">>, Metadata, 0)) - ) - } - end; -pending_join_rpc_entry(_, _) -> - undefined. - --spec pending_join_channel_id(term()) -> integer() | undefined. -pending_join_channel_id(Metadata) when is_map(Metadata) -> - normalize_rpc_integer( - maps:get(channel_id, Metadata, maps:get(<<"channel_id">>, Metadata, undefined)) - ); -pending_join_channel_id(_) -> - undefined. - -spec guild_state_call(pid(), timeout()) -> term(). guild_state_call(GuildPid, Timeout) -> case gen_server:call(GuildPid, {get_voice_guild_state}, Timeout) of @@ -242,10 +179,6 @@ fetch_guild_data(GuildPid) -> #{} end. --spec ensure_map(term()) -> map(). -ensure_map(Map) when is_map(Map) -> Map; -ensure_map(_) -> #{}. - -spec prepend_defined(undefined | map(), [map()]) -> [map()]. prepend_defined(undefined, Acc) -> Acc; prepend_defined(Entry, Acc) -> [Entry | Acc]. @@ -286,20 +219,6 @@ normalize_rpc_binary(Value) when is_list(Value), Value =/= [] -> normalize_rpc_binary(_) -> null. --spec normalize_rpc_millisecond(term()) -> integer(). -normalize_rpc_millisecond(Value) when is_integer(Value), Value >= 0 -> Value; -normalize_rpc_millisecond(Value) when is_binary(Value), byte_size(Value) > 0 -> - try binary_to_integer(Value) of - Integer when Integer >= 0 -> Integer; - _ -> 0 - catch - throw:_ -> 0; - error:_ -> 0; - exit:_ -> 0 - end; -normalize_rpc_millisecond(_) -> - 0. - -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). diff --git a/fluxer_gateway/src/guild/voice/voice_p2p.erl b/fluxer_gateway/src/guild/voice/voice_p2p.erl index acf90adb8..00bd53c88 100644 --- a/fluxer_gateway/src/guild/voice/voice_p2p.erl +++ b/fluxer_gateway/src/guild/voice/voice_p2p.erl @@ -92,7 +92,7 @@ guild_join_decision(Agreed, ChannelId, State) -> fun(Pending) -> maps:get(channel_id, Pending, undefined) =:= ChannelId end, maps:values(guild_voice_connection_pending:pending_voice_connections(State)) ), - Pinned = pinned(guild_voice_member:find_channel_by_id(ChannelId, State)), + Pinned = pinned(guild_permissions_check:find_channel_by_id(ChannelId, State)), join_decision(Agreed, ChannelVoiceStates, HasPendingJoins, Pinned). -spec admit(join_decision(), token_grant() | term()) -> grant() | sfu | rejection(). diff --git a/fluxer_gateway/src/guild/voice/voice_state_utils.erl b/fluxer_gateway/src/guild/voice/voice_state_utils.erl index b1b9b6abb..9c1cccde2 100644 --- a/fluxer_gateway/src/guild/voice/voice_state_utils.erl +++ b/fluxer_gateway/src/guild/voice/voice_state_utils.erl @@ -1,6 +1,10 @@ %% SPDX-License-Identifier: AGPL-3.0-or-later -module(voice_state_utils). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -typing([eqwalizer]). -include_lib("fluxer_gateway/include/voice_state.hrl"). @@ -21,14 +25,9 @@ drop_voice_states/2, broadcast_disconnects/2, voice_flags_from_context/1, - parse_stream_key/1, - normalize_session_id/1 + parse_stream_key/1 ]). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -export_type([ voice_state/0, voice_state_map/0, @@ -241,24 +240,18 @@ build_stream_key_result({guild, GuildId}, ChannelId, ConnId) -> connection_id => ConnId }}. --spec positive_integer(term()) -> integer() | undefined. -positive_integer(Value) -> - guild_voice_connection_normalize:normalize_positive_snowflake(Value). - -spec snowflake_field(term(), binary()) -> integer() | undefined. -snowflake_field(Map, Key) when is_map(Map) -> positive_integer(maps:get(Key, Map, undefined)); -snowflake_field(_, _) -> undefined. +snowflake_field(Map, Key) when is_map(Map) -> + snowflake_id:parse_maybe(maps:get(Key, Map, undefined)); +snowflake_field(_, _) -> + undefined. -spec require_positive_snowflake(term()) -> integer(). require_positive_snowflake(Value) -> - Id = positive_integer(Value), + Id = snowflake_id:parse_maybe(Value), true = is_integer(Id), Id. --spec normalize_session_id(term()) -> binary() | undefined. -normalize_session_id(Value) -> - guild_voice_connection_normalize:normalize_session_id(Value). - -ifdef(TEST). parse_stream_key_dm_test() -> diff --git a/fluxer_gateway/src/guild/voice/voice_utils.erl b/fluxer_gateway/src/guild/voice/voice_utils.erl index a67b25f07..832efccb9 100644 --- a/fluxer_gateway/src/guild/voice/voice_utils.erl +++ b/fluxer_gateway/src/guild/voice/voice_utils.erl @@ -195,9 +195,9 @@ compute_voice_permissions(UserId, ChannelId, State) -> SpeakPerm = constants:speak_permission(), StreamPerm = constants:stream_permission(), AdminPerm = constants:administrator_permission(), - IsAdmin = permission_bits:has(Permissions, AdminPerm), - CanSpeak = IsAdmin orelse permission_bits:has(Permissions, SpeakPerm), - CanStream = IsAdmin orelse permission_bits:has(Permissions, StreamPerm), + IsAdmin = bitset:has(Permissions, AdminPerm), + CanSpeak = IsAdmin orelse bitset:has(Permissions, SpeakPerm), + CanStream = IsAdmin orelse bitset:has(Permissions, StreamPerm), HasVirtualAccess = guild_virtual_channel_access:has_voice_access( UserId, ChannelId, State ), diff --git a/fluxer_gateway/src/presence/presence.erl b/fluxer_gateway/src/presence/presence.erl index ffb2429a3..a7ea476a3 100644 --- a/fluxer_gateway/src/presence/presence.erl +++ b/fluxer_gateway/src/presence/presence.erl @@ -59,7 +59,7 @@ init(PresenceData) -> process_flag(trap_exit, true), erlang:process_flag(fullsweep_after, 20), State = build_initial_state(PresenceData), - StateWithSubs = presence_broadcast:ensure_initial_global_subscriptions(State), + StateWithSubs = presence_broadcast_subscriptions:ensure_initial_global_subscriptions(State), {ok, StateWithSubs}. -spec terminate(term(), state() | term()) -> ok. @@ -91,7 +91,7 @@ handle_call({session_connect, Request}, {Pid, _}, State) when is_map(Request), i FinalState = presence_broadcast:publish_global_presence( maps:get(sessions, NewState), NewState ), - presence_broadcast:send_cached_presences_to_session(SessionPid, FinalState), + presence_broadcast_subscriptions:send_cached_presences_to_session(SessionPid, FinalState), {reply, Reply, FinalState}; handle_call(get_current_visible_presence, _From, State) -> {reply, presence_broadcast:current_visible_presence(State), State}; @@ -146,7 +146,10 @@ handle_cast({sync_friends, FriendIds, FlushedIds}, State) when handle_cast({sync_group_dm_recipients, RecipientsByChannel}, State) when is_map(RecipientsByChannel) -> - {noreply, presence_broadcast:sync_group_dm_subscriptions(RecipientsByChannel, State)}; + {noreply, + presence_broadcast_subscriptions:sync_group_dm_subscriptions( + RecipientsByChannel, State + )}; handle_cast(Msg, State) -> handle_cast_guild(Msg, State). @@ -159,7 +162,7 @@ session_connect_pid(_Request, CallerPid) -> -spec handle_sync_friends_cast([term()], [term()], state()) -> {noreply, state()}. handle_sync_friends_cast(FriendIds, FlushedIds, State) -> {noreply, - presence_broadcast:sync_friend_subscriptions( + presence_broadcast_subscriptions:sync_friend_subscriptions( user_ids(FriendIds), user_ids(FlushedIds), State )}. @@ -311,28 +314,6 @@ thread_user_flip_is_forwarded_to_every_session_test() -> after 1000 -> ?assert(false) end. -reconcile_flattened_presence_uses_current_session_state_test() -> - State = test_state(#{}), - PublishedState = State#{last_published_presence => #{status => <<"offline">>}}, - meck:new(presence_broadcast, [passthrough]), - meck:expect( - presence_broadcast, - publish_global_presence, - fun(Sessions, ReceivedState) -> - ?assertEqual(#{}, Sessions), - ?assertEqual(State, ReceivedState), - PublishedState - end - ), - try - ?assertEqual( - {noreply, PublishedState}, - handle_cast(reconcile_flattened_presence, State) - ) - after - meck:unload(presence_broadcast) - end. - -spec test_state(sessions()) -> state(). test_state(Sessions) -> #{ diff --git a/fluxer_gateway/src/presence/presence_broadcast.erl b/fluxer_gateway/src/presence/presence_broadcast.erl index 5b8870811..817803b40 100644 --- a/fluxer_gateway/src/presence/presence_broadcast.erl +++ b/fluxer_gateway/src/presence/presence_broadcast.erl @@ -8,10 +8,6 @@ force_publish_global_presence/1, dispatch_global_presence/3, dispatch_to_all_sessions/3, - ensure_initial_global_subscriptions/1, - sync_friend_subscriptions/3, - sync_group_dm_subscriptions/2, - send_cached_presences_to_session/2, publish_user_update_to_bus/3, is_last_published_visible/1, cache_if_visible/2, @@ -102,22 +98,6 @@ safe_cast_or_shed(Pid, Msg) -> ok end. --spec ensure_initial_global_subscriptions(state()) -> state(). -ensure_initial_global_subscriptions(State) -> - presence_broadcast_subscriptions:ensure_initial_global_subscriptions(State). - --spec sync_friend_subscriptions([user_id()], [user_id()], state()) -> state(). -sync_friend_subscriptions(FriendIds, FlushedIds, State) -> - presence_broadcast_subscriptions:sync_friend_subscriptions(FriendIds, FlushedIds, State). - --spec sync_group_dm_subscriptions(map(), state()) -> state(). -sync_group_dm_subscriptions(RecipientsByChannel, State) -> - presence_broadcast_subscriptions:sync_group_dm_subscriptions(RecipientsByChannel, State). - --spec send_cached_presences_to_session(pid(), state()) -> ok. -send_cached_presences_to_session(SessionPid, State) -> - presence_broadcast_subscriptions:send_cached_presences_to_session(SessionPid, State). - -spec publish_user_update_to_bus(user_id(), map(), state()) -> ok. publish_user_update_to_bus(UserId, UserData, State) -> LastPublished = maps:get(last_published_presence, State, undefined), diff --git a/fluxer_gateway/src/presence/presence_bus.erl b/fluxer_gateway/src/presence/presence_bus.erl index e7a02a568..19c7538c2 100644 --- a/fluxer_gateway/src/presence/presence_bus.erl +++ b/fluxer_gateway/src/presence/presence_bus.erl @@ -6,11 +6,9 @@ -include_lib("fluxer_gateway/include/timeout_config.hrl"). --export([ - start_link/0, subscribe/1, unsubscribe/1, publish/2, diagnostic_info/0 -]). +-export([start_link/0, subscribe/1, unsubscribe/1, publish/2, diagnostic_info/0]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). --export([select_shard/2, find_shard_by_ref/2, find_shard_by_pid/2]). +-export([select_shard/2]). -type shard() :: #{pid := pid(), ref := reference()}. -type state() :: #{ @@ -44,7 +42,7 @@ diagnostic_info() -> init([]) -> process_flag(trap_exit, true), erlang:process_flag(fullsweep_after, 10), - {ShardCount, _Source} = shard_utils:determine_shard_count(presence_bus_shards), + {ShardCount, _Source} = clustered_ets_cache:determine_shard_count([presence_bus_shards]), Shards = start_shards(ShardCount, #{}), {ok, #{shards => Shards, shard_count => ShardCount}, hibernate}. @@ -92,9 +90,11 @@ handle_cast(_Msg, State) -> -spec handle_info(term(), state()) -> {noreply, state()}. handle_info({'DOWN', Ref, process, _Pid, _Reason}, State) when is_reference(Ref) -> - {noreply, maybe_restart_shard(find_shard_by_ref(Ref, maps:get(shards, State)), State)}; + {noreply, + maybe_restart_shard(shard_utils:find_shard_by_ref(Ref, maps:get(shards, State)), State)}; handle_info({'EXIT', Pid, _Reason}, State) when is_pid(Pid) -> - {noreply, maybe_restart_shard(find_shard_by_pid(Pid, maps:get(shards, State)), State)}; + {noreply, + maybe_restart_shard(shard_utils:find_shard_by_pid(Pid, maps:get(shards, State)), State)}; handle_info(_Info, State) -> {noreply, State}. @@ -219,16 +219,6 @@ restart_shard_index(Index, State) -> select_shard(Key, Count) when Count > 0 -> rendezvous_router:select(Key, Count). --spec find_shard_by_ref(reference(), #{non_neg_integer() => shard()}) -> - {ok, non_neg_integer()} | not_found. -find_shard_by_ref(Ref, Shards) -> - shard_utils:find_shard_by_ref(Ref, Shards). - --spec find_shard_by_pid(pid(), #{non_neg_integer() => shard()}) -> - {ok, non_neg_integer()} | not_found. -find_shard_by_pid(Pid, Shards) -> - shard_utils:find_shard_by_pid(Pid, Shards). - -spec safe_gen_server_call(pid() | atom(), term(), timeout()) -> term(). safe_gen_server_call(Server, Request, Timeout) -> try gen_server:call(Server, Request, Timeout) of diff --git a/fluxer_gateway/src/presence/presence_bus_shard.erl b/fluxer_gateway/src/presence/presence_bus_shard.erl index 1dc07d3d9..745e76cb0 100644 --- a/fluxer_gateway/src/presence/presence_bus_shard.erl +++ b/fluxer_gateway/src/presence/presence_bus_shard.erl @@ -9,10 +9,6 @@ -export([start_link/1]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). --ifdef(TEST). --export([test_receiver/3]). --endif. - -type state() :: #{scope := atom(), pg_pid := pid(), shard_index := non_neg_integer()}. -spec start_link(non_neg_integer()) -> {ok, pid()} | {error, term()}. @@ -197,41 +193,3 @@ normalize_start_link({error, Reason}) -> {error, Reason}; normalize_start_link(ignore) -> {error, ignore}. - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). - -publish_delivers_to_scope_members_test() -> - Scope = presence_bus_test_scope_3, - start_pg_scope(Scope), - UserId = erlang:unique_integer([positive]), - Parent = self(), - Payload = #{<<"status">> => <<"online">>}, - Receiver = spawn(?MODULE, test_receiver, [UserId, Payload, Parent]), - ?assertEqual(ok, do_subscribe(Scope, UserId, Receiver)), - ?assertEqual(ok, do_publish(Scope, UserId, Payload)), - receive - {presence_delivered, Payload} -> ok - after 1000 -> - ?assert(false) - end, - ?assertEqual(ok, do_unsubscribe(Scope, UserId, Receiver)). - -test_receiver(UserId, Payload, Parent) -> - receive - {presence, UserId, Payload} -> - Parent ! {presence_delivered, Payload} - after 5000 -> - Parent ! {presence_delivered, timeout} - end. - -start_pg_scope(Scope) -> - try pg:start(Scope) of - {ok, _} -> ok; - {error, {already_started, _}} -> ok; - _ -> ok - catch - error:_Reason -> ok; - exit:_Reason -> ok - end. --endif. diff --git a/fluxer_gateway/src/presence/presence_cache.erl b/fluxer_gateway/src/presence/presence_cache.erl index d69d0f582..af945b7c8 100644 --- a/fluxer_gateway/src/presence/presence_cache.erl +++ b/fluxer_gateway/src/presence/presence_cache.erl @@ -16,22 +16,13 @@ bulk_get/1, bulk_get_map/1, get_memory_stats/0, - pending_handoff_count/0, - get_pending_handoff_count/0, rebalance/0, - rebalance_async/0, handle_nodedown/1, handle_nodeup/1, - trigger_anti_entropy/0, - generation/0, - pending_operations_count/0 + generation/0 ]). --export([ - put_local/3, - delete_local/2, - local_snapshot/1 -]). +-export([put_local/3, delete_local/2, local_snapshot/1]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). @@ -87,21 +78,6 @@ get_memory_stats() -> _ -> {error, not_available} end. --spec get_pending_handoff_count() -> non_neg_integer(). -get_pending_handoff_count() -> - case presence_cache_api:safe_call_if_enabled(get_pending_handoff_count, 0) of - Count when is_integer(Count), Count >= 0 -> Count; - _ -> 0 - end. - --spec pending_handoff_count() -> non_neg_integer(). -pending_handoff_count() -> - get_pending_handoff_count(). - --spec rebalance_async() -> ok. -rebalance_async() -> - presence_cache_api:safe_cast_if_enabled(rebalance). - -spec rebalance() -> ok. rebalance() -> presence_cache_api:rebalance(). @@ -114,18 +90,10 @@ handle_nodedown(Node) when is_atom(Node) -> handle_nodeup(Node) when is_atom(Node) -> presence_cache_api:safe_cast_if_enabled({nodeup_cancel_grace, Node}). --spec trigger_anti_entropy() -> ok. -trigger_anti_entropy() -> - presence_cache_api:safe_cast_if_enabled(anti_entropy_sync). - -spec generation() -> non_neg_integer(). generation() -> presence_cache_api:generation(). --spec pending_operations_count() -> non_neg_integer(). -pending_operations_count() -> - get_pending_handoff_count(). - -spec put_local(integer(), map(), state()) -> {ok, state()}. put_local(UserId, Presence, State) -> {_Reply, NewState} = presence_cache_shards:forward_put(UserId, Presence, State), @@ -145,7 +113,7 @@ local_snapshot(State) -> init([]) -> process_flag(trap_exit, true), erlang:process_flag(fullsweep_after, 10), - {ShardCount, _Source} = presence_cache_shards:determine_count(presence_cache_shards), + {ShardCount, _Source} = clustered_ets_cache:determine_shard_count([presence_cache_shards]), Shards = presence_cache_shards:start_all(ShardCount), {ok, #{ shards => Shards, @@ -156,21 +124,21 @@ init([]) -> delete_tombstones => #{}, delete_tombstone_order => queue:new(), generation => 0, - anti_entropy_timer => presence_cache_rebalance:schedule_anti_entropy() + anti_entropy_timer => presence_cache_anti_entropy:schedule_anti_entropy() }}. -spec terminate(term(), state()) -> ok. terminate(_Reason, State) -> - presence_cache_rebalance:cancel_pending_retry_timer(State), + presence_cache_pending:cancel_pending_retry_timer(State), presence_cache_rebalance:cancel_all_grace_timers(State), - presence_cache_rebalance:cancel_anti_entropy_timer(State), + presence_cache_anti_entropy:cancel_anti_entropy_timer(State), presence_cache_shards:stop_all(State), ok. -spec code_change(term(), state(), term()) -> {ok, state()}. code_change(_OldVsn, State, _Extra) -> erlang:garbage_collect(), - {ok, presence_cache_rebalance:ensure_pending_state(State)}. + {ok, presence_cache_pending:ensure_pending_state(State)}. -spec handle_call(term(), gen_server:from(), state()) -> {reply, term(), state()}. handle_call({put, UserId, Presence}, _From, State) when is_integer(UserId), is_map(Presence) -> @@ -204,8 +172,6 @@ handle_call_extended({bulk_get_local_map, UserIds}, _From, State) when is_list(U {reply, Reply, NewState}; handle_call_extended(rebalance, _From, State) -> {reply, ok, presence_cache_rebalance:rebalance_ownership(State)}; -handle_call_extended(get_pending_handoff_count, _From, State) -> - {reply, presence_cache_rebalance:count_pending_operations(State), State}; handle_call_extended(get_memory_stats, _From, State) -> {reply, presence_cache_shards:memory_stats(State), State}; handle_call_extended(get_generation, _From, State) -> @@ -214,21 +180,19 @@ handle_call_extended(_Request, _From, State) -> {reply, ok, State}. -spec handle_cast(term(), state()) -> {noreply, state()}. -handle_cast(rebalance, State) -> - {noreply, presence_cache_rebalance:rebalance_ownership(State)}; handle_cast({nodedown_grace, Node}, State) when is_atom(Node) -> {noreply, presence_cache_rebalance:start_nodedown_grace(Node, State)}; handle_cast({nodeup_cancel_grace, Node}, State) when is_atom(Node) -> {noreply, presence_cache_rebalance:cancel_nodedown_grace(Node, State)}; -handle_cast(anti_entropy_sync, State) -> - {noreply, presence_cache_rebalance:perform_anti_entropy(State)}; handle_cast({anti_entropy_digest_request, FromNode, RemoteDigest}, State) when is_atom(FromNode), is_binary(RemoteDigest) -> - presence_cache_rebalance:handle_anti_entropy_digest_request(FromNode, RemoteDigest, State); + presence_cache_anti_entropy:handle_anti_entropy_digest_request( + FromNode, RemoteDigest, State + ); handle_cast({anti_entropy_response, Entries}, State) when is_map(Entries) -> {noreply, - presence_cache_rebalance:merge_anti_entropy_entries( + presence_cache_anti_entropy:merge_anti_entropy_entries( presence_cache_bulk:sanitize_presence_map(Entries), State )}; handle_cast({put, UserId, Presence}, State) when is_integer(UserId), is_map(Presence) -> @@ -246,25 +210,27 @@ handle_cast(_Msg, State) -> -spec handle_info(term(), state()) -> {noreply, state()}. handle_info({'DOWN', Ref, process, _Pid, _Reason}, State) when is_reference(Ref) -> - State1 = presence_cache_rebalance:ensure_pending_state(State), + State1 = presence_cache_pending:ensure_pending_state(State), {noreply, presence_cache_shards:handle_down_by_ref(Ref, State1)}; handle_info(?PENDING_HANDOFF_RETRY_MSG, State) -> - State1 = (presence_cache_rebalance:ensure_pending_state(State))#{ + State1 = (presence_cache_pending:ensure_pending_state(State))#{ pending_retry_timer => undefined }, {noreply, presence_cache_rebalance:rebalance_ownership(State1)}; handle_info({'EXIT', Pid, _Reason}, State) when is_pid(Pid) -> - State1 = presence_cache_rebalance:ensure_pending_state(State), + State1 = presence_cache_pending:ensure_pending_state(State), {noreply, presence_cache_shards:handle_down_by_pid(Pid, State1)}; handle_info({nodedown_grace_expired, Node}, State) when is_atom(Node) -> {noreply, presence_cache_rebalance:process_nodedown_grace_expiry(Node, State)}; handle_info(?ANTI_ENTROPY_MSG, State) -> - State1 = presence_cache_rebalance:perform_anti_entropy(State), - {noreply, State1#{anti_entropy_timer => presence_cache_rebalance:schedule_anti_entropy()}}; + State1 = presence_cache_anti_entropy:perform_anti_entropy(State), + {noreply, State1#{ + anti_entropy_timer => presence_cache_anti_entropy:schedule_anti_entropy() + }}; handle_info({'ETS-TRANSFER', _Table, _FromPid, _HeirData}, State) -> {noreply, State}; handle_info(_Info, State) -> - {noreply, presence_cache_rebalance:ensure_pending_state(State)}. + {noreply, presence_cache_pending:ensure_pending_state(State)}. -spec normalize_start_link(gen_server:start_ret()) -> {ok, pid()} | {error, term()}. normalize_start_link({ok, Pid}) -> diff --git a/fluxer_gateway/src/presence/presence_cache_bulk.erl b/fluxer_gateway/src/presence/presence_cache_bulk.erl index 6681868ad..e36a5d55b 100644 --- a/fluxer_gateway/src/presence/presence_cache_bulk.erl +++ b/fluxer_gateway/src/presence/presence_cache_bulk.erl @@ -193,7 +193,7 @@ add_user_to_owner_group(UserId, Acc) -> -spec local_shard_count() -> pos_integer(). local_shard_count() -> - {Count, _Source} = presence_cache_shards:determine_count(presence_cache_shards), + {Count, _Source} = clustered_ets_cache:determine_shard_count([presence_cache_shards]), Count. -spec select_shard(term(), pos_integer()) -> non_neg_integer(). diff --git a/fluxer_gateway/src/presence/presence_cache_ops.erl b/fluxer_gateway/src/presence/presence_cache_ops.erl index 881ddeb65..96ea300de 100644 --- a/fluxer_gateway/src/presence/presence_cache_ops.erl +++ b/fluxer_gateway/src/presence/presence_cache_ops.erl @@ -20,9 +20,9 @@ handle_put(UserId, Presence, State) -> {Replicated, State1} = replicate_to_nodes(UserId, {put, Presence}, OwnerNodes, State), case Replicated of true -> - presence_cache_rebalance:clear_pending_operation(UserId, State1); + presence_cache_pending:clear_pending_operation(UserId, State1); false -> - presence_cache_rebalance:queue_pending_operation(UserId, {put, Presence}, State1) + presence_cache_pending:queue_pending_operation(UserId, {put, Presence}, State1) end. -spec handle_delete(integer(), state()) -> state(). @@ -30,8 +30,8 @@ handle_delete(UserId, State) -> OwnerNodes = presence_cache_bulk:resolve_owner_nodes(UserId), {Replicated, State1} = replicate_to_nodes(UserId, delete, OwnerNodes, State), case Replicated of - true -> presence_cache_rebalance:clear_pending_operation(UserId, State1); - false -> presence_cache_rebalance:queue_pending_operation(UserId, delete, State1) + true -> presence_cache_pending:clear_pending_operation(UserId, State1); + false -> presence_cache_pending:queue_pending_operation(UserId, delete, State1) end. -spec get_local(integer(), state()) -> {{ok, map()} | not_found, state()}. diff --git a/fluxer_gateway/src/presence/presence_cache_pending.erl b/fluxer_gateway/src/presence/presence_cache_pending.erl index f1936821a..73e4a41ef 100644 --- a/fluxer_gateway/src/presence/presence_cache_pending.erl +++ b/fluxer_gateway/src/presence/presence_cache_pending.erl @@ -7,7 +7,6 @@ sanitize_pending_operations/1, pending_operations/1, ensure_pending_state/1, - count_pending_operations/1, queue_pending_operation/3, clear_pending_operation/2, set_pending_operations/2, @@ -52,10 +51,6 @@ ensure_pending_state(State) -> pending_retry_timer => pending_retry_timer(State) }. --spec count_pending_operations(state()) -> non_neg_integer(). -count_pending_operations(State) -> - maps:size(pending_operations(State)). - -spec queue_pending_operation(integer(), pending_operation(), state()) -> state(). queue_pending_operation(UserId, Operation, State) -> Pending = stored_pending_operations(State), diff --git a/fluxer_gateway/src/presence/presence_cache_rebalance.erl b/fluxer_gateway/src/presence/presence_cache_rebalance.erl index 95a457bd0..7be718fa8 100644 --- a/fluxer_gateway/src/presence/presence_cache_rebalance.erl +++ b/fluxer_gateway/src/presence/presence_cache_rebalance.erl @@ -6,21 +6,10 @@ -export([ rebalance_ownership/1, evict_local/2, - perform_anti_entropy/1, - handle_anti_entropy_digest_request/3, - merge_anti_entropy_entries/2, - schedule_anti_entropy/0, - cancel_anti_entropy_timer/1, start_nodedown_grace/2, cancel_nodedown_grace/2, process_nodedown_grace_expiry/2, cancel_all_grace_timers/1, - sanitize_pending_operations/1, - queue_pending_operation/3, - clear_pending_operation/2, - cancel_pending_retry_timer/1, - ensure_pending_state/1, - count_pending_operations/1, increment_generation/1 ]). @@ -39,28 +28,6 @@ rebalance_ownership(State) -> false -> rebalance_ownership_inner(State) end. --spec schedule_anti_entropy() -> reference(). -schedule_anti_entropy() -> - presence_cache_anti_entropy:schedule_anti_entropy(). - --spec cancel_anti_entropy_timer(state()) -> ok. -cancel_anti_entropy_timer(State) -> - presence_cache_anti_entropy:cancel_anti_entropy_timer(State). - --spec perform_anti_entropy(state()) -> state(). -perform_anti_entropy(State) -> - presence_cache_anti_entropy:perform_anti_entropy(State). - --spec handle_anti_entropy_digest_request(node(), binary(), state()) -> {noreply, state()}. -handle_anti_entropy_digest_request(FromNode, RemoteDigest, State) -> - presence_cache_anti_entropy:handle_anti_entropy_digest_request( - FromNode, RemoteDigest, State - ). - --spec merge_anti_entropy_entries(#{integer() => map()}, state()) -> state(). -merge_anti_entropy_entries(Entries, State) -> - presence_cache_anti_entropy:merge_anti_entropy_entries(Entries, State). - -spec start_nodedown_grace(node(), state()) -> state(). start_nodedown_grace(Node, State) -> PendingCleanups = maps:get(pending_nodedown_cleanups, State, #{}), @@ -98,30 +65,6 @@ cancel_all_grace_timers(State) -> ), ok. --spec sanitize_pending_operations(term()) -> pending_operations(). -sanitize_pending_operations(PendingOperations) -> - presence_cache_pending:sanitize_pending_operations(PendingOperations). - --spec ensure_pending_state(state()) -> state(). -ensure_pending_state(State) -> - presence_cache_pending:ensure_pending_state(State). - --spec count_pending_operations(state()) -> non_neg_integer(). -count_pending_operations(State) -> - presence_cache_pending:count_pending_operations(State). - --spec queue_pending_operation(integer(), pending_operation(), state()) -> state(). -queue_pending_operation(UserId, Operation, State) -> - presence_cache_pending:queue_pending_operation(UserId, Operation, State). - --spec clear_pending_operation(integer(), state()) -> state(). -clear_pending_operation(UserId, State) -> - presence_cache_pending:clear_pending_operation(UserId, State). - --spec cancel_pending_retry_timer(state()) -> ok. -cancel_pending_retry_timer(State) -> - presence_cache_pending:cancel_pending_retry_timer(State). - -spec evict_local(integer(), state()) -> state(). evict_local(UserId, State) -> {_Reply, NewState} = presence_cache_shards:forward_delete(UserId, State), diff --git a/fluxer_gateway/src/presence/presence_cache_shards.erl b/fluxer_gateway/src/presence/presence_cache_shards.erl index 42f1c909c..4604a4aa8 100644 --- a/fluxer_gateway/src/presence/presence_cache_shards.erl +++ b/fluxer_gateway/src/presence/presence_cache_shards.erl @@ -6,7 +6,6 @@ -include_lib("fluxer_gateway/include/timeout_config.hrl"). -export([ - determine_count/1, start_all/1, stop_all/1, handle_down_by_ref/2, @@ -19,8 +18,7 @@ local_snapshot/1, local_snapshot_chunks/2, content_digest/1, - memory_stats/1, - find_by_ref/2 + memory_stats/1 ]). -export_type([shard/0, state/0]). @@ -30,14 +28,6 @@ shards := #{non_neg_integer() => shard()}, shard_count := pos_integer(), _ => _ }. --spec determine_count(atom()) -> {pos_integer(), configured | auto}. -determine_count(ConfigKey) -> - case clustered_ets_cache:determine_shard_count([ConfigKey]) of - {Count, ConfigKey} -> {Count, configured}; - {Count, auto} -> {Count, auto}; - {Count, _Source} -> {Count, configured} - end. - -spec start_all(pos_integer()) -> #{non_neg_integer() => shard()}. start_all(Count) -> lists:foldl(fun start_and_accumulate/2, #{}, lists:seq(0, Count - 1)). @@ -58,7 +48,7 @@ stop_all(State) -> -spec handle_down_by_ref(reference(), state()) -> state(). handle_down_by_ref(Ref, State) -> Shards = maps:get(shards, State), - case find_by_ref(Ref, Shards) of + case shard_utils:find_shard_by_ref(Ref, Shards) of {ok, Index} -> restart_state(Index, State); not_found -> State end. @@ -193,11 +183,6 @@ memory_stats(State) -> ), {ok, #{memory_bytes => TotalMemory, entry_count => TotalEntries}}. --spec find_by_ref(reference(), #{non_neg_integer() => shard()}) -> - {ok, non_neg_integer()} | not_found. -find_by_ref(Ref, Shards) -> - shard_utils:find_shard_by_ref(Ref, Shards). - -spec start(non_neg_integer()) -> {ok, shard()} | {error, term()}. start(Index) -> case presence_cache_shard:start_link(Index) of diff --git a/fluxer_gateway/src/presence/presence_manager.erl b/fluxer_gateway/src/presence/presence_manager.erl index e54ebd87f..714c1668d 100644 --- a/fluxer_gateway/src/presence/presence_manager.erl +++ b/fluxer_gateway/src/presence/presence_manager.erl @@ -104,7 +104,7 @@ init([]) -> ensure_shard_user_table(), presence_manager_shards:ensure_table(), presence_manager_cache:ensure_table(), - {ShardCount, _Source} = presence_manager_shards:determine_count(), + {ShardCount, _Source} = clustered_ets_cache:determine_shard_count([presence_shards]), ShardMap = start_all_shards(ShardCount), State = #{shards => ShardMap, shard_count => ShardCount}, presence_manager_shards:sync_table(State), @@ -155,14 +155,14 @@ handle_info({'DOWN', Ref, process, Pid, _Reason}, State) when is_reference(Ref), Shards = maps:get(shards, State), {noreply, maybe_restart_manager_shard( - presence_manager_shards:find_by_ref(Ref, Shards), State + shard_utils:find_shard_by_ref(Ref, Shards), State )}; handle_info({'EXIT', Pid, _Reason}, State) when is_pid(Pid) -> presence_manager_cache:clean_by_pid(Pid), Shards = maps:get(shards, State), {noreply, maybe_restart_manager_shard( - presence_manager_shards:find_by_pid(Pid, Shards), State + shard_utils:find_shard_by_pid(Pid, Shards), State )}; handle_info(_Info, State) -> {noreply, State}. @@ -324,29 +324,3 @@ safe_cast_presence_rejoin(Pid) -> catch _:_ -> ok end. - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). - -safe_cast_presence_rejoin_delivers_cast_test() -> - Parent = self(), - Pid = spawn(fun() -> rejoin_cast_receiver(Parent) end), - ?assertEqual(ok, safe_cast_presence_rejoin(Pid)), - receive - got_rejoin -> ok; - rejoin_timeout -> ?assert(false, rejoin_cast_not_delivered) - after 1500 -> ?assert(false, no_response) - end. - -rejoin_cast_receiver(Parent) -> - receive - {'$gen_cast', presence_rejoin} -> Parent ! got_rejoin - after 1000 -> Parent ! rejoin_timeout - end. - -safe_cast_presence_rejoin_tolerates_dead_pid_test() -> - Pid = spawn(fun() -> ok end), - ok = gateway_retry_timer:wait(50), - ?assertEqual(ok, safe_cast_presence_rejoin(Pid)). - --endif. diff --git a/fluxer_gateway/src/presence/presence_manager_shards.erl b/fluxer_gateway/src/presence/presence_manager_shards.erl index 11c511945..b6dd8a154 100644 --- a/fluxer_gateway/src/presence/presence_manager_shards.erl +++ b/fluxer_gateway/src/presence/presence_manager_shards.erl @@ -6,7 +6,6 @@ -include_lib("fluxer_gateway/include/timeout_config.hrl"). -export([ - determine_count/0, start/1, restart/2, forward_call/3, @@ -17,10 +16,7 @@ sync_table/1, put_pid/2, clear_pid/1, - pid_from_table/1, - find_by_ref/2, - find_by_pid/2, - default_count/0 + pid_from_table/1 ]). -export_type([user_id/0, shard/0, state/0]). @@ -34,10 +30,6 @@ shards := #{non_neg_integer() => shard()}, shard_count := pos_integer(), _ => _ }. --spec determine_count() -> {pos_integer(), configured | auto}. -determine_count() -> - shard_utils:determine_shard_count(presence_shards). - -spec start(non_neg_integer()) -> {ok, shard()} | {error, term()}. start(Index) -> case presence_manager_shard:start_link(Index) of @@ -151,20 +143,6 @@ pid_from_table(Key) -> error:badarg -> error end. --spec find_by_ref(reference(), #{non_neg_integer() => shard()}) -> - {ok, non_neg_integer()} | not_found. -find_by_ref(Ref, Shards) -> - shard_utils:find_shard_by_ref(Ref, Shards). - --spec find_by_pid(pid(), #{non_neg_integer() => shard()}) -> - {ok, non_neg_integer()} | not_found. -find_by_pid(Pid, Shards) -> - shard_utils:find_shard_by_pid(Pid, Shards). - --spec default_count() -> pos_integer(). -default_count() -> - clustered_ets_cache:default_shard_count(). - -spec ensure_shard(user_id(), state()) -> {non_neg_integer(), state()}. ensure_shard(Key, State) -> Count = maps:get(shard_count, State), diff --git a/fluxer_gateway/src/presence/presence_update.erl b/fluxer_gateway/src/presence/presence_update.erl index 02c23047e..bd4091e72 100644 --- a/fluxer_gateway/src/presence/presence_update.erl +++ b/fluxer_gateway/src/presence/presence_update.erl @@ -175,7 +175,7 @@ flush_push_buffer(#{push_buffer := Buffer} = State) -> entry_buffered_at(#{buffered_at := BufferedAt}) -> BufferedAt; entry_buffered_at(#{message_id := MessageId}) -> - snowflake_util:extract_timestamp(MessageId). + snowflake_id:extract_timestamp(MessageId). -spec maybe_update_push_eligibility(state()) -> state(). maybe_update_push_eligibility(State) -> diff --git a/fluxer_gateway/src/presence/presence_utils.erl b/fluxer_gateway/src/presence/presence_utils.erl index 6a1c1d3ae..a2765809c 100644 --- a/fluxer_gateway/src/presence/presence_utils.erl +++ b/fluxer_gateway/src/presence/presence_utils.erl @@ -78,8 +78,8 @@ dispatch_single_batch(Pid, GuildId, Batch) -> -spec get_members_from_guild_state(map()) -> [map()]. get_members_from_guild_state(GuildState) -> case maps:get(data, GuildState, undefined) of - undefined -> guild_data_index:member_values(GuildState); - Data -> guild_data_index:member_values(Data) + undefined -> guild_data_index_members:member_values(GuildState); + Data -> guild_data_index_members:member_values(Data) end. -spec member_user_id(map()) -> user_id() | undefined. diff --git a/fluxer_gateway/src/push/push.erl b/fluxer_gateway/src/push/push.erl index 8f19d8e52..9f801005b 100644 --- a/fluxer_gateway/src/push/push.erl +++ b/fluxer_gateway/src/push/push.erl @@ -9,13 +9,7 @@ -export([ handle_message_create/1, handle_buffered_message_creates/1, - sync_user_guild_settings/3, sync_user_guild_settings_local/3, - sync_user_blocked_ids/2, - sync_user_blocked_ids_local/2, - invalidate_user_subscriptions_local/1, - invalidate_user_badge_count_local/1, - invalidate_user_badge_counts_local/1, clear_channel_notifications/3, clear_notifications_enabled/0 ]). @@ -79,18 +73,6 @@ handle_call(_Request, _From, State) -> -spec handle_cast(term(), state()) -> {noreply, state()}. handle_cast({handle_message_create, Params}, State) when is_map(Params) -> handle_message_create_cast(Params, State); -handle_cast({sync_user_guild_settings, UserId, GuildId, UserGuildSettings}, State) when - is_integer(UserId), is_integer(GuildId), is_map(UserGuildSettings) --> - push_ets_cache:put_user_guild_settings(UserId, GuildId, UserGuildSettings), - {noreply, State}; -handle_cast({sync_user_blocked_ids, UserId, BlockedIds}, State) when is_integer(UserId) -> - handle_sync_user_blocked_ids(UserId, BlockedIds, State); -handle_cast({cache_user_guild_settings, UserId, GuildId, Settings}, State) when - is_integer(UserId), is_integer(GuildId), is_map(Settings) --> - push_ets_cache:put_user_guild_settings(UserId, GuildId, Settings), - {noreply, State}; handle_cast({clear_channel_notifications, UserId, ChannelId, MessageId}, State) when is_integer(UserId), is_integer(ChannelId), is_integer(MessageId) -> @@ -137,54 +119,12 @@ handle_buffered_message_creates(ParamsList) -> ok end. --spec sync_user_guild_settings(integer(), integer(), map()) -> ok. -sync_user_guild_settings(UserId, GuildId, Settings) -> - maybe_cast(UserId, {sync_user_guild_settings, UserId, GuildId, Settings}). - -spec sync_user_guild_settings_local(integer(), integer(), map()) -> ok. sync_user_guild_settings_local(UserId, GuildId, Settings) -> local_cache_mutation(fun() -> push_ets_cache:put_user_guild_settings(UserId, GuildId, Settings) end). --spec sync_user_blocked_ids(integer(), [integer()]) -> ok. -sync_user_blocked_ids(UserId, BlockedIds) -> - maybe_cast(UserId, {sync_user_blocked_ids, UserId, BlockedIds}). - --spec sync_user_blocked_ids_local(integer(), term()) -> ok. -sync_user_blocked_ids_local(UserId, BlockedIds) -> - case push_normalize:integer_list(BlockedIds) of - {ok, TypedBlockedIds} -> - put_blocked_ids_local(UserId, TypedBlockedIds); - error -> - ok - end. - --spec invalidate_user_subscriptions_local(integer()) -> ok. -invalidate_user_subscriptions_local(_UserId) -> - ok. - --spec invalidate_user_badge_count_local(integer()) -> ok. -invalidate_user_badge_count_local(_UserId) -> - ok. - --spec invalidate_user_badge_counts_local([integer()]) -> ok. -invalidate_user_badge_counts_local(_UserIds) -> - ok. - --spec put_blocked_ids_local(integer(), [integer()]) -> ok. -put_blocked_ids_local(UserId, TypedBlockedIds) -> - local_cache_mutation(fun() -> - push_ets_cache:put_blocked_ids(UserId, TypedBlockedIds) - end). - --spec maybe_cast(term(), term()) -> ok. -maybe_cast(Key, Msg) -> - case is_push_noop() of - true -> ok; - false -> cast_to_push_owner(Key, Msg) - end. - -spec is_push_active() -> boolean(). is_push_active() -> not is_push_noop() andalso env_boolean(push_enabled). @@ -764,16 +704,6 @@ blocked_ids_counters() -> blocked_ids_budget_exhausted => read_counter(?CNT_BUDGET_EXHAUSTED) }. --spec handle_sync_user_blocked_ids(integer(), term(), state()) -> {noreply, state()}. -handle_sync_user_blocked_ids(UserId, BlockedIds, State) -> - case push_normalize:integer_list(BlockedIds) of - {ok, TypedBlockedIds} -> - push_ets_cache:put_blocked_ids(UserId, TypedBlockedIds), - {noreply, State}; - error -> - {noreply, State} - end. - -spec handle_message_create_cast(map(), state()) -> {noreply, state()}. handle_message_create_cast(Params, State) -> SpawnResult = maybe_spawn_push_worker(fun() -> do_handle_message_create(Params) end), @@ -928,13 +858,6 @@ sync_user_guild_settings_local_updates_local_cache_test() -> end), push_ets_cache:delete_user_guild_settings(10, 20). -sync_user_blocked_ids_local_updates_local_cache_test() -> - push_ets_cache:init(), - with_registered_push(fun() -> - ok = sync_user_blocked_ids_local(10, [20, 30]), - ?assertEqual([20, 30], push_ets_cache:get_blocked_ids(10)) - end). - a_message_publishes_every_eligible_recipient_in_one_call_test() -> push_ets_cache:init(), lists:foreach(fun(UserId) -> ok = push_ets_cache:put_blocked_ids(UserId, []) end, [51, 52]), diff --git a/fluxer_gateway/src/push/push_ets_cache.erl b/fluxer_gateway/src/push/push_ets_cache.erl index ae0a57105..6b9881c1f 100644 --- a/fluxer_gateway/src/push/push_ets_cache.erl +++ b/fluxer_gateway/src/push/push_ets_cache.erl @@ -6,8 +6,7 @@ -export([ init/0, get_user_guild_settings/2, - put_user_guild_settings/3, - put_user_guild_settings/4, + put_user_guild_settings/3, put_user_guild_settings/4, delete_user_guild_settings/2, reserve_user_guild_settings/2, get_blocked_ids/1, @@ -16,7 +15,6 @@ reserve_blocked_ids/1, release/1, rebalance/0, - rebalance_async/0, cache_stats/0, evict_tables/1, table_size/1 @@ -169,11 +167,6 @@ release({Table, Token, Keys}) -> Keys ). --spec rebalance_async() -> ok. -rebalance_async() -> - _ = spawn(fun rebalance/0), - ok. - -spec rebalance() -> ok. rebalance() -> init(), @@ -361,15 +354,6 @@ user_id_from_key(_) -> -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -synced_blocked_ids_are_stored_without_an_expiry_test() -> - init(), - try - ok = put_blocked_ids(4001, [4002]), - ?assertEqual([{4001, [4002], infinity}], ets:lookup(?BLOCKED_IDS, 4001)) - after - safe_delete(?BLOCKED_IDS, 4001) - end. - synced_blocked_ids_never_expire_test() -> init(), try diff --git a/fluxer_gateway/src/push/push_job_publisher.erl b/fluxer_gateway/src/push/push_job_publisher.erl index b521889a8..16a5d6148 100644 --- a/fluxer_gateway/src/push/push_job_publisher.erl +++ b/fluxer_gateway/src/push/push_job_publisher.erl @@ -485,31 +485,6 @@ notification_fields_include_title_body_and_tags_test() -> ?assertEqual(<<"/channels/123/456/789">>, maps:get(<<"url">>, Fields)), ?assertEqual(null, maps:get(<<"image_url">>, Fields)). -notification_fields_use_markdown_plaintext_context_test() -> - MessageData = #{<<"content">> => <<"**Hi** <@1> <@&2> <#3>">>, <<"mentions">> => []}, - Context = #{ - <<"preserve_markdown">> => true, - <<"users">> => #{<<"1">> => <<"Ada">>}, - <<"roles">> => #{<<"2">> => <<"Ops">>}, - <<"channels">> => #{<<"3">> => <<"alerts">>} - }, - Fields = test_notification_fields(MessageData, 123, <<"Server">>, <<"general">>, Context), - ?assertEqual(<<"**Hi** @Ada @Ops #alerts">>, maps:get(<<"body">>, Fields)). - -notification_fields_include_safe_attachment_image_test() -> - MessageData = #{ - <<"content">> => <<"Photo">>, - <<"mentions">> => [], - <<"attachments">> => [ - #{ - <<"content_type">> => <<"image/png">>, - <<"proxy_url">> => <<"https://cdn.example/image.png">> - } - ] - }, - Fields = test_notification_fields(MessageData, 123, <<"Server">>, <<"general">>), - ?assertEqual(<<"https://cdn.example/image.png">>, maps:get(<<"image_url">>, Fields)). - notification_fields_omit_sensitive_attachment_image_test() -> MessageData = #{ <<"content">> => <<"Spoiler">>, @@ -525,21 +500,6 @@ notification_fields_omit_sensitive_attachment_image_test() -> Fields = test_notification_fields(MessageData, 123, <<"Server">>, <<"general">>), ?assertEqual(null, maps:get(<<"image_url">>, Fields)). -notification_fields_include_the_fluxer_badge_test() -> - MessageData = #{<<"content">> => <<"Hi">>, <<"mentions">> => []}, - Fields = test_notification_fields(MessageData, 123, <<"Server">>, <<"general">>), - ?assertEqual( - <<"https://static.example/marketing/branding/symbol-white.svg">>, - maps:get(<<"badge">>, Fields) - ). - -notification_fields_omit_the_badge_on_self_hosted_instances_test() -> - MessageData = #{<<"content">> => <<"Hi">>, <<"mentions">> => []}, - Fields = test_notification_fields( - MessageData, 123, <<"Server">>, <<"general">>, #{}, true - ), - ?assertNot(maps:is_key(<<"badge">>, Fields)). - test_notification_fields(MessageData, GuildId, GuildName, ChannelName) -> test_notification_fields(MessageData, GuildId, GuildName, ChannelName, #{}). diff --git a/fluxer_gateway/src/push/push_message_params.erl b/fluxer_gateway/src/push/push_message_params.erl index 178bedd04..aeae18998 100644 --- a/fluxer_gateway/src/push/push_message_params.erl +++ b/fluxer_gateway/src/push/push_message_params.erl @@ -149,7 +149,7 @@ normalize_message_type(_Type) -> -spec owner_fallback_key(map()) -> term(). owner_fallback_key(Params) -> case snowflake_id:parse_maybe(maps:get(author_id, Params, undefined)) of - undefined -> push_normalize:optional_guild_id(maps:get(guild_id, Params, undefined)); + undefined -> snowflake_id:parse_maybe(maps:get(guild_id, Params, undefined)); AuthorId -> AuthorId end. diff --git a/fluxer_gateway/src/push/push_normalize.erl b/fluxer_gateway/src/push/push_normalize.erl index bfcec1ef2..746f8cb27 100644 --- a/fluxer_gateway/src/push/push_normalize.erl +++ b/fluxer_gateway/src/push/push_normalize.erl @@ -3,14 +3,7 @@ -module(push_normalize). -typing([eqwalizer]). --export([ - optional_guild_id/1, - notification_level/1, - integer_list/1 -]). - --spec optional_guild_id(term()) -> pos_integer() | undefined. -optional_guild_id(Value) -> snowflake_id:parse_maybe(Value). +-export([notification_level/1]). -spec notification_level(term()) -> integer() | undefined. notification_level(undefined) -> @@ -24,17 +17,3 @@ notification_level(Value) -> Level when is_integer(Level), Level =< 3 -> Level; _ -> undefined end. - --spec integer_list(term()) -> {ok, [integer()]} | error. -integer_list(Value) when is_list(Value) -> - integer_list(Value, []); -integer_list(_) -> - error. - --spec integer_list([term()], [integer()]) -> {ok, [integer()]} | error. -integer_list([], Acc) -> - {ok, lists:reverse(Acc)}; -integer_list([Value | Rest], Acc) when is_integer(Value) -> - integer_list(Rest, [Value | Acc]); -integer_list(_, _) -> - error. diff --git a/fluxer_gateway/src/push/push_notification_format.erl b/fluxer_gateway/src/push/push_notification_format.erl index 179645aa2..914854446 100644 --- a/fluxer_gateway/src/push/push_notification_format.erl +++ b/fluxer_gateway/src/push/push_notification_format.erl @@ -150,7 +150,7 @@ user_nicknames(_MessageData, _GuildId, GuildData) -> -spec guild_user_nicknames(map()) -> map(). guild_user_nicknames(GuildData) when is_map(GuildData) -> - maps:fold(fun put_member_nickname/3, #{}, guild_data_index:member_map(GuildData)); + maps:fold(fun put_member_nickname/3, #{}, guild_data_index_members:member_map(GuildData)); guild_user_nicknames(_GuildData) -> #{}. @@ -289,7 +289,7 @@ maybe_prepend_snowflake(IdBin, Acc) -> channel_names_from_guild_data([], _GuildData) -> #{}; channel_names_from_guild_data(ChannelIds, GuildData) when is_map(GuildData) -> - ChannelIndex = guild_data_index:channel_index(GuildData), + ChannelIndex = guild_data_index_channels:channel_index(GuildData), lists:foldl( fun(ChannelId, Acc) -> put_index_channel_name(ChannelId, ChannelIndex, Acc) end, #{}, diff --git a/fluxer_gateway/src/push/push_worker_pool.erl b/fluxer_gateway/src/push/push_worker_pool.erl index 259b168d2..f62905507 100644 --- a/fluxer_gateway/src/push/push_worker_pool.erl +++ b/fluxer_gateway/src/push/push_worker_pool.erl @@ -3,7 +3,7 @@ -module(push_worker_pool). -typing([eqwalizer]). --export([init_counter/0, maybe_spawn/1, active_count/0, utilization_pct/0]). +-export([init_counter/0, maybe_spawn/1, active_count/0]). -define(MAX_PUSH_WORKERS, 256). -define(PUSH_WORKER_COUNTER, push_worker_counter). @@ -59,10 +59,6 @@ active_count() -> error:badarg -> 0 end. --spec utilization_pct() -> non_neg_integer(). -utilization_pct() -> - (active_count() * 100) div ?MAX_PUSH_WORKERS. - -spec maybe_warn_high_utilization(non_neg_integer()) -> ok. maybe_warn_high_utilization(Active) -> Pct = (Active * 100) div ?MAX_PUSH_WORKERS, diff --git a/fluxer_gateway/src/session/session.erl b/fluxer_gateway/src/session/session.erl index d9de9afbf..a897b117c 100644 --- a/fluxer_gateway/src/session/session.erl +++ b/fluxer_gateway/src/session/session.erl @@ -219,23 +219,23 @@ do_handle_cast_call(_, State) -> handle_info({presence_connect, Attempt}, #{presence_pid := undefined} = State) when is_integer(Attempt), Attempt >= 0 -> - session_connection:handle_presence_connect(Attempt, State); + session_connection_presence:handle_presence_connect(Attempt, State); handle_info({presence_connect, _Attempt}, State) -> {noreply, State}; handle_info({guild_connect, GuildId, Attempt}, State) when is_integer(GuildId), is_integer(Attempt), Attempt >= 0 -> - session_connection:handle_guild_connect(GuildId, Attempt, State); + session_connection_guild:handle_guild_connect(GuildId, Attempt, State); handle_info({guild_connect_result, _, _, _} = Msg, State) -> handle_info_guild_connect_result(Msg, State); handle_info({guild_connect_timeout, GuildId, Attempt, Token}, State) when is_integer(GuildId), is_integer(Attempt), Attempt >= 0, is_reference(Token) -> - session_connection:handle_guild_connect_timeout(GuildId, Attempt, Token, State); + session_connection_guild:handle_guild_connect_timeout(GuildId, Attempt, Token, State); handle_info({call_reconnect, ChannelId, Attempt}, State) when is_integer(ChannelId), is_integer(Attempt), Attempt >= 0 -> - session_connection:handle_call_reconnect(ChannelId, Attempt, State); + session_connection_retry:handle_call_reconnect(ChannelId, Attempt, State); handle_info({gateway_timing_update, Timings}, State) -> {noreply, gateway_timings:merge_state(Timings, State)}; handle_info({thread_user_flip, _Version}, State) -> @@ -277,7 +277,7 @@ cast_thread_user_flip(SessionId, State) -> handle_info_guild_connect_result( {guild_connect_result, GuildId, Attempt, Result}, State ) when is_integer(GuildId), is_integer(Attempt), Attempt >= 0 -> - session_connection:handle_guild_connect_result( + session_connection_guild:handle_guild_connect_result( GuildId, Attempt, Result, State ); handle_info_guild_connect_result(_, State) -> @@ -337,7 +337,7 @@ do_handle_info_misc({'DOWN', Ref, process, _Pid, Reason}, State) when do_handle_info_misc(check_ack_lag, State) -> erlang:send_after(60000, self(), check_ack_lag), State1 = session_connection_guild:repair_stalled_guild_connects(State), - {noreply, session_connection:repair_presence_connection(State1)}; + {noreply, session_connection_presence:repair_presence_connection(State1)}; do_handle_info_misc({presence_rejoin_check}, State) -> handle_presence_rejoin_check(State); do_handle_info_misc(_Info, State) -> @@ -345,7 +345,7 @@ do_handle_info_misc(_Info, State) -> -spec handle_presence_rejoin_check(session_state()) -> {noreply, session_state()}. handle_presence_rejoin_check(State) -> - RepairedState = session_connection:repair_presence_connection(State), + RepairedState = session_connection_presence:repair_presence_connection(State), {noreply, session_dispatch_presence:sync_presence_targets(RepairedState)}. -spec terminate(term(), session_state()) -> ok. diff --git a/fluxer_gateway/src/session/session_connection.erl b/fluxer_gateway/src/session/session_connection.erl deleted file mode 100644 index 03d63d8ee..000000000 --- a/fluxer_gateway/src/session/session_connection.erl +++ /dev/null @@ -1,64 +0,0 @@ -%% SPDX-License-Identifier: AGPL-3.0-or-later - --module(session_connection). --typing([eqwalizer]). - --export([ - handle_presence_connect/2, - repair_presence_connection/1, - handle_guild_connect/3, - handle_guild_connect_result/4, - handle_guild_connect_timeout/4, - handle_call_reconnect/3 -]). - --export_type([ - session_state/0, - guild_id/0, - channel_id/0, - attempt/0, - guild_connect_result/0, - session_result/0 -]). - --type session_state() :: session:session_state(). --type guild_id() :: session:guild_id(). --type channel_id() :: session:channel_id(). --type attempt() :: non_neg_integer(). --type guild_connect_result() :: - {ok, pid(), map()} - | {ok_unavailable, pid(), map()} - | {ok_cached_unavailable, map()} - | {error, term()}. --type session_result() :: {noreply, session_state()} | {stop, normal, session_state()}. - --spec handle_presence_connect(attempt(), session_state()) -> - session_result(). -handle_presence_connect(Attempt, State) -> - session_connection_presence:handle_presence_connect(Attempt, State). - --spec repair_presence_connection(session_state()) -> session_state(). -repair_presence_connection(State) -> - session_connection_presence:repair_presence_connection(State). - --spec handle_guild_connect(guild_id(), attempt(), session_state()) -> - session_result(). -handle_guild_connect(GuildId, Attempt, State) -> - session_connection_guild:handle_guild_connect(GuildId, Attempt, State). - --spec handle_guild_connect_result( - guild_id(), attempt(), guild_connect_result(), session_state() -) -> - session_result(). -handle_guild_connect_result(GuildId, Attempt, Result, State) -> - session_connection_guild:handle_guild_connect_result(GuildId, Attempt, Result, State). - --spec handle_guild_connect_timeout(guild_id(), attempt(), reference(), session_state()) -> - session_result(). -handle_guild_connect_timeout(GuildId, Attempt, Token, State) -> - session_connection_guild:handle_guild_connect_timeout(GuildId, Attempt, Token, State). - --spec handle_call_reconnect(channel_id(), attempt(), session_state()) -> - {noreply, session_state()}. -handle_call_reconnect(ChannelId, Attempt, State) -> - session_connection_retry:handle_call_reconnect(ChannelId, Attempt, State). diff --git a/fluxer_gateway/src/session/session_dispatch.erl b/fluxer_gateway/src/session/session_dispatch.erl index a2fc70e57..7b7c680d7 100644 --- a/fluxer_gateway/src/session/session_dispatch.erl +++ b/fluxer_gateway/src/session/session_dispatch.erl @@ -482,10 +482,6 @@ base_state(Opts) -> Opts ). -is_oversized_event_small_event_test() -> - Req = #{event => message_create, data => #{<<"content">> => <<"hello">>}, seq => 1}, - ?assertEqual(false, is_oversized_event(buffer_entry_bytes(Req))). - is_oversized_event_large_event_test() -> LargeData = make_large_data(), Req = #{event => guild_create, data => LargeData, seq => 1}, @@ -559,12 +555,6 @@ make_large_data() -> Pairs = [{integer_to_binary(I), lists:duplicate(1000, $x)} || I <- lists:seq(1, 500)], maps:from_list(Pairs). -normal_event_buffered_test() -> - Data = #{<<"content">> => <<"hello">>}, - {noreply, S1} = do_handle_dispatch(message_create, Data, base_state(#{})), - ?assertEqual(1, limited_deque:size(maps:get(buffer, S1))), - ?assertEqual(1, maps:get(seq, S1)). - should_skip_replay_buffer_test() -> ?assertEqual(true, should_skip_replay_buffer(guild_members_chunk)), ?assertEqual(true, should_skip_replay_buffer(<<"GUILD_MEMBERS_CHUNK">>)), diff --git a/fluxer_gateway/src/session/session_dispatch_guild.erl b/fluxer_gateway/src/session/session_dispatch_guild.erl index 0b6966a81..810fe9f3e 100644 --- a/fluxer_gateway/src/session/session_dispatch_guild.erl +++ b/fluxer_gateway/src/session/session_dispatch_guild.erl @@ -154,7 +154,7 @@ normalize_optional_snowflake_field(Data, Key) -> case get_field(Data, Key) of undefined -> undefined; null -> undefined; - Value -> guild_voice_connection_normalize:normalize_positive_snowflake(Value) + Value -> snowflake_id:parse_maybe(Value) end. -spec normalize_binary_field(map(), binary()) -> binary() | undefined. diff --git a/fluxer_gateway/src/session/session_manager.erl b/fluxer_gateway/src/session/session_manager.erl index 9a89fa034..3ff0c5ae2 100644 --- a/fluxer_gateway/src/session/session_manager.erl +++ b/fluxer_gateway/src/session/session_manager.erl @@ -96,7 +96,7 @@ init([]) -> erlang:process_flag(fullsweep_after, 0), _ = fluxer_gateway_env:load(), session_manager_shards:ensure_shard_table(), - {ShardCount, _Source} = shard_utils:determine_shard_count(session_shards), + {ShardCount, _Source} = clustered_ets_cache:determine_shard_count([session_shards]), Shards = session_manager_shards:start_shards(ShardCount), State = #{shards => Shards, shard_count => ShardCount}, session_manager_shards:sync_shard_table(State), @@ -205,7 +205,7 @@ handle_cast(_Msg, State) -> -spec handle_info(term(), state()) -> {noreply, state()}. handle_info({'DOWN', Ref, process, Pid, _Reason}, State) when is_reference(Ref), is_pid(Pid) -> Shards = maps:get(shards, State), - case session_manager_transfer:find_shard_by_ref(Ref, Shards) of + case shard_utils:find_shard_by_ref(Ref, Shards) of {ok, Index} -> restart_shard_noreply(Index, State); not_found -> handle_down_by_pid(Pid, State) end; @@ -298,7 +298,7 @@ restart_shard_noreply(Index, State) -> -spec handle_down_by_pid(pid(), state()) -> {noreply, state()}. handle_down_by_pid(Pid, State) -> Shards = maps:get(shards, State), - case session_manager_transfer:find_shard_by_pid(Pid, Shards) of + case shard_utils:find_shard_by_pid(Pid, Shards) of {ok, Index} -> restart_shard_noreply(Index, State); not_found -> {noreply, State} end. diff --git a/fluxer_gateway/src/session/session_manager_routing.erl b/fluxer_gateway/src/session/session_manager_routing.erl index b68fe66d5..a9f50a831 100644 --- a/fluxer_gateway/src/session/session_manager_routing.erl +++ b/fluxer_gateway/src/session/session_manager_routing.erl @@ -13,8 +13,7 @@ shard_pid_from_table/1, handle_owner_call/5, forward_call/4, - execute_owner_request/4, - start_call_with_drain_guard/4 + execute_owner_request/4 ]). -export_type([session_id/0, state/0]). diff --git a/fluxer_gateway/src/session/session_manager_shard.erl b/fluxer_gateway/src/session/session_manager_shard.erl index 0882bd3a3..ec3a98f2e 100644 --- a/fluxer_gateway/src/session/session_manager_shard.erl +++ b/fluxer_gateway/src/session/session_manager_shard.erl @@ -14,7 +14,6 @@ -type user_id() :: integer(). -type session_ref() :: {pid(), reference()}. -type status() :: online | offline | idle | dnd | invisible. --type identify_timestamp() :: integer(). -type identify_request() :: #{ session_id := session_id(), @@ -43,7 +42,6 @@ -type state() :: #{ sessions := #{session_id() => session_ref()}, - identify_attempts := [identify_timestamp()], pending_identifies := #{session_id() => pending_identify()}, identify_workers := #{reference() => {session_id(), pid()}}, shard_index := non_neg_integer(), @@ -91,7 +89,6 @@ init(Args) -> #{ sessions => #{}, session_refs => #{}, - identify_attempts => [], pending_identifies => #{}, identify_workers => #{}, shard_index => ShardIndex diff --git a/fluxer_gateway/src/session/session_manager_shard_drain.erl b/fluxer_gateway/src/session/session_manager_shard_drain.erl index bcb7fa3e5..ec1e0c24b 100644 --- a/fluxer_gateway/src/session/session_manager_shard_drain.erl +++ b/fluxer_gateway/src/session/session_manager_shard_drain.erl @@ -10,7 +10,6 @@ build_ready_data_for_session/1, filter_guild_ids_for_identify/2, validate_identify_sharding/3, - check_identify_rate_limit/1, should_debounce_reactions/1, thread_channels_capable/1, extract_e2ee_capable/1, @@ -318,22 +317,6 @@ filter_read_state(Row, false, _Viewer) when is_map(Row) -> filter_read_state(_Row, _Capable, _Viewer) -> true. --spec check_identify_rate_limit(list()) -> {ok, list()} | {error, rate_limited}. -check_identify_rate_limit(Attempts) -> - case fluxer_gateway_env:get(identify_rate_limit_enabled) of - true -> do_check_rate_limit(Attempts); - _ -> {ok, Attempts} - end. - --spec do_check_rate_limit(list()) -> {ok, list()} | {error, rate_limited}. -do_check_rate_limit(Attempts) -> - Now = erlang:system_time(millisecond), - AttemptsInWindow = [T || T <- Attempts, (Now - T) < 5000], - case AttemptsInWindow =/= [] of - true -> {error, rate_limited}; - false -> {ok, [Now | AttemptsInWindow]} - end. - -spec should_debounce_reactions(map()) -> boolean(). should_debounce_reactions(IdentifyData) -> case map_utils:get_safe(IdentifyData, flags, 0) of @@ -401,7 +384,7 @@ do_fetch_rpc_data(Request, PeerIP) -> RpcWithCapability = add_thread_channels_capable( RpcWithCoords, thread_channels_capable(IdentifyData) ), - RpcResult = api_rpc_client:call_with_retry(RpcWithCapability, ?SESSION_RPC_RETRY_CONFIG), + RpcResult = rpc_client:call_with_retry(RpcWithCapability, ?SESSION_RPC_RETRY_CONFIG), classify_rpc_result(RpcResult, PeerIP). -spec add_thread_channels_capable(map(), boolean()) -> map(). diff --git a/fluxer_gateway/src/session/session_manager_shard_lifecycle.erl b/fluxer_gateway/src/session/session_manager_shard_lifecycle.erl index 361fcf7b6..4c4e4a751 100644 --- a/fluxer_gateway/src/session/session_manager_shard_lifecycle.erl +++ b/fluxer_gateway/src/session/session_manager_shard_lifecycle.erl @@ -26,7 +26,6 @@ -type session_ref() :: {pid(), reference()}. -type state() :: #{ sessions := #{session_id() => session_ref()}, - identify_attempts := [integer()], pending_identifies := #{session_id() => pending_identify()}, identify_workers := #{reference() => {session_id(), pid()}}, shard_index := non_neg_integer(), @@ -150,14 +149,7 @@ handle_existing_pending( -spec try_start_identify(map(), pid(), gen_server:from(), state(), session_id()) -> {reply, start_reply(), state()} | {noreply, state()}. try_start_identify(Request, SocketPid, From, State, SessionId) -> - Attempts = maps:get(identify_attempts, State), - case session_manager_shard_drain:check_identify_rate_limit(Attempts) of - {ok, NewAttempts} -> - NewState = State#{identify_attempts := NewAttempts}, - start_identify_fetch(Request, SocketPid, SessionId, From, NewState); - {error, rate_limited} -> - {reply, {error, identify_rate_limited}, State} - end. + start_identify_fetch(Request, SocketPid, SessionId, From, State). -spec start_session_process(map(), session_id(), #{session_id() => session_ref()}, state()) -> {reply, start_reply(), state()}. diff --git a/fluxer_gateway/src/session/session_manager_shard_lookup.erl b/fluxer_gateway/src/session/session_manager_shard_lookup.erl index 31bc9be53..615918a37 100644 --- a/fluxer_gateway/src/session/session_manager_shard_lookup.erl +++ b/fluxer_gateway/src/session/session_manager_shard_lookup.erl @@ -30,7 +30,6 @@ -type session_ref() :: {pid(), reference()}. -type state() :: #{ sessions := #{session_id() => session_ref()}, - identify_attempts := [integer()], pending_identifies := map(), identify_workers := map(), shard_index := non_neg_integer(), diff --git a/fluxer_gateway/src/session/session_manager_transfer.erl b/fluxer_gateway/src/session/session_manager_transfer.erl index bffb10dab..3ffe29e5e 100644 --- a/fluxer_gateway/src/session/session_manager_transfer.erl +++ b/fluxer_gateway/src/session/session_manager_transfer.erl @@ -5,12 +5,7 @@ -include_lib("fluxer_gateway/include/timeout_config.hrl"). --export([ - aggregate_counts/2, - aggregate_handoff_to_topology/2, - find_shard_by_ref/2, - find_shard_by_pid/2 -]). +-export([aggregate_counts/2, aggregate_handoff_to_topology/2]). -export_type([handoff_result/0, shard/0, state/0]). @@ -90,16 +85,6 @@ safe_shard_call(Pid, Request) -> exit:_Reason -> unavailable end. --spec find_shard_by_ref(reference(), #{non_neg_integer() => shard()}) -> - {ok, non_neg_integer()} | not_found. -find_shard_by_ref(Ref, Shards) -> - shard_utils:find_shard_by_ref(Ref, Shards). - --spec find_shard_by_pid(pid(), #{non_neg_integer() => shard()}) -> - {ok, non_neg_integer()} | not_found. -find_shard_by_pid(Pid, Shards) -> - shard_utils:find_shard_by_pid(Pid, Shards). - -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). diff --git a/fluxer_gateway/src/session/session_passive.erl b/fluxer_gateway/src/session/session_passive.erl index c9b3c3113..1c1e6198d 100644 --- a/fluxer_gateway/src/session/session_passive.erl +++ b/fluxer_gateway/src/session/session_passive.erl @@ -213,7 +213,7 @@ add_mention_role(_, Acc) -> -spec get_user_roles_for_guild(user_id(), guild_state()) -> [integer()]. get_user_roles_for_guild(UserId, GuildState) -> - case guild_permissions:find_member_by_user_id(UserId, GuildState) of + case guild_permissions_check:find_member_by_user_id(UserId, GuildState) of undefined -> []; Member -> extract_role_ids(maps:get(<<"roles">>, Member, [])) end. diff --git a/fluxer_gateway/src/session/session_state_transfer.erl b/fluxer_gateway/src/session/session_state_transfer.erl index c0508328c..bfff4fca8 100644 --- a/fluxer_gateway/src/session/session_state_transfer.erl +++ b/fluxer_gateway/src/session/session_state_transfer.erl @@ -131,12 +131,6 @@ state_ttl_ms() -> -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -receive_and_pop_state_test() -> - SessionId = <<"test-session-1">>, - SessionState = #{seq => 42, buffer => []}, - {ok, SessionState, Store2} = push_and_pop(SessionId, SessionState, #{}), - ?assertEqual(#{}, Store2). - pop_state_not_found_test() -> Store = #{}, {reply, {error, not_found}, Store} = handle_call( @@ -168,61 +162,6 @@ pop_removes_entry_so_double_pop_fails_test() -> {pop_state, SessionId}, test_from(), Store2 ). -transfer_state_round_trip_preserves_all_fields_test() -> - SessionId = <<"round-trip-session">>, - TransferState = build_round_trip_state(SessionId), - {ok, Recovered, _} = push_and_pop(SessionId, TransferState, #{}), - assert_round_trip_fields(SessionId, Recovered). - -build_round_trip_state(SessionId) -> - #{ - id => SessionId, - user_id => 42, - user_data => #{<<"username">> => <<"tester">>}, - custom_status => null, - version => 9, - token_hash => <<"hash123">>, - auth_session_id_hash => <<"auth_hash">>, - properties => #{<<"os">> => <<"linux">>}, - status => online, - afk => false, - mobile => true, - socket_pid => undefined, - guilds => [100, 200, 300], - active_guilds => sets:from_list([100, 300]), - ready => #{<<"guilds">> => []}, - bot => false, - e2ee_capable => true, - ignored_events => [<<"TYPING_START">>], - initial_guild_id => 100, - debounce_reactions => true, - thread_channels_capable => true, - channels => #{1 => #{}}, - relationships => #{10 => 1}, - seq => 15, - ack_seq => 12, - buffer => [#{seq => 13}, #{seq => 14}, #{seq => 15}], - collected_guild_states => [], - collected_sessions => [], - collected_presences => [], - guild_subscription_state => #{} - }. - -assert_round_trip_fields(SessionId, RS) -> - ?assertEqual(SessionId, maps:get(id, RS)), - ?assertEqual(42, maps:get(user_id, RS)), - ?assertEqual(<<"hash123">>, maps:get(token_hash, RS)), - ?assertEqual(<<"auth_hash">>, maps:get(auth_session_id_hash, RS)), - ?assertEqual(15, maps:get(seq, RS)), - ?assertEqual(12, maps:get(ack_seq, RS)), - ?assertEqual(3, length(maps:get(buffer, RS))), - ?assertEqual([100, 200, 300], maps:get(guilds, RS)), - ?assert(sets:is_element(100, maps:get(active_guilds, RS))), - ?assert(sets:is_element(300, maps:get(active_guilds, RS))), - ?assertEqual(true, maps:get(e2ee_capable, RS)), - ?assertEqual(true, maps:get(debounce_reactions, RS)), - ?assertEqual(true, maps:get(thread_channels_capable, RS)). - push_and_pop(SessionId, SessionState, Store0) -> {reply, ok, Store1} = handle_call( {receive_state, SessionId, SessionState}, test_from(), Store0 diff --git a/fluxer_gateway/src/telemetry/process_memory_stats.erl b/fluxer_gateway/src/telemetry/process_memory_stats.erl index dc508a972..7e7953f42 100644 --- a/fluxer_gateway/src/telemetry/process_memory_stats.erl +++ b/fluxer_gateway/src/telemetry/process_memory_stats.erl @@ -177,7 +177,7 @@ guild_stats_from_state(State, Memory) -> Guild = maps:get(<<"guild">>, Data, #{}), GuildName = maps:get(<<"name">>, Guild, <<"Unknown">>), GuildIcon = maps:get(<<"icon">>, Guild, null), - MemberCount = guild_data_index:member_count(Data), + MemberCount = guild_data_index_members:member_count(Data), SessionCount = map_size(maps:get(sessions, State, #{})), PresenceCount = map_size(maps:get(presences, State, #{})), #{ @@ -193,37 +193,3 @@ guild_stats_from_state(State, Memory) -> session_count => SessionCount, presence_count => PresenceCount }. - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). - -limit_candidates_for_state_fetch_keeps_bounded_memory_order_test() -> - Candidates = [ - #{pid => self(), memory => 5}, - #{pid => self(), memory => 40}, - #{pid => self(), memory => 20} - ], - Sorted = sort_candidates(Candidates), - Limited = limit_candidates_for_state_fetch(Sorted, 2), - ?assertEqual([40, 20, 5], [maps:get(memory, C) || C <- Limited]). - -state_fetch_limit_overfetches_but_stays_bounded_test() -> - ?assertEqual(2, state_fetch_limit(1)), - ?assertEqual(100, state_fetch_limit(50)), - ?assertEqual(150, state_fetch_limit(500)), - ?assertEqual(40, state_fetch_limit(not_a_limit)). - -bounded_process_scan_test() -> - ?assert(length(process_scan_pids()) =< ?MAX_PROCESS_SCAN). - -bounded_ets_rows_respects_limit_test() -> - Table = ets:new(?MODULE, [set]), - try - true = ets:insert(Table, [{1, self()}, {2, self()}, {3, self()}]), - Rows = bounded_ets_rows(Table, 2), - ?assertEqual(2, length(Rows)) - after - ets:delete(Table) - end. - --endif. diff --git a/fluxer_gateway/src/utils/backoff_utils.erl b/fluxer_gateway/src/utils/backoff_utils.erl index 4c4d66768..0884bb31b 100644 --- a/fluxer_gateway/src/utils/backoff_utils.erl +++ b/fluxer_gateway/src/utils/backoff_utils.erl @@ -45,34 +45,10 @@ calculate_with_jitter(Attempt, MaxMs) -> -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -calculate_default_max_test() -> - ?assertEqual(1000, calculate(0)), - ?assertEqual(2000, calculate(1)), - ?assertEqual(4000, calculate(2)), - ?assertEqual(8000, calculate(3)), - ?assertEqual(16000, calculate(4)), - ?assertEqual(30000, calculate(5)), - ?assertEqual(30000, calculate(10)). - -calculate_custom_max_test() -> - ?assertEqual(1000, calculate(0, 5000)), - ?assertEqual(2000, calculate(1, 5000)), - ?assertEqual(4000, calculate(2, 5000)), - ?assertEqual(5000, calculate(3, 5000)), - ?assertEqual(5000, calculate(10, 5000)). - -calculate_small_max_test() -> - ?assertEqual(1000, calculate(0, 1000)), - ?assertEqual(1000, calculate(1, 1000)), - ?assertEqual(1000, calculate(5, 1000)). - calculate_huge_attempt_does_not_overflow_test() -> ?assertEqual(30000, calculate(1000000000)), ?assertEqual(5000, calculate(1000000000, 5000)). -calculate_negative_attempt_is_safe_test() -> - ?assertEqual(1000, calculate(-5, 30000)). - calculate_with_jitter_stays_in_range_test() -> lists:foreach( fun(_) -> @@ -89,23 +65,4 @@ calculate_with_jitter_stays_in_range_test() -> lists:seq(1, 20) ). -calculate_with_jitter_respects_max_test() -> - lists:foreach( - fun(_) -> - V = calculate_with_jitter(10, 5000), - ?assert(V >= 1), - ?assert(V =< 5000) - end, - lists:seq(1, 20) - ). - -calculate_with_jitter_positive_test() -> - lists:foreach( - fun(_) -> - V = calculate_with_jitter(0, 1), - ?assert(V >= 1) - end, - lists:seq(1, 20) - ). - -endif. diff --git a/fluxer_gateway/src/utils/constants.erl b/fluxer_gateway/src/utils/constants.erl index f0667db09..8177d08d1 100644 --- a/fluxer_gateway/src/utils/constants.erl +++ b/fluxer_gateway/src/utils/constants.erl @@ -27,8 +27,6 @@ ban_members_permission/0, view_channel_members_permission/0, manage_threads_permission/0, - create_public_threads_permission/0, - create_private_threads_permission/0, send_messages_in_threads_permission/0, voice_channel_camera_user_limit/0 ]). @@ -163,12 +161,6 @@ view_channel_members_permission() -> 18014398509481984. -spec manage_threads_permission() -> pos_integer(). manage_threads_permission() -> 17179869184. --spec create_public_threads_permission() -> pos_integer(). -create_public_threads_permission() -> 34359738368. - --spec create_private_threads_permission() -> pos_integer(). -create_private_threads_permission() -> 68719476736. - -spec send_messages_in_threads_permission() -> pos_integer(). send_messages_in_threads_permission() -> 274877906944. diff --git a/fluxer_gateway/src/utils/custom_status_expiry.erl b/fluxer_gateway/src/utils/custom_status_expiry.erl index 9d75ad38d..a82a69e3b 100644 --- a/fluxer_gateway/src/utils/custom_status_expiry.erl +++ b/fluxer_gateway/src/utils/custom_status_expiry.erl @@ -118,13 +118,6 @@ status(ExpiresAt) -> status_without_expiry() -> #{<<"emoji_animated">> => false, <<"emoji_name">> => null, <<"text">> => <<"brb">>}. -parser_anchors_on_the_unix_epoch_test() -> - ?assertEqual(0, epoch_ms(<<"1970-01-01T00:00:00.000Z">>)), - ?assertEqual(1500, epoch_ms(<<"1970-01-01T00:00:01.500Z">>)). - -parser_keeps_milliseconds_test() -> - ?assertEqual(497, expiry_ms() rem 1000). - not_expired_test() -> Status = status(?LIVE_EXPIRES_AT), ?assertEqual(Status, clear_if_expired(Status, expiry_ms() - 60000)). @@ -139,10 +132,6 @@ boundary_is_expired_test() -> ?assertEqual({ok, 0}, remaining_ms(Status, expiry_ms())), ?assertEqual(null, clear_if_expired(Status, expiry_ms())). -one_millisecond_before_boundary_is_kept_test() -> - Status = status(?LIVE_EXPIRES_AT), - ?assertEqual(Status, clear_if_expired(Status, expiry_ms() - 1)). - expired_test() -> Status = status(?LIVE_EXPIRES_AT), ?assertEqual(null, clear_if_expired(Status, expiry_ms() + 1)), @@ -168,22 +157,6 @@ malformed_expires_at_is_kept_test() -> [<<"">>, <<"not-a-date">>, <<"2026-13-45T99:99:99Z">>, 1747141347, [], #{}, true] ). -custom_status_null_test() -> - ?assertEqual(none, remaining_ms(null, expiry_ms())), - ?assertEqual(null, clear_if_expired(null, expiry_ms())). - -custom_status_absent_test() -> - ?assertEqual(none, remaining_ms(undefined, expiry_ms())), - ?assertEqual(null, clear_if_expired(undefined, expiry_ms())). - -non_map_custom_status_test() -> - ?assertEqual(null, clear_if_expired(<<"garbage">>, expiry_ms())), - ?assertEqual(null, clear_if_expired(123, expiry_ms())). - -non_integer_reference_time_keeps_status_test() -> - Status = status(?LIVE_EXPIRES_AT), - ?assertEqual(Status, clear_if_expired(Status, not_a_time)). - wakeup_none_without_expiry_test() -> ?assertEqual(none, wakeup_delay(remaining_ms(status_without_expiry(), expiry_ms()))), ?assertEqual(none, wakeup_delay(remaining_ms(null, expiry_ms()))). @@ -211,7 +184,4 @@ clears_expired_and_arms_future_test() -> future_status() -> status(rfc3339(erlang:system_time(millisecond) + 3600000)). -reconcile_message_is_a_gen_server_cast_test() -> - ?assertEqual({'$gen_cast', reconcile_flattened_presence}, reconcile_message()). - -endif. diff --git a/fluxer_gateway/src/utils/map_utils.erl b/fluxer_gateway/src/utils/map_utils.erl index 242e29f38..761506c6f 100644 --- a/fluxer_gateway/src/utils/map_utils.erl +++ b/fluxer_gateway/src/utils/map_utils.erl @@ -12,10 +12,6 @@ -export_type([key/0, default/0]). --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --endif. - -type key() :: atom() | binary() | term(). -type default() :: term(). diff --git a/fluxer_gateway/src/utils/permission_bits.erl b/fluxer_gateway/src/utils/permission_bits.erl deleted file mode 100644 index ccda10bb6..000000000 --- a/fluxer_gateway/src/utils/permission_bits.erl +++ /dev/null @@ -1,42 +0,0 @@ -%% SPDX-License-Identifier: AGPL-3.0-or-later - --module(permission_bits). --typing([eqwalizer]). - --export([ - parse/1, - parse_optional/1, - parse_maybe/1, - has/2, - add/2, - apply_allow_deny/3 -]). - --export_type([t/0, bit/0]). - --type t() :: bitset:t(). --type bit() :: bitset:bit(). - --spec parse(term()) -> t(). -parse(Value) -> - bitset:parse(Value). - --spec parse_optional(term()) -> t() | undefined. -parse_optional(Value) -> - bitset:parse_optional(Value). - --spec parse_maybe(term()) -> t() | undefined. -parse_maybe(Value) -> - bitset:parse_maybe(Value). - --spec has(t(), bit()) -> boolean(). -has(Bits, Bit) -> - bitset:has(Bits, Bit). - --spec add(t(), t()) -> t(). -add(Bits, Mask) -> - bitset:add(Bits, Mask). - --spec apply_allow_deny(t(), t(), t()) -> t(). -apply_allow_deny(Bits, Allow, Deny) -> - bitset:apply_allow_deny(Bits, Allow, Deny). diff --git a/fluxer_gateway/src/utils/shard_utils.erl b/fluxer_gateway/src/utils/shard_utils.erl index 7c3f979f9..f797eded3 100644 --- a/fluxer_gateway/src/utils/shard_utils.erl +++ b/fluxer_gateway/src/utils/shard_utils.erl @@ -5,7 +5,6 @@ -export([ max_positive/1, - determine_shard_count/1, find_shard_by_ref/2, find_shard_by_pid/2, safe_gen_call/3, @@ -25,15 +24,6 @@ max_positive(Candidates) -> lists:max([C || C <- Candidates, is_integer(C), C > 0] ++ [1]). --spec determine_shard_count(atom()) -> {pos_integer(), configured | auto}. -determine_shard_count(ConfigKey) -> - case fluxer_gateway_env:get(ConfigKey) of - Value when is_integer(Value), Value > 0 -> - {Value, configured}; - _ -> - {clustered_ets_cache:default_shard_count(), auto} - end. - -spec find_shard_by_ref(reference(), #{non_neg_integer() => shard()}) -> {ok, non_neg_integer()} | not_found. find_shard_by_ref(Ref, Shards) -> @@ -146,29 +136,11 @@ check_pid_mailbox_pressure(Pid) -> -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -max_positive_normal_test() -> - ?assertEqual(8, max_positive([4, 8, 2])). - -max_positive_fallback_test() -> - ?assertEqual(1, max_positive([])). - -max_positive_non_integers_test() -> - ?assertEqual(1, max_positive([undefined, foo, -1, 0])). - -max_positive_mixed_test() -> - ?assertEqual(4, max_positive([undefined, 4, -1, 0])). - safe_gen_call_noproc_test() -> Pid = spawn(fun() -> ok end), ok = gateway_retry_timer:wait(50), ?assertEqual({error, unavailable}, safe_gen_call(Pid, ping, 100)). -safe_apply_success_test() -> - ?assertEqual(42, safe_apply(fun() -> 42 end, 0)). - -safe_apply_throw_test() -> - ?assertEqual(fallback, safe_apply(fun() -> exit(boom) end, fallback)). - safe_apply_error_test() -> ?assertEqual(fallback, safe_apply(fun() -> error(boom) end, fallback)). diff --git a/fluxer_gateway/src/utils/snowflake_id.erl b/fluxer_gateway/src/utils/snowflake_id.erl index d68ca6609..337e4785f 100644 --- a/fluxer_gateway/src/utils/snowflake_id.erl +++ b/fluxer_gateway/src/utils/snowflake_id.erl @@ -4,6 +4,7 @@ -typing([eqwalizer]). -export([ + extract_timestamp/1, parse/1, parse_optional/1, parse_maybe/1, @@ -207,3 +208,17 @@ get_falls_back_to_binary_key_test() -> ?assertEqual(bin, get(123, #{<<"123">> => bin}, missing)). -endif. + +-define(FLUXER_EPOCH, 1420070400000). +-define(TIMESTAMP_SHIFT, 22). + +-spec extract_timestamp(term()) -> integer() | undefined. +extract_timestamp(SnowflakeValue) -> + try snowflake_id:parse_optional(SnowflakeValue) of + Snowflake when is_integer(Snowflake), Snowflake > 0 -> + (Snowflake bsr ?TIMESTAMP_SHIFT) + ?FLUXER_EPOCH; + undefined -> + undefined + catch + error:{invalid_snowflake, _} -> undefined + end. diff --git a/fluxer_gateway/src/utils/snowflake_util.erl b/fluxer_gateway/src/utils/snowflake_util.erl deleted file mode 100644 index ba7cdcfed..000000000 --- a/fluxer_gateway/src/utils/snowflake_util.erl +++ /dev/null @@ -1,20 +0,0 @@ -%% SPDX-License-Identifier: AGPL-3.0-or-later - --module(snowflake_util). --typing([eqwalizer]). - --export([extract_timestamp/1]). - --define(FLUXER_EPOCH, 1420070400000). --define(TIMESTAMP_SHIFT, 22). - --spec extract_timestamp(term()) -> integer() | undefined. -extract_timestamp(SnowflakeValue) -> - try snowflake_id:parse_optional(SnowflakeValue) of - Snowflake when is_integer(Snowflake), Snowflake > 0 -> - (Snowflake bsr ?TIMESTAMP_SHIFT) + ?FLUXER_EPOCH; - undefined -> - undefined - catch - error:{invalid_snowflake, _} -> undefined - end. diff --git a/fluxer_gateway/src/utils/user_flags.erl b/fluxer_gateway/src/utils/user_flags.erl index b405754ce..f118e6b82 100644 --- a/fluxer_gateway/src/utils/user_flags.erl +++ b/fluxer_gateway/src/utils/user_flags.erl @@ -3,12 +3,7 @@ -module(user_flags). -typing([eqwalizer]). --export([ - staff/0, - parse/1, - has/2, - is_staff/1 -]). +-export([staff/0, is_staff/1]). -export_type([t/0, flag/0]). @@ -21,14 +16,6 @@ staff() -> ?STAFF. --spec parse(term()) -> t(). -parse(Value) -> - bitset:parse(Value). - --spec has(t(), flag()) -> boolean(). -has(Flags, Flag) -> - bitset:has(Flags, Flag). - -spec is_staff(term()) -> boolean(). is_staff(Value) -> - has(parse(Value), staff()). + bitset:has(bitset:parse(Value), staff()). diff --git a/fluxer_gateway/src/utils/user_utils.erl b/fluxer_gateway/src/utils/user_utils.erl index a11a48b16..873464268 100644 --- a/fluxer_gateway/src/utils/user_utils.erl +++ b/fluxer_gateway/src/utils/user_utils.erl @@ -38,9 +38,9 @@ normalize_field(<<"avatar_color">>, null) -> normalize_field(<<"avatar_color">>, Value) -> guild_data_normalize_schema:int(Value); normalize_field(<<"flags">>, Value) -> - user_flags:parse(Value); + bitset:parse(Value); normalize_field(<<"mention_flags">>, Value) -> - user_flags:parse(Value); + bitset:parse(Value); normalize_field(_Key, Value) -> Value. @@ -73,57 +73,4 @@ normalize_user_valid_test() -> ?assertEqual(<<"0001">>, maps:get(<<"discriminator">>, Result)), ?assertEqual(error, maps:find(<<"email">>, Result)). -normalize_user_all_fields_test() -> - User = #{ - <<"id">> => <<"123">>, - <<"username">> => <<"test">>, - <<"discriminator">> => <<"0">>, - <<"global_name">> => <<"Test User">>, - <<"avatar">> => <<"abc123">>, - <<"avatar_color">> => 16#ff0000, - <<"bot">> => false, - <<"system">> => false, - <<"flags">> => 0 - }, - Result = normalize_user(User), - ?assertEqual(9, maps:size(Result)), - ?assertEqual(16#ff0000, maps:get(<<"avatar_color">>, Result)). - -normalize_user_passes_mention_flags_test() -> - User = #{ - <<"id">> => <<"123">>, - <<"username">> => <<"test">>, - <<"discriminator">> => <<"0">>, - <<"mention_flags">> => 1 - }, - Result = normalize_user(User), - ?assertEqual(1, maps:get(<<"mention_flags">>, Result)). - -normalize_user_undefined_values_test() -> - User = #{ - <<"id">> => <<"123">>, - <<"username">> => <<"test">>, - <<"avatar">> => undefined - }, - Result = normalize_user(User), - ?assertEqual(2, maps:size(Result)), - ?assertEqual(error, maps:find(<<"avatar">>, Result)). - -normalize_user_not_map_test() -> - ?assertEqual(#{}, normalize_user(not_a_map)), - ?assertEqual(#{}, normalize_user(123)), - ?assertEqual(#{}, normalize_user(<<"binary">>)), - ?assertEqual(#{}, normalize_user(undefined)). - -normalize_user_empty_map_test() -> - ?assertEqual(#{}, normalize_user(#{})). - -partial_user_fields_test() -> - Fields = partial_user_fields(), - ?assert(is_list(Fields)), - ?assertEqual(10, length(Fields)), - ?assert(lists:member(<<"id">>, Fields)), - ?assert(lists:member(<<"username">>, Fields)), - ?assert(lists:member(<<"flags">>, Fields)). - -endif. diff --git a/fluxer_gateway/test/call_p2p_tests.erl b/fluxer_gateway/test/call_p2p_tests.erl index dae953dca..a4041adc8 100644 --- a/fluxer_gateway/test/call_p2p_tests.erl +++ b/fluxer_gateway/test/call_p2p_tests.erl @@ -37,24 +37,6 @@ race_p2p_join_after_sfu_start_is_rejected_test() -> ?assertEqual([], drain_voice_server_updates()) end). -race_async_join_producing_mixed_states_is_rejected_test() -> - with_stubs(fun() -> - State = join_all([{1, true}], call_state()), - ?assertEqual( - {noreply, State}, - call_voice:handle_join_async( - 2, voice_state(2, false), session_id(2), erlang:get(call_p2p_sink), State - ) - ), - receive - {call_join_result, 1, Result} -> - ?assertEqual({error, voice_p2p_consent_required}, Result) - after 1000 -> - error(call_join_result_not_sent) - end, - ?assertEqual([], drain_voice_server_updates()) - end). - fifth_joiner_over_the_cap_is_rejected_test() -> with_stubs(fun() -> Mesh = join_all([{UserId, true} || UserId <- [1, 2, 3, 4]], call_state()), diff --git a/fluxer_gateway/test/fluxer_gateway_sup_tests.erl b/fluxer_gateway/test/fluxer_gateway_sup_tests.erl index fc8ab19ae..c8dbf9bf7 100644 --- a/fluxer_gateway/test/fluxer_gateway_sup_tests.erl +++ b/fluxer_gateway/test/fluxer_gateway_sup_tests.erl @@ -4,61 +4,6 @@ -typing([eqwalizer]). -include_lib("eunit/include/eunit.hrl"). -cluster_children_empty_when_flag_unset_test() -> - persistent_term:put({fluxer_gateway, runtime_config}, #{}), - ?assertEqual([], fluxer_gateway_sup:cluster_children()), - persistent_term:erase({fluxer_gateway, runtime_config}). - -cluster_children_empty_when_flag_false_test() -> - persistent_term:put( - {fluxer_gateway, runtime_config}, - #{cluster_enabled => false} - ), - ?assertEqual([], fluxer_gateway_sup:cluster_children()), - persistent_term:erase({fluxer_gateway, runtime_config}). - -cluster_children_populated_when_flag_true_test() -> - persistent_term:put( - {fluxer_gateway, runtime_config}, - #{cluster_enabled => true} - ), - [Discovery, Membership, PgScope] = fluxer_gateway_sup:cluster_children(), - ?assertMatch( - #{ - id := gateway_cluster_discovery, - start := {gateway_cluster_discovery, start_link, []} - }, - Discovery - ), - ?assertMatch( - #{ - id := gateway_cluster_membership, - start := {gateway_cluster_membership, start_link, []} - }, - Membership - ), - ?assertMatch( - #{ - id := gateway_pg_scope, - start := {gateway_pg_scope, start_link, []} - }, - PgScope - ), - persistent_term:erase({fluxer_gateway, runtime_config}). - -cluster_children_only_truthy_atom_true_test() -> - persistent_term:put( - {fluxer_gateway, runtime_config}, - #{cluster_enabled => <<"true">>} - ), - ?assertEqual([], fluxer_gateway_sup:cluster_children()), - persistent_term:put( - {fluxer_gateway, runtime_config}, - #{cluster_enabled => 1} - ), - ?assertEqual([], fluxer_gateway_sup:cluster_children()), - persistent_term:erase({fluxer_gateway, runtime_config}). - role_enabled_allows_monolith_and_exact_role_only_test() -> ?assert(fluxer_gateway_sup:role_enabled(sessions, all)), ?assert(fluxer_gateway_sup:role_enabled(guilds, guilds)), diff --git a/fluxer_gateway/test/gateway_handler_tests.erl b/fluxer_gateway/test/gateway_handler_tests.erl index ad5be4eba..9a6be42b9 100644 --- a/fluxer_gateway/test/gateway_handler_tests.erl +++ b/fluxer_gateway/test/gateway_handler_tests.erl @@ -149,14 +149,6 @@ heartbeat_check_replaces_matched_timer_test() -> ?assertNotEqual({TimerRef, Token}, maps:get(heartbeat_timer, State2)), gateway_handler_heartbeat:cancel_heartbeat_timer(State2). -legacy_heartbeat_check_ignored_when_tokened_timer_active_test() -> - State0 = heartbeat_test_state(), - State1 = gateway_handler_heartbeat:schedule_heartbeat_check(State0), - {TimerRef, Token} = maps:get(heartbeat_timer, State1), - {ok, State2} = gateway_handler:websocket_info({heartbeat_check}, State1), - ?assertEqual({TimerRef, Token}, maps:get(heartbeat_timer, State2)), - gateway_handler_heartbeat:cancel_heartbeat_timer(State2). - pending_identify_retry_ignores_stale_token_test() -> Timer = erlang:send_after(60000, self(), stale_pending_identify_retry), CurrentToken = make_ref(), @@ -170,17 +162,6 @@ pending_identify_retry_ignores_stale_token_test() -> ?assertEqual(State0, State1), cancel_pending_identify_timer(State1). -legacy_pending_identify_retry_ignored_when_tokened_timer_active_test() -> - Timer = erlang:send_after(60000, self(), stale_pending_identify_retry), - CurrentToken = make_ref(), - State0 = (new_json_state())#{ - pending_identify => {#{token => <<"t">>}, self()}, - pending_identify_retry_timer => {Timer, CurrentToken} - }, - {ok, State1} = gateway_handler:websocket_info(retry_pending_identify, State0), - ?assertEqual(State0, State1), - cancel_pending_identify_timer(State1). - parse_forwarded_for_ipv4_test() -> ?assertEqual(<<"203.0.113.7">>, gateway_handler:parse_forwarded_for(<<"203.0.113.7">>)). parse_forwarded_for_ipv4_with_port_test() -> diff --git a/fluxer_gateway/test/gateway_retry_timer_tests.erl b/fluxer_gateway/test/gateway_retry_timer_tests.erl index 9e8cb7692..dd99db2d3 100644 --- a/fluxer_gateway/test/gateway_retry_timer_tests.erl +++ b/fluxer_gateway/test/gateway_retry_timer_tests.erl @@ -5,17 +5,6 @@ -include_lib("eunit/include/eunit.hrl"). -wait_zero_returns_ok_test() -> - ?assertEqual(ok, gateway_retry_timer:wait(0)). - -wait_rejects_invalid_delay_test() -> - ?assertEqual({error, invalid_delay}, gateway_retry_timer:wait(-1)), - ?assertEqual({error, invalid_delay}, gateway_retry_timer:wait(not_an_integer)). - -wait_until_expired_deadline_test() -> - DeadlineMs = erlang:monotonic_time(millisecond) - 1, - ?assertEqual(expired, gateway_retry_timer:wait_until(10, DeadlineMs)). - wait_keeps_unrelated_mailbox_messages_test() -> self() ! unrelated_message, ?assertEqual(ok, gateway_retry_timer:wait(0)), diff --git a/fluxer_gateway/test/gateway_rpc_voice_tests.erl b/fluxer_gateway/test/gateway_rpc_voice_tests.erl index 49d9532dc..f4a1fc99e 100644 --- a/fluxer_gateway/test/gateway_rpc_voice_tests.erl +++ b/fluxer_gateway/test/gateway_rpc_voice_tests.erl @@ -15,13 +15,6 @@ voice_channel_read_malformed_payloads_return_empty_results_test() -> <<"guild_id">> => <<"1">>, <<"channel_id">> => null }) - ), - ?assertEqual( - #{<<"pending_joins">> => []}, - gateway_rpc_voice:execute_method(<<"voice.get_pending_joins_for_channel">>, #{ - <<"guild_id">> => <<"bad">>, - <<"channel_id">> => <<"2">> - }) ). validate_voice_channel_read_params_normalizes_ids_test() -> @@ -44,24 +37,6 @@ validate_voice_channel_read_params_normalizes_ids_test() -> }) ). -normalize_pending_joins_ignores_malformed_entries_test() -> - ?assertEqual( - [ - #{ - <<"connection_id">> => <<"conn">>, - <<"user_id">> => <<"42">>, - <<"token_nonce">> => <<>>, - <<"expires_at">> => 0 - } - ], - gateway_rpc_voice:normalize_pending_joins([ - not_a_map, - #{<<"connection_id">> => <<"conn">>, <<"user_id">> => 42}, - #{<<"connection_id">> => undefined, <<"user_id">> => 84}, - #{<<"connection_id">> => <<"missing-user">>} - ]) - ). - normalize_voice_states_ignores_malformed_entries_test() -> ?assertEqual( [ diff --git a/fluxer_gateway/test/guild_availability_tests.erl b/fluxer_gateway/test/guild_availability_tests.erl index 58bf69a4b..9d5b92283 100644 --- a/fluxer_gateway/test/guild_availability_tests.erl +++ b/fluxer_gateway/test/guild_availability_tests.erl @@ -32,7 +32,7 @@ disconnect_ineligible_sessions_staff_only_test() -> ?assert(maps:is_key(<<"staff">>, Sessions)), ?assertEqual( unavailable_for_everyone_but_staff, - guild_availability:get_cached_unavailability_mode(GuildId) + guild_availability_cache:get_cached_unavailability_mode(GuildId) ), receive {non_staff, {'$gen_cast', {guild_leave, GuildId, forced_unavailable}}} -> ok @@ -67,7 +67,8 @@ disconnect_ineligible_sessions_everyone_test() -> UpdatedState = guild_availability:handle_unavailability_transition(BaseState, NewState), ?assertEqual(#{}, maps:get(sessions, UpdatedState, #{})), ?assertEqual( - unavailable_for_everyone, guild_availability:get_cached_unavailability_mode(GuildId) + unavailable_for_everyone, + guild_availability_cache:get_cached_unavailability_mode(GuildId) ), receive {user_one, {'$gen_cast', {guild_leave, GuildId, forced_unavailable}}} -> ok @@ -104,7 +105,8 @@ disconnect_ineligible_sessions_everyone_hidden_test() -> UpdatedState = guild_availability:handle_unavailability_transition(BaseState, NewState), ?assertEqual(#{}, maps:get(sessions, UpdatedState, #{})), ?assertEqual( - unavailable_for_everyone, guild_availability:get_cached_unavailability_mode(GuildId) + unavailable_for_everyone, + guild_availability_cache:get_cached_unavailability_mode(GuildId) ), ?assertEqual( true, guild_availability:is_unavailable_hidden_enabled_from_cache(GuildId) @@ -164,13 +166,15 @@ is_unavailable_hidden_enabled_from_cache_test() -> try _ = guild_availability:update_unavailability_cache_for_state(HiddenUnavailableState), ?assertEqual( - true, guild_availability:is_unavailable_hidden_enabled(HiddenUnavailableState) + true, guild_availability_check:is_unavailable_hidden_enabled(HiddenUnavailableState) ), ?assertEqual( true, guild_availability:is_unavailable_hidden_enabled_from_cache(GuildId) ), _ = guild_availability:update_unavailability_cache_for_state(HiddenOnlyState), - ?assertEqual(false, guild_availability:is_unavailable_hidden_enabled(HiddenOnlyState)), + ?assertEqual( + false, guild_availability_check:is_unavailable_hidden_enabled(HiddenOnlyState) + ), ?assertEqual( false, guild_availability:is_unavailable_hidden_enabled_from_cache(GuildId) ) diff --git a/fluxer_gateway/test/guild_channel_move_equivalence_tests.erl b/fluxer_gateway/test/guild_channel_move_equivalence_tests.erl index 44fc22c85..7ed5371c1 100644 --- a/fluxer_gateway/test/guild_channel_move_equivalence_tests.erl +++ b/fluxer_gateway/test/guild_channel_move_equivalence_tests.erl @@ -550,7 +550,7 @@ input_changes() -> end)}, {roles, with_data(fun(D) -> - [E | Rest] = guild_data_index:role_list(D), + [E | Rest] = guild_data_index_roles:role_list(D), guild_data_index:put_roles([E#{<<"permissions">> => <<"0">>} | Rest], D) end)}, {role_order, @@ -643,7 +643,9 @@ with_channels(Fun) -> with_channels(Fun, State) -> with_data( fun(D) -> - guild_data_index:put_channels([Fun(C) || C <- guild_data_index:channel_list(D)], D) + guild_data_index:put_channels( + [Fun(C) || C <- guild_data_index_channels:channel_list(D)], D + ) end, State ). @@ -882,7 +884,7 @@ run(Spec, Events, Mode) -> apply_hooks(Hooks, State, Env) -> State1 = lists:foldl(fun apply_hook/2, State, Hooks), - State2 = guild_member_list:flush_pending_member_list_syncs(State1), + State2 = guild_member_list_write:flush_pending_member_list_syncs(State1), {Log, Direct, _Fetches} = take_log(Env), {State2, {normalize(Log, Env), normalize(Direct, Env)}}. @@ -896,7 +898,7 @@ apply_hook({channel_create, Channel}, State) -> apply_hook({owner, UserId}, State) -> guild:dispatch_event(guild_update, #{<<"owner_id">> => integer_to_binary(UserId)}, State); apply_hook({member_update, UserId}, State) -> - Member = guild_data_index:get_member(UserId, maps:get(data, State)), + Member = guild_data_index_members:get_member(UserId, maps:get(data, State)), Nick = case maps:get(<<"nick">>, Member, null) of Previous when is_binary(Previous) -> <>; @@ -906,7 +908,7 @@ apply_hook({member_update, UserId}, State) -> guild_member_update, member_payload(Member#{<<"nick">> => Nick}), State ); apply_hook({role_permissions, Index, Perms}, State) -> - Role = maps:get(role_id(Index), guild_data_index:role_index(maps:get(data, State))), + Role = maps:get(role_id(Index), guild_data_index_roles:role_index(maps:get(data, State))), guild:dispatch_event( guild_role_update, #{<<"role">> => Role#{<<"permissions">> => integer_to_binary(Perms)}}, @@ -916,7 +918,7 @@ apply_hook({patch_everyone, Bit}, State) -> {noreply, Next} = guild:handle_cast({patch_everyone_perms, Bit}, State), Next; apply_hook({everyone_permissions, Perms}, State) -> - Role = maps:get(?GUILD_ID, guild_data_index:role_index(maps:get(data, State))), + Role = maps:get(?GUILD_ID, guild_data_index_roles:role_index(maps:get(data, State))), guild:dispatch_event( guild_role_update, #{<<"role">> => Role#{<<"permissions">> => integer_to_binary(Perms)}}, @@ -938,7 +940,7 @@ run_event(Kind, Data, State, Env) -> hook -> apply_hook(Data, State); _ -> guild:dispatch_event(Kind, Data, State) end, - State2 = guild_member_list:flush_pending_member_list_syncs(State1), + State2 = guild_member_list_write:flush_pending_member_list_syncs(State1), {Log, Direct, Fetches} = take_log(Env), Counts = read_counters(), Result = #{ @@ -1070,7 +1072,7 @@ voice_server_loop(VoiceStates) -> {visibility, {guild_visibility_overwrites, compute_and_dispatch_visibility_changes_for_channels, 3}}, {prune, {guild_maintenance, prune_invalid_member_subscriptions, 1}}, - {can_view, {guild_visibility_channels, channel_is_visible, 4}} + {can_view, {guild_permissions_check, can_view_channel, 4}} ]). start_counters() -> @@ -1141,7 +1143,9 @@ materialize(Spec) -> ), S4 = lists:foldl( fun({Sid, ListId}, Acc) -> - {Acc1, _, _} = guild_member_list:subscribe_ranges(Sid, ListId, [{0, 99}], Acc), + {Acc1, _, _} = guild_member_list_subscribe:subscribe_ranges( + Sid, ListId, [{0, 99}], Acc + ), Acc1 end, S3, @@ -1168,7 +1172,7 @@ materialize(Spec) -> #{}, #{}, guild_maintenance:maybe_prune_invalid_member_subscriptions( - guild_role_update, guild_member_list:flush_pending_member_list_syncs(S5) + guild_role_update, guild_member_list_write:flush_pending_member_list_syncs(S5) ) ), ok = guild_permission_cache:put_state(S6), @@ -1189,7 +1193,7 @@ add_session(#{label := Label, user_id := UserId, pending := Pending}, Collectors #{}; false -> guild_sessions:build_viewable_channel_map( - guild_visibility:get_user_viewable_channels(UserId, State) + guild_visibility_channels:get_user_viewable_channels(UserId, State) ) end, Session = #{ @@ -1235,7 +1239,7 @@ normalize_state(State, Env) -> EngineSnapshots = maps:map( fun(ListId, Ref) -> { - guild_member_list:member_list_snapshot(ListId, State), + guild_member_list_read:member_list_snapshot(ListId, State), guild_member_list_engine:get_sorted_user_ids(Ref), guild_member_list_engine:get_counts(Ref), guild_member_list_engine:get_groups(Ref) diff --git a/fluxer_gateway/test/guild_data_index_members_tests.erl b/fluxer_gateway/test/guild_data_index_members_tests.erl index e46f8dd02..157bc86db 100644 --- a/fluxer_gateway/test/guild_data_index_members_tests.erl +++ b/fluxer_gateway/test/guild_data_index_members_tests.erl @@ -35,30 +35,6 @@ member_map_rejects_malformed_snowflake_ids_test() -> #{3 => #{<<"user">> => #{<<"id">> => 3}}}, guild_data_index_members:member_map(Data) ). -member_accessors_test() -> - Data = #{ - <<"members">> => #{ - 5 => #{<<"user">> => #{<<"id">> => <<"5">>}}, - 3 => #{<<"user">> => #{<<"id">> => <<"3">>}} - } - }, - ?assertEqual(2, length(guild_data_index_members:member_values(Data))), - ?assertEqual([], guild_data_index_members:member_list(#{<<"members">> => #{}})), - ?assertEqual([3, 5], lists:sort(guild_data_index_members:member_ids(Data))), - ?assertEqual(undefined, guild_data_index_members:get_member(invalid_user_id(), Data)), - ?assertEqual(undefined, guild_data_index_members:get_member(999, #{<<"members">> => #{}})). - -member_list_sorted_by_user_id_test() -> - Data = #{ - <<"members">> => #{ - 9 => #{<<"user">> => #{<<"id">> => <<"9">>}}, - 3 => #{<<"user">> => #{<<"id">> => <<"3">>}}, - 7 => #{<<"user">> => #{<<"id">> => <<"7">>}} - } - }, - Ids = [member_id(M) || M <- guild_data_index_members:member_list(Data)], - ?assertEqual([<<"3">>, <<"7">>, <<"9">>], Ids). - member_list_uses_cache_consistently_test() -> Base = guild_data_index_members:put_member_map( #{ @@ -80,59 +56,6 @@ member_list_uses_cache_consistently_test() -> member_id(Member) -> maps:get(<<"id">>, maps:get(<<"user">>, Member)). -put_member_test() -> - Data = #{<<"members">> => #{}}, - ?assertEqual( - Data, guild_data_index_members:put_member(#{<<"nick">> => <<"orphan">>}, Data) - ), - ?assertEqual(Data, guild_data_index_members:put_member(invalid_member(), Data)), - ?assertEqual( - not_a_map, - guild_data_index_members:put_member( - #{<<"user">> => #{<<"id">> => <<"1">>}}, invalid_guild_data() - ) - ), - Updated = guild_data_index_members:put_member( - #{<<"user">> => #{<<"id">> => <<"42">>}, <<"nick">> => <<"new">>}, - Data - ), - #{<<"nick">> := Nick} = guild_data_index_members:get_member(42, Updated), - ?assertEqual(<<"new">>, Nick). - -put_member_map_test() -> - Data = #{<<"members">> => #{1 => #{<<"user">> => #{<<"id">> => <<"1">>}}}}, - NewMap = #{2 => #{<<"user">> => #{<<"id">> => <<"2">>}}}, - Updated = guild_data_index_members:put_member_map(NewMap, Data), - ?assertEqual(undefined, guild_data_index_members:get_member(1, Updated)), - #{} = Members = maps:get(<<"members">>, Updated), - ?assertMatch(#{2 := _}, Members), - ?assertEqual(Data, guild_data_index_members:put_member_map(invalid_member_map(), Data)). - -put_member_list_test() -> - Data = #{<<"members">> => #{}}, - Members = [ - #{<<"user">> => #{<<"id">> => <<"1">>}}, #{<<"user">> => #{<<"id">> => <<"2">>}} - ], - Updated = guild_data_index_members:put_member_list(Members, Data), - #{} = UpdatedMembers = maps:get(<<"members">>, Updated), - ?assertEqual(2, map_size(UpdatedMembers)), - ?assertEqual(Data, guild_data_index_members:put_member_list(invalid_member_list(), Data)). - -remove_member_test() -> - Data = #{<<"members">> => #{1 => #{<<"user">> => #{<<"id">> => <<"1">>}}}}, - ?assertEqual(Data, guild_data_index_members:remove_member(invalid_user_id(), Data)), - #{} = - UnchangedMembers = maps:get( - <<"members">>, guild_data_index_members:remove_member(999, Data) - ), - ?assertEqual( - 1, map_size(UnchangedMembers) - ), - ?assertEqual( - undefined, - guild_data_index_members:get_member(1, guild_data_index_members:remove_member(1, Data)) - ). - member_role_index_test() -> ?assertEqual(#{}, guild_data_index_members:member_role_index(invalid_guild_data())), Data = #{ @@ -171,38 +94,5 @@ role_index_sync_test() -> undefined, maps:get(30, guild_data_index_members:member_role_index(Data2), undefined) ). -normalize_member_map_with_binary_keys_test() -> - Normalized = guild_data_index_members:normalize_member_map(#{ - <<"42">> => #{<<"user">> => #{<<"id">> => <<"42">>}, <<"nick">> => <<"test">>} - }), - ?assertMatch(#{42 := _}, Normalized). - -get_member_ets_test() -> - Tab = ets:new(test_members_ets, [set, public, {read_concurrency, true}]), - Member = #{<<"user">> => #{<<"id">> => 100}, <<"nick">> => <<"ets_user">>}, - ets:insert(Tab, {100, Member}), - Data = #{members_ets => Tab, <<"members">> => #{}}, - ?assertEqual(Member, guild_data_index_members:get_member_ets(100, Data)), - ?assertEqual(undefined, guild_data_index_members:get_member_ets(999, Data)), - ets:delete(Tab). - -get_member_ets_falls_back_to_map_when_no_ets_test() -> - Data = #{<<"members">> => #{42 => #{<<"user">> => #{<<"id">> => 42}}}}, - ?assertEqual( - #{<<"user">> => #{<<"id">> => 42}}, guild_data_index_members:get_member_ets(42, Data) - ). - invalid_guild_data() -> eqwalizer:dynamic_cast(not_a_map). - -invalid_member() -> - eqwalizer:dynamic_cast(not_a_map). - -invalid_member_map() -> - eqwalizer:dynamic_cast(not_a_map). - -invalid_member_list() -> - eqwalizer:dynamic_cast(not_a_list). - -invalid_user_id() -> - eqwalizer:dynamic_cast(not_an_integer). diff --git a/fluxer_gateway/test/guild_data_normalize_tests.erl b/fluxer_gateway/test/guild_data_normalize_tests.erl index 48491ab1f..d62f29f46 100644 --- a/fluxer_gateway/test/guild_data_normalize_tests.erl +++ b/fluxer_gateway/test/guild_data_normalize_tests.erl @@ -16,11 +16,11 @@ snowflake_uses_fluxer_response_shape_test() -> ?assertError({invalid_snowflake, null}, snowflake_id:parse(null)). permission_accepts_unsigned_decimal_strings_test() -> - ?assertEqual(0, permission_bits:parse(<<"0">>)), - ?assertEqual(1, permission_bits:parse(<<"001">>)), - ?assertEqual(42, permission_bits:parse(42)), - ?assertError({invalid_bitset, <<"+1">>}, permission_bits:parse(<<"+1">>)), - ?assertError({invalid_bitset, <<"-1">>}, permission_bits:parse(<<"-1">>)). + ?assertEqual(0, bitset:parse(<<"0">>)), + ?assertEqual(1, bitset:parse(<<"001">>)), + ?assertEqual(42, bitset:parse(42)), + ?assertError({invalid_bitset, <<"+1">>}, bitset:parse(<<"+1">>)), + ?assertError({invalid_bitset, <<"-1">>}, bitset:parse(<<"-1">>)). member_role_ids_are_integer_keyed_in_memory_test() -> Member = normalized_member(#{ diff --git a/fluxer_gateway/test/guild_data_tests.erl b/fluxer_gateway/test/guild_data_tests.erl index 15669d44d..8fd2c78e1 100644 --- a/fluxer_gateway/test/guild_data_tests.erl +++ b/fluxer_gateway/test/guild_data_tests.erl @@ -22,9 +22,11 @@ read_model_preserves_query_results_test() -> fun guild_data:get_auth_context/2}, {get_guild_auth_context, #{user_id => 999, channel_id => null}, fun guild_data:get_auth_context/2}, - {get_guild_member, #{user_id => 200}, fun guild_data:get_guild_member/2}, - {get_guild_member, #{user_id => 999}, fun guild_data:get_guild_member/2}, - {has_member, #{user_id => 200}, fun guild_data:has_member/2} + {get_guild_member, #{user_id => 200}, + fun guild_data_members:get_guild_member/2}, + {get_guild_member, #{user_id => 999}, + fun guild_data_members:get_guild_member/2}, + {has_member, #{user_id => 200}, fun guild_data_members:has_member/2} ] ) after @@ -167,7 +169,7 @@ read_model_survives_blocked_owner_and_rejects_dead_tables_test() -> read_model_state() -> State = test_state(), Data = guild_data_index:normalize_map(maps:get(data, State)), - Members = guild_data_index:member_map(Data), + Members = guild_data_index_members:member_map(Data), Tab = ets:new(read_model_members, [set, public]), ets:insert(Tab, maps:to_list(Members)), State#{member_count => map_size(Members), data => Data#{members_ets => Tab}}. @@ -516,7 +518,7 @@ search_guild_members_limits_prefix_matches_without_paginating_all_test() -> ] } }, - {reply, Reply, _State} = guild_data:search_guild_members( + {reply, Reply, _State} = guild_data_members:search_guild_members( #{query => <<"ali">>, limit => 1}, State ), Members = maps:get(members, Reply), @@ -534,7 +536,7 @@ search_guild_members_limits_prefix_matches_without_paginating_all_test() -> search_guild_members_matches_username_of_nicknamed_member_test() -> Nicknamed = (member(1, <<"jiralite">>))#{<<"nick">> => <<"Specsaver engineer">>}, State = #{data => #{<<"members">> => [Nicknamed, member(2, <<"Bob">>)]}}, - {reply, Reply, _State} = guild_data:search_guild_members( + {reply, Reply, _State} = guild_data_members:search_guild_members( #{query => <<"jiralite">>, limit => 25}, State ), ?assertEqual( diff --git a/fluxer_gateway/test/guild_dm_partners_tests.erl b/fluxer_gateway/test/guild_dm_partners_tests.erl index 5bb114620..9298c7b68 100644 --- a/fluxer_gateway/test/guild_dm_partners_tests.erl +++ b/fluxer_gateway/test/guild_dm_partners_tests.erl @@ -120,7 +120,7 @@ state() -> ) }, Viewable = guild_sessions:build_viewable_channel_map( - guild_visibility:get_user_viewable_channels(10, Base) + guild_visibility_channels:get_user_viewable_channels(10, Base) ), Base#{ sessions => #{ diff --git a/fluxer_gateway/test/guild_handoff_freeze_tests.erl b/fluxer_gateway/test/guild_handoff_freeze_tests.erl index 65c914b71..702a405a4 100644 --- a/fluxer_gateway/test/guild_handoff_freeze_tests.erl +++ b/fluxer_gateway/test/guild_handoff_freeze_tests.erl @@ -331,7 +331,7 @@ export_keeps_channel_fields_that_only_the_index_holds_test() -> Slim = maps:get(data, guild_handoff:export_handoff_state(State)), ?assertMatch({rebuilt, _}, bounded_rebuild(Slim)), {rebuilt, Rebuilt} = bounded_rebuild(Slim), - [Channel] = guild_data_index:channel_list(Rebuilt), + [Channel] = guild_data_index_channels:channel_list(Rebuilt), ?assertEqual(900, maps:get(<<"last_message_id">>, Channel)). bounded_rebuild(Data) -> diff --git a/fluxer_gateway/test/guild_manager_shard_lifecycle_tests.erl b/fluxer_gateway/test/guild_manager_shard_lifecycle_tests.erl index b63b0d349..0fdf3ff10 100644 --- a/fluxer_gateway/test/guild_manager_shard_lifecycle_tests.erl +++ b/fluxer_gateway/test/guild_manager_shard_lifecycle_tests.erl @@ -54,27 +54,6 @@ normalize_transferred_guild_state_keeps_only_transferable_fields_test() -> ?assertNot(maps:is_key(thread_resend_pending, Normalized)), ?assertNot(maps:is_key(voice_server_pid, Normalized)). -register_and_monitor_race_kills_duplicate_test_() -> - {timeout, 15, fun() -> - process_registry:init(), - GuildId = 66666, - GuildKey = process_registry:build_process_key(guild, GuildId), - WinnerPid = spawn(fun mock_guild_loop/0), - ets:insert(process_registry_table, {GuildKey, WinnerPid}), - LoserPid = spawn(fun mock_guild_loop/0), - try - Result = process_registry:register_and_monitor(GuildKey, LoserPid, #{}), - ?assertMatch({ok, WinnerPid, _, _}, Result), - timer:sleep(200), - ?assertEqual(false, process_liveness:is_alive(LoserPid)), - ?assert(process_liveness:is_alive(WinnerPid)) - after - process_registry:registry_unregister(GuildKey), - WinnerPid ! stop, - LoserPid ! stop - end - end}. - shard_terminate_drains_pending_requests_test() -> ReplyRef1 = make_ref(), ReplyRef2 = make_ref(), diff --git a/fluxer_gateway/test/guild_member_list_chaos_tests.erl b/fluxer_gateway/test/guild_member_list_chaos_tests.erl index 78acc4968..2e8fb1dec 100644 --- a/fluxer_gateway/test/guild_member_list_chaos_tests.erl +++ b/fluxer_gateway/test/guild_member_list_chaos_tests.erl @@ -38,11 +38,11 @@ guild_member_add_batched_sync() -> JoinedMember = event_member(JoinedUserId, 0, []), ok = dispatch_event(GuildPid, guild_member_add, JoinedMember), StateAfterAdd = get_guild_state(GuildPid), - AddedMember = guild_data_index:get_member( + AddedMember = guild_data_index_members:get_member( JoinedUserId, maps:get(data, StateAfterAdd, #{}) ), ?assert( - guild_permissions:can_view_channel( + guild_permissions_check:can_view_channel( JoinedUserId, ?PUBLIC_CHANNEL_ID, AddedMember, StateAfterAdd ) ), @@ -425,7 +425,7 @@ payload_member_ids(Payload) -> assert_default_engine_consistent(State) -> Data = maps:get(data, State, #{}), - MemberMap = guild_data_index:member_map(Data), + MemberMap = guild_data_index_members:member_map(Data), Ref = maps:get(member_list_engine, State), assert_engine_matches_ids(<<"0">>, Ref, maps:keys(MemberMap), State). @@ -457,7 +457,7 @@ assert_ref_has_user(Label, Ref, UserId, State) -> engine_missing_user, Label, UserId, - guild_data_index:get_member(UserId, Data), + guild_data_index_members:get_member(UserId, Data), guild_member_list_engine:get_counts(Ref), lists:sublist(lists:sort(Ids), 10) }) @@ -481,7 +481,7 @@ assert_engine_matches_ids(ListId, Ref, ExpectedIds0, State) -> assert_snapshot_matches_engine(ListId, Ref, State) -> {Total, Online} = guild_member_list_engine:get_counts(Ref), {SnapshotTotal, SnapshotOnline, SnapshotGroups, SnapshotItems} = - guild_member_list:member_list_snapshot(ListId, State), + guild_member_list_read:member_list_snapshot(ListId, State), ?assertEqual(Total, SnapshotTotal), ?assertEqual(Online, SnapshotOnline), ?assertEqual( @@ -507,7 +507,7 @@ visible_groups(Groups) -> expected_channel_member_ids(ChannelId, State) -> Data = maps:get(data, State, #{}), - MemberMap = guild_data_index:member_map(Data), + MemberMap = guild_data_index_members:member_map(Data), [ UserId || {UserId, Member} <- maps:to_list(MemberMap), @@ -515,7 +515,7 @@ expected_channel_member_ids(ChannelId, State) -> ]. can_view_channel(UserId, ChannelId, Member, State) -> - try guild_permissions:can_view_channel(UserId, ChannelId, Member, State) of + try guild_permissions_check:can_view_channel(UserId, ChannelId, Member, State) of Bool when is_boolean(Bool) -> Bool; _ -> false catch @@ -524,7 +524,7 @@ can_view_channel(UserId, ChannelId, Member, State) -> expected_groups(ExpectedIds, State) -> Data = maps:get(data, State, #{}), - Roles = guild_data_index:role_list(Data), + Roles = guild_data_index_roles:role_list(Data), HoistedRoleIds = guild_member_list_store:prepare_hoisted_role_ids( Roles, maps:get(id, State, undefined) ), @@ -534,7 +534,7 @@ expected_groups(ExpectedIds, State) -> ), Counts = lists:foldl( fun(UserId, Acc) -> - Member = guild_data_index:get_member(UserId, Data), + Member = guild_data_index_members:get_member(UserId, Data), GroupId = expected_group_id(Member, HoistedRoleIds, UserId, State), maps:update_with(GroupId, fun(N) -> N + 1 end, 1, Acc) end, @@ -575,7 +575,7 @@ assert_removed_members_absent(State) -> Data = maps:get(data, State, #{}), lists:foreach( fun(UserId) -> - ?assertEqual(undefined, guild_data_index:get_member(UserId, Data)) + ?assertEqual(undefined, guild_data_index_members:get_member(UserId, Data)) end, removed_user_ids() ). @@ -584,7 +584,7 @@ assert_added_members_present(State) -> Data = maps:get(data, State, #{}), lists:foreach( fun(UserId) -> - ?assertMatch(#{}, guild_data_index:get_member(UserId, Data)) + ?assertMatch(#{}, guild_data_index_members:get_member(UserId, Data)) end, added_user_ids() ). diff --git a/fluxer_gateway/test/guild_member_list_fanout_tests.erl b/fluxer_gateway/test/guild_member_list_fanout_tests.erl index 01120c3b8..bb0d39db1 100644 --- a/fluxer_gateway/test/guild_member_list_fanout_tests.erl +++ b/fluxer_gateway/test/guild_member_list_fanout_tests.erl @@ -20,8 +20,8 @@ batched_fanout_case(RangeMode, ChannelCount, SessionsPerChannel, MemberCount) -> RangeMode, ChannelCount, SessionsPerChannel ), try - {ok, QueuedState} = guild_member_list:broadcast_all_member_list_updates(State), - FlushedState = guild_member_list:flush_pending_member_list_syncs(QueuedState), + {ok, QueuedState} = guild_member_list_write:broadcast_all_member_list_updates(State), + FlushedState = guild_member_list_write:flush_pending_member_list_syncs(QueuedState), {Dispatches, Recipients, Bytes} = collect_batched_fanout_dispatches( ExpectedDispatches ), diff --git a/fluxer_gateway/test/guild_member_list_sync_tests.erl b/fluxer_gateway/test/guild_member_list_sync_tests.erl deleted file mode 100644 index f118104c2..000000000 --- a/fluxer_gateway/test/guild_member_list_sync_tests.erl +++ /dev/null @@ -1,70 +0,0 @@ -%% SPDX-License-Identifier: AGPL-3.0-or-later - --module(guild_member_list_sync_tests). --typing([eqwalizer]). --include_lib("eunit/include/eunit.hrl"). - -is_subset_of_ranges_empty_inner_test() -> - ?assertEqual(true, guild_member_list_sync:is_subset_of_ranges([], [{0, 99}])). - -is_subset_of_ranges_empty_outer_test() -> - ?assertEqual(false, guild_member_list_sync:is_subset_of_ranges([{0, 99}], [])). - -is_subset_of_ranges_exact_match_test() -> - ?assertEqual(true, guild_member_list_sync:is_subset_of_ranges([{0, 99}], [{0, 99}])). - -is_subset_of_ranges_true_subset_test() -> - ?assertEqual(true, guild_member_list_sync:is_subset_of_ranges([{10, 50}], [{0, 99}])). - -is_subset_of_ranges_not_subset_test() -> - ?assertEqual(false, guild_member_list_sync:is_subset_of_ranges([{0, 150}], [{0, 99}])). - -is_subset_of_ranges_partial_overlap_not_subset_test() -> - ?assertEqual(false, guild_member_list_sync:is_subset_of_ranges([{50, 150}], [{0, 99}])). - -is_subset_of_ranges_multiple_inner_covered_test() -> - ?assertEqual( - true, guild_member_list_sync:is_subset_of_ranges([{10, 20}, {30, 40}], [{0, 99}]) - ). - -is_subset_of_ranges_multiple_outer_test() -> - ?assertEqual( - true, - guild_member_list_sync:is_subset_of_ranges([{10, 20}, {110, 120}], [{0, 50}, {100, 150}]) - ). - -is_subset_of_ranges_multiple_outer_not_covered_test() -> - ?assertEqual( - false, - guild_member_list_sync:is_subset_of_ranges([{10, 20}, {60, 80}], [{0, 50}, {100, 150}]) - ). - -compute_range_delta_no_overlap_test() -> - ?assertEqual( - [{100, 199}], guild_member_list_sync:compute_range_delta([{100, 199}], [{0, 50}]) - ). - -compute_range_delta_full_overlap_test() -> - ?assertEqual([], guild_member_list_sync:compute_range_delta([{10, 50}], [{0, 99}])). - -compute_range_delta_partial_overlap_right_test() -> - ?assertEqual( - [{100, 199}], guild_member_list_sync:compute_range_delta([{0, 199}], [{0, 99}]) - ). - -compute_range_delta_partial_overlap_left_test() -> - ?assertEqual( - [{0, 49}], guild_member_list_sync:compute_range_delta([{0, 150}], [{50, 200}]) - ). - -compute_range_delta_middle_gap_test() -> - ?assertEqual( - [{51, 99}], - guild_member_list_sync:compute_range_delta([{0, 150}], [{0, 50}, {100, 200}]) - ). - -compute_range_delta_empty_old_test() -> - ?assertEqual([{0, 99}], guild_member_list_sync:compute_range_delta([{0, 99}], [])). - -compute_range_delta_empty_new_test() -> - ?assertEqual([], guild_member_list_sync:compute_range_delta([], [{0, 99}])). diff --git a/fluxer_gateway/test/guild_member_list_tests.erl b/fluxer_gateway/test/guild_member_list_tests.erl index 453397ef2..263caa0f4 100644 --- a/fluxer_gateway/test/guild_member_list_tests.erl +++ b/fluxer_gateway/test/guild_member_list_tests.erl @@ -9,7 +9,7 @@ get_members_cursor_returns_atom_keys_test() -> Member1 = #{<<"user">> => #{<<"id">> => <<"2">>}}, Member2 = #{<<"user">> => #{<<"id">> => <<"1">>}}, State = #{data => #{<<"members">> => [Member1, Member2]}}, - {reply, Reply, _NewState} = guild_member_list:get_members_cursor( + {reply, Reply, _NewState} = guild_member_list_read:get_members_cursor( #{<<"limit">> => 1}, State ), ?assert(maps:is_key(members, Reply)), @@ -24,7 +24,7 @@ get_members_cursor_with_after_test() -> #{<<"user">> => #{<<"id">> => <<"3">>}} ], State = #{data => #{<<"members">> => Members}}, - {reply, Reply, _} = guild_member_list:get_members_cursor( + {reply, Reply, _} = guild_member_list_read:get_members_cursor( #{<<"limit">> => 10, <<"after">> => 1}, State ), ReturnedMembers = maps:get(members, Reply), @@ -34,14 +34,14 @@ get_members_cursor_with_after_test() -> get_members_cursor_empty_guild_test() -> State = #{data => #{<<"members">> => []}}, - {reply, Reply, _} = guild_member_list:get_members_cursor(#{<<"limit">> => 10}, State), + {reply, Reply, _} = guild_member_list_read:get_members_cursor(#{<<"limit">> => 10}, State), ?assertEqual([], maps:get(members, Reply)), ?assertEqual(0, maps:get(total, Reply)). get_members_cursor_zero_limit_test() -> Members = [#{<<"user">> => #{<<"id">> => <<"1">>}}], State = #{data => #{<<"members">> => Members}}, - {reply, Reply, _} = guild_member_list:get_members_cursor(#{<<"limit">> => 0}, State), + {reply, Reply, _} = guild_member_list_read:get_members_cursor(#{<<"limit">> => 0}, State), ?assertEqual([], maps:get(members, Reply)). get_members_cursor_after_last_member_test() -> @@ -50,7 +50,7 @@ get_members_cursor_after_last_member_test() -> #{<<"user">> => #{<<"id">> => <<"2">>}} ], State = #{data => #{<<"members">> => Members}}, - {reply, Reply, _} = guild_member_list:get_members_cursor( + {reply, Reply, _} = guild_member_list_read:get_members_cursor( #{<<"limit">> => 10, <<"after">> => 999}, State ), ?assertEqual([], maps:get(members, Reply)). @@ -58,7 +58,7 @@ get_members_cursor_after_last_member_test() -> subscribe_builds_channel_store_lazily_test() -> State = base_state(make_subs_tab()), {NewState, ShouldSync, Ranges} = - guild_member_list:subscribe_ranges(<<"s1">>, <<"500">>, [{0, 99}], State), + guild_member_list_subscribe:subscribe_ranges(<<"s1">>, <<"500">>, [{0, 99}], State), try Engines = maps:get(channel_member_list_engines, NewState, #{}), ?assertEqual(true, ShouldSync), @@ -73,9 +73,9 @@ subscribe_prunes_other_session_channel_lists_test() -> SubsTab = make_subs_tab(), State0 = base_state(SubsTab), {State1, true, [{0, 99}]} = - guild_member_list:subscribe_ranges(<<"s1">>, <<"500">>, [{0, 99}], State0), + guild_member_list_subscribe:subscribe_ranges(<<"s1">>, <<"500">>, [{0, 99}], State0), {State2, true, [{10, 20}]} = - guild_member_list:subscribe_ranges(<<"s1">>, <<"600">>, [{10, 20}], State1), + guild_member_list_subscribe:subscribe_ranges(<<"s1">>, <<"600">>, [{10, 20}], State1), try Engines = maps:get(channel_member_list_engines, State2, #{}), ?assertNot(guild_member_list_subs:is_subscribed(<<"500">>, <<"s1">>, SubsTab)), @@ -91,7 +91,7 @@ unsubscribe_last_session_drops_channel_store_test() -> State = (base_state(make_subs_tab([{<<"500">>, <<"s1">>, [{0, 99}]}])))#{ channel_member_list_engines => #{<<"500">> => Ref} }, - NewState = guild_member_list:unsubscribe_session(<<"s1">>, State), + NewState = guild_member_list_subscribe:unsubscribe_session(<<"s1">>, State), Engines = maps:get(channel_member_list_engines, NewState, #{}), ?assertNot(maps:is_key(<<"500">>, Engines)), ?assertEqual(undefined, ets:info(Ref, name)). @@ -101,7 +101,7 @@ empty_ranges_subscribe_drops_channel_engine_test() -> State = (base_state(make_subs_tab([{<<"500">>, <<"s1">>, [{0, 99}]}])))#{ channel_member_list_engines => #{<<"500">> => Ref} }, - {NewState, _ShouldSync, _Ranges} = guild_member_list:subscribe_ranges( + {NewState, _ShouldSync, _Ranges} = guild_member_list_subscribe:subscribe_ranges( <<"s1">>, <<"500">>, [], State ), Engines = maps:get(channel_member_list_engines, NewState, #{}), @@ -123,12 +123,12 @@ run_broadcast_channel_list_dispatches_sync_immediately() -> Member ]), NewState = OldState#{member_presence => #{1 => #{<<"status">> => <<"online">>}}}, - {ok, QueuedState} = guild_member_list:broadcast_member_list_updates( + {ok, QueuedState} = guild_member_list_write:broadcast_member_list_updates( 1, OldState, NewState ), ?assertNot(maps:is_key(pending_member_list_sync_batch, QueuedState)), assert_single_channel_sync_dispatch(NewState, QueuedState), - FlushedState = guild_member_list:flush_pending_member_list_syncs(QueuedState), + FlushedState = guild_member_list_write:flush_pending_member_list_syncs(QueuedState), ?assertNot(maps:is_key(pending_member_list_sync_batch, FlushedState)), assert_no_sync_dispatch() after @@ -152,18 +152,18 @@ run_broadcast_channel_list_fans_out_repeated_syncs() -> Ref, make_subs_tab([{<<"500">>, <<"s1">>, [{0, 99}]}]), Members ), NewState1 = OldState#{coalesce_marker => first}, - {ok, QueuedState1} = guild_member_list:broadcast_member_list_updates( + {ok, QueuedState1} = guild_member_list_write:broadcast_member_list_updates( 1, OldState, NewState1 ), ?assertNot(maps:is_key(pending_member_list_sync_batch, QueuedState1)), assert_channel_sync_dispatch_marker(first, QueuedState1), NewState2 = QueuedState1#{coalesce_marker => final}, - {ok, QueuedState2} = guild_member_list:broadcast_member_list_updates( + {ok, QueuedState2} = guild_member_list_write:broadcast_member_list_updates( 2, QueuedState1, NewState2 ), ?assertNot(maps:is_key(pending_member_list_sync_batch, QueuedState2)), assert_channel_sync_dispatch_marker(final, QueuedState2), - FlushedState = guild_member_list:flush_pending_member_list_syncs(QueuedState2), + FlushedState = guild_member_list_write:flush_pending_member_list_syncs(QueuedState2), ?assertEqual(QueuedState2, FlushedState), assert_no_sync_dispatch() after @@ -200,15 +200,17 @@ run_sync_batch_fans_out_before_subscription_churn() -> SubsTab = make_subs_tab(), State0 = base_state(SubsTab), {State1, true, [{0, 10}]} = - guild_member_list:subscribe_ranges(<<"s1">>, <<"500">>, [{0, 10}], State0), + guild_member_list_subscribe:subscribe_ranges(<<"s1">>, <<"500">>, [{0, 10}], State0), SyncedState = guild_member_list_sync_batch:queue_list_sync(<<"500">>, State1), assert_single_channel_sync_dispatch_with_ranges([{0, 10}], SyncedState), - UnsubscribedState = guild_member_list:unsubscribe_session(<<"s1">>, SyncedState), + UnsubscribedState = guild_member_list_subscribe:unsubscribe_session(<<"s1">>, SyncedState), ?assertNot( maps:is_key(<<"500">>, maps:get(channel_member_list_engines, UnsubscribedState, #{})) ), {ResubscribedState, true, [{20, 30}]} = - guild_member_list:subscribe_ranges(<<"s1">>, <<"500">>, [{20, 30}], UnsubscribedState), + guild_member_list_subscribe:subscribe_ranges( + <<"s1">>, <<"500">>, [{20, 30}], UnsubscribedState + ), try FlushedState = guild_member_list_sync_batch:flush_pending_syncs(ResubscribedState), ?assertEqual(ResubscribedState, FlushedState), @@ -224,10 +226,10 @@ run_sync_batch_skips_unsubscribed_lists_at_queue_time() -> SubsTab = make_subs_tab(), State0 = base_state(SubsTab), {State1, true, _Ranges} = - guild_member_list:subscribe_ranges(<<"s1">>, <<"500">>, [{0, 99}], State0), + guild_member_list_subscribe:subscribe_ranges(<<"s1">>, <<"500">>, [{0, 99}], State0), _SyncedState = guild_member_list_sync_batch:queue_list_sync(<<"500">>, State1), _ = collect_sync_dispatches(1), - UnsubscribedState = guild_member_list:unsubscribe_session(<<"s1">>, State1), + UnsubscribedState = guild_member_list_subscribe:unsubscribe_session(<<"s1">>, State1), ResyncedState = guild_member_list_sync_batch:queue_list_sync(<<"500">>, UnsubscribedState), ?assertNot(maps:is_key(pending_member_list_sync_batch, ResyncedState)), ?assertNot( @@ -281,7 +283,7 @@ run_broadcast_afk_only_change_skips_sync() -> Ref = guild_member_list_engine:new(), try State = presence_delta_state(Ref), - {ok, NewState} = guild_member_list:broadcast_member_list_updates( + {ok, NewState} = guild_member_list_write:broadcast_member_list_updates( 1, State, State, @@ -301,7 +303,7 @@ run_broadcast_status_change_dispatches_sync() -> Ref = guild_member_list_engine:new(), try State = presence_delta_state(Ref), - {ok, NewState} = guild_member_list:broadcast_member_list_updates( + {ok, NewState} = guild_member_list_write:broadcast_member_list_updates( 1, State, State, @@ -321,7 +323,7 @@ run_broadcast_custom_status_change_dispatches_sync() -> Ref = guild_member_list_engine:new(), try State = presence_delta_state(Ref), - {ok, NewState} = guild_member_list:broadcast_member_list_updates( + {ok, NewState} = guild_member_list_write:broadcast_member_list_updates( 1, State, State, @@ -341,7 +343,7 @@ run_broadcast_offline_transition_dispatches_sync() -> Ref = guild_member_list_engine:new(), try State = presence_delta_state(Ref), - {ok, NewState} = guild_member_list:broadcast_member_list_updates( + {ok, NewState} = guild_member_list_write:broadcast_member_list_updates( 1, State, State, diff --git a/fluxer_gateway/test/guild_members_tests.erl b/fluxer_gateway/test/guild_members_tests.erl index 047c1e40e..679c4a6b8 100644 --- a/fluxer_gateway/test/guild_members_tests.erl +++ b/fluxer_gateway/test/guild_members_tests.erl @@ -5,44 +5,23 @@ -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -get_users_to_mention_by_roles_basic_test() -> - State = test_state(), - Request = #{channel_id => 500, role_ids => [200], author_id => 1}, - {reply, #{user_ids := UserIds}, _} = guild_members:get_users_to_mention_by_roles( - Request, State - ), - ?assertEqual([2], UserIds). - get_members_with_role_test() -> State = test_state(), - {reply, #{user_ids := UserIds}, _} = guild_members:get_members_with_role( + {reply, #{user_ids := UserIds}, _} = guild_members_query:get_members_with_role( #{role_id => 200}, State ), ?assertEqual([2], UserIds). get_members_with_role_rejects_malformed_role_id_test() -> State = test_state(), - {reply, #{user_ids := UserIds}, _} = guild_members:get_members_with_role( + {reply, #{user_ids := UserIds}, _} = guild_members_query:get_members_with_role( #{role_id => <<"0200">>}, State ), ?assertEqual([], UserIds). -resolve_all_mentions_test() -> - State = test_state(), - Request = #{ - channel_id => 500, - author_id => 1, - mention_everyone => false, - mention_here => false, - role_ids => [200], - user_ids => [3] - }, - {reply, #{user_ids := UserIds}, _} = guild_members:resolve_all_mentions(Request, State), - ?assertEqual([2, 3], UserIds). - resolve_channel_mentions_skips_invalid_channel_ids_test() -> State = channel_mentions_state([]), - {reply, #{channels := Channels}, _} = guild_members:resolve_channel_mentions( + {reply, #{channels := Channels}, _} = guild_members_mutation:resolve_channel_mentions( #{channel_ids => [<<"001">>, <<"500">>]}, State ), @@ -59,7 +38,7 @@ resolve_channel_mentions_ignores_user_zero_overwrite_test() -> } ], State = channel_mentions_state(Overwrites), - {reply, #{channels := Channels}, _} = guild_members:resolve_channel_mentions( + {reply, #{channels := Channels}, _} = guild_members_mutation:resolve_channel_mentions( #{channel_ids => [<<"500">>]}, State ), @@ -67,7 +46,7 @@ resolve_channel_mentions_ignores_user_zero_overwrite_test() -> get_assignable_roles_owner_test() -> State = test_state(), - {reply, #{role_ids := RoleIds}, _} = guild_members:get_assignable_roles( + {reply, #{role_ids := RoleIds}, _} = guild_members_roles:get_assignable_roles( #{user_id => 1}, State ), ?assertEqual(lists:sort([100, 200, 201]), lists:sort(RoleIds)). @@ -78,7 +57,7 @@ get_viewable_channels_skips_malformed_channel_id_test() -> Channels = maps:get(<<"channels">>, Data0), BadChannel = #{<<"id">> => <<"0502">>, <<"type">> => 0, <<"permission_overwrites">> => []}, State = State0#{data => Data0#{<<"channels">> => [BadChannel | Channels]}}, - {reply, #{channel_ids := ChannelIds}, _} = guild_members:get_viewable_channels( + {reply, #{channel_ids := ChannelIds}, _} = guild_members_query:get_viewable_channels( #{user_id => 1}, State ), ?assert(lists:member(500, ChannelIds)), @@ -86,10 +65,10 @@ get_viewable_channels_skips_malformed_channel_id_test() -> check_target_member_test() -> State = test_state(), - {reply, #{can_manage := true}, _} = guild_members:check_target_member( + {reply, #{can_manage := true}, _} = guild_members_roles:check_target_member( #{user_id => 1, target_user_id => 2}, State ), - {reply, #{can_manage := false}, _} = guild_members:check_target_member( + {reply, #{can_manage := false}, _} = guild_members_roles:check_target_member( #{user_id => 2, target_user_id => 1}, State ). diff --git a/fluxer_gateway/test/guild_permissions_tests.erl b/fluxer_gateway/test/guild_permissions_tests.erl index 2d57e66ed..73ea47fc8 100644 --- a/fluxer_gateway/test/guild_permissions_tests.erl +++ b/fluxer_gateway/test/guild_permissions_tests.erl @@ -96,7 +96,7 @@ administrator_role_grants_all_permissions_test() -> ?assertEqual( all_permissions(), guild_permissions:get_member_permissions(UserId, ChannelId, State) ), - ?assert(guild_permissions:can_view_channel(UserId, ChannelId, undefined, State)). + ?assert(guild_permissions_check:can_view_channel(UserId, ChannelId, undefined, State)). cached_role_permissions_matches_uncached_test() -> Roles = [ @@ -105,7 +105,7 @@ cached_role_permissions_matches_uncached_test() -> #{<<"id">> => <<"300">>, <<"permissions">> => <<"2048">>} ], RoleIndex = guild_data_index:build_id_index(Roles), - Cache = guild_data_index:build_role_perms_cache(Roles), + Cache = guild_data_index_roles:build_role_perms_cache(Roles), MemberRoles = [100, 200, 300], Uncached = uncached_role_permissions(MemberRoles, RoleIndex, 0), Cached = guild_permissions:aggregate_role_permissions_cached( @@ -119,7 +119,7 @@ cached_role_permissions_with_base_test() -> #{<<"id">> => <<"10">>, <<"permissions">> => <<"128">>} ], RoleIndex = guild_data_index:build_id_index(Roles), - Cache = guild_data_index:build_role_perms_cache(Roles), + Cache = guild_data_index_roles:build_role_perms_cache(Roles), BasePerms = 64, MemberRoles = [10], Uncached = uncached_role_permissions(MemberRoles, RoleIndex, BasePerms), @@ -209,7 +209,7 @@ can_view_channel_members_grants_when_bit_set_test() -> <<"channels">> => [#{<<"id">> => <<"500">>, <<"permission_overwrites">> => []}] } }, - ?assert(guild_permissions:can_view_channel_members(UserId, 500, undefined, State)). + ?assert(guild_permissions_check:can_view_channel_members(UserId, 500, undefined, State)). can_view_channel_members_denies_when_bit_unset_test() -> GuildId = 101, @@ -228,7 +228,7 @@ can_view_channel_members_denies_when_bit_unset_test() -> <<"channels">> => [#{<<"id">> => <<"500">>, <<"permission_overwrites">> => []}] } }, - ?assertNot(guild_permissions:can_view_channel_members(UserId, 500, undefined, State)). + ?assertNot(guild_permissions_check:can_view_channel_members(UserId, 500, undefined, State)). can_view_channel_members_owner_always_true_test() -> GuildId = 102, @@ -246,7 +246,7 @@ can_view_channel_members_owner_always_true_test() -> <<"channels">> => [#{<<"id">> => <<"500">>, <<"permission_overwrites">> => []}] } }, - ?assert(guild_permissions:can_view_channel_members(OwnerId, 500, undefined, State)). + ?assert(guild_permissions_check:can_view_channel_members(OwnerId, 500, undefined, State)). can_view_channel_members_admin_grants_test() -> Admin = constants:administrator_permission(), @@ -269,7 +269,7 @@ can_view_channel_members_admin_grants_test() -> <<"channels">> => [#{<<"id">> => <<"500">>, <<"permission_overwrites">> => []}] } }, - ?assert(guild_permissions:can_view_channel_members(UserId, 500, undefined, State)). + ?assert(guild_permissions_check:can_view_channel_members(UserId, 500, undefined, State)). can_view_channel_members_channel_deny_overwrite_blocks_test() -> Bit = constants:view_channel_members_permission(), @@ -306,25 +306,26 @@ can_view_channel_members_channel_deny_overwrite_blocks_test() -> ] } }, - ?assertNot(guild_permissions:can_view_channel_members(UserId, 500, undefined, State)). + ?assertNot(guild_permissions_check:can_view_channel_members(UserId, 500, undefined, State)). can_access_message_with_read_history_test() -> ReadHistory = constants:read_message_history_permission(), State = #{data => #{<<"guild">> => #{}}}, ?assertEqual( - true, guild_permissions:can_access_message_by_permissions(ReadHistory, <<"100">>, State) + true, + guild_permissions_check:can_access_message_by_permissions(ReadHistory, <<"100">>, State) ). can_access_message_no_read_history_no_cutoff_test() -> State = #{data => #{<<"guild">> => #{}}}, ?assertEqual( - false, guild_permissions:can_access_message_by_permissions(0, <<"100">>, State) + false, guild_permissions_check:can_access_message_by_permissions(0, <<"100">>, State) ). can_access_message_no_read_history_null_cutoff_test() -> State = #{data => #{<<"guild">> => #{<<"message_history_cutoff">> => null}}}, ?assertEqual( - false, guild_permissions:can_access_message_by_permissions(0, <<"100">>, State) + false, guild_permissions_check:can_access_message_by_permissions(0, <<"100">>, State) ). can_access_message_no_read_history_message_before_cutoff_test() -> @@ -336,7 +337,7 @@ can_access_message_no_read_history_message_before_cutoff_test() -> MessageId = integer_to_binary(Snowflake), State = #{data => #{<<"guild">> => #{<<"message_history_cutoff">> => CutoffMs}}}, ?assertEqual( - false, guild_permissions:can_access_message_by_permissions(0, MessageId, State) + false, guild_permissions_check:can_access_message_by_permissions(0, MessageId, State) ). can_access_message_no_read_history_message_after_cutoff_test() -> @@ -348,7 +349,7 @@ can_access_message_no_read_history_message_after_cutoff_test() -> MessageId = integer_to_binary(Snowflake), State = #{data => #{<<"guild">> => #{<<"message_history_cutoff">> => CutoffMs}}}, ?assertEqual( - true, guild_permissions:can_access_message_by_permissions(0, MessageId, State) + true, guild_permissions_check:can_access_message_by_permissions(0, MessageId, State) ). can_access_message_no_read_history_message_at_cutoff_test() -> @@ -359,7 +360,7 @@ can_access_message_no_read_history_message_at_cutoff_test() -> MessageId = integer_to_binary(Snowflake), State = #{data => #{<<"guild">> => #{<<"message_history_cutoff">> => CutoffMs}}}, ?assertEqual( - true, guild_permissions:can_access_message_by_permissions(0, MessageId, State) + true, guild_permissions_check:can_access_message_by_permissions(0, MessageId, State) ). can_access_message_with_rfc3339_cutoff_test() -> @@ -372,7 +373,7 @@ can_access_message_with_rfc3339_cutoff_test() -> MessageId = integer_to_binary(Snowflake), State = #{data => #{<<"guild">> => #{<<"message_history_cutoff">> => CutoffBin}}}, ?assertEqual( - true, guild_permissions:can_access_message_by_permissions(0, MessageId, State) + true, guild_permissions_check:can_access_message_by_permissions(0, MessageId, State) ). all_permissions() -> diff --git a/fluxer_gateway/test/guild_session_tracking_tests.erl b/fluxer_gateway/test/guild_session_tracking_tests.erl index 7670eaaf0..06d517613 100644 --- a/fluxer_gateway/test/guild_session_tracking_tests.erl +++ b/fluxer_gateway/test/guild_session_tracking_tests.erl @@ -117,7 +117,11 @@ not_member_reconnect_releases_tracking() -> fun(Sessions) -> with_guild(base_state(Sessions), fun(Guild) -> sys:replace_state(Guild, fun(S) -> - S#{data => guild_data_index:remove_member(?USER_B, maps:get(data, S))} + S#{ + data => guild_data_index_members:remove_member( + ?USER_B, maps:get(data, S) + ) + } end), send_connect(Guild, <<"b1">>, Sessions, 1), ?assertEqual({error, not_member}, await_result(<<"b1">>, 1)), @@ -171,7 +175,7 @@ unavailable_removal_of_fresh_pending_keeps_owner_tracked() -> hold_connect_workers(Guild, 16), send_connect(Guild, <<"a1">>, Sessions, 1), sys:replace_state(Guild, fun(S) -> - guild_sessions:remove_session(<<"a1">>, S) + guild_sessions_connect:remove_session(<<"a1">>, S) end), State = get_state(Guild), ?assertNot(maps:is_key(<<"a1">>, maps:get(sessions, State))), diff --git a/fluxer_gateway/test/guild_state_channel_resync_tests.erl b/fluxer_gateway/test/guild_state_channel_resync_tests.erl index 869e678f6..287555371 100644 --- a/fluxer_gateway/test/guild_state_channel_resync_tests.erl +++ b/fluxer_gateway/test/guild_state_channel_resync_tests.erl @@ -29,16 +29,16 @@ run_resync_case(Event, EventData) -> PidB = spawn(fun idle/0), SubsTab = guild_member_list_subs:new(), State0 = base_state(SubsTab, PidA, PidB), - {State1, _SyncA, _RangesA} = guild_member_list:subscribe_ranges( + {State1, _SyncA, _RangesA} = guild_member_list_subscribe:subscribe_ranges( <<"s_a">>, list_id(), [{0, 99}], State0 ), - {State2, _SyncB, _RangesB} = guild_member_list:subscribe_ranges( + {State2, _SyncB, _RangesB} = guild_member_list_subscribe:subscribe_ranges( <<"s_b">>, list_id(), [{0, 99}], State1 ), try - ?assertEqual({3, 0}, guild_member_list:get_counts(list_id(), State2)), + ?assertEqual({3, 0}, guild_member_list_read:get_counts(list_id(), State2)), {noreply, UpdatedState} = guild_dispatch:handle_dispatch(Event, EventData, State2), - ?assertEqual({2, 0}, guild_member_list:get_counts(list_id(), UpdatedState)), + ?assertEqual({2, 0}, guild_member_list_read:get_counts(list_id(), UpdatedState)), {Pids, Raw, Payload} = receive_member_list_dispatch(), ?assertEqual(lists:sort([PidA, PidB]), lists:sort(Pids)), ?assertEqual(list_id(), maps:get(<<"channel_id">>, Payload)), diff --git a/fluxer_gateway/test/guild_state_roles_tests.erl b/fluxer_gateway/test/guild_state_roles_tests.erl index 7bb9ea506..f8dde30fd 100644 --- a/fluxer_gateway/test/guild_state_roles_tests.erl +++ b/fluxer_gateway/test/guild_state_roles_tests.erl @@ -78,9 +78,9 @@ handle_role_create_test() -> }, EventData = #{<<"role">> => #{<<"id">> => <<"2">>, <<"name">> => <<"New">>}}, Result = guild_state_roles:handle_role_create(EventData, Data), - Roles = guild_data_index:role_list(Result), + Roles = guild_data_index_roles:role_list(Result), ?assertEqual(2, length(Roles)), - RoleIndex = guild_data_index:role_index(Result), + RoleIndex = guild_data_index_roles:role_index(Result), ?assertMatch(#{2 := _}, RoleIndex). handle_role_update_replaces_role_test() -> @@ -94,7 +94,7 @@ handle_role_update_replaces_role_test() -> }, EventData = #{<<"role">> => #{<<"id">> => <<"1">>, <<"name">> => <<"Updated">>}}, Result = guild_state_roles:handle_role_update(EventData, Data), - Roles = guild_data_index:role_list(Result), + Roles = guild_data_index_roles:role_list(Result), [R1, R2] = Roles, ?assertEqual(<<"Updated">>, maps:get(<<"name">>, R1)), ?assertEqual(<<"Keep">>, maps:get(<<"name">>, R2)). @@ -116,7 +116,7 @@ handle_role_update_bulk_test() -> ] }, Result = guild_state_roles:handle_role_update_bulk(EventData, Data), - Roles = guild_data_index:role_list(Result), + Roles = guild_data_index_roles:role_list(Result), [R1, R2, R3] = Roles, ?assertEqual(<<"A2">>, maps:get(<<"name">>, R1)), ?assertEqual(<<"B">>, maps:get(<<"name">>, R2)), @@ -133,7 +133,7 @@ handle_role_delete_removes_role_from_list_test() -> }, EventData = #{<<"role_id">> => <<"2">>}, Result = guild_state_roles:handle_role_delete(EventData, Data), - Roles = guild_data_index:role_list(Result), + Roles = guild_data_index_roles:role_list(Result), ?assertEqual(1, length(Roles)), ?assertEqual(<<"Keep">>, maps:get(<<"name">>, hd(Roles))). @@ -146,7 +146,7 @@ strip_role_from_members_no_affected_users_test() -> <<"channels">> => [] }), Result = guild_state_roles_delete:strip_role_from_members(999, Data), - #{<<"roles">> := Roles} = guild_data_index:get_member(1, Result), + #{<<"roles">> := Roles} = guild_data_index_members:get_member(1, Result), ?assertEqual([100], Roles). strip_role_from_channel_overwrites_preserves_user_overwrites_test() -> @@ -172,7 +172,7 @@ strip_role_from_channel_overwrites_preserves_user_overwrites_test() -> ] }, Result = guild_state_roles_delete:strip_role_from_channel_overwrites(100, Data), - [Ch] = guild_data_index:channel_list(Result), + [Ch] = guild_data_index_channels:channel_list(Result), Overwrites = maps:get(<<"permission_overwrites">>, Ch), ?assertEqual(1, length(Overwrites)), ?assertEqual(1, maps:get(<<"type">>, hd(Overwrites))). diff --git a/fluxer_gateway/test/guild_thread_push_tests.erl b/fluxer_gateway/test/guild_thread_push_tests.erl index 8c4331ce8..9ebf3271f 100644 --- a/fluxer_gateway/test/guild_thread_push_tests.erl +++ b/fluxer_gateway/test/guild_thread_push_tests.erl @@ -127,9 +127,11 @@ mention_request(ChannelId, Overrides) -> ). everyone(Request, State) -> - {reply, #{everyone_user_ids := Ids}, _} = - guild_members_mutation:resolve_mention_sources(Request, State), - lists:sort(Ids). + {reply, #{mentions := Entries}, _} = + guild_members_mutation:resolve_mention_sources_page( + Request#{limit => 100, cursor => undefined}, State + ), + lists:sort([entry_user_id(E) || E <- Entries, maps:get(everyone, E, false)]). everyone_in_a_thread_reaches_recipient_active_members_only_test() -> with_state(true, fun(State) -> @@ -150,11 +152,16 @@ here_in_a_thread_needs_membership_and_a_connection_test() -> direct_mentions_in_a_thread_skip_inactive_recipients_test() -> with_state(true, fun(State) -> - {reply, #{direct_user_ids := Ids}, _} = - guild_members_mutation:resolve_mention_sources( - mention_request(?THREAD, #{user_ids => [?B, ?OUT]}), State + {reply, #{mentions := Entries}, _} = + guild_members_mutation:resolve_mention_sources_page( + (mention_request(?THREAD, #{user_ids => [?B, ?OUT]}))#{ + limit => 100, cursor => undefined + }, + State ), - ?assertEqual([?B], lists:sort(Ids)) + ?assertEqual( + [?B], lists:sort([entry_user_id(E) || E <- Entries, maps:get(direct, E, false)]) + ) end). paged_everyone_in_a_thread_lists_members_only_test() -> diff --git a/fluxer_gateway/test/guild_threads_tests.erl b/fluxer_gateway/test/guild_threads_tests.erl index 3bfc15b2e..a5bd5795a 100644 --- a/fluxer_gateway/test/guild_threads_tests.erl +++ b/fluxer_gateway/test/guild_threads_tests.erl @@ -395,16 +395,16 @@ permissions_fail_closed_for_unknown_ids_test() -> thread_permissions_derive_from_parent_test() -> with_guild(fun(State) -> Perms = guild_permissions:get_member_permissions(?V, ?T1, State), - ?assert(permission_bits:has(Perms, ?VIEW)), - ?assert(permission_bits:has(Perms, ?SEND)), + ?assert(bitset:has(Perms, ?VIEW)), + ?assert(bitset:has(Perms, ?SEND)), ?assertNot( - permission_bits:has(guild_permissions:get_member_permissions(?V, ?T2, State), ?VIEW) + bitset:has(guild_permissions:get_member_permissions(?V, ?T2, State), ?VIEW) ), ?assert( - permission_bits:has(guild_permissions:get_member_permissions(?A, ?T2, State), ?VIEW) + bitset:has(guild_permissions:get_member_permissions(?A, ?T2, State), ?VIEW) ), ?assert( - permission_bits:has(guild_permissions:get_member_permissions(?M, ?T2, State), ?VIEW) + bitset:has(guild_permissions:get_member_permissions(?M, ?T2, State), ?VIEW) ), {reply, #{auth_context := Context}, _} = guild_data:get_auth_context( #{user_id => ?V, channel_id => ?T1}, State @@ -833,7 +833,7 @@ public_online_count_ignores_forums_test() -> WithoutForums = guild_data_index:put_channels( [ C - || C <- guild_data_index:channel_list(Data), + || C <- guild_data_index_channels:channel_list(Data), maps:get(<<"type">>, C) =/= 15 ], Data @@ -915,10 +915,10 @@ flip_result_keeps_live_channels_and_replays_held_events_test() -> ?assert(guild_thread_gate:active(On)), ?assertNot(maps:is_key(thread_flip_held, On)), ?assertNot(maps:is_key(thread_flip_retry, On)), - Index = guild_data_index:channel_index(maps:get(data, On)), + Index = guild_data_index_channels:channel_index(maps:get(data, On)), ?assertMatch(#{<<"type">> := 15}, maps:get(?FORUM, Index)), ?assertEqual([], maps:get(<<"permission_overwrites">>, maps:get(?HIDDEN, Index))), - ?assertEqual(2, length(guild_data_index:role_list(maps:get(data, On)))), + ?assertEqual(2, length(guild_data_index_roles:role_list(maps:get(data, On)))), ?assertNotEqual(undefined, guild_thread_gate:thread(?T1, On)), guild_thread_store:destroy(guild_thread_gate:store(On)) after @@ -1049,7 +1049,7 @@ flip_activation_without_sessions_rebuilds_the_permission_cache_test() -> ?assert( lists:any( fun(C) -> maps:get(<<"type">>, C) =:= 15 end, - guild_data_index:channel_list(Cached) + guild_data_index_channels:channel_list(Cached) ) ), guild_permission_cache:delete(?G), @@ -1071,7 +1071,7 @@ non_viewer_visibility_of_indexed_channels_uses_type_only_test() -> ?assertNot(guild_thread_gate:channel_visible(C, ?FORUM, State)), ?assertNot(guild_thread_gate:channel_visible(C, ?T1, State)), Data = maps:get(data, State), - Index = guild_data_index:channel_index(Data), + Index = guild_data_index_channels:channel_index(Data), Indexed = State#{ data => Data#{ <<"channel_index">> => Index#{?T2 => #{<<"id">> => ?T2, <<"type">> => 12}} @@ -1084,7 +1084,7 @@ forum_only_category_update_skips_non_viewers_test() -> with_guild(fun(State) -> [Category] = [ C - || C <- guild_data_index:channel_list(maps:get(data, State)), + || C <- guild_data_index_channels:channel_list(maps:get(data, State)), maps:get(<<"type">>, C) =:= 4 ], Update = guild_data_wire:payload(Category#{<<"name">> => <<"renamed">>}), @@ -1123,7 +1123,7 @@ visibility_gain_channel_create_is_masked_for_non_viewers_test() -> _ -> C end - || C <- guild_data_index:channel_list(Data) + || C <- guild_data_index_channels:channel_list(Data) ], State#{data => guild_data_index:put_channels(Channels, Data)} end, @@ -1205,6 +1205,8 @@ can_view_channel_call_sites_are_enumerated_test() -> guild_subscription_handler, guild_thread_gate, guild_visibility_channels, + guild_visibility_memo, + guild_visibility_overwrites, guild_visibility_roles, guild_members_search, guild_members_mutation @@ -1804,7 +1806,7 @@ forum_only_shared_channel_grants_no_presence_to_control_sessions_test() -> Data0 = maps:get(data, State0), Forum = [ maps:remove(<<"parent_id">>, C) - || C <- guild_data_index:channel_list(Data0), maps:get(<<"type">>, C) =:= 15 + || C <- guild_data_index_channels:channel_list(Data0), maps:get(<<"type">>, C) =:= 15 ], State = State0#{data => guild_data_index:put_channels(Forum, Data0)}, Sessions = maps:get(sessions, State), @@ -1895,8 +1897,8 @@ news_thread_create_reaches_viewers_only_test() -> news_thread_permissions_derive_from_announcement_parent_test() -> with_news_guild(fun(State) -> Perms = guild_permissions:get_member_permissions(?V, ?NEWS_THREAD, State), - ?assert(permission_bits:has(Perms, ?VIEW)), - ?assert(permission_bits:has(Perms, ?SEND)), + ?assert(bitset:has(Perms, ?VIEW)), + ?assert(bitset:has(Perms, ?SEND)), {reply, #{auth_context := Context}, _} = guild_data:get_auth_context( #{user_id => ?V, channel_id => ?NEWS_THREAD}, State ), @@ -1924,7 +1926,7 @@ news_thread_permission_loss_on_parent_hides_thread_test() -> State1 = dispatch(channel_update, Hidden, State0), _ = drain(), ?assertNot( - permission_bits:has( + bitset:has( guild_permissions:get_member_permissions(?A, ?NEWS_THREAD, State1), ?VIEW ) ), @@ -2044,7 +2046,7 @@ news_thread_handoff_round_trips_test() -> #{<<"type">> := 10}, guild_thread_gate:thread(?NEWS_THREAD, Restored) ), ?assert( - permission_bits:has( + bitset:has( guild_permissions:get_member_permissions(?V, ?NEWS_THREAD, Restored), ?VIEW ) ) diff --git a/fluxer_gateway/test/guild_visibility_permission_tests.erl b/fluxer_gateway/test/guild_visibility_permission_tests.erl index 24f1f22c7..3536d18aa 100644 --- a/fluxer_gateway/test/guild_visibility_permission_tests.erl +++ b/fluxer_gateway/test/guild_visibility_permission_tests.erl @@ -43,7 +43,7 @@ channel_overwrite_denies_view_test() -> ] } }, - Channels = guild_visibility:get_user_viewable_channels(UserId, State), + Channels = guild_visibility_channels:get_user_viewable_channels(UserId, State), ?assertEqual([], Channels). role_overwrite_allows_view_test() -> @@ -90,7 +90,7 @@ role_overwrite_allows_view_test() -> ] } }, - Channels = guild_visibility:get_user_viewable_channels(UserId, State), + Channels = guild_visibility_channels:get_user_viewable_channels(UserId, State), ?assertEqual([ChannelId], Channels). user_overwrite_denies_view_test() -> @@ -137,7 +137,7 @@ user_overwrite_denies_view_test() -> ] } }, - Channels = guild_visibility:get_user_viewable_channels(UserId, State), + Channels = guild_visibility_channels:get_user_viewable_channels(UserId, State), ?assertEqual([], Channels). parent_category_included_when_child_channel_is_visible_test() -> @@ -150,7 +150,7 @@ parent_category_included_when_child_channel_is_visible_test() -> State = parent_category_visible_state( GuildId, UserId, RoleId, CategoryId, ChannelId, ViewPerm ), - Channels = lists:sort(guild_visibility:get_user_viewable_channels(UserId, State)), + Channels = lists:sort(guild_visibility_channels:get_user_viewable_channels(UserId, State)), ?assertEqual([CategoryId, ChannelId], Channels). parent_category_visible_state(GuildId, UserId, RoleId, CategoryId, ChannelId, ViewPerm) -> diff --git a/fluxer_gateway/test/guild_visibility_tests.erl b/fluxer_gateway/test/guild_visibility_tests.erl index 3015bd69e..28896161d 100644 --- a/fluxer_gateway/test/guild_visibility_tests.erl +++ b/fluxer_gateway/test/guild_visibility_tests.erl @@ -12,13 +12,17 @@ preserves_connected_channel_visibility_on_permission_loss_test() -> ViewPerm = constants:view_channel_permission(), OldState = visibility_state(GuildId, UserId, ChannelId, ViewPerm, true), NewState = visibility_state(GuildId, UserId, ChannelId, 0, true), - UpdatedState = guild_visibility:compute_and_dispatch_visibility_changes(OldState, NewState), + UpdatedState = guild_visibility_overwrites:compute_and_dispatch_visibility_changes( + OldState, NewState + ), ?assert(guild_virtual_channel_access:has_virtual_access(UserId, ChannelId, UpdatedState)), ?assertEqual( false, guild_virtual_channel_access:is_pending_join(UserId, ChannelId, UpdatedState) ), - ?assert(guild_permissions:can_view_channel(UserId, ChannelId, undefined, UpdatedState)). + ?assert( + guild_permissions_check:can_view_channel(UserId, ChannelId, undefined, UpdatedState) + ). does_not_add_virtual_access_when_not_connected_test() -> UserId = 20, @@ -27,7 +31,9 @@ does_not_add_virtual_access_when_not_connected_test() -> ViewPerm = constants:view_channel_permission(), OldState = visibility_state(GuildId, UserId, ChannelId, ViewPerm, false), NewState = visibility_state(GuildId, UserId, ChannelId, 0, false), - UpdatedState = guild_visibility:compute_and_dispatch_visibility_changes(OldState, NewState), + UpdatedState = guild_visibility_overwrites:compute_and_dispatch_visibility_changes( + OldState, NewState + ), ?assertNot( guild_virtual_channel_access:has_virtual_access(UserId, ChannelId, UpdatedState) ). @@ -39,7 +45,9 @@ does_not_add_virtual_access_when_permission_remains_test() -> ViewPerm = constants:view_channel_permission(), OldState = visibility_state(GuildId, UserId, ChannelId, ViewPerm, true), NewState = visibility_state(GuildId, UserId, ChannelId, ViewPerm, true), - UpdatedState = guild_visibility:compute_and_dispatch_visibility_changes(OldState, NewState), + UpdatedState = guild_visibility_overwrites:compute_and_dispatch_visibility_changes( + OldState, NewState + ), ?assertNot( guild_virtual_channel_access:has_virtual_access(UserId, ChannelId, UpdatedState) ). @@ -51,7 +59,7 @@ compute_and_dispatch_visibility_changes_for_users_targets_selected_users_test() ViewPerm = constants:view_channel_permission(), OldState = users_visibility_state(GuildId, ChannelId, RoleId, ViewPerm, role, role), NewState = users_visibility_state(GuildId, ChannelId, RoleId, ViewPerm, none, role), - UpdatedState = guild_visibility:compute_and_dispatch_visibility_changes_for_users( + UpdatedState = guild_visibility_overwrites:compute_and_dispatch_visibility_changes_for_users( [10], OldState, NewState ), UpdatedSessions = maps:get(sessions, UpdatedState), @@ -84,7 +92,7 @@ compute_and_dispatch_visibility_changes_for_channels_limits_test() -> NewState = channels_visibility_state( GuildId, UserId, BaseRole, ChannelA, ChannelB, NewChannels ), - UpdatedState = guild_visibility:compute_and_dispatch_visibility_changes_for_channels( + UpdatedState = guild_visibility_overwrites:compute_and_dispatch_visibility_changes_for_channels( [ChannelA], OldState, NewState ), UpdatedSession = maps:get(<<"s10">>, maps:get(sessions, UpdatedState)), diff --git a/fluxer_gateway/test/guild_voice_broadcast_tests.erl b/fluxer_gateway/test/guild_voice_broadcast_tests.erl index 8ba4feb5a..b1f2b22ac 100644 --- a/fluxer_gateway/test/guild_voice_broadcast_tests.erl +++ b/fluxer_gateway/test/guild_voice_broadcast_tests.erl @@ -96,7 +96,7 @@ confirm_join(ConnId, GuildState) -> pending_voice_connections => #{ConnId => pending_join(ConnId)} }, {reply, #{success := true}, _} = - guild_voice_connection:confirm_voice_connection_from_livekit( + guild_voice_connection_confirm:confirm_voice_connection_from_livekit( #{connection_id => ConnId}, VoiceServerState ), pump_relay_to_guild(GuildState). diff --git a/fluxer_gateway/test/guild_voice_connection_confirm_tests.erl b/fluxer_gateway/test/guild_voice_connection_confirm_tests.erl index 4bc2d212d..4f4f8ed4f 100644 --- a/fluxer_gateway/test/guild_voice_connection_confirm_tests.erl +++ b/fluxer_gateway/test/guild_voice_connection_confirm_tests.erl @@ -28,7 +28,7 @@ base_test_state() -> confirm_voice_connection_missing_id_test() -> State = base_test_state(), {reply, {error, validation_error, voice_missing_connection_id}, _} = - guild_voice_connection:confirm_voice_connection_from_livekit(#{}, State). + guild_voice_connection_confirm:confirm_voice_connection_from_livekit(#{}, State). confirm_voice_connection_moves_pending_to_voice_states_test() -> VoiceState = #{ @@ -116,7 +116,7 @@ confirm_voice_connection_validates_nonce_test() -> pending_voice_connections => #{<<"conn1">> => PendingData}, voice_states => #{} }), {reply, {error, validation_error, voice_nonce_mismatch}, _} = - guild_voice_connection:confirm_voice_connection_from_livekit( + guild_voice_connection_confirm:confirm_voice_connection_from_livekit( #{connection_id => <<"conn1">>, token_nonce => <<"wrong-nonce">>}, State ). @@ -129,7 +129,7 @@ confirm_voice_connection_validates_expiry_test() -> pending_voice_connections => #{<<"conn1">> => PendingData}, voice_states => #{} }), {reply, {error, validation_error, voice_pending_expired}, _} = - guild_voice_connection:confirm_voice_connection_from_livekit( + guild_voice_connection_confirm:confirm_voice_connection_from_livekit( #{connection_id => <<"conn1">>, token_nonce => <<"valid-nonce">>}, State ). @@ -152,7 +152,7 @@ confirm_voice_connection_rejects_missing_nonce_test() -> pending_voice_connections => #{<<"conn1">> => PendingData}, voice_states => #{} }), {reply, {error, validation_error, voice_nonce_mismatch}, _} = - guild_voice_connection:confirm_voice_connection_from_livekit( + guild_voice_connection_confirm:confirm_voice_connection_from_livekit( #{connection_id => <<"conn1">>}, State ). @@ -171,9 +171,9 @@ pending_data_with_nonce(Now) -> confirm_success(Data, State) -> {reply, #{success := true}, NewState} = - guild_voice_connection:confirm_voice_connection_from_livekit(Data, State), + guild_voice_connection_confirm:confirm_voice_connection_from_livekit(Data, State), NewState. assert_not_found(Data, State) -> {reply, {error, not_found, voice_connection_not_found}, _} = - guild_voice_connection:confirm_voice_connection_from_livekit(Data, State). + guild_voice_connection_confirm:confirm_voice_connection_from_livekit(Data, State). diff --git a/fluxer_gateway/test/guild_voice_connection_pending_tests.erl b/fluxer_gateway/test/guild_voice_connection_pending_tests.erl index c3bf076a5..6ebc9fc0c 100644 --- a/fluxer_gateway/test/guild_voice_connection_pending_tests.erl +++ b/fluxer_gateway/test/guild_voice_connection_pending_tests.erl @@ -31,7 +31,7 @@ sweep_expired_pending_joins_removes_expired_test() -> Valid = #{user_id => 11, guild_id => 999, channel_id => 101, expires_at => Now + 25000}, PendingConnections = #{<<"expired-conn">> => Expired, <<"valid-conn">> => Valid}, State = maps:put(pending_voice_connections, PendingConnections, base_test_state()), - NewState = guild_voice_connection:sweep_expired_pending_joins(State), + NewState = guild_voice_connection_pending:sweep_expired_pending_joins(State), NewPending = maps:get(pending_voice_connections, NewState, #{}), ?assertNot(maps:is_key(<<"expired-conn">>, NewPending)), ?assert(maps:is_key(<<"valid-conn">>, NewPending)). @@ -42,7 +42,7 @@ sweep_expired_pending_joins_keeps_valid_test() -> V2 = #{user_id => 11, guild_id => 999, channel_id => 101, expires_at => Now + 30000}, PendingConnections = #{<<"conn-1">> => V1, <<"conn-2">> => V2}, State = maps:put(pending_voice_connections, PendingConnections, base_test_state()), - NewState = guild_voice_connection:sweep_expired_pending_joins(State), + NewState = guild_voice_connection_pending:sweep_expired_pending_joins(State), NewPending = maps:get(pending_voice_connections, NewState, #{}), ?assertEqual(2, maps:size(NewPending)), ?assert(maps:is_key(<<"conn-1">>, NewPending)), @@ -61,14 +61,14 @@ sweep_expired_pending_joins_clears_virtual_access_test() -> ?assert(guild_virtual_channel_access:is_pending_join(10, 100, State3)), ?assert(guild_virtual_channel_access:has_preserve(10, 100, State3)), ?assert(guild_virtual_channel_access:is_move_pending(10, 100, State3)), - NewState = guild_voice_connection:sweep_expired_pending_joins(State3), + NewState = guild_voice_connection_pending:sweep_expired_pending_joins(State3), ?assertNot(guild_virtual_channel_access:is_pending_join(10, 100, NewState)), ?assertNot(guild_virtual_channel_access:has_preserve(10, 100, NewState)), ?assertNot(guild_virtual_channel_access:is_move_pending(10, 100, NewState)). sweep_expired_pending_joins_empty_map_test() -> State = maps:put(pending_voice_connections, #{}, base_test_state()), - NewState = guild_voice_connection:sweep_expired_pending_joins(State), + NewState = guild_voice_connection_pending:sweep_expired_pending_joins(State), NewPending = maps:get(pending_voice_connections, NewState, #{}), ?assertEqual(0, maps:size(NewPending)). @@ -77,7 +77,7 @@ sweep_expired_pending_joins_missing_user_id_test() -> InvalidMetadata = #{guild_id => 999, channel_id => 100, expires_at => Now - 1000}, PendingConnections = #{<<"invalid-conn">> => InvalidMetadata}, State = maps:put(pending_voice_connections, PendingConnections, base_test_state()), - NewState = guild_voice_connection:sweep_expired_pending_joins(State), + NewState = guild_voice_connection_pending:sweep_expired_pending_joins(State), NewPending = maps:get(pending_voice_connections, NewState, #{}), ?assertNot(maps:is_key(<<"invalid-conn">>, NewPending)). @@ -145,21 +145,6 @@ resolve_guild_identity_does_not_use_zero_state_id_test() -> guild_voice_connection_util:resolve_guild_identity(#{id => <<"007">>, data => #{}}) ). -normalize_session_id_test() -> - ?assertEqual(undefined, guild_voice_connection_util:normalize_session_id(undefined)), - ?assertEqual(undefined, guild_voice_connection_util:normalize_session_id(null)), - ?assertEqual(<<"abc">>, guild_voice_connection_util:normalize_session_id(<<"abc">>)), - ?assertEqual(<<"123">>, guild_voice_connection_util:normalize_session_id(123)), - ?assertEqual(<<"test">>, guild_voice_connection_util:normalize_session_id("test")). - -normalize_boolean_test() -> - ?assertEqual(true, guild_voice_connection_normalize:normalize_boolean(true)), - ?assertEqual(true, guild_voice_connection_normalize:normalize_boolean(<<"true">>)), - ?assertEqual(false, guild_voice_connection_normalize:normalize_boolean(false)), - ?assertEqual(false, guild_voice_connection_normalize:normalize_boolean(<<"false">>)), - ?assertEqual(false, guild_voice_connection_normalize:normalize_boolean(<<"other">>)), - ?assertEqual(false, guild_voice_connection_normalize:normalize_boolean(123)). - resolve_voice_state_from_pending_uses_stored_voice_state_test() -> VoiceState = #{ <<"user_id">> => <<"5">>, <<"guild_id">> => <<"999">>, <<"channel_id">> => <<"100">> @@ -209,28 +194,6 @@ resolve_voice_state_from_pending_rejects_leading_zero_pending_ids_test() -> ), ?assertEqual(undefined, Result). -get_pending_joins_for_channel_ignores_missing_user_id_test() -> - State = #{ - pending_voice_connections => #{ - <<"missing-user">> => #{channel_id => 100}, - <<"valid">> => #{channel_id => 100, user_id => 42, token_nonce => <<"nonce">>} - } - }, - {reply, #{pending_joins := PendingJoins}, _} = guild_voice_handler:handle_call( - {get_pending_joins_for_channel, <<"100">>}, {self(), make_ref()}, State - ), - ?assertEqual( - [ - #{ - connection_id => <<"valid">>, - user_id => <<"42">>, - token_nonce => <<"nonce">>, - expires_at => 0 - } - ], - PendingJoins - ). - validate_pending_nonce_valid_test() -> Now = erlang:system_time(millisecond), PendingData = #{ @@ -286,11 +249,6 @@ validate_pending_nonce_missing_expires_at_test() -> ) ). -pending_get_value_test() -> - Data = #{key1 => value1, <<"key2">> => value2}, - ?assertEqual(value1, maps:get(key1, Data)), - ?assertEqual(value2, maps:get(<<"key2">>, Data)). - check_pending_expiry_rejects_entry_without_any_timing_test() -> PendingData = #{ user_id => 10, diff --git a/fluxer_gateway/test/guild_voice_p2p_tests.erl b/fluxer_gateway/test/guild_voice_p2p_tests.erl index 9e3312806..1b507959f 100644 --- a/fluxer_gateway/test/guild_voice_p2p_tests.erl +++ b/fluxer_gateway/test/guild_voice_p2p_tests.erl @@ -230,7 +230,7 @@ livekit_restore_into_a_mesh_is_refused_test() -> } }, {reply, Reply, State3} = - guild_voice_connection:confirm_voice_connection_from_livekit( + guild_voice_connection_confirm:confirm_voice_connection_from_livekit( #{connection_id => <<"stale">>}, State2 ), ?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply), @@ -254,7 +254,7 @@ livekit_activation_into_a_mesh_is_refused_test() -> State1 ), {reply, Reply, State3} = - guild_voice_connection:confirm_voice_connection_from_livekit( + guild_voice_connection_confirm:confirm_voice_connection_from_livekit( #{connection_id => <<"late">>, token_nonce => <<"nonce">>}, State2 ), ?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply), @@ -290,7 +290,7 @@ moderator_move_into_a_mesh_is_refused_test() -> with_stubs(grant, fun(State0) -> {Mesh, State1} = join_mesh([10, 11], State0), State2 = with_sfu_voice_state(<<"moved">>, 12, ?OTHER_CHANNEL_ID, State1), - {reply, Reply, State3} = guild_voice:move_member( + {reply, Reply, State3} = guild_voice_move:move_member( move_request(12, ?CHANNEL_ID), State2 ), ?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply), @@ -301,7 +301,7 @@ moderator_move_into_a_mesh_is_refused_test() -> moderator_move_into_an_empty_pinned_channel_moves_on_sfu_test() -> with_stubs(grant, fun(State0) -> State1 = with_sfu_voice_state(<<"moved">>, 12, ?OTHER_CHANNEL_ID, State0), - {reply, Reply, _State2} = guild_voice:move_member( + {reply, Reply, _State2} = guild_voice_move:move_member( move_request(12, ?PINNED_CHANNEL_ID), State1 ), ?assertMatch(#{success := true, needs_token := true}, Reply) @@ -311,7 +311,7 @@ moderator_move_out_of_a_mesh_gets_an_sfu_grant_test() -> with_stubs(grant, fun(State0) -> {[ConnectionA, ConnectionB], State1} = join_mesh([10, 11], State0), {reply, #{success := true, needs_token := true, session_data := SessionData}, State2} = - guild_voice:move_member(move_request(11, ?OTHER_CHANNEL_ID), State1), + guild_voice_move:move_member(move_request(11, ?OTHER_CHANNEL_ID), State1), ?assertEqual([ConnectionA], maps:keys(maps:get(voice_states, State2))), ?assertEqual(true, p2p_flag(ConnectionA, State2)), GuildPid = maps:get(guild_pid, State2), diff --git a/fluxer_gateway/test/guild_voice_state_tests.erl b/fluxer_gateway/test/guild_voice_state_tests.erl index 54db48b37..23bff814b 100644 --- a/fluxer_gateway/test/guild_voice_state_tests.erl +++ b/fluxer_gateway/test/guild_voice_state_tests.erl @@ -5,44 +5,6 @@ -include_lib("eunit/include/eunit.hrl"). -user_matches_voice_state_integer_test() -> - VoiceState = #{<<"user_id">> => <<"10">>}, - ?assert(guild_voice_state:user_matches_voice_state(VoiceState, 10)), - ?assertNot(guild_voice_state:user_matches_voice_state(VoiceState, 11)). - -user_matches_voice_state_binary_test() -> - VoiceState = #{<<"user_id">> => <<"123">>}, - ?assert(guild_voice_state:user_matches_voice_state(VoiceState, <<"123">>)), - ?assertNot(guild_voice_state:user_matches_voice_state(VoiceState, <<"456">>)). - -user_matches_voice_state_undefined_test() -> - VoiceState = #{}, - ?assertNot(guild_voice_state:user_matches_voice_state(VoiceState, 10)). - -extract_session_info_from_voice_state_test() -> - VoiceState = #{ - <<"session_id">> => <<"sess">>, - <<"self_mute">> => true, - <<"self_deaf">> => false, - <<"self_video">> => true, - <<"self_stream">> => false, - <<"is_mobile">> => true, - <<"suppress">> => true, - <<"e2ee_capable">> => true, - <<"latitude">> => <<"1.0">>, - <<"longitude">> => <<"2.0">>, - <<"member">> => #{<<"id">> => <<"m">>} - }, - Info = guild_voice_state:extract_session_info_from_voice_state(<<"conn">>, VoiceState), - ?assertEqual(<<"conn">>, maps:get(connection_id, Info)), - ?assertEqual(<<"sess">>, maps:get(session_id, Info)), - ?assertEqual(true, maps:get(self_mute, Info)), - ?assertEqual(true, maps:get(suppress, Info)), - ?assertEqual(true, maps:get(e2ee_capable, Info)), - ?assertEqual(<<"1.0">>, maps:get(latitude, Info)), - ?assertEqual(<<"2.0">>, maps:get(longitude, Info)), - ?assertEqual(#{<<"id">> => <<"m">>}, maps:get(member, Info)). - update_voice_state_data_no_change_reply_is_external_test() -> ExistingVS = #{ <<"guild_id">> => <<"1">>, diff --git a/fluxer_gateway/test/presence_cache_tests.erl b/fluxer_gateway/test/presence_cache_tests.erl index a7e33e994..ea119a187 100644 --- a/fluxer_gateway/test/presence_cache_tests.erl +++ b/fluxer_gateway/test/presence_cache_tests.erl @@ -8,7 +8,7 @@ -define(TEST_PENDING_CAP, 10000). put_and_get_visible_status_test() -> - {ok, Pid} = maybe_start_for_test(), + {ok, Pid} = start_presence_cache(), Presence = #{<<"status">> => <<"online">>}, ?assertEqual(ok, presence_cache:put(1, Presence)), _ = sys:get_state(Pid), @@ -16,7 +16,7 @@ put_and_get_visible_status_test() -> ?assertEqual(ok, gen_server:stop(Pid)). put_offline_evicted_test() -> - {ok, Pid} = maybe_start_for_test(), + {ok, Pid} = start_presence_cache(), Presence = #{<<"status">> => <<"offline">>}, ?assertEqual(ok, presence_cache:put(2, Presence)), _ = sys:get_state(Pid), @@ -24,7 +24,7 @@ put_offline_evicted_test() -> ?assertEqual(ok, gen_server:stop(Pid)). bulk_get_across_shards_test() -> - {ok, Pid} = maybe_start_for_test(), + {ok, Pid} = start_presence_cache(), Visible = #{<<"status">> => <<"online">>, <<"user">> => #{<<"id">> => <<"3">>}}, presence_cache:put(3, Visible), presence_cache:put(4, Visible), @@ -33,22 +33,6 @@ bulk_get_across_shards_test() -> ?assertEqual(2, length(Results)), ?assertEqual(ok, gen_server:stop(Pid)). -put_pending_operation_overwrites_test() -> - Pending0 = #{100 => delete}, - Pending1 = Pending0#{100 => {put, #{<<"status">> => <<"online">>}}}, - ?assertEqual({put, #{<<"status">> => <<"online">>}}, maps:get(100, Pending1)). - -merge_rebalance_operations_prefers_pending_test() -> - Snapshot = #{42 => #{<<"status">> => <<"online">>}}, - SnapshotOps = #{42 => {put, #{<<"status">> => <<"online">>}}}, - Pending = #{42 => delete, 43 => {put, #{<<"status">> => <<"idle">>}}}, - Operations = maps:merge( - SnapshotOps, presence_cache_rebalance:sanitize_pending_operations(Pending) - ), - ?assertEqual(delete, maps:get(42, Operations)), - ?assertEqual({put, #{<<"status">> => <<"idle">>}}, maps:get(43, Operations)), - _ = Snapshot. - sanitize_pending_operations_filters_invalid_entries_test() -> Pending = #{ 1 => delete, @@ -59,11 +43,11 @@ sanitize_pending_operations_filters_invalid_entries_test() -> }, ?assertEqual( #{1 => delete, 2 => {put, #{}}}, - presence_cache_rebalance:sanitize_pending_operations(Pending) + presence_cache_pending:sanitize_pending_operations(Pending) ). nodedown_grace_period_preserves_entries_test() -> - {ok, Pid} = maybe_start_for_test(), + {ok, Pid} = start_presence_cache(), Presence = #{<<"status">> => <<"online">>}, ?assertEqual(ok, presence_cache:put(100, Presence)), _ = sys:get_state(Pid), @@ -72,16 +56,8 @@ nodedown_grace_period_preserves_entries_test() -> ?assertMatch({ok, _}, presence_cache:get(100)), ?assertEqual(ok, gen_server:stop(Pid)). -nodeup_cancels_grace_period_test() -> - {ok, Pid} = maybe_start_for_test(), - gen_server:cast(presence_cache, {nodedown_grace, 'lost@node'}), - timer:sleep(50), - gen_server:cast(presence_cache, {nodeup_cancel_grace, 'lost@node'}), - timer:sleep(50), - ?assertEqual(ok, gen_server:stop(Pid)). - nodedown_grace_expiry_clears_pending_cleanup_test() -> - {ok, Pid} = maybe_start_for_test(), + {ok, Pid} = start_presence_cache(), ok = presence_cache:handle_nodedown('lost@node'), ?assertMatch(#{'lost@node' := _}, maps:get(pending_nodedown_cleanups, sys:get_state(Pid))), Pid ! {nodedown_grace_expired, 'lost@node'}, @@ -89,7 +65,7 @@ nodedown_grace_expiry_clears_pending_cleanup_test() -> ?assertEqual(ok, gen_server:stop(Pid)). generation_increments_on_write_test() -> - {ok, Pid} = maybe_start_for_test(), + {ok, Pid} = start_presence_cache(), Gen0 = presence_cache:generation(), ?assertEqual(ok, presence_cache:put(200, #{<<"status">> => <<"online">>})), _ = sys:get_state(Pid), @@ -100,13 +76,13 @@ generation_increments_on_write_test() -> cap_pending_operations_enforces_limit_test() -> Large = maps:from_list([{I, delete} || I <- lists:seq(1, 10500)]), BaseState = #{pending_operations => Large, pending_retry_timer => undefined}, - State1 = presence_cache_rebalance:ensure_pending_state(BaseState), + State1 = presence_cache_pending:ensure_pending_state(BaseState), ?assertEqual(10000, maps:size(maps:get(pending_operations, State1))). cap_pending_operations_no_op_under_limit_test() -> Small = #{1 => delete, 2 => {put, #{}}}, BaseState = #{pending_operations => Small, pending_retry_timer => undefined}, - State1 = presence_cache_rebalance:ensure_pending_state(BaseState), + State1 = presence_cache_pending:ensure_pending_state(BaseState), ?assertEqual(Small, maps:get(pending_operations, State1)). put_to_unreachable_remote_owner_queues_pending_operation_test() -> @@ -121,7 +97,7 @@ put_to_unreachable_remote_owner_queues_pending_operation_test() -> maps:get(UserId, maps:get(pending_operations, State1)) ) after - presence_cache_rebalance:cancel_pending_retry_timer(State1) + presence_cache_pending:cancel_pending_retry_timer(State1) end end). @@ -149,7 +125,7 @@ anti_entropy_digest_request_noop_when_digests_match_test() -> with_presence_cache(fun(Pid) -> State = cache_state(sys:get_state(Pid)), Digest = presence_cache_shards:content_digest(State), - {noreply, State1} = presence_cache_rebalance:handle_anti_entropy_digest_request( + {noreply, State1} = presence_cache_anti_entropy:handle_anti_entropy_digest_request( node(), Digest, State ), ?assertEqual(State, State1) @@ -171,13 +147,13 @@ assert_rebalance_keeps_local_on_failed_delete(RemoteNode, Pid) -> }, State0 = cache_state(sys:get_state(Pid)), {_Reply, State1} = presence_cache:put_local(UserId, Presence, State0), - State2 = presence_cache_rebalance:queue_pending_operation(UserId, delete, State1), + State2 = presence_cache_pending:queue_pending_operation(UserId, delete, State1), State3 = presence_cache_rebalance:rebalance_ownership(State2), try ?assertEqual(delete, maps:get(UserId, maps:get(pending_operations, State3))), ?assertMatch({{ok, Presence}, _}, presence_cache_ops:get_local(UserId, State3)) after - presence_cache_rebalance:cancel_pending_retry_timer(State3) + presence_cache_pending:cancel_pending_retry_timer(State3) end. evict_oldest_pending_drops_oldest_inserted_test() -> @@ -189,7 +165,7 @@ evict_oldest_pending_drops_oldest_inserted_test() -> }, State1 = lists:foldl( fun(UserId, AccState) -> - presence_cache_rebalance:queue_pending_operation(UserId, delete, AccState) + presence_cache_pending:queue_pending_operation(UserId, delete, AccState) end, State0, lists:seq(1, ?TEST_PENDING_CAP + 5) @@ -201,7 +177,7 @@ evict_oldest_pending_drops_oldest_inserted_test() -> ?assertNot(maps:is_key(5, Pending)), ?assert(maps:is_key(?TEST_PENDING_CAP + 5, Pending)) after - presence_cache_rebalance:cancel_pending_retry_timer(State1) + presence_cache_pending:cancel_pending_retry_timer(State1) end. anti_entropy_does_not_resurrect_deleted_presence_test() -> @@ -224,14 +200,18 @@ assert_delete_survives_stale_peer_entry(Pid) -> {_PutReply, State1} = presence_cache:put_local(UserId, Presence, State0), {_DeleteReply, State2} = presence_cache:delete_local(UserId, State1), ?assertMatch({not_found, _}, presence_cache_ops:get_local(UserId, State2)), - State3 = presence_cache_rebalance:merge_anti_entropy_entries(#{UserId => Presence}, State2), + State3 = presence_cache_anti_entropy:merge_anti_entropy_entries( + #{UserId => Presence}, State2 + ), ?assertMatch({not_found, _}, presence_cache_ops:get_local(UserId, State3)). assert_merge_repairs_missing_entry(Pid) -> UserId = 77002, Presence = anti_entropy_presence(UserId), State0 = cache_state(sys:get_state(Pid)), - State1 = presence_cache_rebalance:merge_anti_entropy_entries(#{UserId => Presence}, State0), + State1 = presence_cache_anti_entropy:merge_anti_entropy_entries( + #{UserId => Presence}, State0 + ), ?assertMatch({{ok, Presence}, _}, presence_cache_ops:get_local(UserId, State1)). assert_merge_resumes_after_expiry(Pid) -> @@ -243,7 +223,7 @@ assert_merge_resumes_after_expiry(Pid) -> Expired = State2#{ delete_tombstones => #{UserId => erlang:monotonic_time(millisecond) - 1} }, - State3 = presence_cache_rebalance:merge_anti_entropy_entries( + State3 = presence_cache_anti_entropy:merge_anti_entropy_entries( #{UserId => Presence}, Expired ), ?assertMatch({{ok, Presence}, _}, presence_cache_ops:get_local(UserId, State3)), @@ -283,7 +263,7 @@ assert_rebalance_drop_repairs(RemoteNode, Pid) -> end), ?assertMatch({not_found, _}, presence_cache_ops:get_local(UserId, State2)), with_presence_member_nodes([node()], fun() -> - State3 = presence_cache_rebalance:merge_anti_entropy_entries( + State3 = presence_cache_anti_entropy:merge_anti_entropy_entries( #{UserId => Presence}, State2 ), ?assertMatch({{ok, Presence}, _}, presence_cache_ops:get_local(UserId, State3)) @@ -296,16 +276,16 @@ assert_rebalance_delete_tombstones(RemoteNode, Pid) -> GoneUserId = remote_owned_user_id(RemoteNode), GonePresence = anti_entropy_presence(GoneUserId), {_PutReply, State1} = presence_cache:put_local(GoneUserId, GonePresence, State0), - State2 = presence_cache_rebalance:queue_pending_operation(GoneUserId, delete, State1), + State2 = presence_cache_pending:queue_pending_operation(GoneUserId, delete, State1), Deleted = with_reachable_remote(fun() -> presence_cache_rebalance:rebalance_ownership(State2) end), {GoneUserId, GonePresence, Deleted} end), - presence_cache_rebalance:cancel_pending_retry_timer(State3), + presence_cache_pending:cancel_pending_retry_timer(State3), ?assert(maps:is_key(UserId, maps:get(delete_tombstones, State3))), with_presence_member_nodes([node()], fun() -> - State4 = presence_cache_rebalance:merge_anti_entropy_entries( + State4 = presence_cache_anti_entropy:merge_anti_entropy_entries( #{UserId => Presence}, State3 ), ?assertMatch({not_found, _}, presence_cache_ops:get_local(UserId, State4)) @@ -318,7 +298,9 @@ assert_handle_delete_tombstones(Pid) -> {_PutReply, State1} = presence_cache:put_local(UserId, Presence, State0), State2 = presence_cache_ops:handle_delete(UserId, State1), ?assert(maps:is_key(UserId, maps:get(delete_tombstones, State2))), - State3 = presence_cache_rebalance:merge_anti_entropy_entries(#{UserId => Presence}, State2), + State3 = presence_cache_anti_entropy:merge_anti_entropy_entries( + #{UserId => Presence}, State2 + ), ?assertMatch({not_found, _}, presence_cache_ops:get_local(UserId, State3)). with_reachable_remote(Fun) -> @@ -339,7 +321,7 @@ anti_entropy_presence(UserId) -> with_local_presence_cache(Fun) -> with_presence_member_nodes([node()], fun() -> with_presence_cache(Fun) end). -maybe_start_for_test() -> +start_presence_cache() -> case whereis(presence_cache) of undefined -> presence_cache:start_link(); Existing when is_pid(Existing) -> {ok, Existing} @@ -349,7 +331,7 @@ base_pending_state() -> #{pending_operations => #{}, pending_retry_timer => undefined}. with_presence_cache(Fun) -> - {ok, Pid} = maybe_start_for_test(), + {ok, Pid} = start_presence_cache(), try Fun(Pid) after @@ -410,7 +392,7 @@ assert_rebalance_keeps_pending_put(RemoteNode, Pid) -> ), ?assertMatch({{ok, Presence}, _}, presence_cache_ops:get_local(UserId, State2)) after - presence_cache_rebalance:cancel_pending_retry_timer(State2) + presence_cache_pending:cancel_pending_retry_timer(State2) end. cache_state(State) when is_map(State) -> diff --git a/fluxer_gateway/test/process_registry_cleanup_tests.erl b/fluxer_gateway/test/process_registry_cleanup_tests.erl index 4ffafd090..90f3d0be4 100644 --- a/fluxer_gateway/test/process_registry_cleanup_tests.erl +++ b/fluxer_gateway/test/process_registry_cleanup_tests.erl @@ -23,77 +23,6 @@ cleanup_on_down_preserves_loading_test() -> ?assertEqual({AlivePid, Ref2}, maps:get(guild_3, Result)), ?assertEqual(false, maps:is_key(guild_1, Result)). -cleanup_on_down_multiple_loading_test() -> - DeadPid = list_to_pid("<0.100.0>"), - AlivePid = list_to_pid("<0.101.0>"), - Ref1 = make_ref(), - Ref2 = make_ref(), - - Map = #{ - guild_1 => {DeadPid, Ref1}, - guild_2 => loading, - guild_3 => {AlivePid, Ref2}, - guild_4 => loading, - guild_5 => loading - }, - - Result = process_registry:cleanup_on_down(DeadPid, Map), - - ?assertEqual(4, maps:size(Result)), - ?assertEqual(loading, maps:get(guild_2, Result)), - ?assertEqual(loading, maps:get(guild_4, Result)), - ?assertEqual(loading, maps:get(guild_5, Result)), - ?assertEqual({AlivePid, Ref2}, maps:get(guild_3, Result)), - ?assertEqual(false, maps:is_key(guild_1, Result)). - -cleanup_on_down_single_removal_test() -> - DeadPid = list_to_pid("<0.100.0>"), - AlivePid1 = list_to_pid("<0.101.0>"), - AlivePid2 = list_to_pid("<0.102.0>"), - Ref1 = make_ref(), - Ref2 = make_ref(), - Ref3 = make_ref(), - - Map = #{ - guild_1 => {AlivePid1, Ref1}, - guild_2 => {DeadPid, Ref2}, - guild_3 => {AlivePid2, Ref3} - }, - - Result = process_registry:cleanup_on_down(DeadPid, Map), - - ?assertEqual(2, maps:size(Result)), - ?assertEqual({AlivePid1, Ref1}, maps:get(guild_1, Result)), - ?assertEqual({AlivePid2, Ref3}, maps:get(guild_3, Result)), - ?assertEqual(false, maps:is_key(guild_2, Result)). - -cleanup_on_down_empty_test() -> - DeadPid = list_to_pid("<0.100.0>"), - Result = process_registry:cleanup_on_down(DeadPid, #{}), - ?assertEqual(#{}, Result). - -cleanup_on_down_only_loading_test() -> - DeadPid = list_to_pid("<0.100.0>"), - Map = #{ - guild_1 => loading, - guild_2 => loading - }, - Result = process_registry:cleanup_on_down(DeadPid, Map), - ?assertEqual(Map, Result). - -cleanup_on_down_pid_not_found_test() -> - DeadPid = list_to_pid("<0.100.0>"), - AlivePid = list_to_pid("<0.101.0>"), - Ref = make_ref(), - - Map = #{ - guild_1 => {AlivePid, Ref}, - guild_2 => loading - }, - - Result = process_registry:cleanup_on_down(DeadPid, Map), - ?assertEqual(Map, Result). - cleanup_on_down_duplicate_pids_test() -> DeadPid = list_to_pid("<0.100.0>"), Ref1 = make_ref(), diff --git a/fluxer_gateway/test/process_registry_integration_tests.erl b/fluxer_gateway/test/process_registry_integration_tests.erl index 1d23ac9df..64992f337 100644 --- a/fluxer_gateway/test/process_registry_integration_tests.erl +++ b/fluxer_gateway/test/process_registry_integration_tests.erl @@ -6,109 +6,6 @@ -define(REGISTRY_TABLE, process_registry_table). -integration_full_lifecycle_test() -> - process_registry:init(), - Id = 12345, - Key = process_registry:build_process_key(guild, Id), - ?assertEqual({guild, 12345}, Key), - - Pid = spawn_sleep(200), - {_Ref, Map1} = register_and_assert_count(Key, Pid, 1, #{}), - - Map2 = maps:put(guild_67890, loading, Map1), - ?assertEqual(1, process_registry:get_count(Map2)), - - OtherKey = process_registry:build_process_key(channel, 67890), - OtherPid = spawn_sleep(200), - ets:insert(?REGISTRY_TABLE, {OtherKey, OtherPid}), - {ok, OtherPid, _OtherRef, Map3} = process_registry:lookup_or_monitor( - OtherKey, channel_67890, Map2 - ), - ?assertEqual(2, process_registry:get_count(Map3)), - - Map4 = process_registry:cleanup_on_down(Pid, Map3), - ?assertEqual(1, process_registry:get_count(Map4)), - ?assertEqual(loading, maps:get(guild_67890, Map4)), - - process_registry:safe_unregister(Key), - process_registry:safe_unregister(OtherKey), - - ?assertEqual(undefined, process_registry:registry_whereis(Key)), - ?assertEqual(undefined, process_registry:registry_whereis(OtherKey)). - -integration_process_death_test_() -> - {timeout, 10, fun() -> - process_registry:init(), - Key = {test_integration, 5001}, - - Pid = spawn(fun death_probe_loop/0), - - {Ref, Map} = register_and_assert_count(Key, Pid, 1, #{}), - - Pid ! die, - - receive - {'DOWN', Ref, process, Pid, _Reason} -> - Map2 = process_registry:cleanup_on_down(Pid, Map), - ?assertEqual(0, process_registry:get_count(Map2)), - process_registry:safe_unregister(Key) - after 500 -> - ?assert(false) - end - end}. - -integration_race_conditions_test_() -> - {timeout, 10, fun() -> - process_registry:init(), - Key = {test_integration, 5002}, - - Parent = self(), - - FirstPid = spawn_sleep(300), - {_FirstRef, _Map1} = register_and_assert_count(Key, FirstPid, 1, #{}), - - Workers = [spawn_register_worker(Parent, Key) || _ <- lists:seq(1, 3)], - - Results = [ - receive - {register_result, R} -> R - after 1000 -> timeout - end - || _ <- Workers - ], - AllGotFirstPid = lists:all( - fun - ({ok, P, _, _}) -> P =:= FirstPid; - (_) -> false - end, - Results - ), - ?assert(AllGotFirstPid), - - process_registry:safe_unregister(Key) - end}. - -integration_rapid_cycles_test_() -> - {timeout, 10, fun() -> - process_registry:init(), - lists:foreach(fun rapid_cycle/1, lists:seq(1, 10)) - end}. - -force_stop_process_normal_test_() -> - {timeout, 15, fun() -> - Pid = spawn(fun wait_for_stop_loop/0), - ?assert(process_liveness:is_alive(Pid)), - process_registry:force_stop_process(Pid), - timer:sleep(50), - ?assertEqual(false, process_liveness:is_alive(Pid)) - end}. - -force_stop_process_already_dead_test() -> - Pid = spawn(fun() -> ok end), - timer:sleep(10), - ?assertEqual(false, process_liveness:is_alive(Pid)), - process_registry:force_stop_process(Pid). - force_stop_process_kills_unresponsive_test_() -> {timeout, 15, fun() -> Pid = spawn(fun trapped_wait_loop/0), @@ -136,20 +33,6 @@ register_and_monitor_duplicate_stops_loser_test_() -> spawn_sleep(Milliseconds) -> spawn(fun() -> timer:sleep(Milliseconds) end). -death_probe_loop() -> - receive - die -> exit(normal) - after 100 -> - exit(normal) - end. - -wait_for_stop_loop() -> - receive - stop -> ok - after infinity -> - ok - end. - trapped_wait_loop() -> process_flag(trap_exit, true), receive @@ -157,22 +40,3 @@ trapped_wait_loop() -> after infinity -> ok end. - -spawn_register_worker(Parent, Key) -> - spawn(fun() -> - NewPid = spawn_sleep(100), - Result = process_registry:register_and_monitor(Key, NewPid, #{}), - Parent ! {register_result, Result} - end). - -rapid_cycle(N) -> - Key = {test_rapid, N}, - Pid = spawn_sleep(50), - {_Ref, _Map} = register_and_assert_count(Key, Pid, 1, #{}), - process_registry:safe_unregister(Key), - ?assertEqual(undefined, process_registry:registry_whereis(Key)). - -register_and_assert_count(Key, Pid, Count, ProcessMap) -> - {ok, Pid, Ref, Map} = process_registry:register_and_monitor(Key, Pid, ProcessMap), - ?assertEqual(Count, process_registry:get_count(Map)), - {Ref, Map}. diff --git a/fluxer_gateway/test/process_registry_lookup_tests.erl b/fluxer_gateway/test/process_registry_lookup_tests.erl index 99134755b..411bfe8b5 100644 --- a/fluxer_gateway/test/process_registry_lookup_tests.erl +++ b/fluxer_gateway/test/process_registry_lookup_tests.erl @@ -6,107 +6,6 @@ -define(REGISTRY_TABLE, process_registry_table). -lookup_or_monitor_success_test() -> - process_registry:init(), - Key = {test_lookup, 3001}, - MapKey = test_key, - ProcessMap = #{}, - - Pid = spawn(fun() -> timer:sleep(200) end), - ets:insert(?REGISTRY_TABLE, {Key, Pid}), - - Result = process_registry:lookup_or_monitor(Key, MapKey, ProcessMap), - - ?assertMatch({ok, Pid, _Ref, _NewMap}, Result), - {ok, ReturnedPid, Ref, NewMap} = Result, - - ?assertEqual(Pid, ReturnedPid), - ?assert(is_reference(Ref)), - ?assertEqual({Pid, Ref}, maps:get(MapKey, NewMap)), - - process_registry:registry_unregister(Key). - -lookup_or_monitor_not_found_test() -> - process_registry:init(), - Key = {test_lookup, 3002}, - MapKey = test_key, - ProcessMap = #{}, - - Result = process_registry:lookup_or_monitor(Key, MapKey, ProcessMap), - ?assertEqual({error, not_found}, Result). - -lookup_or_monitor_existing_map_test() -> - process_registry:init(), - Key = {test_lookup, 3003}, - MapKey = new_key, - ExistingPid = list_to_pid("<0.100.0>"), - ExistingRef = make_ref(), - ProcessMap = #{existing_key => {ExistingPid, ExistingRef}}, - {_Pid, _Ref, NewMap} = lookup_registered(Key, MapKey, ProcessMap), - - ?assertEqual(2, maps:size(NewMap)), - ?assert(maps:is_key(existing_key, NewMap)), - ?assert(maps:is_key(MapKey, NewMap)), - - process_registry:registry_unregister(Key). - -lookup_or_monitor_different_key_test() -> - process_registry:init(), - Key = {test_lookup, 3004}, - MapKey = different_key_name, - ProcessMap = #{}, - {Pid, Ref, NewMap} = lookup_registered(Key, MapKey, ProcessMap), - - ?assertEqual({Pid, Ref}, maps:get(MapKey, NewMap)), - ?assertEqual(false, maps:is_key(Key, NewMap)), - - process_registry:registry_unregister(Key). - -lookup_or_monitor_dead_process_test() -> - process_registry:init(), - Key = {test_lookup, 3005}, - MapKey = test_key, - ProcessMap = #{}, - - Pid = spawn(fun() -> ok end), - timer:sleep(10), - ets:insert(?REGISTRY_TABLE, {Key, Pid}), - - Result = process_registry:lookup_or_monitor(Key, MapKey, ProcessMap), - ?assertEqual({error, not_found}, Result). - -safe_unregister_registered_test() -> - process_registry:init(), - Key = {test_unreg, 4001}, - - Pid = spawn(fun() -> timer:sleep(100) end), - ets:insert(?REGISTRY_TABLE, {Key, Pid}), - - ?assertEqual(Pid, process_registry:registry_whereis(Key)), - ?assertEqual(ok, process_registry:safe_unregister(Key)), - ?assertEqual(undefined, process_registry:registry_whereis(Key)). - -safe_unregister_unregistered_test() -> - process_registry:init(), - ?assertEqual(ok, process_registry:safe_unregister({test_unreg, 4002})). - -safe_unregister_multiple_test() -> - process_registry:init(), - Key = {test_unreg, 4003}, - - Pid = spawn(fun() -> timer:sleep(100) end), - ets:insert(?REGISTRY_TABLE, {Key, Pid}), - - ?assertEqual(ok, process_registry:safe_unregister(Key)), - ?assertEqual(ok, process_registry:safe_unregister(Key)), - ?assertEqual(ok, process_registry:safe_unregister(Key)). - -safe_unregister_edge_cases_test() -> - process_registry:init(), - ?assertEqual(ok, process_registry:safe_unregister({test_unreg, 4004})), - ?assertEqual(ok, process_registry:safe_unregister({test_unreg, 4005})), - ?assertEqual(ok, process_registry:safe_unregister({test_unreg, 4006})). - lookup_or_monitor_reuses_existing_ref_for_same_pid_test() -> process_registry:init(), Key = {test_lookup, 3006}, @@ -136,9 +35,3 @@ safe_unregister_pid_conditional_keeps_other_owner_test() -> ?assertEqual(ok, process_registry:safe_unregister(Key, NewPid)), ?assertEqual([], ets:lookup(?REGISTRY_TABLE, Key)). - -lookup_registered(Key, MapKey, ProcessMap) -> - Pid = spawn(fun() -> timer:sleep(200) end), - ets:insert(?REGISTRY_TABLE, {Key, Pid}), - {ok, Pid, Ref, NewMap} = process_registry:lookup_or_monitor(Key, MapKey, ProcessMap), - {Pid, Ref, NewMap}. diff --git a/fluxer_gateway/test/process_registry_tests.erl b/fluxer_gateway/test/process_registry_tests.erl index f543da8bc..c4de37914 100644 --- a/fluxer_gateway/test/process_registry_tests.erl +++ b/fluxer_gateway/test/process_registry_tests.erl @@ -6,47 +6,6 @@ -define(REGISTRY_TABLE, process_registry_table). -register_and_monitor_success_test() -> - process_registry:init(), - Key = {test_reg, 1001}, - ProcessMap = #{}, - - Pid = spawn_sleep(100), - - Result = process_registry:register_and_monitor(Key, Pid, ProcessMap), - - ?assertMatch({ok, Pid, _Ref, _NewMap}, Result), - {ok, ReturnedPid, Ref, NewMap} = Result, - - ?assertEqual(Pid, ReturnedPid), - ?assertEqual(Pid, process_registry:registry_whereis(Key)), - - ?assertEqual(1, maps:size(NewMap)), - ?assertEqual({Pid, Ref}, maps:get(Key, NewMap)), - - ?assert(is_reference(Ref)), - - process_registry:registry_unregister(Key). - -register_and_monitor_existing_map_test() -> - process_registry:init(), - Key = {test_reg, 1002}, - ExistingPid = list_to_pid("<0.100.0>"), - ExistingRef = make_ref(), - ProcessMap = #{other_process => {ExistingPid, ExistingRef}}, - - Pid = spawn_sleep(100), - - {ok, _ReturnedPid, _Ref, NewMap} = process_registry:register_and_monitor( - Key, Pid, ProcessMap - ), - - ?assertEqual(2, maps:size(NewMap)), - ?assert(maps:is_key(other_process, NewMap)), - ?assert(maps:is_key(Key, NewMap)), - - process_registry:registry_unregister(Key). - register_and_monitor_race_condition_test() -> process_registry:init(), Key = {test_reg, 1003}, @@ -72,41 +31,6 @@ register_and_monitor_race_condition_test() -> process_registry:registry_unregister(Key). -register_and_monitor_race_dead_test() -> - process_registry:init(), - Key = {test_reg, 1004}, - ProcessMap = #{}, - - DeadPid = spawn(fun() -> ok end), - timer:sleep(10), - ?assertEqual(false, process_liveness:is_alive(DeadPid)), - - NewPid = spawn_sleep(100), - - Result = process_registry:register_and_monitor(Key, NewPid, ProcessMap), - ?assertMatch({ok, NewPid, _Ref, _NewMap}, Result), - - process_registry:registry_unregister(Key). - -register_and_monitor_dead_process_test() -> - process_registry:init(), - Key = {test_reg, 1005}, - ProcessMap = #{}, - - DeadPid = spawn(fun() -> exit(normal) end), - timer:sleep(10), - ?assertEqual(false, process_liveness:is_alive(DeadPid)), - - Result = process_registry:register_and_monitor(Key, DeadPid, ProcessMap), - - case Result of - {ok, DeadPid, _Ref, _NewMap} -> - ok; - {error, _} -> - ok - end, - process_registry:registry_unregister(Key). - register_and_monitor_concurrent_test_() -> {timeout, 10, fun() -> process_registry:init(), @@ -140,14 +64,6 @@ register_and_monitor_concurrent_test_() -> process_registry:registry_unregister(Key) end}. -registry_whereis_alive_test() -> - process_registry:init(), - Key = {test_whereis, 2001}, - Pid = spawn_sleep(200), - ets:insert(?REGISTRY_TABLE, {Key, Pid}), - ?assertEqual(Pid, process_registry:registry_whereis(Key)), - process_registry:registry_unregister(Key). - registry_whereis_dead_test() -> process_registry:init(), Key = {test_whereis, 2002}, @@ -156,20 +72,9 @@ registry_whereis_dead_test() -> ets:insert(?REGISTRY_TABLE, {Key, Pid}), ?assertEqual(undefined, process_registry:registry_whereis(Key)). -registry_whereis_not_found_test() -> - process_registry:init(), - ?assertEqual(undefined, process_registry:registry_whereis({test_whereis, 2003})). - spawn_sleep(Milliseconds) -> spawn(fun() -> timer:sleep(Milliseconds) end). -spawn_register_worker(Parent, Key) -> - spawn(fun() -> - Pid = spawn_sleep(200), - Result = process_registry:register_and_monitor(Key, Pid, #{}), - Parent ! {self(), Result} - end). - assert_success_results_share_pid([{ok, FirstPid, _, _} | RestResults]) -> AllSamePid = lists:all( fun(Result) -> success_pid_matches(FirstPid, Result) end, @@ -181,3 +86,10 @@ success_pid_matches(FirstPid, {ok, Pid, _, _}) -> Pid =:= FirstPid; success_pid_matches(_FirstPid, _Result) -> false. + +spawn_register_worker(Parent, Key) -> + spawn(fun() -> + Pid = spawn_sleep(200), + Result = process_registry:register_and_monitor(Key, Pid, #{}), + Parent ! {self(), Result} + end). diff --git a/fluxer_gateway/test/push_ets_cache_tests.erl b/fluxer_gateway/test/push_ets_cache_tests.erl index 4e59fb993..f5aa6d193 100644 --- a/fluxer_gateway/test/push_ets_cache_tests.erl +++ b/fluxer_gateway/test/push_ets_cache_tests.erl @@ -4,50 +4,6 @@ -typing([eqwalizer]). -include_lib("eunit/include/eunit.hrl"). -init_creates_tables_test() -> - cleanup_tables(), - ok = push_ets_cache:init(), - ?assertNotEqual(undefined, ets:whereis(push_user_guild_settings)), - ?assertNotEqual(undefined, ets:whereis(push_blocked_ids)), - cleanup_tables(). - -init_idempotent_test() -> - cleanup_tables(), - ok = push_ets_cache:init(), - ok = push_ets_cache:init(), - ?assertNotEqual(undefined, ets:whereis(push_user_guild_settings)), - cleanup_tables(). - -user_guild_settings_test() -> - cleanup_tables(), - ok = push_ets_cache:init(), - ?assertEqual(undefined, push_ets_cache:get_user_guild_settings(1, 2)), - ok = push_ets_cache:put_user_guild_settings(1, 2, #{muted => true}), - ?assertEqual(#{muted => true}, push_ets_cache:get_user_guild_settings(1, 2)), - ok = push_ets_cache:delete_user_guild_settings(1, 2), - ?assertEqual(undefined, push_ets_cache:get_user_guild_settings(1, 2)), - cleanup_tables(). - -blocked_ids_test() -> - cleanup_tables(), - ok = push_ets_cache:init(), - ?assertEqual(undefined, push_ets_cache:get_blocked_ids(1)), - ok = push_ets_cache:put_blocked_ids(1, [2, 3]), - ?assertEqual([2, 3], push_ets_cache:get_blocked_ids(1)), - cleanup_tables(). - -cache_stats_test() -> - cleanup_tables(), - ok = push_ets_cache:init(), - ok = push_ets_cache:put_blocked_ids(1, []), - ok = push_ets_cache:put_blocked_ids(2, []), - Stats = push_ets_cache:cache_stats(), - ?assertEqual( - #{blocked_ids_size => 2, user_guild_settings_size => 0}, - Stats - ), - cleanup_tables(). - evict_tables_test() -> cleanup_tables(), ok = push_ets_cache:init(), diff --git a/fluxer_gateway/test/rpc_client_tests.erl b/fluxer_gateway/test/rpc_client_tests.erl index bae66be13..547e3e4a3 100644 --- a/fluxer_gateway/test/rpc_client_tests.erl +++ b/fluxer_gateway/test/rpc_client_tests.erl @@ -24,21 +24,3 @@ is_retryable_4xx_test() -> ?assertNot(rpc_client:is_retryable({rpc_error, 401, <<"Unauthorized">>})), ?assertNot(rpc_client:is_retryable({rpc_error, 404, <<"Not found">>})), ?assertNot(rpc_client:is_retryable({rpc_error, 429, <<"Rate limited">>})). - -backoff_delay_exponential_test() -> - Config = {3, 1000, 30000, 0}, - ?assertEqual(1000, rpc_client:backoff_delay(1, Config)), - ?assertEqual(2000, rpc_client:backoff_delay(2, Config)), - ?assertEqual(4000, rpc_client:backoff_delay(3, Config)). - -backoff_delay_caps_at_max_test() -> - Config = {3, 1000, 3000, 0}, - ?assertEqual(1000, rpc_client:backoff_delay(1, Config)), - ?assertEqual(2000, rpc_client:backoff_delay(2, Config)), - ?assertEqual(3000, rpc_client:backoff_delay(3, Config)). - -backoff_delay_includes_jitter_test() -> - Config = {3, 1000, 30000, 500}, - Delay = rpc_client:backoff_delay(1, Config), - ?assert(Delay >= 1000), - ?assert(Delay =< 1500). diff --git a/fluxer_gateway/test/session_manager_routing_tests.erl b/fluxer_gateway/test/session_manager_routing_tests.erl deleted file mode 100644 index 5a5ccdaed..000000000 --- a/fluxer_gateway/test/session_manager_routing_tests.erl +++ /dev/null @@ -1,13 +0,0 @@ -%% SPDX-License-Identifier: AGPL-3.0-or-later - --module(session_manager_routing_tests). --typing([eqwalizer]). - --include_lib("eunit/include/eunit.hrl"). - -start_call_with_drain_guard_rejects_during_drain_test() -> - State = #{shards => #{}, shard_count => 1}, - ?assertEqual( - {{error, draining}, State}, - session_manager_routing:start_call_with_drain_guard(true, #{}, self(), State) - ). diff --git a/fluxer_gateway/test/session_manager_shard_drain_tests.erl b/fluxer_gateway/test/session_manager_shard_drain_tests.erl index a372d53ee..28ae3151e 100644 --- a/fluxer_gateway/test/session_manager_shard_drain_tests.erl +++ b/fluxer_gateway/test/session_manager_shard_drain_tests.erl @@ -45,9 +45,9 @@ build_session_data_carries_thread_channels_capability_test() -> ?assertEqual(false, maps:get(thread_channels_capable, Unflagged)). session_rpc_sends_thread_channels_capable_only_when_flagged_test() -> - meck:new(api_rpc_client, [passthrough, no_link]), + meck:new(rpc_client, [passthrough, no_link]), Self = self(), - meck:expect(api_rpc_client, call_with_retry, fun(Request, _Config) -> + meck:expect(rpc_client, call_with_retry, fun(Request, _Config) -> Self ! {session_rpc_request, Request}, {ok, #{}} end), @@ -62,7 +62,7 @@ session_rpc_sends_thread_channels_capable_only_when_flagged_test() -> ), ?assertNot(maps:is_key(<<"thread_channels_capable">>, receive_session_rpc_request())) after - meck:unload(api_rpc_client) + meck:unload(rpc_client) end. receive_session_rpc_request() -> diff --git a/fluxer_gateway/test/session_manager_shard_lifecycle_tests.erl b/fluxer_gateway/test/session_manager_shard_lifecycle_tests.erl index 95415294c..88c27b8d6 100644 --- a/fluxer_gateway/test/session_manager_shard_lifecycle_tests.erl +++ b/fluxer_gateway/test/session_manager_shard_lifecycle_tests.erl @@ -8,7 +8,6 @@ build_test_state(Overrides) -> Base = #{ sessions => #{}, - identify_attempts => [], pending_identifies => #{}, identify_workers => #{}, shard_index => 0 @@ -33,17 +32,6 @@ test_proc_loop() -> after 30000 -> ok end. --spec test_from() -> gen_server:from(). -test_from() -> - {self(), make_ref()}. - -handle_start_call_rejects_during_drain_test() -> - State = build_test_state(#{}), - ?assertEqual( - {reply, {error, draining}, State}, - session_manager_shard_lifecycle:handle_start_call(true, #{}, self(), test_from(), State) - ). - identify_fetch_timeout_replies_and_cleans_worker_test() -> SessionId = <<"identify-timeout-session">>, WorkerPid = spawn_test_proc(), diff --git a/fluxer_gateway/test/session_manager_shard_lookup_tests.erl b/fluxer_gateway/test/session_manager_shard_lookup_tests.erl index b73dc353e..68562cb7b 100644 --- a/fluxer_gateway/test/session_manager_shard_lookup_tests.erl +++ b/fluxer_gateway/test/session_manager_shard_lookup_tests.erl @@ -5,36 +5,6 @@ -include_lib("eunit/include/eunit.hrl"). -normalize_status_test() -> - ?assertEqual(undefined, session_manager_shard_lookup:normalize_status(undefined)), - ?assertEqual(undefined, session_manager_shard_lookup:normalize_status(null)), - ?assertEqual(<<"online">>, session_manager_shard_lookup:normalize_status(<<"online">>)), - ?assertEqual(<<"idle">>, session_manager_shard_lookup:normalize_status(<<"idle">>)), - ?assertEqual( - <<"invisible">>, session_manager_shard_lookup:normalize_status(<<"invisible">>) - ), - ?assertEqual(undefined, session_manager_shard_lookup:normalize_status(123)), - ok. - -select_initial_status_test() -> - ?assertEqual( - <<"idle">>, session_manager_shard_lookup:select_initial_status(undefined, <<"idle">>) - ), - ?assertEqual( - <<"dnd">>, session_manager_shard_lookup:select_initial_status(<<"unknown">>, <<"dnd">>) - ), - ?assertEqual( - <<"idle">>, session_manager_shard_lookup:select_initial_status(<<"online">>, <<"idle">>) - ), - ?assertEqual( - <<"online">>, - session_manager_shard_lookup:select_initial_status(<<"online">>, <<"online">>) - ), - ?assertEqual( - <<"dnd">>, session_manager_shard_lookup:select_initial_status(<<"dnd">>, <<"online">>) - ), - ok. - parse_presence_preserves_invisible_stored_status_test() -> Data = #{<<"user_settings">> => #{<<"status">> => <<"invisible">>}}, IdentifyData = #{presence => #{<<"status">> => <<"online">>}}, @@ -45,32 +15,6 @@ parse_presence_preserves_invisible_identify_status_test() -> IdentifyData = #{presence => #{<<"status">> => <<"invisible">>}}, ?assertEqual(invisible, session_manager_shard_lookup:parse_presence(Data, IdentifyData)). -normalize_coordinate_test() -> - ?assertEqual(undefined, session_manager_shard_lookup:normalize_coordinate(undefined)), - ?assertEqual(undefined, session_manager_shard_lookup:normalize_coordinate(null)), - ?assertEqual(1.5, session_manager_shard_lookup:normalize_coordinate(1.5)), - ?assertEqual(<<"test">>, session_manager_shard_lookup:normalize_coordinate(<<"test">>)), - ok. - -add_coordinates_test() -> - Base = #{<<"type">> => <<"session">>}, - ?assertEqual( - Base, session_manager_shard_lookup:add_coordinates(Base, undefined, undefined) - ), - ?assertEqual( - #{<<"type">> => <<"session">>, <<"latitude">> => 1.0}, - session_manager_shard_lookup:add_coordinates(Base, 1.0, undefined) - ), - ?assertEqual( - #{<<"type">> => <<"session">>, <<"longitude">> => 2.0}, - session_manager_shard_lookup:add_coordinates(Base, undefined, 2.0) - ), - ?assertEqual( - #{<<"type">> => <<"session">>, <<"latitude">> => 1.0, <<"longitude">> => 2.0}, - session_manager_shard_lookup:add_coordinates(Base, 1.0, 2.0) - ), - ok. - validate_transfer_token_test() -> GoodToken = <<"good_token">>, GoodHash = utils:hash_token(GoodToken), @@ -103,12 +47,6 @@ lookup_or_rehydrate_restores_state_on_invalid_token_test() -> ?assertEqual({ok, TransferState}, session_state_transfer:pop_state(SessionId)) end). -lookup_remote_on_nodes_empty_test() -> - ?assertEqual( - {error, not_found}, - session_manager_shard_lookup:lookup_remote_on_nodes([], {session, <<"missing">>}) - ). - select_remote_session_pid_ignores_not_found_entries_test() -> Pid = spawn(fun() -> receive @@ -151,65 +89,9 @@ remote_lookup_nodes_prioritizes_hash_owner_test() -> ?assertNot(lists:member(node(), LookupNodes)), ?assertEqual(lists:sort([RemoteA, RemoteB]), lists:sort(LookupNodes)). -get_presence_custom_status_test() -> - ?assertEqual(null, session_manager_shard_lookup:get_presence_custom_status(null)), - ?assertEqual(null, session_manager_shard_lookup:get_presence_custom_status(#{})), - ?assertEqual( - #{<<"text">> => <<"hello">>}, - session_manager_shard_lookup:get_presence_custom_status( - #{<<"custom_status">> => #{<<"text">> => <<"hello">>}} - ) - ), - ?assertEqual(null, session_manager_shard_lookup:get_presence_custom_status(not_a_map)), - ok. - -parse_guild_ids_extracts_from_guilds_array_test() -> - Data = #{ - <<"guilds">> => [ - #{<<"id">> => <<"100">>}, - #{<<"id">> => <<"200">>}, - #{<<"id">> => <<"300">>} - ] - }, - ?assertEqual([100, 200, 300], session_manager_shard_lookup:parse_guild_ids(Data)). - -parse_guild_ids_returns_empty_when_no_guilds_test() -> - ?assertEqual([], session_manager_shard_lookup:parse_guild_ids(#{})), - ?assertEqual([], session_manager_shard_lookup:parse_guild_ids(#{<<"guilds">> => []})). - -parse_guild_ids_skips_non_map_entries_test() -> - Data = #{ - <<"guilds">> => [ - #{<<"id">> => <<"100">>}, - <<"not_a_map">>, - #{<<"id">> => <<"200">>} - ] - }, - ?assertEqual([100, 200], session_manager_shard_lookup:parse_guild_ids(Data)). - -parse_guild_ids_skips_entries_without_id_test() -> - Data = #{ - <<"guilds">> => [ - #{<<"id">> => <<"100">>}, - #{<<"name">> => <<"no_id">>}, - #{<<"id">> => <<"200">>} - ] - }, - ?assertEqual([100, 200], session_manager_shard_lookup:parse_guild_ids(Data)). - -parse_guild_ids_handles_unavailable_guilds_test() -> - Data = #{ - <<"guilds">> => [ - #{<<"id">> => <<"100">>, <<"unavailable">> => true}, - #{<<"id">> => <<"200">>} - ] - }, - ?assertEqual([100, 200], session_manager_shard_lookup:parse_guild_ids(Data)). - new_test_state() -> #{ sessions => #{}, - identify_attempts => [], pending_identifies => #{}, identify_workers => #{}, shard_index => 0 diff --git a/fluxer_gateway/test/session_manager_shard_start_tests.erl b/fluxer_gateway/test/session_manager_shard_start_tests.erl index dc1bbee1a..8ab106a62 100644 --- a/fluxer_gateway/test/session_manager_shard_start_tests.erl +++ b/fluxer_gateway/test/session_manager_shard_start_tests.erl @@ -8,7 +8,6 @@ build_test_state() -> #{ sessions => #{}, - identify_attempts => [], pending_identifies => #{}, identify_workers => #{}, shard_index => 0 diff --git a/fluxer_gateway/test/session_ready_tests.erl b/fluxer_gateway/test/session_ready_tests.erl index c84247ac4..035e74ce0 100644 --- a/fluxer_gateway/test/session_ready_tests.erl +++ b/fluxer_gateway/test/session_ready_tests.erl @@ -6,7 +6,7 @@ -include_lib("eunit/include/eunit.hrl"). process_guild_state_unavailable_dispatches_guild_delete_test() -> - State0 = base_state_for_guild_dispatch_test(), + State0 = base_state_for_guild_dispatch(), GuildState = #{<<"id">> => <<"123">>, <<"unavailable">> => true}, {noreply, State1} = session_ready:process_guild_state(GuildState, State0), Buffer = limited_deque:to_list(maps:get(buffer, State1)), @@ -17,7 +17,7 @@ process_guild_state_unavailable_dispatches_guild_delete_test() -> ok. process_guild_state_available_dispatches_guild_create_test() -> - State0 = base_state_for_guild_dispatch_test(), + State0 = base_state_for_guild_dispatch(), GuildState = #{ <<"id">> => <<"123">>, <<"unavailable">> => false, @@ -34,7 +34,7 @@ process_guild_state_available_dispatches_guild_create_test() -> process_guild_state_bot_guild_create_marks_guild_available_test() -> drain_mailbox(), - State0 = (base_state_for_guild_dispatch_test())#{bot => true, socket_pid => self()}, + State0 = (base_state_for_guild_dispatch())#{bot => true, socket_pid => self()}, GuildState = #{<<"id">> => <<"123">>, <<"channels">> => [], <<"members">> => []}, {noreply, _State1} = session_ready:process_guild_state(GuildState, State0), receive @@ -47,7 +47,7 @@ process_guild_state_bot_guild_create_marks_guild_available_test() -> process_guild_state_bot_guild_create_after_join_omits_unavailable_test() -> drain_mailbox(), - State0 = (base_state_for_guild_dispatch_test())#{bot => true, socket_pid => self()}, + State0 = (base_state_for_guild_dispatch())#{bot => true, socket_pid => self()}, {noreply, State1} = session:handle_cast({guild_join, 123}, State0), GuildState = #{<<"id">> => <<"123">>, <<"channels">> => [], <<"members">> => []}, {noreply, State2} = session_ready:process_guild_state(GuildState, State1), @@ -69,7 +69,7 @@ process_guild_state_bot_guild_create_after_join_omits_unavailable_test() -> process_guild_state_bot_guild_create_after_join_and_outage_omits_unavailable_test() -> drain_mailbox(), - State0 = (base_state_for_guild_dispatch_test())#{bot => true, socket_pid => self()}, + State0 = (base_state_for_guild_dispatch())#{bot => true, socket_pid => self()}, {noreply, State1} = session:handle_cast({guild_join, 123}, State0), {noreply, State2} = session_ready:mark_guild_unavailable(123, State1), receive @@ -92,7 +92,7 @@ process_guild_state_bot_guild_create_after_join_and_outage_omits_unavailable_tes process_guild_state_bot_guild_create_after_join_and_removal_marks_guild_available_test() -> drain_mailbox(), - State0 = (base_state_for_guild_dispatch_test())#{ + State0 = (base_state_for_guild_dispatch())#{ bot => true, socket_pid => self(), user_id => 1 @@ -120,7 +120,7 @@ process_guild_state_bot_guild_create_after_join_and_removal_marks_guild_availabl relayed_guild_create_to_bot_marks_guild_available_test() -> drain_mailbox(), - State0 = (base_state_for_guild_dispatch_test())#{bot => true, socket_pid => self()}, + State0 = (base_state_for_guild_dispatch())#{bot => true, socket_pid => self()}, GuildState = #{<<"id">> => <<"123">>, <<"channels">> => [], <<"members">> => []}, {noreply, _State1} = session:handle_cast({dispatch, guild_create, GuildState}, State0), receive @@ -133,7 +133,7 @@ relayed_guild_create_to_bot_marks_guild_available_test() -> process_guild_state_user_guild_create_omits_unavailable_test() -> drain_mailbox(), - State0 = (base_state_for_guild_dispatch_test())#{bot => false, socket_pid => self()}, + State0 = (base_state_for_guild_dispatch())#{bot => false, socket_pid => self()}, GuildState = #{<<"id">> => <<"123">>, <<"channels">> => [], <<"members">> => []}, {noreply, _State1} = session_ready:process_guild_state(GuildState, State0), receive @@ -159,7 +159,7 @@ mark_guild_unavailable_hidden_includes_flag_test() -> mark_guild_unavailable_after_ready_dispatches_guild_delete_test() -> drain_mailbox(), - State0 = (base_state_for_guild_dispatch_test())#{ + State0 = (base_state_for_guild_dispatch())#{ guilds => #{777 => undefined}, socket_pid => self() }, @@ -562,8 +562,8 @@ base_ready_state(SessionId, UserId, Bot, Guilds, CollectedGuildStates) -> presence_pid => undefined }. --spec base_state_for_guild_dispatch_test() -> map(). -base_state_for_guild_dispatch_test() -> +-spec base_state_for_guild_dispatch() -> map(). +base_state_for_guild_dispatch() -> #{ guilds => #{}, ready => undefined, diff --git a/fluxer_gateway/test/voice_utils_tests.erl b/fluxer_gateway/test/voice_utils_tests.erl index d3fa042a3..52bcea592 100644 --- a/fluxer_gateway/test/voice_utils_tests.erl +++ b/fluxer_gateway/test/voice_utils_tests.erl @@ -4,49 +4,6 @@ -typing([eqwalizer]). -include_lib("eunit/include/eunit.hrl"). -build_voice_token_rpc_request_guild_test() -> - Req = voice_utils:build_voice_token_rpc_request(123, 456, 789, null, null, null), - ?assertEqual(<<"voice_get_token">>, maps:get(<<"type">>, Req)), - ?assertEqual(<<"123">>, maps:get(<<"guild_id">>, Req)), - ?assertEqual(<<"456">>, maps:get(<<"channel_id">>, Req)), - ?assertEqual(<<"789">>, maps:get(<<"user_id">>, Req)), - ?assertNot(maps:is_key(<<"connection_id">>, Req)). - -build_voice_token_rpc_request_dm_test() -> - Req = voice_utils:build_voice_token_rpc_request(null, 456, 789, null, null, null), - ?assertEqual(<<"voice_get_token">>, maps:get(<<"type">>, Req)), - ?assertNot(maps:is_key(<<"guild_id">>, Req)), - ?assertEqual(<<"456">>, maps:get(<<"channel_id">>, Req)). - -build_voice_token_rpc_request_with_connection_test() -> - Req = voice_utils:build_voice_token_rpc_request(123, 456, 789, <<"conn-id">>, null, null), - ?assertEqual(<<"conn-id">>, maps:get(<<"connection_id">>, Req)). - -build_voice_token_rpc_request_dm_with_connection_test() -> - Req = voice_utils:build_voice_token_rpc_request(null, 456, 789, <<"conn-id">>, null, null), - ?assertEqual(<<"conn-id">>, maps:get(<<"connection_id">>, Req)). - -add_geolocation_to_request_test() -> - BaseReq = #{<<"type">> => <<"test">>}, - WithGeo = voice_utils:add_geolocation_to_request(BaseReq, <<"1.0">>, <<"2.0">>), - ?assertEqual(<<"1.0">>, maps:get(<<"latitude">>, WithGeo)), - ?assertEqual(<<"2.0">>, maps:get(<<"longitude">>, WithGeo)), - WithoutGeo = voice_utils:add_geolocation_to_request(BaseReq, null, null), - ?assertNot(maps:is_key(<<"latitude">>, WithoutGeo)). - -add_geolocation_to_request_number_test() -> - BaseReq = #{<<"type">> => <<"test">>}, - WithGeo = voice_utils:add_geolocation_to_request(BaseReq, 1.5, 2.25), - ?assertEqual(<<"1.5">>, maps:get(<<"latitude">>, WithGeo)), - ?assertEqual(<<"2.25">>, maps:get(<<"longitude">>, WithGeo)). - -add_rtc_region_to_request_test() -> - BaseReq = #{<<"type">> => <<"test">>}, - WithRegion = voice_utils:add_rtc_region_to_request(BaseReq, <<"us-east">>), - ?assertEqual(<<"us-east">>, maps:get(<<"rtc_region">>, WithRegion)), - WithoutRegion = voice_utils:add_rtc_region_to_request(BaseReq, null), - ?assertNot(maps:is_key(<<"rtc_region">>, WithoutRegion)). - build_force_disconnect_rpc_request_test() -> Req = voice_utils:build_force_disconnect_rpc_request(123, 456, 789, <<"conn">>), ?assertEqual(<<"voice_force_disconnect_participant">>, maps:get(<<"type">>, Req)), @@ -155,27 +112,6 @@ stream_denied_overwrite_state(UserId, ChannelId) -> } }. -generate_token_nonce_format_test() -> - Nonce = voice_utils:generate_token_nonce(), - ?assert(is_binary(Nonce)), - ?assertEqual(32, byte_size(Nonce)), - ?assert( - lists:all( - fun(C) -> - (C >= $0 andalso C =< $9) orelse (C >= $a andalso C =< $f) - end, - binary_to_list(Nonce) - ) - ). - -generate_token_nonce_unique_test() -> - Nonce1 = voice_utils:generate_token_nonce(), - Nonce2 = voice_utils:generate_token_nonce(), - Nonce3 = voice_utils:generate_token_nonce(), - ?assertNot(Nonce1 =:= Nonce2), - ?assertNot(Nonce2 =:= Nonce3), - ?assertNot(Nonce1 =:= Nonce3). - build_voice_token_rpc_request_with_nonce_test() -> VoicePerms = #{ can_speak => true, @@ -188,30 +124,3 @@ build_voice_token_rpc_request_with_nonce_test() -> ?assertEqual(<<"test-nonce-123">>, maps:get(<<"token_nonce">>, Req)), ?assertEqual(true, maps:get(<<"can_speak">>, Req)), ?assertEqual(false, maps:get(<<"can_stream">>, Req)). - -build_voice_token_rpc_request_without_nonce_test() -> - VoicePerms = #{ - can_speak => true, - can_stream => true, - can_video => true - }, - Req = voice_utils:build_voice_token_rpc_request( - 123, 456, 789, null, null, null, VoicePerms, null - ), - ?assertNot(maps:is_key(<<"token_nonce">>, Req)), - ?assertEqual(true, maps:get(<<"can_speak">>, Req)). - -build_voice_token_rpc_request_undefined_nonce_test() -> - VoicePerms = #{ - can_speak => false, - can_stream => true, - can_video => true - }, - Req = voice_utils:build_voice_token_rpc_request( - 123, 456, 789, null, null, null, VoicePerms, undefined_token_nonce() - ), - ?assertNot(maps:is_key(<<"token_nonce">>, Req)), - ?assertEqual(false, maps:get(<<"can_speak">>, Req)). - -undefined_token_nonce() -> - eqwalizer:dynamic_cast(undefined). diff --git a/fluxer_gifs/src/klipy.rs b/fluxer_gifs/src/klipy.rs index f1aab1f2b..89e983bbf 100644 --- a/fluxer_gifs/src/klipy.rs +++ b/fluxer_gifs/src/klipy.rs @@ -13,7 +13,6 @@ use std::time::Duration; use tokio::time::{sleep, timeout}; const KLIPY_BASE_URL: &str = "https://api.klipy.com/v2"; -const KLIPY_DIRECT_BASE_URL: &str = "https://api.klipy.com/api/v1"; const DEFAULT_CONTENT_FILTER: &str = "low"; const CLIENT_KEY: &str = "fluxer"; const MAX_RETRIES: usize = 3; @@ -139,18 +138,6 @@ enum KlipyJsonFetch { NotFound, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct KlipyPath { - path_type: KlipyPathType, - slug: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum KlipyPathType { - Gif, - Clip, -} - impl KlipyClient { pub fn new(media_proxy: MediaProxyUrlBuilder) -> anyhow::Result { let http_client = reqwest::Client::builder() @@ -399,7 +386,7 @@ impl KlipyClient { api_key: &str, path: &KlipyPath, ) -> anyhow::Result> { - let url = self.create_direct_url(api_key, path)?; + let url = fluxer_common::klipy::direct_items_url(api_key, path.kind, &path.slug)?; let mut last_error = None; for attempt in 0..MAX_RETRIES { match self.fetch_direct_gif_once(url.clone(), path).await { @@ -420,7 +407,7 @@ impl KlipyClient { path: &KlipyPath, ) -> anyhow::Result> { match self - .fetch_json_response::(url, klipy_resource(path.path_type)) + .fetch_json_response::(url, path.kind.resource()) .await? { KlipyJsonFetch::NotFound => Ok(None), @@ -526,21 +513,6 @@ impl KlipyClient { Ok(url) } - fn create_direct_url(&self, api_key: &str, path: &KlipyPath) -> anyhow::Result { - let mut url = Url::parse(KLIPY_DIRECT_BASE_URL)?; - { - let mut segments = url - .path_segments_mut() - .map_err(|_| anyhow::anyhow!("KLIPY direct base URL cannot be a base"))?; - segments - .push(api_key) - .push(klipy_resource(path.path_type)) - .push("items"); - } - url.query_pairs_mut().append_pair("slugs", &path.slug); - Ok(url) - } - fn transform_gif(&self, input: KlipyGif) -> Option { self.transform_gif_with_path(input, None) } @@ -563,7 +535,7 @@ impl KlipyClient { .unwrap_or(fallback_id.as_str()) .to_owned(); let normalized_type = resolved_path - .map(|path| path.path_type) + .map(|path| path.kind) .unwrap_or(KlipyPathType::Gif); let normalized_url = if resolved_path.is_some() || explicit_slug.is_some() { build_share_url_with_type(normalized_type, &normalized_slug) @@ -719,7 +691,7 @@ pub fn normalize_locale(locale: &str) -> String { pub fn resolve_cache_key(url: &str) -> String { match parse_klipy_path(url) { - Some(path) => format!("{}:{}", klipy_resource(path.path_type), path.slug), + Some(path) => format!("{}:{}", path.kind.resource(), path.slug), None => UNRESOLVABLE_CACHE_KEY.to_owned(), } } @@ -736,23 +708,7 @@ fn klipy_id_as_string(value: &Value) -> Option { } fn parse_klipy_path(raw_url: &str) -> Option { - let parsed = Url::parse(raw_url).ok()?; - let hostname = parsed.host_str()?.to_ascii_lowercase(); - if hostname != "klipy.com" && hostname != "www.klipy.com" { - return None; - } - let mut segments = parsed.path_segments()?; - let kind = segments.next()?.to_ascii_lowercase(); - let slug = segments.next()?.trim().to_owned(); - if slug.is_empty() { - return None; - } - let path_type = match kind.as_str() { - "gif" | "gifs" => KlipyPathType::Gif, - "clip" | "clips" => KlipyPathType::Clip, - _ => return None, - }; - Some(KlipyPath { path_type, slug }) + fluxer_common::klipy::parse_share_path(&Url::parse(raw_url).ok()?) } fn build_share_url_with_type(path_type: KlipyPathType, slug: &str) -> String { @@ -764,13 +720,6 @@ fn build_share_url_with_type(path_type: KlipyPathType, slug: &str) -> String { format!("https://klipy.com/{base_path}/{encoded_slug}") } -fn klipy_resource(path_type: KlipyPathType) -> &'static str { - match path_type { - KlipyPathType::Gif => "gifs", - KlipyPathType::Clip => "clips", - } -} - fn size_key_prefix(size: &str) -> Option<&'static str> { SIZE_KEY_PREFIXES .iter() @@ -841,6 +790,8 @@ fn category_response(name: String, gif: Option) -> GifCategoryTag { } } +use fluxer_common::klipy::{ShareKind as KlipyPathType, SharePath as KlipyPath}; + #[cfg(test)] mod tests { use super::*; @@ -883,7 +834,7 @@ mod tests { let address = listener.local_addr().expect("local addr"); tokio::spawn(async move { axum::serve(listener, app).await }); - let client = KlipyClient::new(MediaProxyUrlBuilder::for_test( + let client = KlipyClient::new(MediaProxyUrlBuilder::new( "https://media.example.test", "secret", )) @@ -1007,7 +958,7 @@ mod tests { #[test] fn sized_payloads_emit_exactly_the_frozen_key_set() { - let client = KlipyClient::new(MediaProxyUrlBuilder::for_test( + let client = KlipyClient::new(MediaProxyUrlBuilder::new( "https://media.example.test", "secret", )) diff --git a/fluxer_gifs/src/media_proxy.rs b/fluxer_gifs/src/media_proxy.rs index 19ec9653b..15f99fab3 100644 --- a/fluxer_gifs/src/media_proxy.rs +++ b/fluxer_gifs/src/media_proxy.rs @@ -6,25 +6,10 @@ use url::Url; #[derive(Clone)] pub struct MediaProxyUrlBuilder { endpoint: String, - endpoint_host: Option, secret_key: String, } impl MediaProxyUrlBuilder { - #[cfg(test)] - pub(crate) fn for_test(endpoint: &str, secret_key: &str) -> Self { - let endpoint = endpoint.trim_end_matches('/').to_owned(); - let endpoint_host = Url::parse(&endpoint) - .ok() - .and_then(|parsed| parsed.host_str().map(ToOwned::to_owned)); - - Self { - endpoint, - endpoint_host, - secret_key: secret_key.to_owned(), - } - } - pub fn from_env() -> anyhow::Result { let Some(endpoint) = optional_env("FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT") .or_else(|| optional_env("FLUXER_MEDIA_ENDPOINT")) @@ -41,24 +26,19 @@ impl MediaProxyUrlBuilder { let endpoint = fluxer_common::config::normalize_public_endpoint_from_env( endpoint.trim_end_matches('/'), ); - let endpoint_host = Url::parse(&endpoint) - .ok() - .and_then(|parsed| parsed.host_str().map(ToOwned::to_owned)); + Ok(Self::new(&endpoint, &secret_key)) + } - Ok(Self { - endpoint, - endpoint_host, - secret_key, - }) + pub fn new(endpoint: &str, secret_key: &str) -> Self { + Self { + endpoint: endpoint.trim_end_matches('/').to_owned(), + secret_key: secret_key.to_owned(), + } } pub fn external_proxy_url(&self, input_url: &str) -> Option { let parsed = Url::parse(input_url).ok()?; - if self - .endpoint_host - .as_deref() - .is_some_and(|host| parsed.host_str() == Some(host)) - { + if fluxer_common::external_media_path::is_own_media_url(&self.endpoint, &parsed) { return Some(input_url.to_owned()); } @@ -78,7 +58,6 @@ mod tests { fn external_proxy_url_builds_v2_signed_url() { let builder = MediaProxyUrlBuilder { endpoint: "https://media.example.test".to_owned(), - endpoint_host: Some("media.example.test".to_owned()), secret_key: "secret".to_owned(), }; diff --git a/fluxer_gifs/src/router_impl.rs b/fluxer_gifs/src/router_impl.rs index e503f911a..5e55daab1 100644 --- a/fluxer_gifs/src/router_impl.rs +++ b/fluxer_gifs/src/router_impl.rs @@ -47,10 +47,6 @@ impl RouterService for GifsRouter { self.l1.insert(key, resp.clone()); } } - - fn l1_invalidate(&self, key: &str) { - self.l1.invalidate(key); - } } fn request_l1_key(req: &GifRequest) -> Option { diff --git a/fluxer_media_proxy/Cargo.toml b/fluxer_media_proxy/Cargo.toml index 882003183..f568a6b26 100644 --- a/fluxer_media_proxy/Cargo.toml +++ b/fluxer_media_proxy/Cargo.toml @@ -14,12 +14,8 @@ path = "src/lib.rs" name = "fluxer-media-proxy" path = "src/main.rs" -[[bench]] -name = "core" -harness = false - [dependencies] -fluxer_common = { path = "../fluxer_common" } +fluxer_common = { path = "../fluxer_common", features = ["healthcheck"] } anyhow = "1.0.104" axum = {version = "0.8.9", default-features = false, features = ["http1", "json", "query", "tokio"]} base64 = "0.23.1" @@ -59,7 +55,6 @@ cc = "1.4.6" pkg-config = "0.3.34" [dev-dependencies] -criterion = "0.8.2" flate2 = "1.1.10" proptest = "1.11.0" tower = {version = "0.5.3", features = ["util"]} diff --git a/fluxer_media_proxy/benches/core.rs b/fluxer_media_proxy/benches/core.rs deleted file mode 100644 index 32a7885d2..000000000 --- a/fluxer_media_proxy/benches/core.rs +++ /dev/null @@ -1,125 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use criterion::{Criterion, criterion_group, criterion_main}; -use fluxer_media_proxy::{ - asset_hash::AssetHash, - aws_sigv4, - image_quality::ImageQuality, - image_transform::{EncodeEffort, ResizeMode}, - mime, - output_format::OutputFormat, - range, - server::transform::{ - cache_key::{TransformCacheKeyInput, transform_cache_key}, - parameters::TransformRoute, - }, - signing, thumbhash, -}; -use std::hint::black_box; - -fn vp8x_webp_header() -> [u8; 30] { - let mut webp = [0u8; 30]; - webp[0..4].copy_from_slice(b"RIFF"); - webp[8..12].copy_from_slice(b"WEBP"); - webp[12..16].copy_from_slice(b"VP8X"); - webp[16..20].copy_from_slice(&10u32.to_le_bytes()); - webp[20] = 0x12; - webp[24..27].copy_from_slice(&[0xff, 0x03, 0]); - webp[27..30].copy_from_slice(&[0xff, 0x03, 0]); - webp -} - -fn external_cache_key_input(identity: &str) -> TransformCacheKeyInput<'_> { - TransformCacheKeyInput { - route: TransformRoute::External, - asset_kind: None, - cache_identity: identity, - width: Some(1024), - height: Some(1024), - format: OutputFormat::WebP, - quality: Some(ImageQuality::High), - animated: false, - effort: EncodeEffort::parse_lenient("4"), - resize_mode: Some(ResizeMode::Fit), - } -} - -fn bench_range(c: &mut Criterion) { - c.bench_function("range_parse_explicit", |b| { - b.iter(|| range::parse_range(Some("bytes=1024-65535"), 10 * 1024 * 1024)) - }); - c.bench_function("content_range_parse", |b| { - b.iter(|| range::parse_content_range(black_box(Some("bytes 1048576-2097151/8388608")))) - }); -} - -fn bench_signing(c: &mut Criterion) { - c.bench_function("external_signature", |b| { - b.iter(|| { - signing::create_signature("v2/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWFnZS5wbmc", b"secret") - }) - }); - c.bench_function("aws_sigv4_get", |b| { - b.iter(|| { - let mut opts = aws_sigv4::Options::new( - "GET", - "https://examplebucket.s3.amazonaws.com/test.txt", - "us-east-1", - "AKIAIOSFODNN7EXAMPLE", - "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - ); - opts.timestamp = Some(aws_sigv4::format_timestamp(2013, 5, 24, 0, 0, 0)); - aws_sigv4::sign(opts).unwrap() - }) - }); -} - -fn bench_mime(c: &mut Criterion) { - let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x20\x00\x00\x00\x20\x08\x06"; - c.bench_function("mime_sniff_png", |b| b.iter(|| mime::sniff(png))); - let webp = vp8x_webp_header(); - c.bench_function("mime_sniff_webp_vp8x", |b| { - b.iter(|| mime::sniff(black_box(&webp))) - }); -} - -fn bench_asset_hash(c: &mut Criterion) { - c.bench_function("asset_hash_parse", |b| { - b.iter(|| AssetHash::parse(black_box("deadbeef"))) - }); - c.bench_function("asset_hash_parse_animated", |b| { - b.iter(|| AssetHash::parse(black_box("a_deadbeef"))) - }); -} - -fn bench_transform_cache_key(c: &mut Criterion) { - let identity = - "https://example.invalid/media/asset.png?width=1024&height=1024&token=0123456789abcdef"; - c.bench_function("external_transform_cache_key", |b| { - b.iter(|| transform_cache_key(external_cache_key_input(black_box(identity)))) - }); -} - -fn bench_thumbhash(c: &mut Criterion) { - let mut pixels = vec![0u8; 64 * 64 * 4]; - for (i, px) in pixels.as_chunks_mut::<4>().0.iter_mut().enumerate() { - px[0] = (i % 64) as u8; - px[1] = (i / 64) as u8; - px[2] = 128; - px[3] = 255; - } - c.bench_function("thumbhash_64_rgba", |b| { - b.iter(|| thumbhash::encode_rgba(&pixels, 64, 64).unwrap()) - }); -} - -criterion_group!( - benches, - bench_range, - bench_signing, - bench_mime, - bench_asset_hash, - bench_transform_cache_key, - bench_thumbhash -); -criterion_main!(benches); diff --git a/fluxer_media_proxy/deny.toml b/fluxer_media_proxy/deny.toml deleted file mode 100644 index cfbfd9f86..000000000 --- a/fluxer_media_proxy/deny.toml +++ /dev/null @@ -1,55 +0,0 @@ -[graph] -targets = ["x86_64-unknown-linux-gnu"] -all-features = true -no-default-features = false - -[advisories] -yanked = "deny" -ignore = [] - -[licenses] -allow = [ - "Apache-2.0", - "AGPL-3.0-or-later", - "BSD-2-Clause", - "BSD-3-Clause", - "CC0-1.0", - "ISC", - "MIT", - "Unicode-3.0", -] -confidence-threshold = 0.8 -exceptions = [] - -[licenses.private] -ignore = true -registries = [] - -[bans] -multiple-versions = "warn" -wildcards = "deny" -allow-wildcard-paths = true -highlight = "simplest-path" -workspace-default-features = "allow" -external-default-features = "allow" -allow = [] -deny = [] -skip = [ - { crate = "getrandom@0.3.4", reason = "transitive dependency requires the prior randomness API" }, - { crate = "http@0.2.12", reason = "transitive dependency requires the older HTTP API" }, - { crate = "http-body@0.4.6", reason = "transitive dependency requires the older HTTP body API" }, - { crate = "rand_core@0.9.5", reason = "transitive dependency requires the prior randomness API" }, - { crate = "socket2@0.5.10", reason = "transitive dependency requires the older socket API" }, -] -skip-tree = [] - -[sources] -unknown-registry = "deny" -unknown-git = "deny" -allow-registry = ["https://github.com/rust-lang/crates.io-index"] -allow-git = [] - -[sources.allow-org] -github = [] -gitlab = [] -bitbucket = [] diff --git a/fluxer_media_proxy/fuzz/Cargo.lock b/fluxer_media_proxy/fuzz/Cargo.lock index ca5f7501f..fb8dcd63c 100644 --- a/fluxer_media_proxy/fuzz/Cargo.lock +++ b/fluxer_media_proxy/fuzz/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.5" @@ -73,6 +79,18 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "async-compression" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -90,24 +108,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "aws-credential-types" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" -dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-types", - "zeroize", -] - [[package]] name = "aws-lc-rs" version = "1.18.1" @@ -131,112 +131,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "aws-sigv4" -version = "1.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31d955e76ff96acd555bf06fa0fa6d5bf9335fa84ae7c64481b20ae61d231f70" -dependencies = [ - "aws-credential-types", - "aws-smithy-http", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "form_urlencoded", - "hex", - "hmac", - "http 0.2.12", - "http 1.5.0", - "percent-encoding", - "sha2", - "time", - "tracing", -] - -[[package]] -name = "aws-smithy-async" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" -dependencies = [ - "futures-util", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "aws-smithy-http" -version = "0.64.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" -dependencies = [ - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "bytes-utils", - "futures-core", - "futures-util", - "http 1.5.0", - "http-body 1.1.0", - "http-body-util", - "percent-encoding", - "pin-project-lite", - "pin-utils", - "tracing", -] - -[[package]] -name = "aws-smithy-runtime-api" -version = "1.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d881a7b7ad179fd6611680c9de89f716fb00ab40299a9a7b8c6913e8f7511a8" -dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api-macros", - "aws-smithy-types", - "bytes", - "http 0.2.12", - "http 1.5.0", - "pin-project-lite", - "tokio", - "tracing", - "zeroize", -] - -[[package]] -name = "aws-smithy-runtime-api-macros" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "aws-smithy-types" -version = "1.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b791f3ac597193fe1d08b82366986eb1f5bc31f2ac6c194c0855276116c76cd" -dependencies = [ - "base64-simd", - "bytes", - "bytes-utils", - "http 0.2.12", - "http 1.5.0", - "http-body 0.4.6", - "http-body 1.1.0", - "http-body-util", - "itoa", - "num-integer", - "pin-project-lite", - "pin-utils", - "ryu", - "serde", - "time", -] - [[package]] name = "axum" version = "0.8.9" @@ -247,8 +141,8 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "http-body-util", "hyper", "hyper-util", @@ -277,8 +171,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "http-body-util", "mime", "pin-project-lite", @@ -299,16 +193,6 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" -[[package]] -name = "base64-simd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" -dependencies = [ - "outref", - "vsimd", -] - [[package]] name = "bitflags" version = "2.13.2" @@ -336,16 +220,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "bytes-utils" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" -dependencies = [ - "bytes", - "either", -] - [[package]] name = "cc" version = "1.4.6" @@ -452,6 +326,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + [[package]] name = "const-oid" version = "0.10.2" @@ -483,6 +374,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -525,12 +425,6 @@ dependencies = [ "cmov", ] -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - [[package]] name = "digest" version = "0.11.3" @@ -560,12 +454,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" - [[package]] name = "equivalent" version = "1.0.2" @@ -594,6 +482,17 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "fluxer-media-proxy" version = "0.1.0" @@ -608,11 +507,12 @@ dependencies = [ "futures-util", "hex", "hmac", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "http-body-util", "hyper", "libc", + "memmap2", "moka", "parking_lot", "percent-encoding", @@ -628,7 +528,7 @@ dependencies = [ "tempfile", "thiserror", "tokio", - "tokio-util", + "tower-http 0.7.1", "tracing", "tracing-subscriber", "url", @@ -640,6 +540,7 @@ name = "fluxer-media-proxy-fuzz" version = "0.0.0" dependencies = [ "fluxer-media-proxy", + "fluxer_common", "libfuzzer-sys", ] @@ -648,29 +549,22 @@ name = "fluxer_common" version = "0.1.0" dependencies = [ "anyhow", - "aws-credential-types", - "aws-sigv4", - "axum", "base64 0.23.1", + "hex", "hmac", - "maxminddb", - "moka", + "http", + "libc", "reqwest", + "serde", "serde_json", "sha2", - "tempfile", "thiserror", - "time", + "tokio", "tracing", - "urlencoding", + "tracing-subscriber", + "url", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -822,17 +716,6 @@ dependencies = [ "digest", ] -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - [[package]] name = "http" version = "1.5.0" @@ -843,17 +726,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - [[package]] name = "http-body" version = "1.1.0" @@ -861,7 +733,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http 1.5.0", + "http", ] [[package]] @@ -872,8 +744,8 @@ checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "pin-project-lite", ] @@ -908,8 +780,8 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "httparse", "httpdate", "itoa", @@ -925,7 +797,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.5.0", + "http", "hyper", "hyper-util", "rustls", @@ -944,8 +816,8 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "hyper", "ipnet", "libc", @@ -1067,12 +939,6 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" -[[package]] -name = "ipnetwork" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1225,30 +1091,37 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" -[[package]] -name = "maxminddb" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b43d2458d977b610b04a3858e6504f06133663d303a1a0606601b530bfc9bc6" -dependencies = [ - "ipnetwork", - "memchr", - "serde", - "thiserror", -] - [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.3" @@ -1286,30 +1159,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -1328,12 +1177,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "outref" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" - [[package]] name = "parking_lot" version = "0.12.5" @@ -1396,12 +1239,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1568,11 +1405,10 @@ checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ "base64 0.23.1", "bytes", - "futures-channel", "futures-core", "futures-util", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "http-body-util", "hyper", "hyper-rustls", @@ -1592,7 +1428,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", @@ -1609,7 +1445,7 @@ checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" dependencies = [ "anyhow", "async-trait", - "http 1.5.0", + "http", "reqwest", "thiserror", "tower-service", @@ -1625,7 +1461,7 @@ dependencies = [ "async-trait", "futures", "getrandom 0.2.17", - "http 1.5.0", + "http", "hyper", "reqwest", "reqwest-middleware", @@ -1929,6 +1765,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -2075,36 +1917,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.8.4" @@ -2195,8 +2007,8 @@ dependencies = [ "bitflags", "bytes", "futures-util", - "http 1.5.0", - "http-body 1.1.0", + "http", + "http-body", "pin-project-lite", "tower", "tower-layer", @@ -2204,6 +2016,26 @@ dependencies = [ "url", ] +[[package]] +name = "tower-http" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "http", + "http-body", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2326,12 +2158,6 @@ dependencies = [ "serde", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -2361,12 +2187,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vsimd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" - [[package]] name = "walkdir" version = "2.5.0" @@ -2718,6 +2538,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/fluxer_media_proxy/fuzz/Cargo.toml b/fluxer_media_proxy/fuzz/Cargo.toml index 65f81e33a..3d611d4c3 100644 --- a/fluxer_media_proxy/fuzz/Cargo.toml +++ b/fluxer_media_proxy/fuzz/Cargo.toml @@ -8,6 +8,7 @@ edition = "2024" cargo-fuzz = true [dependencies] +fluxer_common = { path = "../../fluxer_common" } libfuzzer-sys = "0.4" fluxer-media-proxy = {path = ".."} diff --git a/fluxer_media_proxy/fuzz/fuzz_targets/signing_external_path.rs b/fluxer_media_proxy/fuzz/fuzz_targets/signing_external_path.rs index 9e22dbd54..b93935ed3 100644 --- a/fluxer_media_proxy/fuzz/fuzz_targets/signing_external_path.rs +++ b/fluxer_media_proxy/fuzz/fuzz_targets/signing_external_path.rs @@ -2,25 +2,25 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use fluxer_media_proxy::{external_path, signing}; +use fluxer_common::external_media_path; use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { if let Ok(text) = std::str::from_utf8(data) { - if let Ok(path) = external_path::build_external_media_proxy_path(text) { - let _ = external_path::reconstruct_original_url(&path); - let sig = signing::create_signature(&path, b"fuzz-secret"); - let _ = signing::verify_signature(&path, &sig, b"fuzz-secret"); - let _ = signing::verify_signature(text, &sig, b"other-secret"); + if let Ok(path) = external_media_path::build_external_media_proxy_path(text) { + let _ = external_media_path::reconstruct_original_url(&path); + let sig = external_media_path::create_signature(&path, b"fuzz-secret"); + let _ = external_media_path::verify_signature(&path, &sig, b"fuzz-secret"); + let _ = external_media_path::verify_signature(text, &sig, b"other-secret"); } - let opaque = external_path::build_opaque_external_media_proxy_path(text); - let _ = external_path::reconstruct_original_url(&opaque); - let opaque_signature = signing::create_signature(&opaque, b"fuzz-secret"); - let _ = signing::verify_signature(&opaque, &opaque_signature, b"fuzz-secret"); - let _ = signing::verify_signature(&opaque, "", b"fuzz-secret"); - let _ = external_path::reconstruct_original_url(text); - let _ = external_path::percent_decode(text, true); - let _ = external_path::percent_decode(text, false); - let _ = external_path::percent_decode_string(text, true); + let opaque = external_media_path::build_opaque_external_media_proxy_path(text); + let _ = external_media_path::reconstruct_original_url(&opaque); + let opaque_signature = external_media_path::create_signature(&opaque, b"fuzz-secret"); + let _ = external_media_path::verify_signature(&opaque, &opaque_signature, b"fuzz-secret"); + let _ = external_media_path::verify_signature(&opaque, "", b"fuzz-secret"); + let _ = external_media_path::reconstruct_original_url(text); + let _ = external_media_path::percent_decode(text, true); + let _ = external_media_path::percent_decode(text, false); + let _ = external_media_path::percent_decode_string(text, true); } }); diff --git a/fluxer_media_proxy/src/asset_hash.rs b/fluxer_media_proxy/src/asset_hash.rs index 297a3c5da..826511724 100644 --- a/fluxer_media_proxy/src/asset_hash.rs +++ b/fluxer_media_proxy/src/asset_hash.rs @@ -35,12 +35,6 @@ pub fn has_animation_prefix(hash: &str) -> bool { mod tests { use super::*; - #[test] - fn strip_animation_prefix_removes_only_virtual_animated_prefix() { - assert_eq!("abc123", strip_animation_prefix("a_abc123")); - assert_eq!("abc123", strip_animation_prefix("abc123")); - } - #[test] fn parsed_hashes_split_the_animation_prefix_from_the_digest() { let plain = AssetHash::parse("abc123"); diff --git a/fluxer_media_proxy/src/byte_budget.rs b/fluxer_media_proxy/src/byte_budget.rs index 4d2f971f5..e88d1994c 100644 --- a/fluxer_media_proxy/src/byte_budget.rs +++ b/fluxer_media_proxy/src/byte_budget.rs @@ -213,13 +213,6 @@ impl Drop for ByteReservationInner { mod tests { use super::*; - #[test] - fn try_reserve_up_to_limit_succeeds() { - let budget = ByteBudget::new(100); - let reservation = budget.try_reserve(60).expect("reserve under limit"); - assert_eq!(reservation.amount(), 60); - } - #[test] fn try_reserve_exactly_at_limit_succeeds() { let budget = ByteBudget::new(100); @@ -252,14 +245,6 @@ mod tests { assert_eq!(remaining.amount(), 40); } - #[test] - fn try_grow_zero_is_a_no_op() { - let budget = ByteBudget::new(100); - let mut reservation = budget.try_reserve(60).expect("reserve under limit"); - assert!(reservation.try_grow(0)); - assert_eq!(reservation.amount(), 60); - } - #[test] fn shrink_to_releases_delta_back_to_budget() { let budget = ByteBudget::new(100); @@ -289,20 +274,4 @@ mod tests { assert_eq!(budgeted.len(), 5); assert_eq!(budgeted.resident_bytes(), 50); } - - #[test] - fn resident_bytes_uses_data_len_when_unbudgeted() { - let unbudgeted = BudgetedBytes::unbudgeted(Bytes::from_static(b"hello")); - assert_eq!(unbudgeted.resident_bytes(), 5); - } - - #[test] - fn empty_data_reports_empty_and_exposes_the_underlying_bytes() { - let empty = BudgetedBytes::unbudgeted(Bytes::new()); - assert!(empty.is_empty()); - assert_eq!(empty.as_bytes(), &Bytes::new()); - let hello = BudgetedBytes::unbudgeted(Bytes::from_static(b"hello")); - assert!(!hello.is_empty()); - assert_eq!(hello.as_bytes(), &Bytes::from_static(b"hello")); - } } diff --git a/fluxer_media_proxy/src/coalescer/mod.rs b/fluxer_media_proxy/src/coalescer/mod.rs index d4ba4c975..b2894a967 100644 --- a/fluxer_media_proxy/src/coalescer/mod.rs +++ b/fluxer_media_proxy/src/coalescer/mod.rs @@ -177,7 +177,7 @@ impl ByteCoalescer { let _waiter_permit = waiter_permit; if let Some(permit) = coordinator_permit { - self.metrics.record_leader(); + self.metrics.coalescer_leader.inc(); let mut guard = CoordinatorGuard { coalescer: self, key: key.as_str(), @@ -199,7 +199,7 @@ impl ByteCoalescer { drop(work); drop(complete); - self.metrics.record_waiter(); + self.metrics.coalescer_waiter.inc(); loop { let notified = slot.notify.notified(); tokio::pin!(notified); @@ -232,7 +232,7 @@ impl ByteCoalescer { .try_acquire_owned() .map_err(|error| match error { TryAcquireError::NoPermits => { - self.metrics.record_waiter_rejected(); + self.metrics.coalescer_waiter_rejected.inc(); CoalescerError::Overloaded } TryAcquireError::Closed => CoalescerError::Unavailable, diff --git a/fluxer_media_proxy/src/coalescer/tests.rs b/fluxer_media_proxy/src/coalescer/tests.rs index c7b60cd2d..7558ba836 100644 --- a/fluxer_media_proxy/src/coalescer/tests.rs +++ b/fluxer_media_proxy/src/coalescer/tests.rs @@ -10,7 +10,7 @@ fn coalescer() -> Arc { ByteBudget::new(1 << 20), 8, 64, - Arc::new(CoalescerMetrics::new()), + Arc::new(CoalescerMetrics::default()), )) } diff --git a/fluxer_media_proxy/src/config/mod.rs b/fluxer_media_proxy/src/config/mod.rs index d341c4d3f..8377accff 100644 --- a/fluxer_media_proxy/src/config/mod.rs +++ b/fluxer_media_proxy/src/config/mod.rs @@ -5,7 +5,7 @@ mod parse; mod tests; use crate::constants; -use crate::secret::{SecretBytes, SecretString}; +use fluxer_common::secret::{SecretBytes, SecretString}; use http::HeaderValue; use parse::{ EnvMap, decode_upload_relay_secret, default_native_transform_concurrency, @@ -150,7 +150,7 @@ impl Config { K: Into, V: Into, { - let env = EnvMap::from_iter(vars); + let env = EnvMap::from_pairs(vars); let mode = parse_mode_env(env.get("FLUXER_MEDIA_PROXY_MODE"))?.unwrap_or(DeploymentMode::Mp); diff --git a/fluxer_media_proxy/src/config/parse.rs b/fluxer_media_proxy/src/config/parse.rs index c4ab237c7..4f03028ad 100644 --- a/fluxer_media_proxy/src/config/parse.rs +++ b/fluxer_media_proxy/src/config/parse.rs @@ -1,35 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +pub(super) use fluxer_common::config::{EnvMap, parse_bool, parse_number}; + use super::{BucketStyle, DeploymentMode, PolicyMode, StorageBackend}; -use crate::{constants::DESKTOP_APP_ORIGIN, secret::SecretBytes}; +use crate::constants::DESKTOP_APP_ORIGIN; use base64::{Engine as _, engine::general_purpose}; +use fluxer_common::secret::SecretBytes; use http::HeaderValue; -#[derive(Debug, Default)] -pub(super) struct EnvMap(Vec<(String, String)>); - -impl EnvMap { - pub(super) fn from_iter(vars: I) -> Self - where - I: IntoIterator, - K: Into, - V: Into, - { - Self( - vars.into_iter() - .map(|(k, v)| (k.into(), v.into())) - .collect(), - ) - } - - pub(super) fn get(&self, key: &str) -> Option<&str> { - self.0 - .iter() - .find_map(|(k, v)| (k == key).then_some(v.as_str())) - .filter(|v| !v.trim().is_empty()) - } -} - fn parse_mode(raw: &str) -> Option { match raw.to_ascii_lowercase().as_str() { "mp" => Some(DeploymentMode::Mp), @@ -225,20 +203,6 @@ pub(super) fn decode_upload_relay_secret( Ok(SecretBytes::new(decoded)) } -pub(super) fn parse_bool(var_name: &str, raw: Option<&str>) -> anyhow::Result> { - let Some(raw) = raw else { - return Ok(None); - }; - let raw = raw.trim(); - match raw.to_ascii_lowercase().as_str() { - "true" | "1" | "yes" => Ok(Some(true)), - "false" | "0" | "no" => Ok(Some(false)), - _ => Err(anyhow::anyhow!( - "{var_name} must be a boolean: true, false, 1, 0, yes, or no" - )), - } -} - pub(super) fn parse_u16( var_name: &str, raw: Option<&str>, @@ -289,30 +253,6 @@ pub(super) fn parse_f32( Ok(parsed) } -fn parse_number( - var_name: &str, - raw: Option<&str>, - default_value: T, - min_value: T, - max_value: T, -) -> anyhow::Result -where - T: std::str::FromStr + PartialOrd + std::fmt::Display + Copy, -{ - let Some(raw) = raw else { - return Ok(default_value); - }; - let parsed = raw - .trim() - .parse::() - .map_err(|_| anyhow::anyhow!("{var_name} must be a number"))?; - anyhow::ensure!( - parsed >= min_value && parsed <= max_value, - "{var_name} must be between {min_value} and {max_value}" - ); - Ok(parsed) -} - pub(super) fn default_native_transform_concurrency() -> usize { std::thread::available_parallelism() .map(usize::from) diff --git a/fluxer_media_proxy/src/config/tests/attachment_signature.rs b/fluxer_media_proxy/src/config/tests/attachment_signature.rs index 795d37fec..a5cd101d1 100644 --- a/fluxer_media_proxy/src/config/tests/attachment_signature.rs +++ b/fluxer_media_proxy/src/config/tests/attachment_signature.rs @@ -64,16 +64,6 @@ fn rejects_an_unknown_signature_mode() { } } -#[test] -fn the_cors_mode_error_still_names_its_own_variable() { - let err = Config::load_from_iter(env_with(&[("FLUXER_MEDIA_PROXY_CORS_MODE", "strict")])) - .unwrap_err(); - assert_eq!( - "FLUXER_MEDIA_PROXY_CORS_MODE must be one of: off, report, enforce", - err.to_string() - ); -} - #[test] fn report_and_enforce_require_a_secret() { for mode in ["report", "enforce"] { diff --git a/fluxer_media_proxy/src/config/tests/cors.rs b/fluxer_media_proxy/src/config/tests/cors.rs index 194346688..afdc7aed4 100644 --- a/fluxer_media_proxy/src/config/tests/cors.rs +++ b/fluxer_media_proxy/src/config/tests/cors.rs @@ -20,29 +20,6 @@ fn cors_defaults_to_off_with_an_empty_allowlist() { assert!(cfg.cors.allowed_origins.is_empty()); } -#[test] -fn cors_mode_parses_every_variant_case_insensitively() { - for (raw, expected) in [("off", PolicyMode::Off), ("OFF", PolicyMode::Off)] { - let cfg = - Config::load_from_iter(env_with(&[("FLUXER_MEDIA_PROXY_CORS_MODE", raw)])).unwrap(); - assert_eq!(expected, cfg.cors.mode, "{raw:?}"); - assert!(cfg.cors.allowed_origins.is_empty()); - } - - for (raw, expected) in [ - (" report ", PolicyMode::Report), - ("Enforce", PolicyMode::Enforce), - ] { - let cfg = Config::load_from_iter(env_with(&[ - ("FLUXER_MEDIA_PROXY_CORS_MODE", raw), - (ORIGINS_KEY, "https://web.fluxer.app"), - ])) - .unwrap(); - assert_eq!(expected, cfg.cors.mode, "{raw:?}"); - assert_eq!(vec!["https://web.fluxer.app"], allowed_origins(&cfg)); - } -} - #[test] fn rejects_an_unknown_cors_mode() { for raw in ["strict", "true", "1"] { diff --git a/fluxer_media_proxy/src/config/tests/mod.rs b/fluxer_media_proxy/src/config/tests/mod.rs index b615b6aba..127b53bb5 100644 --- a/fluxer_media_proxy/src/config/tests/mod.rs +++ b/fluxer_media_proxy/src/config/tests/mod.rs @@ -63,19 +63,6 @@ fn upload_mode_requires_relay_secret() { ); } -#[test] -fn relay_mode_requires_the_relay_secret() { - let err = Config::load_from_iter([ - ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), - ("FLUXER_MEDIA_PROXY_MODE", "relay"), - ]) - .unwrap_err(); - assert_eq!( - "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required in upload and relay modes", - err.to_string() - ); -} - #[test] fn rejects_a_body_limit_above_the_spool_budget() { let err = Config::load_from_iter([ diff --git a/fluxer_media_proxy/src/config/tests/s3_read.rs b/fluxer_media_proxy/src/config/tests/s3_read.rs index 768a7710a..ced945974 100644 --- a/fluxer_media_proxy/src/config/tests/s3_read.rs +++ b/fluxer_media_proxy/src/config/tests/s3_read.rs @@ -1,32 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use super::{base_env, env_with}; +use super::env_with; use crate::config::{BucketStyle, Config}; -#[test] -fn read_endpoint_defaults_to_disabled() { - let cfg = Config::load_from_iter(base_env()).unwrap(); - assert_eq!(None, cfg.storage.s3_read_endpoint); - assert_eq!("cdn", cfg.storage.s3_read_bucket); - assert_eq!(BucketStyle::Path, cfg.storage.s3_read_bucket_style); - assert!(!cfg.storage.s3_read_signed); -} - -#[test] -fn empty_read_vars_are_treated_as_unset() { - let cfg = Config::load_from_iter(env_with(&[ - ("FLUXER_S3_READ_ENDPOINT", " "), - ("FLUXER_S3_READ_BUCKET", ""), - ("FLUXER_S3_READ_BUCKET_STYLE", ""), - ("FLUXER_S3_READ_SIGNED", ""), - ])) - .unwrap(); - assert_eq!(None, cfg.storage.s3_read_endpoint); - assert_eq!("cdn", cfg.storage.s3_read_bucket); - assert_eq!(BucketStyle::Path, cfg.storage.s3_read_bucket_style); - assert!(!cfg.storage.s3_read_signed); -} - #[test] fn every_new_var_tolerates_a_blank_value_individually() { for var in [ @@ -73,38 +49,6 @@ fn read_bucket_style_inherits_force_path_style() { assert!(!cfg.storage.s3_force_path_style); } -#[test] -fn read_bucket_style_parses_all_values_case_insensitively() { - for (raw, expected) in [ - ("path", BucketStyle::Path), - ("PATH", BucketStyle::Path), - ("virtual", BucketStyle::VirtualHosted), - (" Virtual ", BucketStyle::VirtualHosted), - ("root", BucketStyle::Rooted), - ("ROOT", BucketStyle::Rooted), - ] { - let cfg = - Config::load_from_iter(env_with(&[("FLUXER_S3_READ_BUCKET_STYLE", raw)])).unwrap(); - assert_eq!(expected, cfg.storage.s3_read_bucket_style, "raw={raw}"); - } -} - -#[test] -fn read_bucket_style_rejects_unknown_value() { - let err = - Config::load_from_iter(env_with(&[("FLUXER_S3_READ_BUCKET_STYLE", "cdn")])).unwrap_err(); - assert!(err.to_string().contains("FLUXER_S3_READ_BUCKET_STYLE")); -} - -#[test] -fn read_signed_parses_boolean() { - let cfg = Config::load_from_iter(env_with(&[("FLUXER_S3_READ_SIGNED", "true")])).unwrap(); - assert!(cfg.storage.s3_read_signed); - - let err = Config::load_from_iter(env_with(&[("FLUXER_S3_READ_SIGNED", "maybe")])).unwrap_err(); - assert!(err.to_string().contains("FLUXER_S3_READ_SIGNED")); -} - #[test] fn read_endpoint_is_validated_at_startup() { let cfg = Config::load_from_iter(env_with(&[( diff --git a/fluxer_media_proxy/src/constants.rs b/fluxer_media_proxy/src/constants.rs index 5b475e08f..27165747d 100644 --- a/fluxer_media_proxy/src/constants.rs +++ b/fluxer_media_proxy/src/constants.rs @@ -72,6 +72,18 @@ pub enum AssetExtension { } impl AssetExtension { + pub const ALL: [Self; 10] = [ + Self::Png, + Self::Jpeg, + Self::Webp, + Self::Gif, + Self::Apng, + Self::Avif, + Self::Heic, + Self::Heif, + Self::Jxl, + Self::Svg, + ]; pub fn parse(raw: &str) -> Option { match raw.to_ascii_lowercase().as_str() { "png" => Some(Self::Png), @@ -154,20 +166,6 @@ mod tests { clamp_size(parse_image_size(raw), kind) } - #[test] - fn image_size_snaps_up_to_the_next_rung() { - assert_eq!(128, parse_image_size(None)); - assert_eq!(640, parse_image_size(Some("640"))); - assert_eq!(1024, parse_image_size(Some("641"))); - assert_eq!(1024, parse_image_size(Some("1000"))); - assert_eq!(16, parse_image_size(Some("0"))); - assert_eq!(16384, parse_image_size(Some("99999"))); - assert_eq!(128, parse_image_size(Some("not-a-number"))); - assert_eq!(128, parse_image_size(Some(""))); - assert_eq!(128, parse_image_size(Some("-1"))); - assert_eq!(128, parse_image_size(Some("99999999999"))); - } - #[test] fn off_ladder_size_never_serves_fewer_pixels_than_requested() { for requested in 1..=4096u32 { @@ -179,22 +177,6 @@ mod tests { } } - #[test] - fn every_rung_snaps_to_itself() { - for rung in IMAGE_SIZES { - assert_eq!(*rung, parse_image_size(Some(&rung.to_string()))); - } - } - - #[test] - fn snapping_is_idempotent() { - for raw in [0u32, 1, 17, 641, 1000, 4097, 99999] { - let once = parse_image_size(Some(&raw.to_string())); - let twice = parse_image_size(Some(&once.to_string())); - assert_eq!(once, twice, "size={raw} did not settle"); - } - } - #[test] fn sub_minimum_sizes_collapse_to_one_avatar_cache_key() { let canonical = asset_cache_key(Some("128"), AssetKind::Avatar); diff --git a/fluxer_media_proxy/src/external_path.rs b/fluxer_media_proxy/src/external_path.rs deleted file mode 100644 index c5643189c..000000000 --- a/fluxer_media_proxy/src/external_path.rs +++ /dev/null @@ -1,170 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub use fluxer_common::external_media_path::{ - ExternalPathError, build_external_media_proxy_path, build_opaque_external_media_proxy_path, - percent_decode, percent_decode_string, reconstruct_original_url, -}; - -#[cfg(test)] -mod tests { - use super::*; - use crate::{public_net_policy, signing, test_fixtures::ADVERSARIAL_TEXT_INPUTS}; - use proptest::prelude::*; - - const SECRET: &[u8] = b"external-media-proxy-secret"; - - fn without_fragment(url: &str) -> &str { - url.split_once('#').map_or(url, |(head, _)| head) - } - - fn signed_round_trip(url: &str) -> String { - let path = build_external_media_proxy_path(url).expect("the external path builds"); - let signature = signing::create_signature(&path, SECRET); - assert!( - signing::verify_signature(&path, &signature, SECRET), - "{url}" - ); - assert!( - !signing::verify_signature(&path, &signature, b"other-secret"), - "{url}" - ); - reconstruct_original_url(&path).expect("the original url reconstructs") - } - - fn adversarial_urls(text: &str) -> [String; 4] { - [ - format!("https://example.com/{text}"), - format!("https://example.com/media/{text}/photo.webp"), - format!("http://example.com:8443/x?v={text}"), - format!("https://example.com/{text}/{text}?{text}=1"), - ] - } - - #[test] - fn adversarial_text_round_trips_through_the_signed_legacy_path() { - for text in ADVERSARIAL_TEXT_INPUTS { - for url in adversarial_urls(text) { - assert_eq!(without_fragment(&url), signed_round_trip(&url)); - } - } - } - - #[test] - fn adversarial_text_round_trips_through_the_signed_opaque_path() { - for text in ADVERSARIAL_TEXT_INPUTS { - for url in adversarial_urls(text) { - let path = build_opaque_external_media_proxy_path(&url); - let signature = signing::create_signature(&path, SECRET); - assert!( - signing::verify_signature(&path, &signature, SECRET), - "{url}" - ); - assert_eq!( - url, - reconstruct_original_url(&path).expect("the opaque path reconstructs") - ); - } - } - } - - #[test] - fn a_signature_computed_over_a_different_path_never_verifies() { - for text in ADVERSARIAL_TEXT_INPUTS { - let path = build_external_media_proxy_path(&format!("https://example.com/{text}")) - .expect("the external path builds"); - for other in [ - format!("https://example.com/other/{text}"), - format!("https://example.org/{text}"), - format!("http://example.com/{text}"), - ] { - let other_path = - build_external_media_proxy_path(&other).expect("the external path builds"); - assert_ne!(path, other_path, "{other}"); - let signature = signing::create_signature(&other_path, SECRET); - assert!( - !signing::verify_signature(&path, &signature, SECRET), - "{other}" - ); - } - } - } - - #[test] - fn adversarial_text_is_never_a_usable_external_path_on_its_own() { - for text in ADVERSARIAL_TEXT_INPUTS { - assert_eq!( - Err(ExternalPathError::InvalidExternalPath), - build_external_media_proxy_path(text) - ); - assert!(reconstruct_original_url(text).is_err(), "{text:?}"); - } - } - - #[test] - fn a_signed_path_never_decodes_into_a_url_the_fetch_policy_refuses() { - let credentialed = "https://reader:secret@cdn.example.com/i.png"; - assert_eq!( - Err(public_net_policy::Error::BlockedUrl), - public_net_policy::parse_url(credentialed) - ); - - let path = build_external_media_proxy_path(credentialed).expect("path builds"); - let decoded = reconstruct_original_url(&path).expect("path decodes"); - - assert_eq!("https://cdn.example.com/i.png", decoded); - assert!(public_net_policy::parse_url(&decoded).is_ok()); - } - - #[test] - fn a_fragment_never_splits_the_path_for_bytes_the_fetch_would_not_see() { - assert_eq!( - build_external_media_proxy_path("https://cdn.example.com/i.png").expect("path builds"), - build_external_media_proxy_path("https://cdn.example.com/i.png#one") - .expect("path builds") - ); - assert_eq!( - public_net_policy::parse_url("https://cdn.example.com/i.png") - .expect("plain url parses") - .path_query, - public_net_policy::parse_url("https://cdn.example.com/i.png#one") - .expect("anchored url parses") - .path_query - ); - } - - proptest! { - #![proptest_config(ProptestConfig { - cases: 256, - failure_persistence: None, - ..ProptestConfig::default() - })] - - #[test] - fn every_generated_media_url_survives_the_sign_and_reconstruct_round_trip( - scheme in prop::sample::select(vec!["http", "https"]), - host in "[a-z][a-z0-9.-]{0,24}", - port in prop::option::of(1u16..=65_535), - path in prop::collection::vec("[^/?#]{0,12}", 1..5), - query in prop::option::of("[^\u{0}#]{1,24}"), - ) { - let authority = match port { - Some(port) => format!("{host}:{port}"), - None => host, - }; - let url = format!( - "{scheme}://{authority}/{}{}", - path.join("/"), - query.map(|query| format!("?{query}")).unwrap_or_default() - ); - let proxy_path = build_external_media_proxy_path(&url) - .expect("a generated media url always builds a proxy path"); - let signature = signing::create_signature(&proxy_path, SECRET); - prop_assert!(signing::verify_signature(&proxy_path, &signature, SECRET)); - prop_assert_eq!( - url, - reconstruct_original_url(&proxy_path) - .expect("a generated proxy path always reconstructs") - ); - } - } -} diff --git a/fluxer_media_proxy/src/healthcheck.rs b/fluxer_media_proxy/src/healthcheck.rs index 3610711cf..2ff102c77 100644 --- a/fluxer_media_proxy/src/healthcheck.rs +++ b/fluxer_media_proxy/src/healthcheck.rs @@ -1,53 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use anyhow::Context as _; -use std::{ - net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, - time::Duration, -}; - pub async fn run() -> anyhow::Result<()> { - let addr = target( + fluxer_common::healthcheck::run( fluxer_common::config::env_value("FLUXER_MEDIA_PROXY_HOST").as_deref(), fluxer_common::config::env_value("FLUXER_MEDIA_PROXY_PORT").as_deref(), - )?; - probe(addr).await -} - -fn target(host: Option<&str>, port: Option<&str>) -> anyhow::Result { - let host = host.map(str::trim).unwrap_or("127.0.0.1"); - let ip = host - .parse::() - .with_context(|| format!("FLUXER_MEDIA_PROXY_HOST is not an IP address: {host}"))?; - let ip = match ip { - IpAddr::V4(ip) if ip.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST), - IpAddr::V6(ip) if ip.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST), - ip => ip, - }; - let port = match port.map(str::trim) { - Some(port) => port - .parse::() - .with_context(|| format!("FLUXER_MEDIA_PROXY_PORT is not a port number: {port}"))?, - None => 8080, - }; - Ok(SocketAddr::new(ip, port)) -} - -async fn probe(addr: SocketAddr) -> anyhow::Result<()> { - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_millis(500)) - .timeout(Duration::from_millis(2_000)) - .no_proxy() - .build()?; - let status = client - .get(format!("http://{addr}/_health")) - .send() - .await - .with_context(|| format!("health request to {addr} failed"))? - .status(); - anyhow::ensure!( - status == reqwest::StatusCode::OK, - "health returned {status}" - ); - Ok(()) + "FLUXER_MEDIA_PROXY_HOST", + "FLUXER_MEDIA_PROXY_PORT", + 8080, + ) + .await } diff --git a/fluxer_media_proxy/src/http_client/mod.rs b/fluxer_media_proxy/src/http_client/mod.rs index 55a8ebc2c..db6e06190 100644 --- a/fluxer_media_proxy/src/http_client/mod.rs +++ b/fluxer_media_proxy/src/http_client/mod.rs @@ -139,11 +139,13 @@ pub fn build( .build()) } +#[cfg(test)] pub fn build_default(metrics: Arc) -> HttpClient { build(HTTPClientOptions::default(), metrics) .expect("default HTTP client configuration is valid") } +#[cfg(test)] pub fn build_raw_default() -> reqwest::Client { build_raw(HTTPClientOptions::default()).expect("default HTTP client configuration is valid") } diff --git a/fluxer_media_proxy/src/http_client/retry.rs b/fluxer_media_proxy/src/http_client/retry.rs index 140f573e2..4a9a9147d 100644 --- a/fluxer_media_proxy/src/http_client/retry.rs +++ b/fluxer_media_proxy/src/http_client/retry.rs @@ -43,8 +43,8 @@ impl RetryPolicy for ObservableRetryPolicy { self.inner.should_retry(request_start_time, n_past_retries) }; match decision { - RetryDecision::Retry { .. } => self.metrics.record_retry(), - RetryDecision::DoNotRetry => self.metrics.record_retries_exhausted(), + RetryDecision::Retry { .. } => self.metrics.http_retries.inc(), + RetryDecision::DoNotRetry => self.metrics.http_retries_exhausted.inc(), } decision } @@ -65,13 +65,13 @@ impl RetryableStrategy for MediaProxyRetryStrategy { match result { Ok(response) => retryable_status(response.status()).map(|retryable| { if retryable == Retryable::Transient { - self.metrics.record_retryable_status(); + self.metrics.http_retryable_status.inc(); } retryable }), Err(error) => retryable_error(error).map(|retryable| { if retryable == Retryable::Transient { - self.metrics.record_retryable_error(); + self.metrics.http_retryable_error.inc(); } retryable }), @@ -352,7 +352,7 @@ mod tests { ObservableRetryPolicy::new( ExponentialBackoff::builder().build_with_max_retries(2), window, - Arc::new(HTTPClientMetrics::new()), + Arc::new(HTTPClientMetrics::default()), ) } diff --git a/fluxer_media_proxy/src/http_headers.rs b/fluxer_media_proxy/src/http_headers.rs index 20db9ecb5..6754bd009 100644 --- a/fluxer_media_proxy/src/http_headers.rs +++ b/fluxer_media_proxy/src/http_headers.rs @@ -1,12 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::range::ByteRange; -use http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}; +#[cfg(test)] +pub use fluxer_common::server::{REFERRER_POLICY, STRICT_TRANSPORT_SECURITY}; +use http::{HeaderMap, HeaderValue, StatusCode, header}; pub const ROBOTS: &str = "noindex, nofollow, nosnippet, noimageindex, notranslate, max-snippet:0, max-image-preview:none, max-video-preview:0"; pub const MEDIA_CSP: &str = "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'none'; script-src-attr 'none'; script-src-elem 'none'; style-src 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self' blob:; sandbox allow-same-origin"; -pub const STRICT_TRANSPORT_SECURITY: &str = "max-age=31536000; includeSubDomains; preload"; -pub const REFERRER_POLICY: &str = "strict-origin-when-cross-origin"; pub const PERMISSIONS_POLICY: &str = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"; pub fn parse_content_length(headers: &HeaderMap) -> Option { @@ -20,24 +20,10 @@ pub fn parse_content_length(headers: &HeaderMap) -> Option { } pub fn add_security_headers(headers: &mut HeaderMap) { - set_static_header( - headers, - HeaderName::from_static("strict-transport-security"), - STRICT_TRANSPORT_SECURITY, - ); - set_static_header(headers, header::X_CONTENT_TYPE_OPTIONS, "nosniff"); - set_static_header( - headers, - HeaderName::from_static("referrer-policy"), - REFERRER_POLICY, - ); - set_static_header(headers, HeaderName::from_static("x-frame-options"), "DENY"); - set_static_header( - headers, - HeaderName::from_static("permissions-policy"), - PERMISSIONS_POLICY, - ); - set_static_header(headers, header::CONTENT_SECURITY_POLICY, MEDIA_CSP); + fluxer_common::server::set_baseline_security_headers(headers, PERMISSIONS_POLICY); + headers + .entry(header::CONTENT_SECURITY_POLICY) + .or_insert(HeaderValue::from_static(MEDIA_CSP)); } pub fn neutralize_script_content_type(status: StatusCode, headers: &mut HeaderMap) { @@ -62,12 +48,6 @@ pub fn neutralize_script_content_type(status: StatusCode, headers: &mut HeaderMa } } -fn set_static_header(headers: &mut HeaderMap, name: HeaderName, value: &'static str) { - headers - .entry(name) - .or_insert(HeaderValue::from_static(value)); -} - pub fn add_media_headers( headers: &mut HeaderMap, size: usize, @@ -420,42 +400,4 @@ mod tests { headers.insert(header::CONTENT_LENGTH, HeaderValue::from_static("00")); assert_eq!(parse_content_length(&headers), Some(0)); } - - #[test] - fn media_headers_always_cache_forever() { - let mut headers = HeaderMap::new(); - add_media_headers(&mut headers, 10, "image/png", None); - assert_eq!( - "public, max-age=31536000", - headers.get(header::CACHE_CONTROL).unwrap() - ); - - let mut streamable = HeaderMap::new(); - add_media_headers(&mut streamable, 10, "video/mp4", None); - assert_eq!( - "public, max-age=31536000, no-transform", - streamable.get(header::CACHE_CONTROL).unwrap() - ); - - let mut audio = HeaderMap::new(); - add_media_headers(&mut audio, 10, "audio/ogg", None); - assert_eq!( - "public, max-age=31536000, no-transform", - audio.get(header::CACHE_CONTROL).unwrap() - ); - } - - #[test] - fn media_headers_pair_the_policy_with_cdn_cache_control_and_omit_expires() { - let mut headers = HeaderMap::new(); - add_media_headers(&mut headers, 10, "image/png", None); - assert_eq!( - "public, max-age=31536000", - headers.get("CDN-Cache-Control").unwrap() - ); - assert!( - headers.get(header::EXPIRES).is_none(), - "Cache-Control is authoritative; a fixed Expires date only contradicts it" - ); - } } diff --git a/fluxer_media_proxy/src/image_transform.rs b/fluxer_media_proxy/src/image_transform.rs index 89f1579e2..b9ec7432c 100644 --- a/fluxer_media_proxy/src/image_transform.rs +++ b/fluxer_media_proxy/src/image_transform.rs @@ -138,33 +138,3 @@ impl Default for ImageOptions { } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn effort_parsing_stays_lenient_and_clamps_at_nine() { - assert_eq!(None, EncodeEffort::parse_lenient("")); - assert_eq!(None, EncodeEffort::parse_lenient("not-a-number")); - assert_eq!(None, EncodeEffort::parse_lenient("-1")); - assert_eq!( - Some(0), - EncodeEffort::parse_lenient("0").map(EncodeEffort::get) - ); - assert_eq!( - Some(6), - EncodeEffort::parse_lenient("6").map(EncodeEffort::get) - ); - assert_eq!( - Some(9), - EncodeEffort::parse_lenient("9").map(EncodeEffort::get) - ); - assert_eq!( - Some(9), - EncodeEffort::parse_lenient("250").map(EncodeEffort::get) - ); - assert_eq!(None, EncodeEffort::parse_lenient("256")); - assert_eq!(0, EncodeEffort::minimum().get()); - } -} diff --git a/fluxer_media_proxy/src/lib.rs b/fluxer_media_proxy/src/lib.rs index 45093788b..7bfbe904e 100644 --- a/fluxer_media_proxy/src/lib.rs +++ b/fluxer_media_proxy/src/lib.rs @@ -10,7 +10,6 @@ mod coalescer; pub mod config; pub mod constants; pub mod disposition; -pub mod external_path; pub mod healthcheck; pub mod http_client; pub mod http_headers; @@ -28,9 +27,7 @@ pub mod public_net_policy; pub mod range; pub mod request_log; mod response_body_limit; -mod secret; pub mod server; -pub mod signing; pub mod spool; pub mod storage; pub mod thumbhash; diff --git a/fluxer_media_proxy/src/media_process/animated_transform.rs b/fluxer_media_proxy/src/media_process/animated_transform.rs index d8542b4d7..912ee32bf 100644 --- a/fluxer_media_proxy/src/media_process/animated_transform.rs +++ b/fluxer_media_proxy/src/media_process/animated_transform.rs @@ -51,19 +51,19 @@ pub(super) fn try_decode_heif_primary_still( NativeStatus::Ok if image.is_some() => {} NativeStatus::Ok | NativeStatus::Unsupported | NativeStatus::CodecFailure => { clear_vips_error(); - metrics.record_heif_primary_still_decode_failure(); + metrics.avif_libheif_decode_failures.inc(); return Ok(None); } status => { clear_vips_error(); - metrics.record_heif_primary_still_decode_failure(); + metrics.avif_libheif_decode_failures.inc(); return Err(native_status_error(status, MediaError::MediaDecodeFailed) .expect("non-success native status must map to an error")); } } - metrics.record_heif_primary_still_decode(); + metrics.avif_libheif_decode_count.inc(); if facts.hdr_gain_map_detected != 0 { - metrics.record_heif_hdr_gain_map_detected(); + metrics.heif_hdr_gain_map_count.inc(); } let image = image.ok_or(MediaError::MediaDecodeFailed)?; Ok(Some(DecodedHEIFPrimaryStill { diff --git a/fluxer_media_proxy/src/media_process/av_metadata.rs b/fluxer_media_proxy/src/media_process/av_metadata.rs index 1ceb340ff..ef43623fe 100644 --- a/fluxer_media_proxy/src/media_process/av_metadata.rs +++ b/fluxer_media_proxy/src/media_process/av_metadata.rs @@ -1,17 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use super::encoding::{VipsEncodeRequest, anim_limits_from_options, encode_vips_image}; use super::loaded_image::{resize_loaded_image, validate_dimensions_u32, validate_vips_image}; use super::native_runtime::{clear_vips_error, vips_runtime}; use super::placeholder::encode_thumbhash_image; use super::{MediaError, native_status_error}; use crate::{ constants, - image_quality::ResolvedImageQuality, image_transform::ImageOptions, media_limits::MediaLimits, native::{self, NativeStatus, VipsImageHandle, buffer::NativeBuffer}, - output_format::OutputFormat, thumbhash, }; use libc::c_int; @@ -57,30 +54,6 @@ impl AVMetadataFrame { encode_thumbhash_image(&resized, deadline_ms) } - pub fn encode_nsfw_jpeg( - &self, - media_limits: &MediaLimits, - deadline_ms: Option, - ) -> Result, MediaError> { - let image = self.borrow_as_vips_image(media_limits)?; - encode_vips_image( - &image, - VipsEncodeRequest { - format: OutputFormat::JPEG, - quality: ResolvedImageQuality::Low, - page_height: None, - effort_override: None, - animation: anim_limits_from_options(&ImageOptions { - deadline_ms, - ..Default::default() - }), - animation_loop_count: None, - media_limits, - full_canvas_animation: false, - }, - ) - } - fn borrow_as_vips_image( &self, media_limits: &MediaLimits, diff --git a/fluxer_media_proxy/src/media_process/metadata.rs b/fluxer_media_proxy/src/media_process/metadata.rs index b56c3dc16..592800860 100644 --- a/fluxer_media_proxy/src/media_process/metadata.rs +++ b/fluxer_media_proxy/src/media_process/metadata.rs @@ -291,15 +291,3 @@ pub async fn finish_metadata( let response = metadata_finalize(prepared, verdict); serde_json::to_string(&response).map_err(|_| MediaError::MediaEncodeFailed) } - -pub async fn metadata_json_with_options( - input: &[u8], - _filename: &str, - options: MetadataOptions, - media_limits: &MediaLimits, - nsfw_client: &NSFWClient, - metrics: &TransformMetrics, -) -> Result { - let prepared = prepare_metadata(input, &options, media_limits, metrics)?; - finish_metadata(prepared, nsfw_client).await -} diff --git a/fluxer_media_proxy/src/media_process/mod.rs b/fluxer_media_proxy/src/media_process/mod.rs index 1ba9ef7f8..81cf9d222 100644 --- a/fluxer_media_proxy/src/media_process/mod.rs +++ b/fluxer_media_proxy/src/media_process/mod.rs @@ -30,13 +30,8 @@ mod video_thumbnail; #[cfg(test)] mod tests; -pub use av_metadata::{ - AVMetadata, AVMetadataFrame, AVProbe, NSFW_PREVIEW_MAX_DIMENSION, probe_av_metadata, -}; -pub use metadata::{ - MetadataOptions, PreparedMetadata, finish_metadata, metadata_json_with_options, - prepare_metadata, -}; +pub use av_metadata::{NSFW_PREVIEW_MAX_DIMENSION, probe_av_metadata}; +pub use metadata::{MetadataOptions, finish_metadata, prepare_metadata}; pub use native_cost::{ AV_NATIVE_COST_BYTES, NativeCost, av_native_cost, image_transform_cost, metadata_cost, }; diff --git a/fluxer_media_proxy/src/media_process/nsfw_processing/tests.rs b/fluxer_media_proxy/src/media_process/nsfw_processing/tests.rs index 09f552870..48cedc001 100644 --- a/fluxer_media_proxy/src/media_process/nsfw_processing/tests.rs +++ b/fluxer_media_proxy/src/media_process/nsfw_processing/tests.rs @@ -88,7 +88,7 @@ fn the_video_scan_never_spends_a_slot_on_the_container_first_frame() { return; }; let media_limits = limits(); - let metrics = TransformMetrics::new(); + let metrics = TransformMetrics::default(); let metadata = super::super::probe_av_metadata( &mp4, super::super::NSFW_PREVIEW_MAX_DIMENSION, @@ -96,12 +96,6 @@ fn the_video_scan_never_spends_a_slot_on_the_container_first_frame() { None, ) .expect("probe generated mp4"); - let preview = metadata - .frame - .as_ref() - .expect("the generated mp4 decodes a preview frame") - .encode_nsfw_jpeg(&media_limits, None) - .expect("encode the preview frame"); let frames = nsfw_video_scan_buffers(&NSFWScanSource { media_limits: &media_limits, metrics: &metrics, @@ -119,10 +113,6 @@ fn the_video_scan_never_spends_a_slot_on_the_container_first_frame() { frames.len(), "every corroborating slot must hold a sampled frame" ); - assert!( - !frames.contains(&preview), - "the t=0 preview frame must not take one of the sampled slots" - ); for frame in &frames { assert_bounded_jpeg(frame, &media_limits); } diff --git a/fluxer_media_proxy/src/media_process/tests/encoding.rs b/fluxer_media_proxy/src/media_process/tests/encoding.rs index 7222a1dc2..2928db195 100644 --- a/fluxer_media_proxy/src/media_process/tests/encoding.rs +++ b/fluxer_media_proxy/src/media_process/tests/encoding.rs @@ -6,32 +6,6 @@ use super::super::{AnimatedProbe, EncodeEffort, ImageOptions, ImageQuality, Reso use super::fixtures::effort; use crate::{mime, output_format::OutputFormat}; -#[test] -fn webp_effort_override_is_clamped_to_the_encoder_maximum() { - for requested in [7u8, 8, 9, 200] { - let suffix = webp_suffix(effort(requested)); - assert!( - suffix.contains("effort=6"), - "effort={requested} produced {suffix}, but libvips webpsave rejects effort above 6 \ - and silently falls back to its own default" - ); - } - assert!(webp_suffix(effort(5)).contains("effort=5")); -} - -fn webp_suffix(requested: EncodeEffort) -> String { - output_suffix( - OutputFormat::WebP, - ResolvedImageQuality::High, - None, - Some(requested), - 0, - ) - .expect("webp output suffix") - .into_string() - .expect("utf8 output suffix") -} - #[test] fn png_compression_drops_for_outputs_above_a_megapixel() { let png_suffix = |output_pixels| { @@ -64,13 +38,6 @@ fn png_compression_drops_for_outputs_above_a_megapixel() { ); } -#[test] -fn animated_webp_default_effort_matches_fast_tier() { - assert_eq!(2, ResolvedImageQuality::Low.default_effort(true)); - assert_eq!(2, ResolvedImageQuality::High.default_effort(true)); - assert_eq!(4, ResolvedImageQuality::High.default_effort(false)); -} - #[test] fn auto_animated_palette_quality_matches_v1_thresholds() { let gif_header = b"GIF89a\x01\x00\x01\x00"; diff --git a/fluxer_media_proxy/src/media_process/tests/fixtures.rs b/fluxer_media_proxy/src/media_process/tests/fixtures.rs index 5f0bb7e88..cc5904909 100644 --- a/fluxer_media_proxy/src/media_process/tests/fixtures.rs +++ b/fluxer_media_proxy/src/media_process/tests/fixtures.rs @@ -3,7 +3,7 @@ use super::super::native_runtime::ensure_vips_init; use super::super::{ AnimationLimits, AnimationMode, EncodeEffort, ImageOptions, MediaError, MediaLimits, - MetadataOptions, PNG_SIGNATURE, ProcessedMedia, metadata_json_with_options, + MetadataOptions, PNG_SIGNATURE, ProcessedMedia, finish_metadata, prepare_metadata, }; use crate::{ constants, @@ -69,12 +69,6 @@ pub(super) fn png_ihdr_dimensions(ihdr: &[u8]) -> Option<(u32, u32)> { )) } -pub(super) fn png_zlib_level(bytes: &[u8]) -> Option { - let chunks = parse_png_chunks(bytes).ok()?; - let idat = chunks.iter().find(|chunk| chunk.kind == *b"IDAT")?; - Some(idat.data.get(1)? >> 6) -} - pub(super) fn test_media_limits() -> MediaLimits { MediaLimits::default_from_config() } @@ -100,22 +94,24 @@ pub(super) fn transform_image( input, options, &test_media_limits(), - &TransformMetrics::new(), + &TransformMetrics::default(), ) } -pub(super) fn metadata_value(input: &[u8], filename: &str) -> serde_json::Value { +pub(super) fn metadata_value(input: &[u8], _filename: &str) -> serde_json::Value { let meta = tokio::runtime::Builder::new_current_thread() .build() .unwrap() .block_on(async { - metadata_json_with_options( - input, - filename, - MetadataOptions::default(), - &test_media_limits(), + finish_metadata( + prepare_metadata( + input, + &MetadataOptions::default(), + &test_media_limits(), + &TransformMetrics::default(), + ) + .expect("metadata prepares"), &NSFWClient::disabled(), - &TransformMetrics::new(), ) .await .unwrap() diff --git a/fluxer_media_proxy/src/media_process/tests/heif.rs b/fluxer_media_proxy/src/media_process/tests/heif.rs index 4033535d6..3fd64c84a 100644 --- a/fluxer_media_proxy/src/media_process/tests/heif.rs +++ b/fluxer_media_proxy/src/media_process/tests/heif.rs @@ -156,7 +156,7 @@ fn hdr_pq_avif_tone_maps_to_sdr_pixels() { .decode(hdr_b64) .expect("fixture decodes"); assert_eq!("image/avif", mime::sniff(&avif).mime); - let metrics = TransformMetrics::new(); + let metrics = TransformMetrics::default(); let png = super::super::transform_image( &avif, &ImageOptions { @@ -229,7 +229,7 @@ fn hdr_pq_avif_tone_maps_even_when_the_colour_signal_is_one_libheif_cannot_model cases.push((label, mutated)); } for (label, bytes) in cases { - let metrics = TransformMetrics::new(); + let metrics = TransformMetrics::default(); let png = super::super::transform_image( &bytes, &ImageOptions { diff --git a/fluxer_media_proxy/src/media_process/tests/metadata.rs b/fluxer_media_proxy/src/media_process/tests/metadata.rs index 12bdb817e..65ee9e24b 100644 --- a/fluxer_media_proxy/src/media_process/tests/metadata.rs +++ b/fluxer_media_proxy/src/media_process/tests/metadata.rs @@ -1,10 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use super::super::nsfw_processing::{VideoNSFWFramesRequest, extract_video_frames_for_nsfw}; -use super::super::placeholder::{encode_thumbhash, optional_thumbhash}; +use super::super::placeholder::optional_thumbhash; use super::super::{ AV_NATIVE_COST_BYTES, MediaError, MetadataOptions, NSFW_PREVIEW_MAX_DIMENSION, NativeCost, - extract_video_thumbnail, metadata_cost, metadata_json_with_options, probe_av_metadata, + extract_video_thumbnail, finish_metadata, metadata_cost, prepare_metadata, probe_av_metadata, }; use super::fixtures::{metadata_value, test_media_limits}; use crate::{ @@ -38,13 +38,15 @@ fn metadata_json_includes_dimensions_and_placeholder() { .build() .unwrap() .block_on(async { - metadata_json_with_options( - &png, - "test.png", - MetadataOptions::default(), - &test_media_limits(), + finish_metadata( + prepare_metadata( + &png, + &MetadataOptions::default(), + &test_media_limits(), + &TransformMetrics::default(), + ) + .expect("metadata prepares"), &NSFWClient::disabled(), - &TransformMetrics::new(), ) .await .unwrap() @@ -68,20 +70,22 @@ fn metadata_json_returns_unavailable_when_nsfw_service_fails() { let client = NSFWClient::new( "http://127.0.0.1:9", 0.85, - Arc::new(crate::metrics::nsfw::NSFWMetrics::new()), + Arc::new(crate::metrics::nsfw::NSFWMetrics::default()), ) .expect("nsfw client builds"); - metadata_json_with_options( - &png, - "test.png", - MetadataOptions { - placeholder: false, - nsfw: NSFWPolicy::enabled(0.85).expect("valid threshold"), - deadline_ms: None, - }, - &test_media_limits(), + finish_metadata( + prepare_metadata( + &png, + &MetadataOptions { + placeholder: false, + nsfw: NSFWPolicy::enabled(0.85).expect("valid threshold"), + deadline_ms: None, + }, + &test_media_limits(), + &TransformMetrics::default(), + ) + .expect("metadata prepares"), &client, - &TransformMetrics::new(), ) .await .unwrap_err() @@ -184,16 +188,14 @@ fn metadata_json_still_rejects_a_playable_video_stream_outside_the_codec_allowli .build() .unwrap() .block_on(async { - metadata_json_with_options( + prepare_metadata( &mkv, - "png.mkv", - MetadataOptions::default(), + &MetadataOptions::default(), &test_media_limits(), - &NSFWClient::disabled(), - &TransformMetrics::new(), + &TransformMetrics::default(), ) - .await - .unwrap_err() + .err() + .expect("metadata preparation fails") }); assert_eq!(MediaError::MediaDecodeFailed, err); } @@ -218,13 +220,6 @@ fn metadata_keeps_the_placeholder_for_every_aspect_ratio() { } } -#[test] -fn thumbhash_for_valid_image_is_non_empty() { - let png = synthetic_png(16, 16); - let hash = encode_thumbhash(&test_media_limits(), &png, None).unwrap(); - assert!(!hash.is_empty()); -} - #[test] fn metadata_omits_the_placeholder_when_thumbhash_allocation_fails() { assert_eq!( @@ -259,21 +254,14 @@ fn metadata_refuses_jxl_above_the_decode_pixel_cap() { JXL_4100_LOSSY_RGB_DEPTH, JXL_4100_LOSSLESS_RGB16, ] { - let err = tokio::runtime::Builder::new_current_thread() - .build() - .unwrap() - .block_on(async { - metadata_json_with_options( - jxl, - "big.jxl", - MetadataOptions::default(), - &test_media_limits(), - &NSFWClient::disabled(), - &TransformMetrics::new(), - ) - .await - .unwrap_err() - }); + let err = prepare_metadata( + jxl, + &MetadataOptions::default(), + &test_media_limits(), + &TransformMetrics::default(), + ) + .err() + .expect("metadata preparation fails"); assert_eq!(MediaError::TooLargeToProcess, err); } } @@ -295,16 +283,14 @@ fn metadata_refuses_an_image_past_the_buffer_bound() { .build() .unwrap() .block_on(async { - metadata_json_with_options( + prepare_metadata( &png, - "huge.png", - MetadataOptions::default(), + &MetadataOptions::default(), &test_media_limits(), - &NSFWClient::disabled(), - &TransformMetrics::new(), + &TransformMetrics::default(), ) - .await - .unwrap_err() + .err() + .expect("metadata preparation fails") }); assert_eq!(MediaError::StreamTooLong, err); } diff --git a/fluxer_media_proxy/src/media_process/tests/nsfw.rs b/fluxer_media_proxy/src/media_process/tests/nsfw.rs index 4c22e2c27..bc55bb520 100644 --- a/fluxer_media_proxy/src/media_process/tests/nsfw.rs +++ b/fluxer_media_proxy/src/media_process/tests/nsfw.rs @@ -2,7 +2,6 @@ use super::super::nsfw_processing::{ VideoNSFWFramesRequest, compute_frame_sample_timestamps, extract_video_frames_for_nsfw, - nsfw_frame_seed, }; use super::super::{MediaError, probe_av_metadata}; use super::fixtures::test_media_limits; @@ -30,50 +29,6 @@ fn compute_frame_sample_timestamps_distributes_start_middle_end() { ); } -#[test] -fn nsfw_frame_seed_is_deterministic_per_input() { - let a = b"hello world this is a video header blob".to_vec(); - let b = b"hello world this is a video header blob".to_vec(); - let c = b"hello world this is a different blob xx".to_vec(); - assert_eq!(nsfw_frame_seed(&a), nsfw_frame_seed(&b)); - assert_ne!(nsfw_frame_seed(&a), nsfw_frame_seed(&c)); -} - -#[test] -fn extract_video_frames_for_nsfw_returns_multiple_frames() { - let fixture = std::path::Path::new("tests/fixtures/big-buck-bunny-720p-10s.mp4"); - let alt = std::path::Path::new(".benchmark-cache/media/big-buck-bunny-720p-10s.mp4"); - let path = if fixture.exists() { - fixture - } else if alt.exists() { - alt - } else { - eprintln!("skipping: no video fixture available"); - return; - }; - let bytes = std::fs::read(path).expect("read fixture"); - let limits = test_media_limits(); - let duration = probe_av_metadata(&bytes, 0, &limits, None) - .expect("probe fixture") - .probe - .duration_seconds; - let frames = extract_video_frames_for_nsfw(VideoNSFWFramesRequest { - input: &bytes, - duration_seconds: duration, - deadline_ms: None, - }) - .expect("extract frames"); - assert!( - !frames.is_empty() && frames.len() <= 3, - "expected 1-3 frames, got {}", - frames.len() - ); - for f in &frames { - assert!(f.len() > 100, "JPEG frame too small ({} bytes)", f.len()); - assert_eq!(&f[..2], &[0xFF, 0xD8], "not a JPEG"); - } -} - #[test] fn an_empty_input_fails_to_decode_for_the_probe_and_the_frame_sampler() { let frames = extract_video_frames_for_nsfw(VideoNSFWFramesRequest { diff --git a/fluxer_media_proxy/src/media_process/tests/transform.rs b/fluxer_media_proxy/src/media_process/tests/transform.rs index 3a5f986aa..9243a9689 100644 --- a/fluxer_media_proxy/src/media_process/tests/transform.rs +++ b/fluxer_media_proxy/src/media_process/tests/transform.rs @@ -3,11 +3,8 @@ use super::super::image_probe::{load_image, probe_image_dims}; use super::super::loaded_image::validate_jxl_decode_pixels; use super::super::native_runtime::ensure_vips_init; -use super::super::transform::source_supports_pages; use super::super::{ImageDimensions, ImageOptions, MediaError, NativeCost, image_transform_cost}; -use super::fixtures::{ - animated_mode, metadata_value, png_zlib_level, test_media_limits, transform_image, -}; +use super::fixtures::{animated_mode, metadata_value, test_media_limits, transform_image}; use crate::{ constants, metrics::transform::TransformMetrics, @@ -20,22 +17,6 @@ use crate::{ }, }; -#[test] -fn transforms_png_to_webp() { - let png = synthetic_png(32, 24); - let out = transform_image( - &png, - &ImageOptions { - width: Some(16), - format: OutputFormat::WebP, - ..Default::default() - }, - ) - .unwrap(); - assert_eq!("image/webp", out.content_type); - assert!(out.bytes.starts_with(b"RIFF")); -} - #[test] fn transforms_static_png_with_animated_flag_does_not_pass_n_to_pngload() { let png = synthetic_png(48, 48); @@ -100,19 +81,6 @@ fn reports_bmp_metadata_dimensions_and_placeholder() { assert!(meta["placeholder"].as_str().is_some_and(|p| !p.is_empty())); } -#[test] -fn source_supports_pages_matches_libvips_loader_list() { - assert!(source_supports_pages("image/webp")); - assert!(source_supports_pages("image/gif")); - assert!(source_supports_pages("image/apng")); - assert!(source_supports_pages("image/heif")); - assert!(source_supports_pages("image/avif")); - assert!(!source_supports_pages("image/png")); - assert!(!source_supports_pages("image/jpeg")); - assert!(!source_supports_pages("image/bmp")); - assert!(!source_supports_pages("application/octet-stream")); -} - #[test] fn refuses_jxl_above_the_decode_pixel_cap_before_decoding() { for jxl in [ @@ -229,7 +197,7 @@ fn full_size_jxl_is_downscaled_to_fit_the_native_memory_budget() { JXL_7000X4000_LOSSY_RGB, &options, &media_limits, - &TransformMetrics::new(), + &TransformMetrics::default(), ) .unwrap(); let dims = probe_image_dims(&test_media_limits(), &out.bytes).unwrap(); @@ -344,16 +312,3 @@ fn transforms_a_rotated_jpeg_with_mismatched_scan_headers_at_full_size() { .unwrap(); assert_eq!(Some((768, 1024)), png_dimensions(&out.bytes)); } - -#[test] -fn png_outputs_above_a_megapixel_use_default_zlib_compression() { - let png_options = ImageOptions { - format: OutputFormat::PNG, - ..Default::default() - }; - let small = transform_image(&synthetic_bmp(160, 160), &png_options).unwrap(); - assert_eq!(Some(3), png_zlib_level(&small.bytes)); - let large = transform_image(&synthetic_bmp(1100, 1000), &png_options).unwrap(); - assert_eq!(Some((1100, 1000)), png_dimensions(&large.bytes)); - assert_eq!(Some(2), png_zlib_level(&large.bytes)); -} diff --git a/fluxer_media_proxy/src/media_process/tests/video.rs b/fluxer_media_proxy/src/media_process/tests/video.rs index b7bc5422d..ea3dec96c 100644 --- a/fluxer_media_proxy/src/media_process/tests/video.rs +++ b/fluxer_media_proxy/src/media_process/tests/video.rs @@ -4,13 +4,12 @@ use super::super::{ MediaError, VideoThumbnailOptions, extract_video_thumbnail, extract_video_thumbnail_with_options, }; -use super::fixtures::{decode_rgba, metadata_value, png_zlib_level, test_media_limits}; +use super::fixtures::{decode_rgba, metadata_value, test_media_limits}; use crate::{ image_quality::ResolvedImageQuality, output_format::OutputFormat, test_fixtures::{ - ffmpeg_gen_media, ffmpeg_gen_mp4, ffmpeg_gen_rotated_mp4, ffmpeg_mirror_mp4, - fixture_h264_mp4, png_dimensions, + ffmpeg_gen_media, ffmpeg_gen_mp4, ffmpeg_gen_rotated_mp4, ffmpeg_mirror_mp4, png_dimensions, }, }; @@ -36,14 +35,6 @@ fn color_tagged_video(primaries: &str, transfer: &str, matrix: &str) -> Option Self { - Self { - coalescer_leader: AtomicU64::new(0), - coalescer_waiter: AtomicU64::new(0), - coalescer_waiter_rejected: AtomicU64::new(0), - } - } +impl CoalescerMetrics {} - pub fn record_leader(&self) { - self.coalescer_leader.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_waiter(&self) { - self.coalescer_waiter.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_waiter_rejected(&self) { - self.coalescer_waiter_rejected - .fetch_add(1, Ordering::Relaxed); - } -} - -#[derive(Debug)] +#[derive(Debug, Default)] pub struct TransformCacheMetrics { - pub(super) transform_cache_hits: AtomicU64, - pub(super) transform_cache_misses: AtomicU64, - pub(super) transform_cache_insert_rejected: AtomicU64, - pub(super) transform_cache_evictions: AtomicU64, + pub transform_cache_hits: Counter, + pub transform_cache_misses: Counter, + pub transform_cache_insert_rejected: Counter, + pub transform_cache_evictions: Counter, } -impl TransformCacheMetrics { - pub(crate) fn new() -> Self { - Self { - transform_cache_hits: AtomicU64::new(0), - transform_cache_misses: AtomicU64::new(0), - transform_cache_insert_rejected: AtomicU64::new(0), - transform_cache_evictions: AtomicU64::new(0), - } - } +impl TransformCacheMetrics {} - pub fn record_hit(&self) { - self.transform_cache_hits.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_miss(&self) { - self.transform_cache_misses.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_insert_rejected(&self) { - self.transform_cache_insert_rejected - .fetch_add(1, Ordering::Relaxed); - } - - pub fn record_eviction(&self) { - self.transform_cache_evictions - .fetch_add(1, Ordering::Relaxed); - } -} +use super::Counter; diff --git a/fluxer_media_proxy/src/metrics/external.rs b/fluxer_media_proxy/src/metrics/external.rs index 79950e013..df706542f 100644 --- a/fluxer_media_proxy/src/metrics/external.rs +++ b/fluxer_media_proxy/src/metrics/external.rs @@ -1,40 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use std::sync::atomic::{AtomicU64, Ordering}; - -#[derive(Debug)] +#[derive(Debug, Default)] pub struct ExternalMetrics { - pub(super) fetch_failures: AtomicU64, - pub(super) blocked_url_attempts: AtomicU64, - pub(super) external_buffer_rejected: AtomicU64, - pub(super) external_stream_overruns: AtomicU64, + pub fetch_failures: Counter, + pub blocked_url_attempts: Counter, + pub external_buffer_rejected: Counter, + pub external_stream_overruns: Counter, } -impl ExternalMetrics { - pub(crate) fn new() -> Self { - Self { - fetch_failures: AtomicU64::new(0), - blocked_url_attempts: AtomicU64::new(0), - external_buffer_rejected: AtomicU64::new(0), - external_stream_overruns: AtomicU64::new(0), - } - } +impl ExternalMetrics {} - pub fn record_fetch_failure(&self) { - self.fetch_failures.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_blocked_url(&self) { - self.blocked_url_attempts.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_buffer_rejected(&self) { - self.external_buffer_rejected - .fetch_add(1, Ordering::Relaxed); - } - - pub fn record_stream_overrun(&self) { - self.external_stream_overruns - .fetch_add(1, Ordering::Relaxed); - } -} +use super::Counter; diff --git a/fluxer_media_proxy/src/metrics/http_client.rs b/fluxer_media_proxy/src/metrics/http_client.rs index 67c74e4c7..f8e72ad76 100644 --- a/fluxer_media_proxy/src/metrics/http_client.rs +++ b/fluxer_media_proxy/src/metrics/http_client.rs @@ -1,38 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use std::sync::atomic::{AtomicU64, Ordering}; - -#[derive(Debug)] +#[derive(Debug, Default)] pub struct HTTPClientMetrics { - pub(super) http_retries: AtomicU64, - pub(super) http_retries_exhausted: AtomicU64, - pub(super) http_retryable_status: AtomicU64, - pub(super) http_retryable_error: AtomicU64, + pub http_retries: Counter, + pub http_retries_exhausted: Counter, + pub http_retryable_status: Counter, + pub http_retryable_error: Counter, } -impl HTTPClientMetrics { - pub(crate) fn new() -> Self { - Self { - http_retries: AtomicU64::new(0), - http_retries_exhausted: AtomicU64::new(0), - http_retryable_status: AtomicU64::new(0), - http_retryable_error: AtomicU64::new(0), - } - } +impl HTTPClientMetrics {} - pub fn record_retry(&self) { - self.http_retries.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_retries_exhausted(&self) { - self.http_retries_exhausted.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_retryable_status(&self) { - self.http_retryable_status.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_retryable_error(&self) { - self.http_retryable_error.fetch_add(1, Ordering::Relaxed); - } -} +use super::Counter; diff --git a/fluxer_media_proxy/src/metrics/mod.rs b/fluxer_media_proxy/src/metrics/mod.rs index f90d2ca08..28319d2a3 100644 --- a/fluxer_media_proxy/src/metrics/mod.rs +++ b/fluxer_media_proxy/src/metrics/mod.rs @@ -3,7 +3,6 @@ pub mod attachment_signature; pub mod cache; pub mod external; -pub mod histogram; pub mod http_client; pub mod nsfw; pub mod relay; @@ -24,24 +23,9 @@ use self::{ storage::StorageMetrics, transform::{NativeTransformMetrics, TransformMetrics}, }; -use libc::{CLOCK_MONOTONIC, clock_gettime, timespec}; use std::sync::Arc; use std::time::Duration; -pub fn now_ms() -> i64 { - let mut ts = timespec { - tv_sec: 0, - tv_nsec: 0, - }; - let rc = unsafe { clock_gettime(CLOCK_MONOTONIC, &mut ts) }; - if rc != 0 { - return 0; - } - ts.tv_sec - .saturating_mul(1_000) - .saturating_add(ts.tv_nsec / 1_000_000) -} - pub fn duration_millis(duration: Duration) -> u64 { u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) } @@ -65,15 +49,15 @@ impl Metrics { pub fn new() -> Self { Self { request: Arc::new(RequestMetrics::new()), - transform: Arc::new(TransformMetrics::new()), + transform: Arc::new(TransformMetrics::default()), native_transform: Arc::new(NativeTransformMetrics::new()), - coalescer: Arc::new(CoalescerMetrics::new()), - transform_cache: Arc::new(TransformCacheMetrics::new()), - storage: Arc::new(StorageMetrics::new()), - nsfw: Arc::new(NSFWMetrics::new()), - external: Arc::new(ExternalMetrics::new()), - relay: Arc::new(RelayMetrics::new()), - http_client: Arc::new(HTTPClientMetrics::new()), + coalescer: Arc::new(CoalescerMetrics::default()), + transform_cache: Arc::new(TransformCacheMetrics::default()), + storage: Arc::new(StorageMetrics::default()), + nsfw: Arc::new(NSFWMetrics::default()), + external: Arc::new(ExternalMetrics::default()), + relay: Arc::new(RelayMetrics::default()), + http_client: Arc::new(HTTPClientMetrics::default()), attachment_signature: Arc::new(AttachmentSignatureMetrics::new()), start_ms: now_ms(), } @@ -136,3 +120,16 @@ impl Default for Metrics { Self::new() } } + +pub use fluxer_common::metrics::now_ms; + +#[derive(Debug, Default)] +pub struct Counter(std::sync::atomic::AtomicU64); +impl Counter { + pub fn inc(&self) { + self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + pub fn get(&self) -> u64 { + self.0.load(std::sync::atomic::Ordering::Relaxed) + } +} diff --git a/fluxer_media_proxy/src/metrics/nsfw.rs b/fluxer_media_proxy/src/metrics/nsfw.rs index 5134b7688..80964725b 100644 --- a/fluxer_media_proxy/src/metrics/nsfw.rs +++ b/fluxer_media_proxy/src/metrics/nsfw.rs @@ -1,32 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use std::sync::atomic::{AtomicU64, Ordering}; - -#[derive(Debug)] +#[derive(Debug, Default)] pub struct NSFWMetrics { - pub(super) nsfw_calls_ok: AtomicU64, - pub(super) nsfw_calls_failed: AtomicU64, - pub(super) nsfw_calls_disabled: AtomicU64, + pub nsfw_calls_ok: Counter, + pub nsfw_calls_failed: Counter, + pub nsfw_calls_disabled: Counter, } -impl NSFWMetrics { - pub(crate) fn new() -> Self { - Self { - nsfw_calls_ok: AtomicU64::new(0), - nsfw_calls_failed: AtomicU64::new(0), - nsfw_calls_disabled: AtomicU64::new(0), - } - } +impl NSFWMetrics {} - pub fn record_success(&self) { - self.nsfw_calls_ok.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_failure(&self) { - self.nsfw_calls_failed.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_disabled(&self) { - self.nsfw_calls_disabled.fetch_add(1, Ordering::Relaxed); - } -} +use super::Counter; diff --git a/fluxer_media_proxy/src/metrics/relay.rs b/fluxer_media_proxy/src/metrics/relay.rs index 1e4e57ba2..bcdc904b4 100644 --- a/fluxer_media_proxy/src/metrics/relay.rs +++ b/fluxer_media_proxy/src/metrics/relay.rs @@ -1,40 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use std::sync::atomic::{AtomicU64, Ordering}; - -#[derive(Debug)] +#[derive(Debug, Default)] pub struct RelayMetrics { - pub(super) relay_upstream_success: AtomicU64, - pub(super) relay_upstream_retries: AtomicU64, - pub(super) relay_upstream_failures_retryable: AtomicU64, - pub(super) relay_upstream_failures_hard: AtomicU64, + pub relay_upstream_success: Counter, + pub relay_upstream_retries: Counter, + pub relay_upstream_failures_retryable: Counter, + pub relay_upstream_failures_hard: Counter, } -impl RelayMetrics { - pub(crate) fn new() -> Self { - Self { - relay_upstream_success: AtomicU64::new(0), - relay_upstream_retries: AtomicU64::new(0), - relay_upstream_failures_retryable: AtomicU64::new(0), - relay_upstream_failures_hard: AtomicU64::new(0), - } - } +impl RelayMetrics {} - pub fn record_success(&self) { - self.relay_upstream_success.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_retry(&self) { - self.relay_upstream_retries.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_retryable_failure(&self) { - self.relay_upstream_failures_retryable - .fetch_add(1, Ordering::Relaxed); - } - - pub fn record_hard_failure(&self) { - self.relay_upstream_failures_hard - .fetch_add(1, Ordering::Relaxed); - } -} +use super::Counter; diff --git a/fluxer_media_proxy/src/metrics/rendering.rs b/fluxer_media_proxy/src/metrics/rendering.rs index b5d402ae2..345af0086 100644 --- a/fluxer_media_proxy/src/metrics/rendering.rs +++ b/fluxer_media_proxy/src/metrics/rendering.rs @@ -1,9 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use super::{ - Metrics, attachment_signature, - histogram::Histogram, - now_ms, + Metrics, attachment_signature, now_ms, request::{REQUEST_KIND_COUNT, RequestKind}, }; use std::{ @@ -57,12 +55,12 @@ pub(super) fn render_metrics(out: &mut String, metrics: &Metrics) -> fmt::Result "fluxer_media_proxy_native_transform_wait_ms", &metrics.native_transform.native_transform_wait, )?; - render_counter( + render_atomic_counter( out, "fluxer_media_proxy_native_transform_rejected_total", &metrics.native_transform.native_transform_rejected, )?; - render_counter( + render_atomic_counter( out, "fluxer_media_proxy_native_tasks_detached_total", &metrics.native_transform.native_tasks_detached, @@ -209,18 +207,12 @@ pub(super) fn render_metrics(out: &mut String, metrics: &Metrics) -> fmt::Result writeln!( out, "fluxer_media_proxy_http_retryable_classifications_total{{reason=\"status\"}} {}", - metrics - .http_client - .http_retryable_status - .load(Ordering::Relaxed) + metrics.http_client.http_retryable_status.get() )?; writeln!( out, "fluxer_media_proxy_http_retryable_classifications_total{{reason=\"error\"}} {}", - metrics - .http_client - .http_retryable_error - .load(Ordering::Relaxed) + metrics.http_client.http_retryable_error.get() )?; writeln!( out, @@ -229,18 +221,12 @@ pub(super) fn render_metrics(out: &mut String, metrics: &Metrics) -> fmt::Result writeln!( out, "fluxer_media_proxy_relay_upstream_failures_total{{status=\"503\",retryable=\"true\"}} {}", - metrics - .relay - .relay_upstream_failures_retryable - .load(Ordering::Relaxed) + metrics.relay.relay_upstream_failures_retryable.get() )?; writeln!( out, "fluxer_media_proxy_relay_upstream_failures_total{{status=\"502\",retryable=\"false\"}} {}", - metrics - .relay - .relay_upstream_failures_hard - .load(Ordering::Relaxed) + metrics.relay.relay_upstream_failures_hard.get() )?; render_counter( out, @@ -291,9 +277,9 @@ pub(super) fn render_metrics(out: &mut String, metrics: &Metrics) -> fmt::Result ) } -fn render_counter(out: &mut String, name: &str, counter: &AtomicU64) -> fmt::Result { +fn render_counter(out: &mut String, name: &str, counter: &super::Counter) -> fmt::Result { writeln!(out, "# TYPE {name} counter")?; - writeln!(out, "{name} {}", counter.load(Ordering::Relaxed)) + writeln!(out, "{name} {}", counter.get()) } fn render_gauge(out: &mut String, name: &str, gauge: &AtomicU64) -> fmt::Result { @@ -335,3 +321,9 @@ fn render_per_kind_histogram( } Ok(()) } + +use fluxer_common::metrics::Histogram; + +fn render_atomic_counter(out: &mut String, name: &str, counter: &AtomicU64) -> fmt::Result { + fluxer_common::metrics::render_counter(out, name, counter) +} diff --git a/fluxer_media_proxy/src/metrics/request.rs b/fluxer_media_proxy/src/metrics/request.rs index 61e4daf59..340163238 100644 --- a/fluxer_media_proxy/src/metrics/request.rs +++ b/fluxer_media_proxy/src/metrics/request.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use super::histogram::Histogram; +use fluxer_common::metrics::Histogram; use std::sync::atomic::{AtomicU64, Ordering}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/fluxer_media_proxy/src/metrics/storage.rs b/fluxer_media_proxy/src/metrics/storage.rs index 6475b98c3..63d8ef956 100644 --- a/fluxer_media_proxy/src/metrics/storage.rs +++ b/fluxer_media_proxy/src/metrics/storage.rs @@ -1,32 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use std::sync::atomic::{AtomicU64, Ordering}; - -#[derive(Debug)] +#[derive(Debug, Default)] pub struct StorageMetrics { - pub(super) storage_hits: AtomicU64, - pub(super) storage_misses: AtomicU64, - pub(super) storage_errors: AtomicU64, + pub storage_hits: Counter, + pub storage_misses: Counter, + pub storage_errors: Counter, } -impl StorageMetrics { - pub(crate) fn new() -> Self { - Self { - storage_hits: AtomicU64::new(0), - storage_misses: AtomicU64::new(0), - storage_errors: AtomicU64::new(0), - } - } +impl StorageMetrics {} - pub fn record_hit(&self) { - self.storage_hits.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_miss(&self) { - self.storage_misses.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_error(&self) { - self.storage_errors.fetch_add(1, Ordering::Relaxed); - } -} +use super::Counter; diff --git a/fluxer_media_proxy/src/metrics/transform.rs b/fluxer_media_proxy/src/metrics/transform.rs index 5b9a1a164..8cc9b32fe 100644 --- a/fluxer_media_proxy/src/metrics/transform.rs +++ b/fluxer_media_proxy/src/metrics/transform.rs @@ -1,33 +1,21 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use super::histogram::Histogram; +use fluxer_common::metrics::Histogram; use std::sync::atomic::{AtomicU64, Ordering}; +#[derive(Default)] pub struct TransformMetrics { - pub(super) transform_image_duration: Histogram, - pub(super) transform_video_duration: Histogram, - pub(super) transform_failures: AtomicU64, - pub(super) decode_failures: AtomicU64, - pub(super) hdr_tone_map_count: AtomicU64, - pub(super) heif_hdr_gain_map_count: AtomicU64, - pub(super) avif_libheif_decode_count: AtomicU64, - pub(super) avif_libheif_decode_failures: AtomicU64, + pub transform_image_duration: Histogram, + pub transform_video_duration: Histogram, + pub transform_failures: Counter, + pub decode_failures: Counter, + pub hdr_tone_map_count: Counter, + pub heif_hdr_gain_map_count: Counter, + pub avif_libheif_decode_count: Counter, + pub avif_libheif_decode_failures: Counter, } impl TransformMetrics { - pub(crate) fn new() -> Self { - Self { - transform_image_duration: Histogram::new(), - transform_video_duration: Histogram::new(), - transform_failures: AtomicU64::new(0), - decode_failures: AtomicU64::new(0), - hdr_tone_map_count: AtomicU64::new(0), - heif_hdr_gain_map_count: AtomicU64::new(0), - avif_libheif_decode_count: AtomicU64::new(0), - avif_libheif_decode_failures: AtomicU64::new(0), - } - } - pub fn observe_image_duration(&self, ms: u64) { self.transform_image_duration.observe(ms); } @@ -36,34 +24,8 @@ impl TransformMetrics { self.transform_video_duration.observe(ms); } - pub fn record_transform_failure(&self) { - self.transform_failures.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_decode_failure(&self) { - self.decode_failures.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_hdr_tone_map(&self) { - self.hdr_tone_map_count.fetch_add(1, Ordering::Relaxed); - } - pub fn hdr_tone_map_count(&self) -> u64 { - self.hdr_tone_map_count.load(Ordering::Relaxed) - } - - pub fn record_heif_hdr_gain_map_detected(&self) { - self.heif_hdr_gain_map_count.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_heif_primary_still_decode(&self) { - self.avif_libheif_decode_count - .fetch_add(1, Ordering::Relaxed); - } - - pub fn record_heif_primary_still_decode_failure(&self) { - self.avif_libheif_decode_failures - .fetch_add(1, Ordering::Relaxed); + self.hdr_tone_map_count.get() } } @@ -112,3 +74,5 @@ impl NativeTransformMetrics { self.native_task_detached_duration.observe(ms); } } + +use super::Counter; diff --git a/fluxer_media_proxy/src/mime/tests.rs b/fluxer_media_proxy/src/mime/tests.rs index de9f72a82..1fa3e8004 100644 --- a/fluxer_media_proxy/src/mime/tests.rs +++ b/fluxer_media_proxy/src/mime/tests.rs @@ -121,12 +121,6 @@ fn javascript_content_type_leaves_every_other_type_alone() { } } -#[test] -fn the_inert_content_type_is_not_javascript() { - assert_eq!("text/plain; charset=utf-8", INERT_CONTENT_TYPE); - assert!(!is_javascript_content_type(INERT_CONTENT_TYPE)); -} - #[test] fn javascript_content_type_never_panics_on_adversarial_text() { for text in ADVERSARIAL_TEXT_INPUTS { diff --git a/fluxer_media_proxy/src/native_shim/animation_decode.c b/fluxer_media_proxy/src/native_shim/animation_decode.c index c3b71f5da..faec73935 100644 --- a/fluxer_media_proxy/src/native_shim/animation_decode.c +++ b/fluxer_media_proxy/src/native_shim/animation_decode.c @@ -51,7 +51,6 @@ struct ff_animation_decode_request { const AVInputFormat *forced_format; int expected_frames; int minimum_frames; - int decode_pixels; }; struct ff_animation_decode_context { @@ -188,10 +187,6 @@ static int ff_animation_accept_frame( context->request.max_total_pixels / frame_pixels) { return FF_APPEND_FRAME_LIMIT; } - if (context->request.decode_pixels == 0) { - context->frames++; - return FLUXER_NATIVE_STATUS_OK; - } status = ff_animation_reserve_frames(context, frame_bytes); if (status != FLUXER_NATIVE_STATUS_OK) return status; @@ -251,12 +246,6 @@ static int ff_animation_decode_request_valid( if (request->minimum_frames > request->max_frames) { return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; } - if (request->decode_pixels < 0) { - return FLUXER_NATIVE_STATUS_CODEC_FAILURE; - } - if (request->decode_pixels > 1) { - return FLUXER_NATIVE_STATUS_CODEC_FAILURE; - } return FLUXER_NATIVE_STATUS_OK; } @@ -590,7 +579,6 @@ static int ff_animation_build_image( assert(context != NULL); assert(out != NULL); assert(*out == NULL); - assert(context->request.decode_pixels == 1); assert(context->pixels != NULL); assert(context->delays != NULL); int deadline_status = fluxer_native_deadline_status( @@ -698,17 +686,12 @@ static int ffmpeg_decode_animation_stack( const char *allowed_formats, const AVInputFormat *forced_format, int expected_frames, - int minimum_frames, - int decode_pixels + int minimum_frames ) { if (out_frame_count == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; *out_frame_count = 0; - if (decode_pixels != 0) { - if (out == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; - *out = NULL; - } else if (out != NULL) { - return FLUXER_NATIVE_STATUS_CODEC_FAILURE; - } + if (out == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out = NULL; struct ff_animation_decode_request request = { .data = media_data, .len = media_len, @@ -721,7 +704,6 @@ static int ffmpeg_decode_animation_stack( .forced_format = forced_format, .expected_frames = expected_frames, .minimum_frames = minimum_frames, - .decode_pixels = decode_pixels, }; int status = ff_animation_decode_request_valid(&request); if (status != FLUXER_NATIVE_STATUS_OK) return status; @@ -730,16 +712,10 @@ static int ffmpeg_decode_animation_stack( ff_animation_decode_context_init(&context, &request); status = ff_animation_decode_execute(&context); if (status == FLUXER_NATIVE_STATUS_OK) { - if (decode_pixels != 0) { - status = ff_animation_build_image(&context, out); - } + status = ff_animation_build_image(&context, out); } if (status == FLUXER_NATIVE_STATUS_OK) { *out_frame_count = context.frames; - if (decode_pixels == 0) { - assert(context.pixels == NULL); - assert(context.delays == NULL); - } } ff_animation_decode_context_clear(&context); if (status != FLUXER_NATIVE_STATUS_OK) { @@ -779,7 +755,7 @@ int fluxer_ffmpeg_decode_apng( apng_data, apng_len, decoder_threads, deadline_monotonic_ms, out, &decoded_frames, max_frames, max_total_pixels, require_complete, "apng", av_find_input_format("apng"), - expected_frames, 1, 1); + expected_frames, 1); if (status == FLUXER_NATIVE_STATUS_OK) { *out_num_plays = num_plays; } else { @@ -805,7 +781,7 @@ int fluxer_ffmpeg_decode_bmp( int status = ffmpeg_decode_animation_stack( bmp_data, bmp_len, decoder_threads, deadline_monotonic_ms, out, &decoded_frames, 1, max_total_pixels, 1, - FLUXER_AV_BMP_INPUT_FORMAT, bmp_format, 1, 1, 1); + FLUXER_AV_BMP_INPUT_FORMAT, bmp_format, 1, 1); if (status == FLUXER_NATIVE_STATUS_OK) { assert(*out != NULL); assert(decoded_frames == 1); @@ -815,31 +791,7 @@ int fluxer_ffmpeg_decode_bmp( return status; } -int fluxer_ffmpeg_count_heif_sequence_frames( - const void *heif_data, - size_t heif_len, - int decoder_threads, - long long deadline_monotonic_ms, - int max_frames, - size_t max_total_pixels, - int *out_frame_count -) { - if (out_frame_count == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; - *out_frame_count = 0; - int validation_status = fluxer_heif_validate( - heif_data, heif_len, deadline_monotonic_ms); - if (validation_status != FLUXER_NATIVE_STATUS_OK) return validation_status; - int status = ffmpeg_decode_animation_stack( - heif_data, heif_len, decoder_threads, deadline_monotonic_ms, - NULL, out_frame_count, max_frames, max_total_pixels, - 1, FLUXER_AV_INPUT_FORMATS, NULL, 0, 2, 0); - if (status == FLUXER_NATIVE_STATUS_OK) { - assert(*out_frame_count >= 2); - } else { - assert(*out_frame_count == 0); - } - return status; -} + int fluxer_ffmpeg_decode_heif_sequence( const void *heif_data, @@ -861,7 +813,7 @@ int fluxer_ffmpeg_decode_heif_sequence( int status = ffmpeg_decode_animation_stack( heif_data, heif_len, decoder_threads, deadline_monotonic_ms, out, out_frame_count, max_frames, max_total_pixels, - 1, FLUXER_AV_INPUT_FORMATS, NULL, 0, 2, 1); + 1, FLUXER_AV_INPUT_FORMATS, NULL, 0, 2); if (status == FLUXER_NATIVE_STATUS_OK) { assert(*out != NULL); assert(*out_frame_count >= 2); diff --git a/fluxer_media_proxy/src/native_shim/vips_image.c b/fluxer_media_proxy/src/native_shim/vips_image.c index 9610d1955..00d6001cc 100644 --- a/fluxer_media_proxy/src/native_shim/vips_image.c +++ b/fluxer_media_proxy/src/native_shim/vips_image.c @@ -180,37 +180,8 @@ struct fluxer_vips_deadline_guard { int reached; }; -static int fluxer_vips_deadline_guard_start( - struct fluxer_vips_deadline_guard *guard, - VipsImage *image, - long long deadline_monotonic_ms); -static int fluxer_vips_deadline_guard_finish( - struct fluxer_vips_deadline_guard *guard); -int fluxer_vips_image_copy_memory( - VipsImage *in, - long long deadline_monotonic_ms, - VipsImage **out -) { - if (out == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; - *out = NULL; - if (in == NULL || deadline_monotonic_ms < 0) { - return FLUXER_NATIVE_STATUS_CODEC_FAILURE; - } - struct fluxer_vips_deadline_guard deadline_guard; - int status = fluxer_vips_deadline_guard_start( - &deadline_guard, in, deadline_monotonic_ms); - if (status != FLUXER_NATIVE_STATUS_OK) return status; - VipsImage *memory = vips_image_copy_memory(in); - int deadline_status = fluxer_vips_deadline_guard_finish(&deadline_guard); - if (deadline_status != FLUXER_NATIVE_STATUS_OK) { - if (memory != NULL) g_object_unref(memory); - return deadline_status; - } - if (memory == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; - *out = memory; - return FLUXER_NATIVE_STATUS_OK; -} + int fluxer_vips_autorot( VipsImage *in, diff --git a/fluxer_media_proxy/src/nsfw.rs b/fluxer_media_proxy/src/nsfw.rs index 0676d7604..0dc9ae581 100644 --- a/fluxer_media_proxy/src/nsfw.rs +++ b/fluxer_media_proxy/src/nsfw.rs @@ -265,7 +265,7 @@ impl NSFWClient { Self { transport: None, policy: NSFWPolicy::Disabled, - metrics: Arc::new(NSFWMetrics::new()), + metrics: Arc::new(NSFWMetrics::default()), } } @@ -274,7 +274,7 @@ impl NSFWClient { } pub fn record_declined_scan(&self) { - self.metrics.record_disabled(); + self.metrics.nsfw_calls_disabled.inc(); } pub async fn check_buffers( @@ -283,9 +283,9 @@ impl NSFWClient { ) -> Result { let outcome = timed_stage(Stage::Nsfw, self.classify(request)).await; match outcome { - Ok(_) => self.metrics.record_success(), - Err(NSFWError::Disabled) => self.metrics.record_disabled(), - Err(_) => self.metrics.record_failure(), + Ok(_) => self.metrics.nsfw_calls_ok.inc(), + Err(NSFWError::Disabled) => self.metrics.nsfw_calls_disabled.inc(), + Err(_) => self.metrics.nsfw_calls_failed.inc(), } outcome } @@ -449,19 +449,11 @@ mod tests { #[test] fn disabled_when_endpoint_empty() { - let client = NSFWClient::new("", 0.85, Arc::new(NSFWMetrics::new())).unwrap(); + let client = NSFWClient::new("", 0.85, Arc::new(NSFWMetrics::default())).unwrap(); assert_eq!(NSFWPolicy::Disabled, client.policy()); assert_eq!(None, client.policy().scan_threshold(true)); } - #[test] - fn trim_trailing_slash_matches_service_urls() { - assert_eq!("http://x", trim_trailing_slash("http://x")); - assert_eq!("http://x", trim_trailing_slash("http://x/")); - assert_eq!("http://x", trim_trailing_slash("http://x///")); - assert_eq!("", trim_trailing_slash("/")); - } - #[test] fn parse_probability_accepts_known_shapes() { assert!((parse_probability(br#"{"nsfw_probability":0.42}"#).unwrap() - 0.42).abs() < 0.001); @@ -530,20 +522,4 @@ mod tests { assert!(rendered.contains("fluxer_media_proxy_nsfw_calls_failed_total 1")); assert!(rendered.contains("fluxer_media_proxy_nsfw_calls_ok_total 0")); } - - #[tokio::test] - async fn a_disabled_client_records_a_disabled_call() { - let metrics = Metrics::new(); - let client = NSFWClient::new("", 0.85, metrics.nsfw()).unwrap(); - let request = NSFWScanRequest::new(threshold(), vec![vec![1]]).unwrap(); - assert_eq!( - Err(NSFWError::Disabled), - client.check_buffers(request).await - ); - assert!( - metrics - .render() - .contains("fluxer_media_proxy_nsfw_calls_disabled_total 1") - ); - } } diff --git a/fluxer_media_proxy/src/policy_tests/parsers.rs b/fluxer_media_proxy/src/policy_tests/parsers.rs index 420b5a81c..e3f7967d6 100644 --- a/fluxer_media_proxy/src/policy_tests/parsers.rs +++ b/fluxer_media_proxy/src/policy_tests/parsers.rs @@ -1,39 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::{ - asset_hash::AssetHash, constants::{DEFAULT_IMAGE_SIZE, parse_image_size}, image_quality::ImageQuality, image_transform::EncodeEffort, }; -#[test] -fn asset_hash_parsing_stays_lenient_about_the_digest_shape() { - for raw in [ - "", - "deadbee", - "deadbeef0", - "DEADBEEF", - "gggggggg", - "0123abcd", - "a-deadbeef", - ] { - let parsed = AssetHash::parse(raw); - assert_eq!(parsed.digest(), raw, "the digest rewrote {raw}"); - assert!(!parsed.is_animated(), "{raw} was read as animated"); - } - for (raw, digest) in [ - ("a_deadbeef", "deadbeef"), - ("a_DEADBEEF", "DEADBEEF"), - ("a_gggggggg", "gggggggg"), - ("a_", ""), - ] { - let parsed = AssetHash::parse(raw); - assert_eq!(parsed.digest(), digest, "the digest rewrote {raw}"); - assert!(parsed.is_animated(), "{raw} lost its animation prefix"); - } -} - #[test] fn malformed_query_values_fall_back_instead_of_failing_the_request() { for raw in ["", "-1", "1e3", "99999999999", "128px", " 128"] { diff --git a/fluxer_media_proxy/src/public_net_policy/mod.rs b/fluxer_media_proxy/src/public_net_policy/mod.rs index 908ec8b68..9171c5429 100644 --- a/fluxer_media_proxy/src/public_net_policy/mod.rs +++ b/fluxer_media_proxy/src/public_net_policy/mod.rs @@ -1,12 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -mod ip_tables; mod resolver; #[cfg(test)] mod tests; -pub use ip_tables::is_blocked_ip_literal; +use fluxer_common::public_ip::is_public_ip; +pub use fluxer_common::public_ip::is_valid_public_hostname; pub use resolver::{PinnedDnsResolver, is_pinned_dns_failure}; use std::net::IpAddr; @@ -14,8 +14,6 @@ use thiserror::Error; use url::{Host, Url}; const MAX_URL_LEN: usize = 8192; -const MAX_PUBLIC_HOSTNAME_BYTES: usize = 253; -const MAX_PUBLIC_HOSTNAME_LABEL_BYTES: usize = 63; const ALLOWED_PUBLIC_URL_PORTS: [u16; 2] = [80, 443]; #[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] @@ -127,30 +125,6 @@ fn normalize_host(raw: &str) -> Result { Ok(without_dot.to_ascii_lowercase()) } -pub fn is_valid_public_hostname(host: &str) -> bool { - if host.is_empty() || host.len() > MAX_PUBLIC_HOSTNAME_BYTES || !host.contains('.') { - return false; - } - let mut last = ""; - for label in host.split('.') { - if label.is_empty() || label.len() > MAX_PUBLIC_HOSTNAME_LABEL_BYTES { - return false; - } - let bytes = label.as_bytes(); - if !bytes[0].is_ascii_alphanumeric() || !bytes[bytes.len() - 1].is_ascii_alphanumeric() { - return false; - } - if !bytes - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') - { - return false; - } - last = label; - } - !last.bytes().all(|byte| byte.is_ascii_digit()) -} - pub fn validate_url(url: &str) -> Result<(), Error> { let parsed = parse_url(url)?; if parsed.fragment.is_some() { @@ -174,7 +148,7 @@ pub fn validate_url(url: &str) -> Result<(), Error> { None => return Err(Error::InvalidUrl), }; if let Some(address) = address { - return if ip_tables::is_public_ip(address) { + return if is_public_ip(address) { Ok(()) } else { Err(Error::BlockedUrl) diff --git a/fluxer_media_proxy/src/public_net_policy/resolver.rs b/fluxer_media_proxy/src/public_net_policy/resolver.rs index 62c8b8782..bab87f642 100644 --- a/fluxer_media_proxy/src/public_net_policy/resolver.rs +++ b/fluxer_media_proxy/src/public_net_policy/resolver.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use super::ip_tables::is_public_ip; +use fluxer_common::public_ip::is_public_ip; use reqwest::dns::{Addrs, Name, Resolve, Resolving}; use std::net::SocketAddr; use thiserror::Error; diff --git a/fluxer_media_proxy/src/public_net_policy/tests.rs b/fluxer_media_proxy/src/public_net_policy/tests.rs index 02fb0045d..0925448c8 100644 --- a/fluxer_media_proxy/src/public_net_policy/tests.rs +++ b/fluxer_media_proxy/src/public_net_policy/tests.rs @@ -1,8 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use super::ip_tables::is_public_ip; use super::resolver::{MAX_PUBLIC_DNS_ADDRESSES, ResolveError, screen_resolved_addresses}; use super::*; +use fluxer_common::public_ip::is_public_ip; use std::net::SocketAddr; fn public(literal: &str) -> bool { @@ -16,12 +16,12 @@ fn socket_addresses(literal: &str, count: usize) -> Vec { #[test] fn blocks_private_and_special_ip_literals() { - assert!(is_blocked_ip_literal("127.0.0.1")); - assert!(is_blocked_ip_literal("10.1.2.3")); - assert!(is_blocked_ip_literal("::1")); - assert!(is_blocked_ip_literal("::ffff:192.168.1.1")); - assert!(!is_blocked_ip_literal("8.8.8.8")); - assert!(!is_blocked_ip_literal("2606:4700:4700::1111")); + assert!(!public("127.0.0.1")); + assert!(!public("10.1.2.3")); + assert!(!public("::1")); + assert!(!public("::ffff:192.168.1.1")); + assert!(public("8.8.8.8")); + assert!(public("2606:4700:4700::1111")); } #[test] @@ -66,7 +66,7 @@ fn blocks_every_documented_ipv4_ssrf_range() { "240.0.0.1", "255.255.255.255", ] { - assert!(is_blocked_ip_literal(ip), "{ip}"); + assert!(!public(ip), "{ip}"); } } @@ -84,7 +84,7 @@ fn blocks_every_documented_ipv6_ssrf_range() { "::7f00:1", "2002:a9fe:a9fe::", ] { - assert!(is_blocked_ip_literal(ip), "{ip}"); + assert!(!public(ip), "{ip}"); } } @@ -190,14 +190,6 @@ fn ipv4_written_in_ipv6_notation_is_revalidated_against_the_v4_table() { } } -#[test] -fn unparseable_ip_literals_fail_closed() { - assert!(is_blocked_ip_literal("")); - assert!(is_blocked_ip_literal("example.com")); - assert!(is_blocked_ip_literal("127.0.0.1:80")); - assert!(is_blocked_ip_literal("0x7f000001")); -} - #[test] fn public_urls_are_restricted_to_the_standard_web_ports() { assert_eq!(Ok(()), validate_url("https://example.com/a")); @@ -313,3 +305,22 @@ fn redirect_targets_are_checked_on_their_canonical_host() { let next = resolve_redirect("https://example.com/start.gif", "//0x7f.0x0.0x0.0x1/a").unwrap(); assert_eq!(Err(Error::BlockedUrl), validate_url(&next)); } + +use fluxer_common::external_media_path::{ + build_external_media_proxy_path, reconstruct_original_url, +}; + +#[test] +fn a_signed_path_never_decodes_into_a_url_the_fetch_policy_refuses() { + let credentialed = "https://reader:secret@cdn.example.com/i.png"; + assert_eq!( + Err(super::Error::BlockedUrl), + super::parse_url(credentialed) + ); + + let path = build_external_media_proxy_path(credentialed).expect("path builds"); + let decoded = reconstruct_original_url(&path).expect("path decodes"); + + assert_eq!("https://cdn.example.com/i.png", decoded); + assert!(super::parse_url(&decoded).is_ok()); +} diff --git a/fluxer_media_proxy/src/range.rs b/fluxer_media_proxy/src/range.rs index 8defb82a3..b9b91c91a 100644 --- a/fluxer_media_proxy/src/range.rs +++ b/fluxer_media_proxy/src/range.rs @@ -1,18 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct ByteRange { - pub start: usize, - pub end: usize, -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum RangeSelection { - #[default] - Full, - Partial(ByteRange), - Unsatisfiable, -} +pub use fluxer_common::http_range::{ByteRange, RangeSelection, parse_decimal_usize, parse_range}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct ContentRange { @@ -28,81 +16,6 @@ pub enum RequestRange<'a> { Unsatisfiable, } -pub fn parse_range(header: Option<&str>, file_size: usize) -> RangeSelection { - let Some(raw) = header else { - return RangeSelection::Full; - }; - let trimmed = raw.trim_matches([' ', '\t']); - // The unit is matched the way classify_request_range matches it, which is what decides - // whether a GET forwards the Range upstream at all. Accepting more units here alone would - // answer 206 on a HEAD whose GET serves the whole body. - let Some(spec) = trimmed.strip_prefix("bytes=") else { - return RangeSelection::Full; - }; - if spec.contains(',') { - return RangeSelection::Full; - } - let Some(dash) = spec.find('-') else { - return RangeSelection::Full; - }; - let end_offset = dash - .checked_add(1) - .expect("range delimiter offset must fit usize"); - let start_part = &spec[..dash]; - let end_part = &spec[end_offset..]; - if start_part.is_empty() && end_part.is_empty() { - return RangeSelection::Full; - } - // A range-spec that does not parse is ignored outright and the full resource is served - // (RFC 9110 s14.2). That check has to happen before the zero-length shortcut below, or a - // malformed spec against an empty object reports 416 instead of 200. - if !start_part.is_empty() { - let Some(start) = parse_decimal_usize(start_part) else { - return RangeSelection::Full; - }; - let parsed_end = if end_part.is_empty() { - None - } else if let Some(end) = parse_decimal_usize(end_part) { - Some(end) - } else { - return RangeSelection::Full; - }; - if file_size == 0 { - return RangeSelection::Unsatisfiable; - } - let last_byte = file_size - .checked_sub(1) - .expect("nonempty media resource must have a last byte"); - let requested_end = parsed_end.unwrap_or(last_byte); - if start >= file_size || requested_end < start { - return RangeSelection::Unsatisfiable; - } - return RangeSelection::Partial(ByteRange { - start, - end: requested_end.min(last_byte), - }); - } - let Some(suffix_len) = parse_decimal_usize(end_part) else { - return RangeSelection::Full; - }; - if file_size == 0 { - return RangeSelection::Unsatisfiable; - } - let last_byte = file_size - .checked_sub(1) - .expect("nonempty media resource must have a last byte"); - if suffix_len == 0 { - return RangeSelection::Unsatisfiable; - } - let resolved_len = suffix_len.min(file_size); - RangeSelection::Partial(ByteRange { - start: file_size - .checked_sub(resolved_len) - .expect("resolved suffix length must not exceed the media resource"), - end: last_byte, - }) -} - pub fn classify_request_range(header: Option<&str>) -> RequestRange<'_> { let Some(raw) = header else { return RequestRange::Absent; @@ -188,13 +101,6 @@ pub fn parse_content_range(header: Option<&str>) -> Option { }) } -pub(crate) fn parse_decimal_usize(raw: &str) -> Option { - if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) { - return None; - } - raw.parse().ok() -} - #[cfg(test)] mod tests { use super::*; @@ -232,14 +138,6 @@ mod tests { ); } - #[test] - fn zero_length_suffix_is_unsatisfiable() { - assert_eq!( - RangeSelection::Unsatisfiable, - parse_range(Some("bytes=-0"), 100) - ); - } - #[test] fn a_malformed_spec_is_ignored_even_against_a_zero_length_object() { // RFC 9110 s14.2: an unparsable range-spec is ignored and the full resource is served. @@ -287,34 +185,6 @@ mod tests { assert_eq!(RangeSelection::Full, parse_range(Some("bytes= x-y"), 100)); } - #[test] - fn multi_range_not_supported() { - assert_eq!( - RangeSelection::Full, - parse_range(Some("bytes=0-1, 2-3"), 100) - ); - } - - #[test] - fn reversed_start_end_is_unsatisfiable() { - assert_eq!( - RangeSelection::Unsatisfiable, - parse_range(Some("bytes=10-5"), 100) - ); - } - - #[test] - fn empty_file_is_unsatisfiable_for_any_byte_range() { - assert_eq!( - RangeSelection::Unsatisfiable, - parse_range(Some("bytes=0-9"), 0) - ); - assert_eq!( - RangeSelection::Unsatisfiable, - parse_range(Some("bytes=-5"), 0) - ); - } - #[test] fn end_past_eof_clamps() { assert_eq!( diff --git a/fluxer_media_proxy/src/server/asset_path.rs b/fluxer_media_proxy/src/server/asset_path.rs index 638caa792..fc0a763fd 100644 --- a/fluxer_media_proxy/src/server/asset_path.rs +++ b/fluxer_media_proxy/src/server/asset_path.rs @@ -3,7 +3,6 @@ use crate::{ asset_hash::AssetHash, constants::{AssetExtension, AssetKind}, - external_path, storage::safe_key, }; use std::fmt; @@ -204,7 +203,10 @@ pub(in crate::server) enum StorageKeyDecodeError { } pub(in crate::server) fn decode_storage_key(path: &str) -> Result { - let key = external_path::percent_decode_string(path.trim_start_matches('/'), false); + let key = fluxer_common::external_media_path::percent_decode_string( + path.trim_start_matches('/'), + false, + ); safe_key(&key).map_err(|_| StorageKeyDecodeError::InvalidKey)?; Ok(key) } diff --git a/fluxer_media_proxy/src/server/attachment_signature.rs b/fluxer_media_proxy/src/server/attachment_signature.rs index 0e828e350..c38932749 100644 --- a/fluxer_media_proxy/src/server/attachment_signature.rs +++ b/fluxer_media_proxy/src/server/attachment_signature.rs @@ -165,6 +165,9 @@ fn now_secs() -> u64 { .as_secs() } +#[cfg(test)] +use crate::test_fixtures::CapturedLog; + #[cfg(test)] mod tests { use super::*; @@ -172,45 +175,11 @@ mod tests { use axum::body::to_bytes; use base64::{Engine as _, engine::general_purpose}; use fluxer_common::attachment_url_signature::{UrlKind, issue_window, sign}; - use std::sync::{Arc, Mutex}; - use tracing_subscriber::fmt::MakeWriter; const SECRET: [u8; 32] = [3u8; 32]; const KEY: &str = "attachments/1/2/cat.gif"; const PATH: &str = "/attachments/1/2/cat.gif"; - #[derive(Clone, Default)] - struct CapturedLog(Arc>>); - - impl CapturedLog { - fn text(&self) -> String { - String::from_utf8(self.0.lock().expect("captured log is not poisoned").clone()) - .expect("captured log is utf-8") - } - } - - impl std::io::Write for CapturedLog { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0 - .lock() - .expect("captured log is not poisoned") - .extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - impl<'a> MakeWriter<'a> for CapturedLog { - type Writer = Self; - - fn make_writer(&'a self) -> Self::Writer { - self.clone() - } - } - fn enforcing_config() -> AttachmentSignatureConfig { crate::config::Config::load_from_iter([ ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), diff --git a/fluxer_media_proxy/src/server/cors.rs b/fluxer_media_proxy/src/server/cors.rs index c4c0ed918..9ab975289 100644 --- a/fluxer_media_proxy/src/server/cors.rs +++ b/fluxer_media_proxy/src/server/cors.rs @@ -122,6 +122,9 @@ fn origin_for_log(request: &HeaderMap) -> &str { &origin[..origin.len().min(ORIGIN_LOG_BYTES_MAX)] } +#[cfg(test)] +use crate::test_fixtures::CapturedLog; + #[cfg(test)] mod tests { use super::*; @@ -132,44 +135,10 @@ mod tests { test_fixtures::ADVERSARIAL_TEXT_INPUTS, }; use axum::body::to_bytes; - use std::sync::{Arc, Mutex}; - use tracing_subscriber::fmt::MakeWriter; const WEB: &str = "https://web.fluxer.app"; const CANARY: &str = "https://web.canary.fluxer.app"; - #[derive(Clone, Default)] - struct CapturedLog(Arc>>); - - impl CapturedLog { - fn text(&self) -> String { - String::from_utf8(self.0.lock().expect("captured log is not poisoned").clone()) - .expect("captured log is utf-8") - } - } - - impl std::io::Write for CapturedLog { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0 - .lock() - .expect("captured log is not poisoned") - .extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - impl<'a> MakeWriter<'a> for CapturedLog { - type Writer = Self; - - fn make_writer(&'a self) -> Self::Writer { - self.clone() - } - } - fn allowlist() -> Vec { vec![ HeaderValue::from_static(WEB), diff --git a/fluxer_media_proxy/src/server/download_stream.rs b/fluxer_media_proxy/src/server/download_stream.rs index 210b8be3c..347bfe1a0 100644 --- a/fluxer_media_proxy/src/server/download_stream.rs +++ b/fluxer_media_proxy/src/server/download_stream.rs @@ -119,7 +119,7 @@ impl ActiveDownloadStream { fn overrun(&self) -> GuardedFrame { if let Some(overruns) = &self.overruns { - overruns.record_stream_overrun(); + overruns.external_stream_overruns.inc(); } fail(io::Error::new(io::ErrorKind::InvalidData, OVERRUN_MESSAGE)) } diff --git a/fluxer_media_proxy/src/server/external/fetch.rs b/fluxer_media_proxy/src/server/external/fetch.rs index 972133315..01c0bed62 100644 --- a/fluxer_media_proxy/src/server/external/fetch.rs +++ b/fluxer_media_proxy/src/server/external/fetch.rs @@ -76,7 +76,7 @@ async fn send_external_request( visited.push(current_url.clone()); if let Err(err) = public_net_policy::validate_url(¤t_url) { warn!(?err, url = %current_url, "blocked external fetch"); - external_metrics.record_blocked_url(); + external_metrics.blocked_url_attempts.inc(); return Err(ExternalFetchError::BlockedUrl); } let mut outbound = app @@ -89,11 +89,11 @@ async fn send_external_request( let response = outbound.send().await.map_err(|err| { if public_net_policy::is_pinned_dns_failure(&err) { warn!(?err, url = %current_url, "blocked external fetch"); - external_metrics.record_blocked_url(); + external_metrics.blocked_url_attempts.inc(); return ExternalFetchError::BlockedUrl; } warn!(url = %current_url, %err, "external send failed"); - external_metrics.record_fetch_failure(); + external_metrics.fetch_failures.inc(); ExternalFetchError::FetchFailed })?; let status = response.status(); @@ -105,14 +105,14 @@ async fn send_external_request( Some(location) if !location.is_empty() => location, Some(_) | None => { warn!(url = %current_url, status = status.as_u16(), "redirect has an invalid Location"); - external_metrics.record_fetch_failure(); + external_metrics.fetch_failures.inc(); return Err(ExternalFetchError::FetchFailed); } }; current_url = public_net_policy::resolve_redirect(¤t_url, location).map_err(|err| { warn!(url = %current_url, %location, ?err, "redirect target blocked"); - external_metrics.record_blocked_url(); + external_metrics.blocked_url_attempts.inc(); ExternalFetchError::BlockedUrl })?; continue; @@ -183,7 +183,7 @@ async fn fetch_external_inner( constants::MAX_MEDIA_PROXY_BYTES, ) else { warn!(url = %current_url, "invalid upstream partial response"); - external_metrics.record_fetch_failure(); + external_metrics.fetch_failures.inc(); return Err(ExternalFetchError::FetchFailed); }; ExternalSuccessStatus::Partial(partial) @@ -226,7 +226,7 @@ async fn fetch_external_inner( .is_some_and(|expected| data.len() as u64 != expected) { warn!(url = %current_url, "upstream partial response body length mismatch"); - external_metrics.record_fetch_failure(); + external_metrics.fetch_failures.inc(); return Err(ExternalFetchError::FetchFailed); } Ok(FetchedExternal { @@ -258,7 +258,7 @@ async fn external_body_prefix( while prefix.len() < EXTERNAL_SNIFF_PREFIX_BYTES { let Some(chunk) = response.chunk().await.map_err(|err| { warn!(url = %url, %err, "external body read failed"); - metrics.record_fetch_failure(); + metrics.fetch_failures.inc(); ExternalFetchError::FetchFailed })? else { @@ -368,7 +368,7 @@ pub(super) async fn buffer_external_response( let mut empty_chunks_remaining = response_body_limit::response_body_chunk_limit(limit as u64); while let Some(chunk) = response.chunk().await.map_err(|err| { warn!(url = %url, %err, "external body read failed"); - metrics.record_fetch_failure(); + metrics.fetch_failures.inc(); ExternalFetchError::FetchFailed })? { if chunk.is_empty() { @@ -397,7 +397,7 @@ pub(super) async fn buffer_external_response( .checked_sub(reserved_bytes) .expect("an external buffer target capacity covers its reservation"); if !reservation.try_grow(additional) { - metrics.record_buffer_rejected(); + metrics.external_buffer_rejected.inc(); return Err(ExternalFetchError::BufferBudgetExhausted); } reserved_bytes = target_capacity; @@ -417,7 +417,7 @@ pub(super) async fn buffer_external_response( actual_length = buf.len(), "external buffered body length did not match Content-Length" ); - metrics.record_fetch_failure(); + metrics.fetch_failures.inc(); return Err(ExternalFetchError::FetchFailed); } reservation.shrink_to(buf.capacity()); @@ -430,7 +430,7 @@ fn reserve( amount: usize, ) -> Result { budget.try_reserve(amount).ok_or_else(|| { - metrics.record_buffer_rejected(); + metrics.external_buffer_rejected.inc(); ExternalFetchError::BufferBudgetExhausted }) } @@ -445,7 +445,7 @@ fn grow_to_capacity( return Ok(reserved_bytes); } if !reservation.try_grow(capacity - reserved_bytes) { - metrics.record_buffer_rejected(); + metrics.external_buffer_rejected.inc(); return Err(ExternalFetchError::BufferBudgetExhausted); } Ok(capacity) diff --git a/fluxer_media_proxy/src/server/external/mod.rs b/fluxer_media_proxy/src/server/external/mod.rs index 530e49518..4c9bb60d1 100644 --- a/fluxer_media_proxy/src/server/external/mod.rs +++ b/fluxer_media_proxy/src/server/external/mod.rs @@ -8,7 +8,7 @@ use crate::{ byte_budget::BudgetedBytes, config::Config, constants::{self, AssetExtension}, - external_path, mime, + mime, server::{ download_stream::DownloadStreamPolicy, external::{ @@ -38,7 +38,6 @@ use crate::{ parameters::TransformRoute, serve_bytes_or_transform, }, }, - signing, }; use axum::{ http::{HeaderMap, Method, StatusCode, header}, @@ -199,10 +198,15 @@ impl<'a> PreparedExternalRequest<'a> { let Some((sig, proxy_path)) = rest.split_once('/') else { return Err(Box::new(text(StatusCode::BAD_REQUEST, "Bad Request"))); }; - if !signing::verify_signature(proxy_path, sig, app.cfg.secret_key.as_bytes()) { + if !fluxer_common::external_media_path::verify_signature( + proxy_path, + sig, + app.cfg.secret_key.as_bytes(), + ) { return Err(Box::new(text(StatusCode::UNAUTHORIZED, "Unauthorized"))); } - let Ok(url) = external_path::reconstruct_original_url(proxy_path) else { + let Ok(url) = fluxer_common::external_media_path::reconstruct_original_url(proxy_path) + else { return Err(Box::new(text(StatusCode::BAD_REQUEST, "Bad Request"))); }; let url_ext_is_svg = diff --git a/fluxer_media_proxy/src/server/external/response.rs b/fluxer_media_proxy/src/server/external/response.rs index dd1383c81..39d12476a 100644 --- a/fluxer_media_proxy/src/server/external/response.rs +++ b/fluxer_media_proxy/src/server/external/response.rs @@ -2,7 +2,7 @@ use super::{ExternalBody, FetchedExternal, byte_range::ExternalPartial}; use crate::{ - constants, http_headers, + constants, server::{ download_stream::DownloadStreamPolicy, format_policy::{content_type_is_trustworthy, is_svg_content_type}, @@ -62,27 +62,18 @@ pub(super) fn external_partial_response(request: ExternalPartialResponse) -> Res let body_len = partial .content_length() .or_else(|| external_body_length(&body)); - let mut response = Response::new(external_body(method, body, body_len, stream_policy)); - *response.status_mut() = StatusCode::PARTIAL_CONTENT; - http_headers::add_media_headers( - response.headers_mut(), + let mut response = media_body_response( + external_body(method, body, body_len, stream_policy), + StatusCode::PARTIAL_CONTENT, body_length_hint(body_len), &content_type, None, + body_len, + disposition, ); - if let Some(body_len) = body_len { - response - .headers_mut() - .insert(header::CONTENT_LENGTH, HeaderValue::from(body_len)); - } if let Some(value) = partial.header_value() { response.headers_mut().insert(header::CONTENT_RANGE, value); } - if let Some(value) = disposition { - response - .headers_mut() - .insert(header::CONTENT_DISPOSITION, value); - } response } @@ -111,25 +102,15 @@ pub(super) fn external_streaming_response(request: ExternalStreamingResponse<'_> } else { guarded_stream(response, prefix, content_length, stream_policy) }; - let mut http_response = Response::new(body); - *http_response.status_mut() = StatusCode::OK; - http_headers::add_media_headers( - http_response.headers_mut(), + media_body_response( + body, + StatusCode::OK, body_length_hint(content_length), content_type, None, - ); - if let Some(content_length) = content_length { - http_response - .headers_mut() - .insert(header::CONTENT_LENGTH, HeaderValue::from(content_length)); - } - if let Some(value) = disposition { - http_response - .headers_mut() - .insert(header::CONTENT_DISPOSITION, value); - } - http_response + content_length, + disposition, + ) } fn external_body_length(body: &ExternalBody) -> Option { @@ -178,3 +159,5 @@ fn body_length_hint(length: Option) -> usize { .and_then(|length| usize::try_from(length).ok()) .unwrap_or(constants::MAX_MEDIA_PROXY_BYTES) } + +use crate::server::response::media_body_response; diff --git a/fluxer_media_proxy/src/server/external/tests/cache_policy.rs b/fluxer_media_proxy/src/server/external/tests/cache_policy.rs deleted file mode 100644 index 41828e7f3..000000000 --- a/fluxer_media_proxy/src/server/external/tests/cache_policy.rs +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use super::{test_stream_policy, validated_partial}; -use crate::{ - byte_budget::BudgetedBytes, - server::external::{ - ExternalBody, ExternalSuccessStatus, FetchedExternal, - response::{ - ExternalPartialResponse, ExternalStreamingResponse, external_partial_response, - external_streaming_response, - }, - }, -}; -use axum::{ - http::{Method, StatusCode}, - response::Response, -}; -use bytes::Bytes; -use http::header; - -fn cache_control_of(response: &Response) -> &str { - response - .headers() - .get(header::CACHE_CONTROL) - .expect("cache-control is always set") - .to_str() - .expect("cache-control is ASCII") -} - -#[tokio::test] -async fn external_streaming_response_caches_forever() { - for (content_type, expected) in [ - ("video/mp4", "public, max-age=31536000, no-transform"), - ("image/webp", "public, max-age=31536000"), - ] { - let upstream = reqwest::Response::from( - http::Response::builder() - .status(StatusCode::OK) - .body("streamed bytes") - .unwrap(), - ); - let response = external_streaming_response(ExternalStreamingResponse { - method: Method::GET, - response: upstream, - prefix: Bytes::new(), - content_length: Some(14), - content_type, - disposition: None, - stream_policy: test_stream_policy(), - }); - - assert_eq!( - expected, - cache_control_of(&response), - "content_type={content_type}" - ); - } -} - -#[test] -fn external_partial_response_caches_forever() { - let fetched = FetchedExternal { - url: "https://media.example.test/clip.webm".to_owned(), - status: ExternalSuccessStatus::Partial(validated_partial("bytes=0-3", "bytes 0-3/10", 4)), - body: ExternalBody::Buffered(BudgetedBytes::unbudgeted(Bytes::from_static(b"abcd"))), - content_type: "video/webm".to_owned(), - }; - let response = external_partial_response(ExternalPartialResponse { - method: Method::GET, - partial: fetched - .status - .partial() - .expect("the fetched response is partial"), - fetched, - disposition: None, - stream_policy: test_stream_policy(), - }); - - assert_eq!( - "public, max-age=31536000, no-transform", - cache_control_of(&response) - ); -} diff --git a/fluxer_media_proxy/src/server/external/tests/mod.rs b/fluxer_media_proxy/src/server/external/tests/mod.rs index 8e063977b..1e1daaf48 100644 --- a/fluxer_media_proxy/src/server/external/tests/mod.rs +++ b/fluxer_media_proxy/src/server/external/tests/mod.rs @@ -1,7 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -mod cache_policy; - use super::{ ExternalBody, ExternalFetchError, ExternalSuccessStatus, FetchedExternal, PreparedExternalRequest, @@ -25,10 +23,8 @@ use crate::{ byte_budget::{BudgetedBytes, ByteBudget}, config::Config, constants, - external_path::build_external_media_proxy_path, metrics::external::ExternalMetrics, server::{download_stream::DownloadStreamPolicy, state::AppState}, - signing, }; use axum::{ body::to_bytes, @@ -524,7 +520,7 @@ fn external_partial_validation_rejects_a_partial_the_client_did_not_request() { #[tokio::test] async fn external_buffering_rejects_a_body_shorter_than_its_content_length() { let budget = ByteBudget::new(constants::MAX_MEDIA_PROXY_BYTES * 4); - let metrics = ExternalMetrics::new(); + let metrics = ExternalMetrics::default(); let truncated = buffer_external_response(ExternalBufferRequest { response: reqwest::Response::from( http::Response::builder() @@ -701,7 +697,7 @@ async fn external_streaming_response_emits_the_sniff_prefix_then_the_rest() { #[tokio::test] async fn external_buffering_prepends_the_sniff_prefix() { let budget = ByteBudget::new(constants::MAX_MEDIA_PROXY_BYTES * 4); - let metrics = ExternalMetrics::new(); + let metrics = ExternalMetrics::default(); let buffered = buffer_external_response(ExternalBufferRequest { response: reqwest::Response::from( http::Response::builder() @@ -727,13 +723,11 @@ async fn a_cached_external_transform_is_served_without_refetching_the_origin() { use super::{external_hint, serve_external}; use crate::{ config::Config, - external_path::build_external_media_proxy_path, server::{ params::url_filename, state::AppState, transform::{ServeBytesRequest, parameters::TransformRoute, serve_bytes_or_transform}, }, - signing, test_fixtures::fixture_jpeg, }; use axum::http::HeaderMap; @@ -750,7 +744,10 @@ async fn a_cached_external_transform_is_served_without_refetching_the_origin() { let proxy_path = build_external_media_proxy_path(url).expect("the external path builds"); let rest = format!( "{}/{proxy_path}", - signing::create_signature(&proxy_path, app.cfg.secret_key.as_bytes()) + fluxer_common::external_media_path::create_signature( + &proxy_path, + app.cfg.secret_key.as_bytes() + ) ); let filename = url_filename(url); let data = Bytes::from(fixture_jpeg()); @@ -890,7 +887,10 @@ async fn an_upstream_that_ignores_the_client_range_still_streams_a_complete_body let app = Arc::new(AppState::for_tests(cfg)); let url = "https://media.example.test/clip.mp4"; let proxy_path = build_external_media_proxy_path(url).expect("the external path builds"); - let signature = signing::create_signature(&proxy_path, app.cfg.secret_key.as_bytes()); + let signature = fluxer_common::external_media_path::create_signature( + &proxy_path, + app.cfg.secret_key.as_bytes(), + ); let rest = format!("{signature}/{proxy_path}"); let params = HashMap::new(); let mut headers = HeaderMap::new(); @@ -937,7 +937,7 @@ async fn external_buffering_accepts_transport_chunks_of_any_size_within_the_limi const SMALL_CHUNK_BYTES: usize = 1448; const SMALL_CHUNKS: usize = 512; let budget = ByteBudget::new(constants::MAX_MEDIA_PROXY_BYTES * 4); - let metrics = ExternalMetrics::new(); + let metrics = ExternalMetrics::default(); let buffer = |response: reqwest::Response, length: usize| { buffer_external_response(ExternalBufferRequest { response, @@ -970,3 +970,5 @@ async fn external_buffering_accepts_transport_chunks_of_any_size_within_the_limi .expect("packet-sized transport chunks"); assert_eq!(SMALL_CHUNK_BYTES * SMALL_CHUNKS, small.as_bytes().len()); } + +use fluxer_common::external_media_path::build_external_media_proxy_path; diff --git a/fluxer_media_proxy/src/server/format_policy.rs b/fluxer_media_proxy/src/server/format_policy.rs index de966d733..6395a610e 100644 --- a/fluxer_media_proxy/src/server/format_policy.rs +++ b/fluxer_media_proxy/src/server/format_policy.rs @@ -201,20 +201,11 @@ pub(in crate::server) fn is_svg_content_type(content_type: &str) -> bool { .is_some_and(|value| value.eq_ignore_ascii_case("image/svg+xml")) } -pub(in crate::server) fn extension_from_mime(content_type: &str) -> Option { - match mime::normalize(Some(content_type))? { - "image/jpeg" => Some(AssetExtension::Jpeg), - "image/png" => Some(AssetExtension::Png), - "image/webp" => Some(AssetExtension::Webp), - "image/gif" => Some(AssetExtension::Gif), - "image/apng" => Some(AssetExtension::Apng), - "image/avif" => Some(AssetExtension::Avif), - "image/heic" => Some(AssetExtension::Heic), - "image/heif" => Some(AssetExtension::Heif), - "image/jxl" => Some(AssetExtension::Jxl), - "image/svg+xml" => Some(AssetExtension::Svg), - _ => None, - } +pub(in crate::server) fn extension_from_mime(mime: &str) -> Option { + let mime = mime::normalize(Some(mime))?; + AssetExtension::ALL + .into_iter() + .find(|extension| extension.mime() == mime) } pub(in crate::server) fn image_extension_from_filename(filename: &str) -> Option { diff --git a/fluxer_media_proxy/src/server/native_task_executor.rs b/fluxer_media_proxy/src/server/native_task_executor.rs index 033919bbd..4225bf64a 100644 --- a/fluxer_media_proxy/src/server/native_task_executor.rs +++ b/fluxer_media_proxy/src/server/native_task_executor.rs @@ -202,7 +202,7 @@ impl NativeTaskExecutor { .as_ref() .is_err_and(|error| error.downcast_ref() == Some(&MediaError::MediaDecodeFailed)) { - transform_metrics.record_decode_failure(); + transform_metrics.decode_failures.inc(); } if let Err(Err(error)) = sender.send(result) { error!(error = ?error, "detached native transform failed"); diff --git a/fluxer_media_proxy/src/server/params.rs b/fluxer_media_proxy/src/server/params.rs index 4197e5f74..c2964203a 100644 --- a/fluxer_media_proxy/src/server/params.rs +++ b/fluxer_media_proxy/src/server/params.rs @@ -40,12 +40,6 @@ pub(in crate::server) fn explicit_output_format( AssetExtension::parse(raw).map(Some).ok_or(()) } -#[cfg(test)] -fn parse_dimension(raw: Option<&str>, limits: &MediaLimits) -> Option { - raw.and_then(|v| v.parse::().ok()) - .filter(|v| *v > 0 && *v <= limits.image_dimension()) -} - pub(in crate::server) fn parse_optional_dimension_param( params: &HashMap, key: &str, @@ -124,14 +118,6 @@ pub(in crate::server) fn extension_of(filename: &str) -> Option<&str> { mod tests { use super::*; - #[test] - fn dimensions_are_bounded() { - let limits = MediaLimits::default_from_config(); - assert_eq!(Some(128), parse_dimension(Some("128"), &limits)); - assert_eq!(None, parse_dimension(Some("0"), &limits)); - assert_eq!(None, parse_dimension(Some("999999999"), &limits)); - } - #[test] fn asset_manual_format_override_is_v1_compatible() { assert_eq!( diff --git a/fluxer_media_proxy/src/server/relay/tests.rs b/fluxer_media_proxy/src/server/relay/tests.rs index 1f53de5f3..e4eca8079 100644 --- a/fluxer_media_proxy/src/server/relay/tests.rs +++ b/fluxer_media_proxy/src/server/relay/tests.rs @@ -5,7 +5,6 @@ use super::body::{ }; use crate::{ config::Config, - secret::SecretBytes, server::{ routes::relay::{relay_cors, relay_error, relay_put}, state::AppState, @@ -815,3 +814,5 @@ async fn relay_put_omits_the_etag_when_the_store_returns_none() { .unwrap(); assert_eq!(body.as_ref(), stored.as_slice()); } + +use fluxer_common::secret::SecretBytes; diff --git a/fluxer_media_proxy/src/server/response/error.rs b/fluxer_media_proxy/src/server/response/error.rs index e72cbd52c..812c8a7e4 100644 --- a/fluxer_media_proxy/src/server/response/error.rs +++ b/fluxer_media_proxy/src/server/response/error.rs @@ -100,6 +100,9 @@ pub(in crate::server) fn json_response(status: StatusCode, body: String) -> Resp response } +#[cfg(test)] +use crate::test_fixtures::CapturedLog; + #[cfg(test)] mod tests { use super::*; @@ -108,40 +111,6 @@ mod tests { request_log::{RequestId, trace_public_request}, }; use axum::http::{HeaderMap, Method}; - use std::sync::{Arc, Mutex}; - use tracing_subscriber::fmt::MakeWriter; - - #[derive(Clone, Default)] - struct CapturedLog(Arc>>); - - impl CapturedLog { - fn text(&self) -> String { - String::from_utf8(self.0.lock().expect("captured log is not poisoned").clone()) - .expect("captured log is utf-8") - } - } - - impl std::io::Write for CapturedLog { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0 - .lock() - .expect("captured log is not poisoned") - .extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - impl<'a> MakeWriter<'a> for CapturedLog { - type Writer = Self; - - fn make_writer(&'a self) -> Self::Writer { - self.clone() - } - } #[test] fn text_responses_set_nosniff_header() { @@ -192,22 +161,6 @@ mod tests { } } - #[test] - fn successful_text_responses_are_left_to_the_media_cache_policy() { - assert!( - text(StatusCode::OK, "fine") - .headers() - .get(header::CACHE_CONTROL) - .is_none() - ); - assert!( - json_response(StatusCode::OK, "{}".to_owned()) - .headers() - .get(header::CACHE_CONTROL) - .is_none() - ); - } - #[tokio::test] async fn a_very_long_storage_key_cannot_bloat_the_logged_source() { let key = format!("attachments/1/2/{}.png", "k".repeat(4096)); diff --git a/fluxer_media_proxy/src/server/response/mod.rs b/fluxer_media_proxy/src/server/response/mod.rs index 5c75b1f01..5b9f700aa 100644 --- a/fluxer_media_proxy/src/server/response/mod.rs +++ b/fluxer_media_proxy/src/server/response/mod.rs @@ -53,16 +53,39 @@ pub(in crate::server) fn media_response(response: MediaResponse<'_>) -> Response (StatusCode::OK, retained) }; let body_len = body_bytes.len(); - let mut response = if method == Method::HEAD { - Response::new(Body::empty()) + let body = if method == Method::HEAD { + Body::empty() } else { - Response::new(Body::from(body_bytes)) + Body::from(body_bytes) }; + media_body_response( + body, + status, + total_len, + content_type, + byte_range, + Some(body_len as u64), + disposition, + ) +} + +pub(in crate::server) fn media_body_response( + body: Body, + status: StatusCode, + total_len: usize, + content_type: &str, + byte_range: Option, + content_length: Option, + disposition: Option, +) -> Response { + let mut response = Response::new(body); *response.status_mut() = status; http_headers::add_media_headers(response.headers_mut(), total_len, content_type, byte_range); - response - .headers_mut() - .insert(header::CONTENT_LENGTH, HeaderValue::from(body_len)); + if let Some(length) = content_length { + response + .headers_mut() + .insert(header::CONTENT_LENGTH, HeaderValue::from(length)); + } if let Some(value) = disposition { response .headers_mut() @@ -169,15 +192,4 @@ mod tests { assert!(!value.contains("tail.png")); assert!(value.len() <= disposition::PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES); } - - #[test] - fn a_long_multi_byte_external_filename_still_gets_a_disposition() { - let filename = "\u{e9}".repeat(1100); - let inline = disposition_string("image/png", false, Some(&filename)); - assert!(inline.starts_with("inline; filename=\"")); - assert!(inline.len() <= disposition::PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES); - let attachment = disposition_string("application/octet-stream", false, Some(&filename)); - assert!(attachment.starts_with("attachment; filename=\"")); - assert!(attachment.len() <= disposition::PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES); - } } diff --git a/fluxer_media_proxy/src/server/routes/dispatch.rs b/fluxer_media_proxy/src/server/routes/dispatch.rs index abf3bf508..fa6913882 100644 --- a/fluxer_media_proxy/src/server/routes/dispatch.rs +++ b/fluxer_media_proxy/src/server/routes/dispatch.rs @@ -204,6 +204,9 @@ fn without_signature_parameters(params: &HashMap) -> HashMap>>); - - impl CapturedLog { - fn text(&self) -> String { - String::from_utf8(self.0.lock().expect("captured log is not poisoned").clone()) - .expect("captured log is utf-8") - } - } - - impl std::io::Write for CapturedLog { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0 - .lock() - .expect("captured log is not poisoned") - .extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - impl<'a> MakeWriter<'a> for CapturedLog { - type Writer = Self; - - fn make_writer(&'a self) -> Self::Writer { - self.clone() - } - } const STORED_BYTES: &[u8] = b"raw-stored-bytes"; const THEME_BYTES: &[u8] = b"body{color:#fff}"; @@ -482,36 +451,6 @@ mod tests { .map(|value| value.to_str().expect("header is ascii").to_owned()) } - #[tokio::test] - async fn static_mode_caches_every_asset_forever() { - let tmp = tempfile::tempdir().expect("storage root"); - let root = tmp.path().canonicalize().expect("canonical storage root"); - let app = static_mode_app(root.as_path(), &[]); - for key in STATIC_KEYS { - let response = dispatch(&app, Method::GET, &format!("/{key}")).await; - assert_eq!(StatusCode::OK, response.status(), "key={key}"); - assert_eq!( - Some(STATIC_CACHE_CONTROL.to_owned()), - header_value(&response, header::CACHE_CONTROL), - "key={key}" - ); - } - } - - #[tokio::test] - async fn static_mode_omits_expires_and_relies_on_cache_control() { - let tmp = tempfile::tempdir().expect("storage root"); - let root = tmp.path().canonicalize().expect("canonical storage root"); - let app = static_mode_app(root.as_path(), &[]); - let response = dispatch(&app, Method::GET, "/avatars/0.png").await; - assert_eq!(StatusCode::OK, response.status()); - assert_eq!(None, header_value(&response, header::EXPIRES)); - assert_eq!( - Some(STATIC_CACHE_CONTROL.to_owned()), - header_value(&response, "CDN-Cache-Control") - ); - } - #[tokio::test] async fn media_mode_matches_the_v1_route_order() { let tmp = tempfile::tempdir().expect("storage root"); @@ -1090,52 +1029,6 @@ mod tests { } } - #[tokio::test] - async fn report_mode_logs_one_would_deny_line_for_each_refused_origin_only() { - let tmp = tempfile::tempdir().expect("storage root"); - let root = tmp.path().canonicalize().expect("canonical storage root"); - let root = root.as_path(); - write_stored_representations(root); - let report = dispatch_app("mp", root, REPORT); - let cases: [(&[&[u8]], Option<&str>); 4] = [ - (&[EVIL.as_bytes()], Some("origin=https://evil.example")), - (&[b"null".as_slice()], Some("origin=null")), - (&[WEB.as_bytes()], None), - (&[], None), - ]; - for (origins, expected_origin) in cases { - let label = format!("origins={origins:?}"); - let captured = CapturedLog::default(); - let subscriber = tracing_subscriber::fmt() - .with_writer(captured.clone()) - .with_ansi(false) - .with_max_level(tracing::Level::TRACE) - .finish(); - let response = { - let _guard = tracing::subscriber::set_default(subscriber); - dispatch_request( - &report, - read_request(Method::GET, ATTACHMENT_PATH, origins, None), - ) - .await - }; - assert_eq!(StatusCode::OK, response.status(), "{label}"); - let log = captured.text(); - let would_deny: Vec<&str> = log - .lines() - .filter(|line| line.contains("reason=\"cors_origin_would_deny\"")) - .collect(); - match expected_origin { - Some(origin) => { - assert_eq!(1, would_deny.len(), "{label}\n{log}"); - assert!(would_deny[0].contains(" WARN "), "{label}\n{log}"); - assert!(would_deny[0].trim_end().ends_with(origin), "{label}\n{log}"); - } - None => assert!(would_deny.is_empty(), "{label}\n{log}"), - } - } - } - #[tokio::test] async fn off_mode_never_inspects_origin() { let tmp = tempfile::tempdir().expect("storage root"); @@ -1872,28 +1765,6 @@ mod tests { } } - #[tokio::test] - async fn report_leaves_a_refusable_read_caching_forever() { - let tmp = tempfile::tempdir().expect("storage root"); - let root = tmp.path().canonicalize().expect("canonical storage root"); - let root = root.as_path(); - write_signed_representations(root); - let report = dispatch_app("mp", root, SIGNATURE_REPORT); - for uri in [ - ATTACHMENT_PATH.to_owned(), - expired_uri(ATTACHMENT_PATH, ATTACHMENT_KEY), - foreign_key_uri(ATTACHMENT_PATH), - ] { - let response = dispatch_uri(&report, Method::GET, &uri, &[], None).await; - assert_eq!(StatusCode::OK, response.status(), "{uri}"); - assert_eq!( - Some(STATIC_CACHE_CONTROL.to_owned()), - header_value(&response, header::CACHE_CONTROL), - "{uri}" - ); - } - } - #[tokio::test] async fn only_enforce_masks_a_missing_object_so_it_matches_a_refusal() { let tmp = tempfile::tempdir().expect("storage root"); @@ -1991,59 +1862,6 @@ mod tests { ); } - #[tokio::test] - async fn report_mode_logs_one_line_for_each_refused_verdict_only() { - let tmp = tempfile::tempdir().expect("storage root"); - let root = tmp.path().canonicalize().expect("canonical storage root"); - let root = root.as_path(); - write_signed_representations(root); - let report = dispatch_app("mp", root, SIGNATURE_REPORT); - let cases: [(&str, String); 8] = [ - ("missing", ATTACHMENT_PATH.to_owned()), - ("malformed", malformed_uri(ATTACHMENT_PATH, ATTACHMENT_KEY)), - ("mismatch", foreign_key_uri(ATTACHMENT_PATH)), - ("expired", expired_uri(ATTACHMENT_PATH, ATTACHMENT_KEY)), - ("", signed_uri(ATTACHMENT_PATH, ATTACHMENT_KEY, "")), - ("", data_package_uri(ATTACHMENT_PATH, ATTACHMENT_KEY, "")), - ( - "malformed", - data_package_uri(ATTACHMENT_PATH, ATTACHMENT_KEY, "").replace("&uc=dp", ""), - ), - ( - "mismatch", - foreign_secret_data_package_uri(ATTACHMENT_PATH, ATTACHMENT_KEY), - ), - ]; - for (verdict, uri) in cases { - let captured = CapturedLog::default(); - let subscriber = tracing_subscriber::fmt() - .with_writer(captured.clone()) - .with_ansi(false) - .with_max_level(tracing::Level::TRACE) - .finish(); - let response = { - let _guard = tracing::subscriber::set_default(subscriber); - dispatch_uri(&report, Method::GET, &uri, &[], None).await - }; - assert_eq!(StatusCode::OK, response.status(), "{verdict} {uri}"); - let log = captured.text(); - let lines: Vec<&str> = log - .lines() - .filter(|line| line.contains("reason=\"attachment_signature_would_deny\"")) - .collect(); - if verdict.is_empty() { - assert!(lines.is_empty(), "{uri}\n{log}"); - continue; - } - assert_eq!(1, lines.len(), "{verdict} {uri}\n{log}"); - assert!(lines[0].contains(" WARN "), "{verdict}\n{log}"); - assert!( - lines[0].contains(&format!("verdict=\"{verdict}\"")), - "{verdict}\n{log}" - ); - } - } - #[tokio::test] async fn off_mode_serves_a_signed_request_unchanged() { let tmp = tempfile::tempdir().expect("storage root"); diff --git a/fluxer_media_proxy/src/server/routes/ops.rs b/fluxer_media_proxy/src/server/routes/ops.rs index 2e2dfc4ef..d9d04cfa6 100644 --- a/fluxer_media_proxy/src/server/routes/ops.rs +++ b/fluxer_media_proxy/src/server/routes/ops.rs @@ -19,10 +19,6 @@ pub(in crate::server) async fn health(State(app): State>) -> Strin ) } -fn is_loopback_peer(peer: &SocketAddr) -> bool { - peer.ip().to_canonical().is_loopback() -} - pub(in crate::server) async fn metrics_handler( ConnectInfo(peer): ConnectInfo, State(app): State>, @@ -42,6 +38,8 @@ pub(in crate::server) async fn metrics_handler( response } +use fluxer_common::metrics::is_loopback_peer; + #[cfg(test)] mod tests { use super::*; @@ -58,23 +56,6 @@ mod tests { Arc::new(AppState::for_tests(cfg)) } - #[test] - fn metrics_guard_accepts_loopback_peers() { - assert!(is_loopback_peer(&test_peer("127.0.0.1:5000"))); - assert!(is_loopback_peer(&test_peer("127.0.0.2:5000"))); - assert!(is_loopback_peer(&test_peer("[::1]:5000"))); - assert!(is_loopback_peer(&test_peer("[::ffff:127.0.0.1]:5000"))); - } - - #[test] - fn metrics_guard_rejects_remote_peers() { - assert!(!is_loopback_peer(&test_peer("8.8.8.8:5000"))); - assert!(!is_loopback_peer(&test_peer("10.0.0.5:5000"))); - assert!(!is_loopback_peer(&test_peer("172.18.0.4:5000"))); - assert!(!is_loopback_peer(&test_peer("[fe80::1]:5000"))); - assert!(!is_loopback_peer(&test_peer("[::ffff:8.8.8.8]:5000"))); - } - #[tokio::test] async fn metrics_denial_uses_the_shared_text_shape() { let response = metrics_handler( diff --git a/fluxer_media_proxy/src/server/routes/relay.rs b/fluxer_media_proxy/src/server/routes/relay.rs index 035a763d4..040107936 100644 --- a/fluxer_media_proxy/src/server/routes/relay.rs +++ b/fluxer_media_proxy/src/server/routes/relay.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::{ - external_path, http_client, http_headers, + http_client, http_headers, server::{ relay::body::{ RelayBodyProgress, RelayBodyStream, RelayBodyStreamRequest, buffer_relay_body, @@ -56,7 +56,7 @@ pub(in crate::server) async fn relay_put( if !app.cfg.mode.serves_upload_relay() { return text(StatusCode::NOT_FOUND, "Not Found"); } - let key = external_path::percent_decode_string(&key, false); + let key = fluxer_common::external_media_path::percent_decode_string(&key, false); let token_raw = match token::token_from_query(params.get("t").map(String::as_str)) { Ok(token_raw) => token_raw, Err(err) => return relay_error(err), @@ -154,7 +154,7 @@ pub(in crate::server) async fn relay_put( return relay_error(RelayError::ClientUploadFailed); } Err(SpoolError::BudgetExhausted) => { - app.metrics.relay().record_retryable_failure(); + app.metrics.relay().relay_upstream_failures_retryable.inc(); return relay_error(RelayError::UpstreamRetryable); } Err(SpoolError::Io(_)) => { @@ -190,7 +190,7 @@ pub(in crate::server) async fn relay_put( _ => None, }; if let Some(body) = retry_body { - app.metrics.relay().record_retry(); + app.metrics.relay().relay_upstream_retries.inc(); outcome = app .store .relay_put_object(&app.cfg.storage.bucket_uploads, &key, relay_options(body)) @@ -199,7 +199,7 @@ pub(in crate::server) async fn relay_put( match outcome { Ok(etag) => match relay_success_etag(etag, streamed.as_ref(), body_length) { Ok(etag) => { - app.metrics.relay().record_success(); + app.metrics.relay().relay_upstream_success.inc(); relay_success_response(etag) } Err(err) => relay_error(err), @@ -212,7 +212,7 @@ pub(in crate::server) async fn relay_put( return relay_error(*client_err); } warn!(error = %err, "upload relay upstream S3 PUT failed"); - app.metrics.relay().record_hard_failure(); + app.metrics.relay().relay_upstream_failures_hard.inc(); relay_error(RelayError::UpstreamS3Error) } } diff --git a/fluxer_media_proxy/src/server/runtime.rs b/fluxer_media_proxy/src/server/runtime.rs index 2e6cafd92..35b29cda8 100644 --- a/fluxer_media_proxy/src/server/runtime.rs +++ b/fluxer_media_proxy/src/server/runtime.rs @@ -66,7 +66,7 @@ pub async fn run(cfg: Config) -> anyhow::Result<()> { listener, app.into_make_service_with_connect_info::(), ) - .with_graceful_shutdown(shutdown_signal()) + .with_graceful_shutdown(fluxer_common::shutdown::wait_for_shutdown()) .await?; drain_and_shutdown(&state, &drain).await } @@ -170,27 +170,6 @@ async fn drain_and_shutdown(state: &Arc, drain: &HttpRequestDrain) -> aggregate_results("media proxy shutdown", [requests, coalescer, native_tasks]) } -async fn shutdown_signal() { - let ctrl_c = async { - let _ = tokio::signal::ctrl_c().await; - }; - #[cfg(unix)] - let terminate = async { - let Ok(mut sigterm) = - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - else { - return; - }; - sigterm.recv().await; - }; - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - tokio::select! { - _ = ctrl_c => {}, - _ = terminate => {}, - } -} - #[cfg(test)] mod tests { use super::*; @@ -278,13 +257,6 @@ mod tests { } const SIGNATURE_SECRET: [u8; 32] = [11u8; 32]; - const SIGNATURE_REPORT: &[(&str, &str)] = &[ - ("FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE", "report"), - ( - "FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64", - "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws=", - ), - ]; const SIGNATURE_ENFORCE: &[(&str, &str)] = &[ ("FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE", "enforce"), ( @@ -295,31 +267,6 @@ mod tests { const ATTACHMENT_PATH: &str = "/attachments/1/2/file.bin"; const ATTACHMENT_KEY: &str = "attachments/1/2/file.bin"; - async fn body_text(response: Response) -> String { - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("response body"); - String::from_utf8(body.to_vec()).expect("body is utf-8") - } - - #[tokio::test] - async fn health_names_the_attachment_signature_mode() { - for (extra, expected) in [ - (&[][..], "off"), - (SIGNATURE_REPORT, "report"), - (SIGNATURE_ENFORCE, "enforce"), - ] { - let router = test_router_for(test_config("mp", extra)); - let response = probe(router, "/_health", Method::GET).await; - assert_eq!(StatusCode::OK, response.status(), "{expected}"); - assert_eq!( - format!("OK attachment_signature={expected}"), - body_text(response).await, - "{expected}" - ); - } - } - #[tokio::test] async fn an_enforced_refusal_is_countable_apart_from_a_genuine_missing_object() { let tmp = tempfile::tempdir().expect("storage root"); diff --git a/fluxer_media_proxy/src/server/self_origin.rs b/fluxer_media_proxy/src/server/self_origin.rs index e55095157..64ac7e32f 100644 --- a/fluxer_media_proxy/src/server/self_origin.rs +++ b/fluxer_media_proxy/src/server/self_origin.rs @@ -3,7 +3,7 @@ use crate::{ config::DeploymentMode, constants::{AssetExtension, AssetKind}, - external_path, public_net_policy, + public_net_policy, server::{ asset_path::{ decode_storage_key, parse_entrance_sound_path, parse_guild_member_asset_path, @@ -11,7 +11,6 @@ use crate::{ }, state::AppState, }, - signing, }; pub(in crate::server) enum SelfOrigin { @@ -37,10 +36,15 @@ pub(in crate::server) fn resolve(app: &AppState, url: &str) -> Option, ) -> Response { let body_len = byte_range.map(|r| r.end - r.start + 1).unwrap_or(total_len); - let mut response = Response::new(Body::empty()); - *response.status_mut() = if byte_range.is_some() { + let status = if byte_range.is_some() { StatusCode::PARTIAL_CONTENT } else { StatusCode::OK }; - http_headers::add_media_headers(response.headers_mut(), total_len, content_type, byte_range); - response - .headers_mut() - .insert(header::CONTENT_LENGTH, HeaderValue::from(body_len)); - if let Some(value) = disposition { - response - .headers_mut() - .insert(header::CONTENT_DISPOSITION, value); - } - response + media_body_response( + Body::empty(), + status, + total_len, + content_type, + byte_range, + Some(body_len as u64), + disposition, + ) } pub(super) struct StreamingMediaResponse<'a> { @@ -77,29 +73,26 @@ pub(super) fn streaming_media_response(response: StreamingMediaResponse<'_>) -> } else { content_type }; - let mut response = if method == Method::HEAD { - Response::new(Body::empty()) + let body = if method == Method::HEAD { + Body::empty() } else { - Response::new(stream_policy.guard(object.body, body_len)) + stream_policy.guard(object.body, body_len) }; - *response.status_mut() = status; - http_headers::add_media_headers( - response.headers_mut(), + media_body_response( + body, + status, total_len, response_content_type, effective_byte_range, - ); - response - .headers_mut() - .insert(header::CONTENT_LENGTH, HeaderValue::from(body_len)); - if let Some(value) = disposition { - response - .headers_mut() - .insert(header::CONTENT_DISPOSITION, value); - } - response + Some(body_len as u64), + disposition, + ) } +use crate::server::response::media_body_response; +#[cfg(test)] +use axum::http::header; + #[cfg(test)] mod tests { use super::*; diff --git a/fluxer_media_proxy/src/server/stored/tests.rs b/fluxer_media_proxy/src/server/stored/tests.rs index af7aba6ea..6a2d590b6 100644 --- a/fluxer_media_proxy/src/server/stored/tests.rs +++ b/fluxer_media_proxy/src/server/stored/tests.rs @@ -655,39 +655,3 @@ async fn an_explicit_quality_reuses_the_entry_the_resolved_default_minted() { ); assert!(rendered.contains("fluxer_media_proxy_transform_cache_misses_total 1\n")); } - -fn cache_control_of(response: &Response) -> &str { - response - .headers() - .get(header::CACHE_CONTROL) - .expect("cache-control is always set") - .to_str() - .expect("cache-control is ASCII") -} - -#[test] -fn stored_media_responses_cache_forever() { - let stored = media_response(MediaResponse { - method: Method::GET, - data: Bytes::from_static(b"stored bytes").into(), - content_type: "image/webp", - range_header: None, - disposition: None, - }); - assert_eq!("public, max-age=31536000", cache_control_of(&stored)); - - let streamable = media_response(MediaResponse { - method: Method::GET, - data: Bytes::from_static(b"stored bytes").into(), - content_type: "video/mp4", - range_header: None, - disposition: None, - }); - assert_eq!( - "public, max-age=31536000, no-transform", - cache_control_of(&streamable) - ); - - let head = super::response::passthrough_head_response("image/webp", 12, None, None); - assert_eq!("public, max-age=31536000", cache_control_of(&head)); -} diff --git a/fluxer_media_proxy/src/server/transform/cache.rs b/fluxer_media_proxy/src/server/transform/cache.rs index bfc472c31..8ef71caa1 100644 --- a/fluxer_media_proxy/src/server/transform/cache.rs +++ b/fluxer_media_proxy/src/server/transform/cache.rs @@ -10,11 +10,6 @@ use crate::{ use axum::{http::StatusCode, response::Response}; use std::{future::Future, time::Instant}; -pub(in crate::server) struct CachedTransformHit { - pub(in crate::server) data: BudgetedBytes, - pub(in crate::server) format: OutputFormat, -} - pub(in crate::server) struct CachedTransformRequest<'a, F> { pub(in crate::server) runtime: &'a TransformRuntime, pub(in crate::server) cache_key: String, @@ -26,12 +21,8 @@ pub(in crate::server) struct CachedTransformRequest<'a, F> { pub(in crate::server) fn cached_transform_hit( runtime: &TransformRuntime, cache_key: &str, -) -> Option { - let cached = runtime.cache().get(cache_key)?; - Some(CachedTransformHit { - data: cached.data, - format: cached.format, - }) +) -> Option { + runtime.cache().get(cache_key) } pub(in crate::server) async fn cached_transform( @@ -56,7 +47,7 @@ where result, Err(CoalescerError::WorkFailed | CoalescerError::TooLargeToProcess) ) { - runtime.metrics().record_transform_failure(); + runtime.metrics().transform_failures.inc(); } result } diff --git a/fluxer_media_proxy/src/signing.rs b/fluxer_media_proxy/src/signing.rs deleted file mode 100644 index a30422be1..000000000 --- a/fluxer_media_proxy/src/signing.rs +++ /dev/null @@ -1,147 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use base64::prelude::*; -use hmac::{Hmac, KeyInit, Mac}; -use sha2::Sha256; - -type HmacSha256 = Hmac; - -pub fn create_signature(input: &str, secret: &[u8]) -> String { - let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length"); - mac.update(input.as_bytes()); - BASE64_URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) -} - -pub fn verify_signature(input: &str, provided: &str, secret: &[u8]) -> bool { - let expected = create_signature(input, secret); - if expected.len() != provided.len() { - return false; - } - expected - .bytes() - .zip(provided.bytes()) - .fold(0u8, |diff, (a, b)| diff | (a ^ b)) - == 0 -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_fixtures::ADVERSARIAL_TEXT_INPUTS; - use proptest::prelude::*; - - const HMAC_SHA256_BASE64URL_LEN: usize = 43; - - #[test] - fn signature_matches_node_hmac_base64url_behavior() { - let sig = create_signature("v2/aHR0cHM6Ly9leGFtcGxlLmNvbS8", b"secret"); - assert_eq!("jkGeYYCiqJ67eS5T6lWJ4eQ77pwIaf6yoXXF5LncsHY", sig); - assert!(verify_signature( - "v2/aHR0cHM6Ly9leGFtcGxlLmNvbS8", - &sig, - b"secret" - )); - } - - #[test] - fn verify_rejects_tampered_signature() { - let sig = create_signature("input", b"secret"); - let mut tampered = sig.clone().into_bytes(); - tampered[0] = if tampered[0] == b'A' { b'B' } else { b'A' }; - assert!(!verify_signature( - "input", - std::str::from_utf8(&tampered).unwrap(), - b"secret" - )); - } - - #[test] - fn verify_rejects_wrong_secret() { - let sig = create_signature("input", b"secret"); - assert!(!verify_signature("input", &sig, b"different")); - } - - #[test] - fn verify_rejects_wrong_input() { - let sig = create_signature("input-a", b"secret"); - assert!(!verify_signature("input-b", &sig, b"secret")); - } - - #[test] - fn verify_rejects_different_length_signature_without_panic() { - assert!(!verify_signature("input", "short", b"secret")); - assert!(!verify_signature( - "input", - "definitely-too-long-of-a-signature-for-hmac-sha256-base64url", - b"secret" - )); - } - - #[test] - fn signature_is_deterministic() { - assert_eq!( - create_signature("input", b"secret"), - create_signature("input", b"secret") - ); - } - - #[test] - fn adversarial_text_signs_and_verifies_without_panicking() { - for input in ADVERSARIAL_TEXT_INPUTS { - let signature = create_signature(input, b"secret"); - assert_eq!(HMAC_SHA256_BASE64URL_LEN, signature.len()); - assert!(verify_signature(input, &signature, b"secret")); - assert!(!verify_signature(input, &signature, b"another-secret")); - assert!(!verify_signature(input, &signature, b"")); - } - } - - #[test] - fn an_empty_provided_signature_is_rejected_by_the_length_check() { - for input in ADVERSARIAL_TEXT_INPUTS { - assert!(!verify_signature(input, "", b"secret")); - assert!(!verify_signature(input, " ", b"secret")); - let truncated = &create_signature(input, b"secret")[..HMAC_SHA256_BASE64URL_LEN - 1]; - assert!(!verify_signature(input, truncated, b"secret")); - } - } - - #[test] - fn a_signature_from_another_adversarial_input_never_verifies() { - for input in ADVERSARIAL_TEXT_INPUTS { - let signature = create_signature(input, b"secret"); - for other in ADVERSARIAL_TEXT_INPUTS { - assert_eq!( - input == other, - verify_signature(other, &signature, b"secret"), - "{input:?} against {other:?}" - ); - } - } - } - - proptest! { - #![proptest_config(ProptestConfig { - cases: 256, - failure_persistence: None, - ..ProptestConfig::default() - })] - - #[test] - fn only_the_signed_input_and_its_own_secret_verify( - input in ".{0,96}", - other in ".{0,96}", - secret in prop::collection::vec(1u8..=u8::MAX, 0..64), - other_secret in prop::collection::vec(1u8..=u8::MAX, 0..64), - ) { - let signature = create_signature(&input, &secret); - prop_assert_eq!(HMAC_SHA256_BASE64URL_LEN, signature.len()); - prop_assert!(verify_signature(&input, &signature, &secret)); - prop_assert_eq!(input == other, verify_signature(&other, &signature, &secret)); - prop_assert_eq!( - secret == other_secret, - verify_signature(&input, &signature, &other_secret) - ); - } - } -} diff --git a/fluxer_media_proxy/src/storage/local.rs b/fluxer_media_proxy/src/storage/local.rs index b4e8157e0..76cb04dd8 100644 --- a/fluxer_media_proxy/src/storage/local.rs +++ b/fluxer_media_proxy/src/storage/local.rs @@ -131,6 +131,7 @@ impl Store { }) } + #[cfg(test)] pub(super) async fn write_local( &self, bucket: &str, diff --git a/fluxer_media_proxy/src/storage/mod.rs b/fluxer_media_proxy/src/storage/mod.rs index e0e9c5281..30344dd7c 100644 --- a/fluxer_media_proxy/src/storage/mod.rs +++ b/fluxer_media_proxy/src/storage/mod.rs @@ -133,6 +133,7 @@ pub struct Store { } impl Store { + #[cfg(test)] pub fn new( cfg: Config, metrics: Arc, @@ -166,20 +167,22 @@ impl Store { }) } + #[cfg(test)] pub async fn write_object( &self, bucket: &str, key: &str, data: &[u8], - content_type: &str, + _content_type: &str, ) -> Result<(), StorageError> { if self.cfg.read_only { return Err(StorageError::ReadOnlyStorage); } - match self.cfg.storage.backend { - StorageBackend::Local => self.write_local(bucket, key, data).await, - StorageBackend::S3 => self.write_s3(bucket, key, data, content_type).await, - } + assert!( + matches!(self.cfg.storage.backend, StorageBackend::Local), + "write_object is a local-only test fixture" + ); + self.write_local(bucket, key, data).await } pub async fn relay_put_object( @@ -214,9 +217,9 @@ pub(crate) fn unversioned_read_budget(limit: usize) -> ByteBudget { fn record_storage_outcome(metrics: &StorageMetrics, result: &Result) { match result { - Ok(_) => metrics.record_hit(), - Err(StorageError::NotFound) => metrics.record_miss(), - Err(_) => metrics.record_error(), + Ok(_) => metrics.storage_hits.inc(), + Err(StorageError::NotFound) => metrics.storage_misses.inc(), + Err(_) => metrics.storage_errors.inc(), } } diff --git a/fluxer_media_proxy/src/storage/object_read.rs b/fluxer_media_proxy/src/storage/object_read.rs index db11b1773..a0ce197ce 100644 --- a/fluxer_media_proxy/src/storage/object_read.rs +++ b/fluxer_media_proxy/src/storage/object_read.rs @@ -56,18 +56,11 @@ impl Store { &self, request: BufferedObjectReadRequest<'_>, ) -> Result { - let result = self.read_object_direct(request).await; + let result = match self.cfg.storage.backend { + StorageBackend::Local => self.read_local(request).await, + StorageBackend::S3 => self.read_s3(request).await, + }; record_storage_outcome(&self.metrics, &result); result } - - async fn read_object_direct( - &self, - request: BufferedObjectReadRequest<'_>, - ) -> Result { - match self.cfg.storage.backend { - StorageBackend::Local => self.read_local(request).await, - StorageBackend::S3 => self.read_s3(request).await, - } - } } diff --git a/fluxer_media_proxy/src/storage/object_stream.rs b/fluxer_media_proxy/src/storage/object_stream.rs index 1de0f81d8..23f1d769f 100644 --- a/fluxer_media_proxy/src/storage/object_stream.rs +++ b/fluxer_media_proxy/src/storage/object_stream.rs @@ -20,21 +20,14 @@ impl Store { key: &str, range_header: Option<&str>, ) -> Result { - self.stream_object_inner(ObjectStreamPlan { + let plan = ObjectStreamPlan { bucket, key, range: match range_header { Some(header) => StreamRange::Header(header), None => StreamRange::Full, }, - }) - .await - } - - async fn stream_object_inner( - &self, - plan: ObjectStreamPlan<'_>, - ) -> Result { + }; let result = match self.cfg.storage.backend { StorageBackend::Local => self.stream_local(plan).await, StorageBackend::S3 => self.stream_s3(plan).await, diff --git a/fluxer_media_proxy/src/storage/s3.rs b/fluxer_media_proxy/src/storage/s3.rs index 08b35938e..8c302cd59 100644 --- a/fluxer_media_proxy/src/storage/s3.rs +++ b/fluxer_media_proxy/src/storage/s3.rs @@ -178,34 +178,6 @@ impl Store { }) } - pub(super) async fn write_s3( - &self, - bucket: &str, - key: &str, - data: &[u8], - content_type: &str, - ) -> Result<(), StorageError> { - let url = self.s3_url(bucket, key)?; - let extra = [aws_sigv4::Header { - name: "Content-Type", - value: content_type, - }]; - let signed = self.sign(Method::PUT, &url, data, None, &extra)?; - let mut headers = signed_headers(&signed, &self.cfg); - headers.insert(header::CONTENT_TYPE, content_type_header(content_type)); - let response = self - .client - .put(&url) - .headers(headers) - .body(data.to_vec()) - .send() - .await?; - if !response.status().is_success() { - return Err(StorageError::S3(response.status().to_string())); - } - Ok(()) - } - pub(super) async fn relay_put_s3( &self, bucket: &str, diff --git a/fluxer_media_proxy/src/storage/tests/mod.rs b/fluxer_media_proxy/src/storage/tests/mod.rs index 3222bcf7a..a7f5d3ff3 100644 --- a/fluxer_media_proxy/src/storage/tests/mod.rs +++ b/fluxer_media_proxy/src/storage/tests/mod.rs @@ -14,7 +14,6 @@ use crate::{ }, constants, metrics::{Metrics, http_client::HTTPClientMetrics, storage::StorageMetrics}, - secret::{SecretBytes, SecretString}, storage::Store, }; use axum::response::Response; @@ -95,11 +94,11 @@ fn test_config(root: &Path) -> Config { } fn store(cfg: Config) -> Store { - store_with_storage_metrics(cfg, Arc::new(StorageMetrics::new())) + store_with_storage_metrics(cfg, Arc::new(StorageMetrics::default())) } fn store_with_storage_metrics(cfg: Config, metrics: Arc) -> Store { - Store::new(cfg, metrics, Arc::new(HTTPClientMetrics::new())) + Store::new(cfg, metrics, Arc::new(HTTPClientMetrics::default())) } fn rendered_counter(metrics: &Metrics, name: &str) -> u64 { @@ -321,3 +320,5 @@ pub(crate) async fn connection_dropping_front( }); (endpoint, accepted) } + +use fluxer_common::secret::{SecretBytes, SecretString}; diff --git a/fluxer_media_proxy/src/test_fixtures/captured_log.rs b/fluxer_media_proxy/src/test_fixtures/captured_log.rs new file mode 100644 index 000000000..9b14070d9 --- /dev/null +++ b/fluxer_media_proxy/src/test_fixtures/captured_log.rs @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::{Arc, Mutex}; +use tracing_subscriber::fmt::MakeWriter; + +#[derive(Clone, Default)] +pub(crate) struct CapturedLog(Arc>>); + +impl CapturedLog { + pub(crate) fn text(&self) -> String { + String::from_utf8(self.0.lock().expect("captured log is not poisoned").clone()) + .expect("captured log is utf-8") + } +} + +impl std::io::Write for CapturedLog { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0 + .lock() + .expect("captured log is not poisoned") + .extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl<'a> MakeWriter<'a> for CapturedLog { + type Writer = Self; + + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } +} diff --git a/fluxer_media_proxy/src/test_fixtures/mod.rs b/fluxer_media_proxy/src/test_fixtures/mod.rs index 52e0255cc..a8b3e4af9 100644 --- a/fluxer_media_proxy/src/test_fixtures/mod.rs +++ b/fluxer_media_proxy/src/test_fixtures/mod.rs @@ -22,3 +22,6 @@ pub use media::{ fixture_audio_only_mp4, fixture_h264_mp4, fixture_jpeg, fixture_mkv_with_png_video_stream, fixture_mp4_with_undecodable_video, synthetic_wav, }; + +mod captured_log; +pub(crate) use captured_log::CapturedLog; diff --git a/fluxer_media_proxy/src/thumbhash.rs b/fluxer_media_proxy/src/thumbhash.rs index a786c74b4..cf9e97ec1 100644 --- a/fluxer_media_proxy/src/thumbhash.rs +++ b/fluxer_media_proxy/src/thumbhash.rs @@ -204,21 +204,6 @@ mod tests { ); } - #[test] - fn encode_produces_non_empty_deterministic_output() { - let mut pixels = [0u8; 16 * 16 * 4]; - for px in pixels.as_chunks_mut::<4>().0 { - px[0] = 200; - px[1] = 100; - px[2] = 50; - px[3] = 255; - } - let out1 = encode_rgba(&pixels, 16, 16).unwrap(); - let out2 = encode_rgba(&pixels, 16, 16).unwrap(); - assert!(out1.len() > 5); - assert_eq!(out1, out2); - } - #[test] fn alpha_changes_header_layout() { let mut opaque = [0u8; 4 * 4 * 4]; @@ -234,21 +219,4 @@ mod tests { assert_eq!(0, opaque_out[2] & 0x80); assert_ne!(0, trans_out[2] & 0x80); } - - #[test] - fn synthetic_gradient_is_wire_stable() { - let mut pixels = [0u8; 8 * 8 * 4]; - for y in 0..8 { - for x in 0..8 { - let i = (y * 8 + x) * 4; - pixels[i] = (x * 32) as u8; - pixels[i + 1] = (y * 32) as u8; - pixels[i + 2] = 50; - pixels[i + 3] = 255; - } - } - let out = encode_rgba(&pixels, 8, 8).unwrap(); - assert_eq!(0, out[2] & 0x80); - assert!(out.len() >= 5); - } } diff --git a/fluxer_media_proxy/src/timed_semaphore.rs b/fluxer_media_proxy/src/timed_semaphore.rs index 1f8232135..8436e08fb 100644 --- a/fluxer_media_proxy/src/timed_semaphore.rs +++ b/fluxer_media_proxy/src/timed_semaphore.rs @@ -18,64 +18,42 @@ pub enum TimedSemaphoreError { #[derive(Clone, Debug)] pub struct TimedSemaphore { execution: Arc, - admission: Option>, + admission: Arc, } #[derive(Debug)] pub struct TimedSemaphoreAdmission { - _permit: Option, + _permit: OwnedSemaphorePermit, } #[derive(Debug)] pub struct TimedSemaphorePermit { _execution: OwnedSemaphorePermit, - _admission: Option, } impl TimedSemaphore { - pub fn new(permits: usize) -> Self { - Self { - execution: Arc::new(Semaphore::new(permits)), - admission: None, - } - } - pub fn with_queue_capacity(permits: usize, queue_capacity: usize) -> Self { let admission_capacity = permits .checked_add(queue_capacity) .expect("timed semaphore admission capacity overflow"); Self { execution: Arc::new(Semaphore::new(permits)), - admission: Some(Arc::new(Semaphore::new(admission_capacity))), + admission: Arc::new(Semaphore::new(admission_capacity)), } } pub fn try_admit(&self) -> Result { - let permit = - match &self.admission { - Some(admission) => Some(admission.clone().try_acquire_owned().map_err( - |error| match error { - TryAcquireError::NoPermits => TimedSemaphoreError::QueueFull, - TryAcquireError::Closed => TimedSemaphoreError::Closed, - }, - )?), - None => None, - }; + let permit = self + .admission + .clone() + .try_acquire_owned() + .map_err(|error| match error { + TryAcquireError::NoPermits => TimedSemaphoreError::QueueFull, + TryAcquireError::Closed => TimedSemaphoreError::Closed, + })?; Ok(TimedSemaphoreAdmission { _permit: permit }) } - pub async fn wait_until( - &self, - deadline: Option, - ) -> Result { - let admission = self.try_admit()?; - let execution = self.acquire_execution(deadline).await?; - Ok(TimedSemaphorePermit { - _execution: execution, - _admission: Some(admission), - }) - } - pub async fn wait_until_admitted( &self, _admission: &TimedSemaphoreAdmission, @@ -84,23 +62,6 @@ impl TimedSemaphore { let execution = self.acquire_execution(deadline).await?; Ok(TimedSemaphorePermit { _execution: execution, - _admission: None, - }) - } - - pub fn try_wait(&self) -> Result { - let admission = self.try_admit()?; - let execution = - self.execution - .clone() - .try_acquire_owned() - .map_err(|error| match error { - TryAcquireError::NoPermits => TimedSemaphoreError::RequestTimeout, - TryAcquireError::Closed => TimedSemaphoreError::Closed, - })?; - Ok(TimedSemaphorePermit { - _execution: execution, - _admission: Some(admission), }) } @@ -128,48 +89,17 @@ mod tests { use super::*; use std::time::Duration; - #[tokio::test] - async fn wait_until_times_out_when_no_permits_available() { - let sem = TimedSemaphore::new(0); - let err = sem - .wait_until(Some(Instant::now() + Duration::from_millis(5))) - .await - .unwrap_err(); - assert_eq!(TimedSemaphoreError::RequestTimeout, err); - } - - #[tokio::test] - async fn wait_until_consumes_and_post_restores_permits() { - let sem = TimedSemaphore::new(1); - let permit = sem - .wait_until(Some(Instant::now() + Duration::from_millis(100))) - .await - .unwrap(); - let err = sem - .wait_until(Some(Instant::now() + Duration::from_millis(1))) - .await - .unwrap_err(); - assert_eq!(TimedSemaphoreError::RequestTimeout, err); - drop(permit); - let _permit2 = sem - .wait_until(Some(Instant::now() + Duration::from_millis(100))) - .await - .unwrap(); - } - #[tokio::test] async fn queue_capacity_rejects_the_waiter_past_the_bound() { let sem = TimedSemaphore::with_queue_capacity(1, 2); - let _held = sem.wait_until(None).await.unwrap(); + let held_admission = sem.try_admit().unwrap(); + let _held = sem + .wait_until_admitted(&held_admission, None) + .await + .unwrap(); let first = sem.try_admit().unwrap(); let _second = sem.try_admit().unwrap(); assert_eq!(TimedSemaphoreError::QueueFull, sem.try_admit().unwrap_err()); - assert_eq!( - TimedSemaphoreError::QueueFull, - sem.wait_until(Some(Instant::now() + Duration::from_millis(50))) - .await - .unwrap_err() - ); drop(first); let _third = sem.try_admit().unwrap(); } @@ -177,8 +107,15 @@ mod tests { #[tokio::test] async fn dropping_a_waiting_future_restores_the_permit_and_the_queue_slot() { let sem = TimedSemaphore::with_queue_capacity(1, 1); - let held = sem.wait_until(None).await.unwrap(); - let mut waiting = Box::pin(sem.wait_until(None)); + let held_admission = sem.try_admit().unwrap(); + let held = sem + .wait_until_admitted(&held_admission, None) + .await + .unwrap(); + let mut waiting = Box::pin(async { + let admission = sem.try_admit().unwrap(); + sem.wait_until_admitted(&admission, None).await + }); assert!( tokio::time::timeout(Duration::from_millis(20), &mut waiting) .await @@ -187,7 +124,12 @@ mod tests { assert_eq!(TimedSemaphoreError::QueueFull, sem.try_admit().unwrap_err()); drop(waiting); drop(held); - let _reacquired = sem.try_wait().unwrap(); + drop(held_admission); + let reacquired_admission = sem.try_admit().unwrap(); + let _reacquired = sem + .wait_until_admitted(&reacquired_admission, None) + .await + .unwrap(); let _queued = sem.try_admit().unwrap(); assert_eq!(TimedSemaphoreError::QueueFull, sem.try_admit().unwrap_err()); } @@ -209,18 +151,4 @@ mod tests { drop(admission); let _readmitted = sem.try_admit().unwrap(); } - - #[tokio::test] - async fn new_leaves_the_admission_queue_unbounded() { - let sem = TimedSemaphore::new(1); - let _held = sem.wait_until(None).await.unwrap(); - let admissions = (0..1024) - .map(|_| sem.try_admit().unwrap()) - .collect::>(); - assert_eq!(1024, admissions.len()); - assert_eq!( - TimedSemaphoreError::RequestTimeout, - sem.try_wait().unwrap_err() - ); - } } diff --git a/fluxer_media_proxy/src/transform_cache.rs b/fluxer_media_proxy/src/transform_cache.rs index 8ce51c7b1..6e4d776ca 100644 --- a/fluxer_media_proxy/src/transform_cache.rs +++ b/fluxer_media_proxy/src/transform_cache.rs @@ -43,7 +43,7 @@ impl TransformCache { let eviction_metrics = Arc::clone(&settings.cache_metrics); Self { cached: Arc::new(ByteCache::new(settings.cache, move || { - eviction_metrics.record_eviction(); + eviction_metrics.transform_cache_evictions.inc(); })), in_flight: Arc::new(ByteCoalescer::with_budget( settings.budget, @@ -57,7 +57,7 @@ impl TransformCache { pub fn get(&self, key: &str) -> Option { let cached = self.cached.get(key)?; - self.metrics.record_hit(); + self.metrics.transform_cache_hits.inc(); Some(cached) } @@ -84,7 +84,7 @@ impl TransformCache { assert_eq!(cached.format, format); return Ok(cached.data); } - self.metrics.record_miss(); + self.metrics.transform_cache_misses.inc(); let cached = Arc::clone(&self.cached); let metrics = Arc::clone(&self.metrics); self.in_flight @@ -98,7 +98,7 @@ impl TransformCache { format, }, ) { - metrics.record_insert_rejected(); + metrics.transform_cache_insert_rejected.inc(); } } }) diff --git a/fluxer_media_proxy/src/upload_relay/token.rs b/fluxer_media_proxy/src/upload_relay/token.rs index 453d143ca..e08d3fda7 100644 --- a/fluxer_media_proxy/src/upload_relay/token.rs +++ b/fluxer_media_proxy/src/upload_relay/token.rs @@ -1,7 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::{secret::SecretBytes, upload_relay::RelayError}; +use crate::upload_relay::RelayError; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use fluxer_common::secret::SecretBytes; use hmac::{Hmac, KeyInit, Mac}; use serde::{Deserialize, Serialize}; use sha2::Sha256; @@ -53,14 +54,8 @@ pub enum TokenError { pub fn encode_token(token: &TokenPayload, secret: &[u8]) -> anyhow::Result { let payload = serde_json::to_vec(token)?; let encoded_payload = URL_SAFE_NO_PAD.encode(payload); - let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length"); - mac.update(encoded_payload.as_bytes()); - let sig = mac.finalize().into_bytes(); - Ok(format!( - "{}.{}", - encoded_payload, - URL_SAFE_NO_PAD.encode(sig) - )) + let signature = fluxer_common::external_media_path::create_signature(&encoded_payload, secret); + Ok(format!("{encoded_payload}.{signature}")) } pub fn decode_token(raw: &str, secret: &[u8], now_unix: u64) -> Result { diff --git a/fluxer_media_proxy/src/vips_shim.h b/fluxer_media_proxy/src/vips_shim.h index f59a1497a..a72233abe 100644 --- a/fluxer_media_proxy/src/vips_shim.h +++ b/fluxer_media_proxy/src/vips_shim.h @@ -29,6 +29,7 @@ enum fluxer_native_status { FLUXER_NATIVE_STATUS_ALLOCATION_FAILED = -6 }; +int fluxer_native_deadline_status(long long deadline_monotonic_ms); int fluxer_monotonic_deadline_status(long long deadline_monotonic_ms); extern const int fluxer_vips_format_uchar; @@ -63,8 +64,6 @@ int fluxer_vips_resize(VipsImage *in, VipsImage **out, double scale); int fluxer_vips_join_animation_pages( VipsImage *source, VipsImage **pages, int n_pages, int max_pages, size_t max_total_pixels, VipsImage **out); -int fluxer_vips_image_copy_memory( - VipsImage *in, long long deadline_monotonic_ms, VipsImage **out); #define FLUXER_THUMB_CROP_NONE 0 #define FLUXER_THUMB_CROP_CENTRE 1 int fluxer_vips_thumbnail_buffer_ex( @@ -97,15 +96,6 @@ int fluxer_heif_decode_primary_still( VipsImage **out, size_t max_pixels, int max_dimension, struct fluxer_heif_primary_still_decode_facts *facts); -int fluxer_ffmpeg_count_heif_sequence_frames( - const void *heif_data, - size_t heif_len, - int decoder_threads, - long long deadline_monotonic_ms, - int max_frames, - size_t max_total_pixels, - int *out_frame_count -); int fluxer_ffmpeg_decode_heif_sequence( const void *heif_data, diff --git a/fluxer_media_proxy/src/webp_animation.c b/fluxer_media_proxy/src/webp_animation.c index ee7212b65..522450e28 100644 --- a/fluxer_media_proxy/src/webp_animation.c +++ b/fluxer_media_proxy/src/webp_animation.c @@ -272,18 +272,13 @@ static int fluxer_webp_next_container_bound( return 0; } -static int fluxer_webp_deadline_status_at(long long deadline_monotonic_ms) { - int status = fluxer_monotonic_deadline_status(deadline_monotonic_ms); - return status == FLUXER_DEADLINE_PENDING - ? FLUXER_NATIVE_STATUS_OK - : FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; -} + static int fluxer_webp_deadline_status( const struct fluxer_webp_animation_encoder *encoder ) { assert(encoder != NULL); - return fluxer_webp_deadline_status_at(encoder->deadline_monotonic_ms); + return fluxer_native_deadline_status(encoder->deadline_monotonic_ms); } static int fluxer_webp_encode_progress( @@ -785,13 +780,13 @@ static int scale_webp_bgra_frame( if (state->source_width == state->target_width) { if (state->source_height == state->target_height) { *out_bgra = source_bgra; - return fluxer_webp_deadline_status_at(state->deadline_monotonic_ms); + return fluxer_native_deadline_status(state->deadline_monotonic_ms); } } if (state->scaled_bgra == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; for (int clip_y = 0; clip_y < state->target_height; clip_y += FLUXER_WEBP_SCALE_DEADLINE_ROWS) { - int deadline_status = fluxer_webp_deadline_status_at( + int deadline_status = fluxer_native_deadline_status( state->deadline_monotonic_ms); if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; int clip_height = state->target_height - clip_y; @@ -816,7 +811,7 @@ static int scale_webp_bgra_frame( if (rc > 0) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; if (rc < 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; } - int deadline_status = fluxer_webp_deadline_status_at( + int deadline_status = fluxer_native_deadline_status( state->deadline_monotonic_ms); if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; *out_bgra = state->scaled_bgra; @@ -831,7 +826,7 @@ static int transform_webp_animation_frame( uint8_t *source_bgra = NULL; int next_source_timestamp = 0; if (!WebPAnimDecoderGetNext(state->decoder, &source_bgra, &next_source_timestamp)) { - int deadline_status = fluxer_webp_deadline_status_at( + int deadline_status = fluxer_native_deadline_status( state->deadline_monotonic_ms); if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; return FLUXER_NATIVE_STATUS_CODEC_FAILURE; diff --git a/fluxer_messages/Cargo.toml b/fluxer_messages/Cargo.toml index ed0605692..f1178fc2c 100644 --- a/fluxer_messages/Cargo.toml +++ b/fluxer_messages/Cargo.toml @@ -23,13 +23,6 @@ tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread", "signal"] tracing = "0.1.44" url = "2.5.8" -[dev-dependencies] -criterion = { version = "0.8", default-features = false, features = ["cargo_bench_support"] } - -[[bench]] -name = "message_response" -harness = false - [features] default = [] scylla = ["dep:scylla", "fluxer-svc/scylla"] diff --git a/fluxer_messages/benches/message_response.rs b/fluxer_messages/benches/message_response.rs deleted file mode 100644 index f6c1fffb7..000000000 --- a/fluxer_messages/benches/message_response.rs +++ /dev/null @@ -1,177 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use criterion::{Criterion, criterion_group, criterion_main}; -use fluxer_messages::mention_extractor::extract_mentions_from_markdown; -use fluxer_messages::types::{ - ApiEmbedFieldResponse, ApiEmbedMediaResponse, ApiMessageAttachmentResponse, - ApiMessageEmbedChildResponse, ApiMessageEmbedResponse, ApiMessageReactionResponse, - ApiMessageReferenceResponse, ApiMessageResponse, ApiReactionEmojiResponse, - ApiUserPartialResponse, -}; -use std::hint::black_box; - -fn message_corpus() -> Vec { - (0..5_000) - .map(|index| { - format!( - "message {index} <@{}> <@!{}> <#{}> <@&{}> `ignored <@{}>` [visible <@{}>](https://example.com/<@{}>)", - 10_000 + index, - 20_000 + index, - 30_000 + index, - 40_000 + index, - 50_000 + index, - 60_000 + index, - 70_000 + index - ) - }) - .collect() -} - -fn sample_user(id: u64) -> ApiUserPartialResponse { - ApiUserPartialResponse { - id: id.to_string(), - username: format!("user{id}"), - discriminator: "0000".to_owned(), - global_name: Some(format!("User {id}")), - avatar: Some(format!("avatar-{id}")), - avatar_color: Some(0x336699), - bot: None, - system: None, - flags: 0, - mention_flags: None, - } -} - -fn sample_message(index: u64) -> ApiMessageResponse { - let user = sample_user(10_000 + index); - ApiMessageResponse { - id: (1_000_000 + index).to_string(), - channel_id: "123456789".to_owned(), - author: user.clone(), - webhook_id: None, - message_type: 0, - flags: 0, - content: format!("hello <@{}> <#{}>", user.id, 30_000 + index), - timestamp: "2026-01-01T00:00:00.000Z".to_owned(), - edited_timestamp: None, - pinned: false, - mention_everyone: false, - tts: false, - mentions: vec![user.clone()], - mention_roles: vec![(40_000 + index).to_string()], - mention_channels: None, - users: Some(vec![sample_user(60_000 + index)]), - embeds: vec![ApiMessageEmbedResponse { - base: ApiMessageEmbedChildResponse { - embed_type: "rich".to_owned(), - title: Some(format!("embed {index}")), - description: Some(format!("embed mentions <@{}>", 70_000 + index)), - url: Some("https://example.com".to_owned()), - timestamp: None, - color: Some(0x123456), - author: None, - provider: None, - thumbnail: Some(ApiEmbedMediaResponse { - url: "https://cdn.example.com/thumb.png".to_owned(), - proxy_url: "https://media.example.com/external/sign/v2/path".to_owned(), - width: Some(320), - height: Some(180), - duration: None, - description: Some("thumbnail".to_owned()), - content_type: Some("image/png".to_owned()), - content_hash: None, - placeholder: None, - flags: Some(0), - }), - image: None, - video: None, - audio: None, - footer: None, - fields: Some(vec![ApiEmbedFieldResponse { - name: "field".to_owned(), - value: "value".to_owned(), - is_inline: false, - }]), - nsfw: None, - html: None, - html_width: None, - html_height: None, - }, - children: None, - }], - attachments: vec![ApiMessageAttachmentResponse { - id: (80_000 + index).to_string(), - filename: "image.png".to_owned(), - title: None, - description: None, - content_type: Some("image/png".to_owned()), - content_hash: Some("hash".to_owned()), - size: 123_456, - url: Some("https://media.example.com/attachments/1/2/image.png".to_owned()), - proxy_url: Some("https://media.example.com/attachments/1/2/image.png".to_owned()), - width: Some(640), - height: Some(480), - placeholder: None, - flags: 0, - nsfw: None, - duration: None, - waveform: None, - expires_at: None, - expired: None, - }], - stickers: Vec::new(), - reactions: Some(vec![ApiMessageReactionResponse { - emoji: ApiReactionEmojiResponse { - id: None, - name: "thumbsup".to_owned(), - animated: None, - }, - count: 3, - me: Some(true), - }]), - message_reference: Some(ApiMessageReferenceResponse { - channel_id: "123456789".to_owned(), - message_id: Some((900_000 + index).to_string()), - guild_id: Some("987654321".to_owned()), - reference_type: 0, - }), - message_snapshots: None, - nonce: None, - call: None, - referenced_message: None, - } -} - -fn bench_extract_markdown_mentions(c: &mut Criterion) { - let corpus = message_corpus(); - c.bench_function( - "extract markdown mentions for 5000 message page corpus", - |b| { - b.iter(|| { - let mut total = 0usize; - for content in &corpus { - let mentions = extract_mentions_from_markdown(Some(content)); - total += mentions.users.len() + mentions.roles.len() + mentions.channels.len(); - } - black_box(total); - }); - }, - ); -} - -fn bench_serialize_message_page(c: &mut Criterion) { - let messages = (0..50).map(sample_message).collect::>(); - c.bench_function("serialize 50 api message responses", |b| { - b.iter(|| { - let bytes = serde_json::to_vec(black_box(&messages)).expect("serializes"); - black_box(bytes); - }); - }); -} - -criterion_group!( - benches, - bench_extract_markdown_mentions, - bench_serialize_message_page -); -criterion_main!(benches); diff --git a/fluxer_messages/src/mention_extractor.rs b/fluxer_messages/src/mention_extractor.rs index bcccf29c8..2d8b26ebc 100644 --- a/fluxer_messages/src/mention_extractor.rs +++ b/fluxer_messages/src/mention_extractor.rs @@ -350,4 +350,12 @@ mod tests { assert_eq!(mentions.users.len(), 1); assert_eq!(mentions.roles.len(), 1); } + #[test] + fn markdown_extractor_keeps_link_text_mentions_but_not_url_mentions() { + let mentions = + extract_mentions_from_markdown(Some("[<@101>](https://example.com/<@202>) <#303>")); + assert!(mentions.users.contains(&101)); + assert!(!mentions.users.contains(&202)); + assert!(mentions.channels.contains(&303)); + } } diff --git a/fluxer_messages/src/shard_impl.rs b/fluxer_messages/src/shard_impl.rs index 1ec52d1e9..18be9f787 100644 --- a/fluxer_messages/src/shard_impl.rs +++ b/fluxer_messages/src/shard_impl.rs @@ -9,7 +9,7 @@ use crate::types::{ ApiMessageAttachmentResponse, ApiMessageCallResponse, ApiMessageEmbedChildResponse, ApiMessageEmbedResponse, ApiMessageReactionResponse, ApiMessageReferenceResponse, ApiMessageResponse, ApiMessageSnapshotResponse, ApiMessageStickerResponse, - ApiReactionEmojiResponse, ApiUserPartialResponse, Message, MessageAttachment, MessageCall, + ApiReactionEmojiResponse, ApiUserPartial, Message, MessageAttachment, MessageCall, MessageEmbed, MessageEmbedAuthor, MessageEmbedChild, MessageEmbedField, MessageEmbedFooter, MessageEmbedMedia, MessageEmbedProvider, MessageReference, MessageRequest, MessageResponse, MessageSnapshot, MessageStickerItem, @@ -17,7 +17,8 @@ use crate::types::{ use crate::udt; use base64::prelude::{BASE64_STANDARD, Engine}; use chrono::{DateTime, Utc}; -use fluxer_common::user_flags::{USER_FLAG_STAFF, visible_user_flags}; +use fluxer_common::time::{unix_millis, unix_seconds}; +use fluxer_common::user_partial::{FLUXER_SYSTEM_USER_ID, UserPartialFields, fluxer_system_user}; use fluxer_svc::shard::ShardService; use fluxer_svc::transport::Transport; use fluxer_svc::{postgres, postgres::BigIntBound, postgres::KeyPart}; @@ -34,7 +35,6 @@ use serde::Deserialize; use std::collections::{HashMap, HashSet}; #[cfg(feature = "scylla")] use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; use url::Url; const BUCKET_DURATION_MS: i64 = 864_000_000; @@ -93,11 +93,6 @@ const MESSAGE_TYPE_THREAD_CREATED: i32 = 18; const THREAD_MESSAGE_FLAG_MASK: i64 = 0x120; const THREAD_FEATURE_CHANNEL_TYPES: [i32; 5] = [10, 11, 12, 15, 16]; const EMBED_MEDIA_OWNED_ATTACHMENT_FLAG: i32 = 1 << 30; -#[cfg(test)] -const USER_FLAG_DELETED: i64 = 1_i64 << 34; -const FLUXER_SYSTEM_USER_ID: i64 = 0; -const FLUXER_SYSTEM_USERNAME: &str = "Fluxer"; -const FLUXER_SYSTEM_DISCRIMINATOR: &str = "0000"; const DELETED_USER_USERNAME: &str = "DeletedUser"; const DELETED_USER_GLOBAL_NAME: &str = "Deleted User"; const BUCKET_SCAN_CONCURRENCY: usize = 16; @@ -427,7 +422,7 @@ impl ResponseBuildOptions { #[derive(Debug, Default)] struct ResponseContext { - users: HashMap, + users: HashMap, reactions: HashMap>, referenced_messages: HashMap<(i64, i64), Message>, attachment_decay: HashMap>, @@ -1551,11 +1546,7 @@ impl MessagesShard { } } - fn resolve_author( - &self, - message: &Message, - context: &ResponseContext, - ) -> ApiUserPartialResponse { + fn resolve_author(&self, message: &Message, context: &ResponseContext) -> ApiUserPartial { if let Some(author_id) = message.author_id { return context .users @@ -1566,7 +1557,7 @@ impl MessagesShard { if let (Some(webhook_id), Some(webhook_name)) = (message.webhook_id, message.webhook_name.clone()) { - return ApiUserPartialResponse { + return ApiUserPartial { id: webhook_id.to_string(), username: webhook_name, discriminator: "0000".to_owned(), @@ -1593,14 +1584,14 @@ impl MessagesShard { let filename = attachment.filename.clone().unwrap_or_default(); let decay = context.attachment_decay.get(&attachment_id); let expired = - decay.is_some_and(|expires_at| expires_at.timestamp_millis() <= now_epoch_millis()); + decay.is_some_and(|expires_at| expires_at.timestamp_millis() <= unix_millis()); let url = (!expired).then(|| { attachment_cdn_url( channel_id, attachment_id, &filename, options, - now_epoch_secs(), + unix_seconds(), ) }); let content_type = attachment.content_type.clone().unwrap_or_else(|| { @@ -2786,20 +2777,6 @@ impl ShardService for MessagesShard { } } -fn now_epoch_millis() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64 -} - -fn now_epoch_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() -} - fn message_row_key(channel_id: i64, bucket: i32, message_id: i64) -> anyhow::Result { postgres::kv_key(&[ KeyPart::BigInt(channel_id), @@ -2963,11 +2940,7 @@ fn snowflake_to_bucket(snowflake: i64) -> i32 { } fn current_bucket() -> i32 { - let now_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - epoch_millis_to_bucket(now_ms) + epoch_millis_to_bucket(unix_millis()) } fn epoch_millis_to_iso(epoch_millis: i64) -> String { @@ -3163,45 +3136,26 @@ fn collect_user_ids_from_embed_child(embed: &MessageEmbedChild, target: &mut Has target.extend(mentions.users); } -fn map_user_partial(partial: UserPartialServiceResponse) -> ApiUserPartialResponse { - if partial.user_id == FLUXER_SYSTEM_USER_ID { - return fluxer_system_user(); - } - let flags = partial.flags.unwrap_or_default(); - ApiUserPartialResponse { - id: partial.user_id.to_string(), +fn map_user_partial(partial: UserPartialServiceResponse) -> ApiUserPartial { + ApiUserPartial::from(UserPartialFields { + user_id: partial.user_id, username: partial.username, - discriminator: format!("{:04}", partial.discriminator), + discriminator: partial.discriminator, global_name: partial.global_name, - avatar: partial.avatar_hash, + avatar_hash: partial.avatar_hash, avatar_color: partial.avatar_color, - bot: partial.bot.filter(|bot| *bot), - system: partial.system.filter(|system| *system), - flags: i64::from(visible_user_flags(flags)), - mention_flags: partial.mention_flags.filter(|flags| *flags != 0), - } + bot: partial.bot, + system: partial.system, + flags: partial.flags, + mention_flags: partial.mention_flags, + }) } -fn fluxer_system_user() -> ApiUserPartialResponse { - ApiUserPartialResponse { - id: FLUXER_SYSTEM_USER_ID.to_string(), - username: FLUXER_SYSTEM_USERNAME.to_owned(), - discriminator: FLUXER_SYSTEM_DISCRIMINATOR.to_owned(), - global_name: None, - avatar: None, - avatar_color: None, - bot: Some(true), - system: Some(true), - flags: USER_FLAG_STAFF, - mention_flags: None, - } -} - -fn deleted_user(user_id: i64) -> ApiUserPartialResponse { +fn deleted_user(user_id: i64) -> ApiUserPartial { if user_id == FLUXER_SYSTEM_USER_ID { return fluxer_system_user(); } - ApiUserPartialResponse { + ApiUserPartial { id: user_id.to_string(), username: DELETED_USER_USERNAME.to_owned(), discriminator: "0000".to_owned(), @@ -3378,23 +3332,6 @@ fn attachment_cdn_url( ) } -fn is_own_endpoint(media_endpoint: &str, target: &Url) -> bool { - let Ok(base) = Url::parse(media_endpoint) else { - return false; - }; - let same_host = match (base.host_str(), target.host_str()) { - (Some(base_host), Some(target_host)) => base_host.eq_ignore_ascii_case(target_host), - _ => false, - }; - same_host - && base.scheme().eq_ignore_ascii_case(target.scheme()) - && base.port_or_known_default() == target.port_or_known_default() - && target - .path() - .strip_prefix(base.path().trim_end_matches('/')) - .is_some_and(|rest| rest.starts_with('/')) -} - fn raw_url_path(url: &str) -> &str { let Some((_, after_scheme)) = url.split_once("://") else { return ""; @@ -3457,13 +3394,13 @@ fn sign_own_url(input_url: &str, options: &ResponseBuildOptions) -> String { sign_own_media_url( input_url, options.media_endpoint.trim_end_matches('/'), - now_epoch_secs(), + unix_seconds(), &options.attachment_url_secret, ) } fn external_media_proxy_url(input_url: &str, options: &ResponseBuildOptions) -> String { - media_proxy_url_at(input_url, options, now_epoch_secs()) + media_proxy_url_at(input_url, options, unix_seconds()) } fn media_proxy_url_at(input_url: &str, options: &ResponseBuildOptions, now_secs: u64) -> String { @@ -3472,7 +3409,7 @@ fn media_proxy_url_at(input_url: &str, options: &ResponseBuildOptions, now_secs: Ok(url) => url, Err(_) => return input_url.to_owned(), }; - if is_own_endpoint(media_endpoint, &parsed_url) { + if fluxer_common::external_media_path::is_own_media_url(media_endpoint, &parsed_url) { return sign_own_media_url( input_url, media_endpoint, @@ -3731,31 +3668,6 @@ mod tests { }; use serde_json::json; - #[test] - fn id_zero_is_always_fluxer_system_user() { - let mapped = map_user_partial(UserPartialServiceResponse { - user_id: 0, - username: DELETED_USER_USERNAME.to_owned(), - discriminator: 0, - global_name: Some(DELETED_USER_GLOBAL_NAME.to_owned()), - avatar_hash: None, - bot: Some(true), - system: Some(true), - flags: Some(USER_FLAG_DELETED), - avatar_color: None, - mention_flags: None, - content_hidden_since: None, - }); - - assert_eq!(mapped.id, "0"); - assert_eq!(mapped.username, "Fluxer"); - assert_eq!(mapped.discriminator, "0000"); - assert_eq!(mapped.global_name, None); - assert_eq!(mapped.bot, Some(true)); - assert_eq!(mapped.system, Some(true)); - assert_eq!(mapped.flags, USER_FLAG_STAFF); - } - #[test] fn missing_id_zero_fallback_is_fluxer_system_user() { let mapped = deleted_user(0); @@ -3781,30 +3693,6 @@ mod tests { assert_eq!(mapped.system, None); } - #[test] - fn deleted_flagged_user_partials_are_not_synthetically_masked() { - let mapped = map_user_partial(UserPartialServiceResponse { - user_id: 42, - username: "Ada".to_owned(), - discriminator: 7, - global_name: Some("Ada Lovelace".to_owned()), - avatar_hash: Some("avatar_hash".to_owned()), - bot: Some(false), - system: Some(false), - flags: Some(USER_FLAG_DELETED), - avatar_color: Some(0x336699), - mention_flags: None, - content_hidden_since: None, - }); - - assert_eq!(mapped.id, "42"); - assert_eq!(mapped.username, "Ada"); - assert_eq!(mapped.discriminator, "0007"); - assert_eq!(mapped.global_name, Some("Ada Lovelace".to_owned())); - assert_eq!(mapped.avatar, Some("avatar_hash".to_owned())); - assert_eq!(mapped.flags, 0); - } - fn reaction( user_id: i64, emoji_id: i64, @@ -3960,24 +3848,6 @@ mod tests { ); } - #[test] - fn bucket_page_limit_is_large_enough_for_sparse_channels() { - assert_eq!(bucket_page_limit(0), 32); - assert_eq!(bucket_page_limit(25), 32); - assert_eq!(bucket_page_limit(50), 50); - assert_eq!(bucket_page_limit(500), BUCKET_INDEX_PAGE_SIZE); - } - - #[test] - fn bucket_wave_ramp_is_bounded_by_scan_concurrency() { - assert_eq!(next_bucket_wave(BUCKET_SCAN_WAVE), BUCKET_SCAN_WAVE * 2); - assert_eq!( - next_bucket_wave(BUCKET_SCAN_CONCURRENCY), - BUCKET_SCAN_CONCURRENCY - ); - assert_eq!(next_bucket_wave(usize::MAX), BUCKET_SCAN_CONCURRENCY); - } - #[tokio::test] async fn bucket_scan_stops_once_the_newest_buckets_fill_the_page() { let buckets = (0..50).map(|index| 500 - index).collect::>(); @@ -4085,24 +3955,6 @@ mod tests { assert_eq!(snowflake_to_bucket(1_509_256_674_043_502_592), 416); } - #[test] - fn message_id_descending_order_matches_api_contract() { - let mut ids = [ - 1_506_928_431_237_816_380_i64, - 1_507_125_930_562_373_970_i64, - 1_507_125_959_675_038_036_i64, - ]; - ids.sort_unstable_by(|left, right| right.cmp(left)); - assert_eq!( - ids, - [ - 1_507_125_959_675_038_036_i64, - 1_507_125_930_562_373_970_i64, - 1_506_928_431_237_816_380_i64, - ] - ); - } - #[test] fn embed_field_response_defaults_null_name_and_value_to_empty_strings() { let response = map_embed_field_response(MessageEmbedField { @@ -4633,7 +4485,7 @@ mod tests { &url, &unsigned_attachment_url(&options), &options, - now_epoch_secs(), + unix_seconds(), ); } @@ -4700,7 +4552,7 @@ mod tests { let context = ResponseContext { attachment_decay: [( SIGNED_ATTACHMENT_ID, - DateTime::::from_timestamp_millis(now_epoch_millis() - 1_000).unwrap(), + DateTime::::from_timestamp_millis(unix_millis() - 1_000).unwrap(), )] .into_iter() .collect(), @@ -4737,7 +4589,7 @@ mod tests { let shard = MessagesShard { storage: MessagesStorage::Expiries(std::sync::Arc::new(HashMap::from([( SIGNED_ATTACHMENT_ID, - DateTime::::from_timestamp_millis(now_epoch_millis() - 1_000).unwrap(), + DateTime::::from_timestamp_millis(unix_millis() - 1_000).unwrap(), )]))), transport: InMemoryTransport::new(), }; @@ -4796,7 +4648,7 @@ mod tests { let mapped = shard.map_embed(&embed, &options); - let now = now_epoch_secs(); + let now = unix_seconds(); let base = mapped.base; let author = base.author.expect("the embed has an author"); let provider = base.provider.expect("the embed has a provider"); @@ -5724,7 +5576,7 @@ mod tests { &storage_key, Some(query), &[&options.attachment_url_secret], - now_epoch_secs(), + unix_seconds(), ) .verdict, "{url}" diff --git a/fluxer_messages/src/types.rs b/fluxer_messages/src/types.rs index deada4529..9174ec104 100644 --- a/fluxer_messages/src/types.rs +++ b/fluxer_messages/src/types.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +pub use fluxer_common::user_partial::ApiUserPartial; + use serde::{Deserialize, Serialize}; fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result>, D::Error> @@ -220,23 +222,6 @@ pub struct Message { pub message_snapshots: Option>, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ApiUserPartialResponse { - pub id: String, - pub username: String, - pub discriminator: String, - pub global_name: Option, - pub avatar: Option, - pub avatar_color: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub bot: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub system: Option, - pub flags: i64, - #[serde(skip_serializing_if = "Option::is_none")] - pub mention_flags: Option, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApiMessageAttachmentResponse { pub id: String, @@ -452,7 +437,7 @@ pub struct ApiMessageCallResponse { pub struct ApiMessageResponse { pub id: String, pub channel_id: String, - pub author: ApiUserPartialResponse, + pub author: ApiUserPartial, #[serde(skip_serializing_if = "Option::is_none")] pub webhook_id: Option, #[serde(rename = "type")] @@ -464,12 +449,12 @@ pub struct ApiMessageResponse { pub pinned: bool, pub mention_everyone: bool, pub tts: bool, - pub mentions: Vec, + pub mentions: Vec, pub mention_roles: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub mention_channels: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub users: Option>, + pub users: Option>, pub embeds: Vec, pub attachments: Vec, pub stickers: Vec, @@ -768,7 +753,7 @@ mod tests { fn minimal_api_message( referenced: Option>>, ) -> ApiMessageResponse { - let author = ApiUserPartialResponse { + let author = ApiUserPartial { id: "1".to_string(), username: "user".to_string(), discriminator: "0001".to_string(), @@ -840,30 +825,4 @@ mod tests { let absent = serde_json::to_value(minimal_api_message(None)).expect("serialises"); assert!(absent.get("referenced_message").is_none()); } - - #[test] - fn message_reference_without_message_id_survives_json_and_msgpack() { - let mut message = minimal_api_message(None); - message.message_reference = Some(ApiMessageReferenceResponse { - channel_id: "5".to_string(), - message_id: None, - guild_id: Some("6".to_string()), - reference_type: 0, - }); - - let value = serde_json::to_value(&message).expect("serialises"); - assert_eq!( - value["message_reference"], - json!({"channel_id": "5", "guild_id": "6", "type": 0}) - ); - - let encoded = rmp_serde::to_vec_named(&message).expect("encodes to msgpack"); - let decoded = rmp_serde::from_slice::(&encoded) - .expect("decodes from msgpack") - .message_reference - .expect("reference survives"); - assert_eq!(decoded.channel_id, "5"); - assert_eq!(decoded.message_id, None); - assert_eq!(decoded.guild_id.as_deref(), Some("6")); - } } diff --git a/fluxer_messages/tests/message_response_logic.rs b/fluxer_messages/tests/message_response_logic.rs deleted file mode 100644 index bb89c9ee7..000000000 --- a/fluxer_messages/tests/message_response_logic.rs +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use fluxer_messages::mention_extractor::extract_mentions_from_markdown; -use fluxer_messages::types::ExtractedMentionsResponse; - -#[test] -fn markdown_extractor_keeps_link_text_mentions_but_not_url_mentions() { - let mentions = - extract_mentions_from_markdown(Some("[<@101>](https://example.com/<@202>) <#303>")); - assert!(mentions.users.contains(&101)); - assert!(!mentions.users.contains(&202)); - assert!(mentions.channels.contains(&303)); -} - -#[test] -fn extracted_mentions_response_serializes_ids_as_strings() { - let mentions = extract_mentions_from_markdown(Some("<@123456789012345678> <@&999> <#888>")); - let response = ExtractedMentionsResponse::from(mentions); - assert!(response.users.contains(&"123456789012345678".to_owned())); - assert!(response.roles.contains(&"999".to_owned())); - assert!(response.channels.contains(&"888".to_owned())); - - let json = serde_json::to_string(&response).unwrap(); - assert!(json.contains("\"123456789012345678\"")); - assert!(!json.contains("123456789012345678,")); -} - -#[test] -fn everyone_and_here_survive_response_conversion() { - let mentions = extract_mentions_from_markdown(Some("@everyone @here <@1>")); - assert!(mentions.everyone); - assert!(mentions.here); - let response = ExtractedMentionsResponse::from(mentions); - assert!(response.everyone); - assert!(response.here); -} diff --git a/fluxer_push/Cargo.toml b/fluxer_push/Cargo.toml index 76ab2acfc..6d0ac58d7 100644 --- a/fluxer_push/Cargo.toml +++ b/fluxer_push/Cargo.toml @@ -16,6 +16,7 @@ name = "fluxer-push" path = "src/main.rs" [dependencies] +fluxer_common = { path = "../fluxer_common", features = ["healthcheck"] } fluxer-svc = { path = "../fluxer_svc", default-features = false } anyhow = "1.0.104" axum = { version = "0.8.9", default-features = false, features = ["http1", "http2", "tokio"] } diff --git a/fluxer_push/src/cli.rs b/fluxer_push/src/cli.rs index 3925c2db9..5b35a470f 100644 --- a/fluxer_push/src/cli.rs +++ b/fluxer_push/src/cli.rs @@ -28,7 +28,7 @@ pub enum Command { pub fn load_config(args: &Args) -> anyhow::Result { let vars = - fluxer_svc::config::resolve_env_files(std::env::vars()).map_err(anyhow::Error::msg)?; + fluxer_common::config::resolve_env_files(std::env::vars()).map_err(anyhow::Error::msg)?; load_config_from_iter(args, vars) } diff --git a/fluxer_push/src/config.rs b/fluxer_push/src/config.rs index 56ae4564f..0b737bee6 100644 --- a/fluxer_push/src/config.rs +++ b/fluxer_push/src/config.rs @@ -1,7 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::secret::SecretString; +use fluxer_common::config::{parse_bool, parse_number}; + use anyhow::Context as _; +use fluxer_common::secret::SecretString; use serde::Deserialize; use std::net::{IpAddr, SocketAddr}; @@ -669,42 +671,6 @@ fn parse_environment(var_name: &str, raw: &str) -> anyhow::Result) -> anyhow::Result> { - let Some(raw) = raw else { - return Ok(None); - }; - match raw.to_ascii_lowercase().as_str() { - "true" | "1" | "yes" => Ok(Some(true)), - "false" | "0" | "no" => Ok(Some(false)), - _ => Err(anyhow::anyhow!( - "{var_name} must be a boolean: true, false, 1, 0, yes, or no" - )), - } -} - -fn parse_number( - var_name: &str, - raw: Option<&str>, - default_value: T, - min_value: T, - max_value: T, -) -> anyhow::Result -where - T: std::str::FromStr + PartialOrd + std::fmt::Display + Copy, -{ - let Some(raw) = raw else { - return Ok(default_value); - }; - let parsed = raw - .parse::() - .map_err(|_| anyhow::anyhow!("{var_name} must be a number"))?; - anyhow::ensure!( - parsed >= min_value && parsed <= max_value, - "{var_name} must be between {min_value} and {max_value}" - ); - Ok(parsed) -} - struct Env(Vec<(String, String)>); impl Env { diff --git a/fluxer_push/src/healthcheck.rs b/fluxer_push/src/healthcheck.rs index 89843b48b..7cedebf09 100644 --- a/fluxer_push/src/healthcheck.rs +++ b/fluxer_push/src/healthcheck.rs @@ -1,55 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::config::Mode; -use anyhow::Context as _; -use std::{ - net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, - time::Duration, -}; - -pub async fn run(mode: Mode) -> anyhow::Result<()> { - let addr = target( - fluxer_svc::config::optional_env("FLUXER_PUSH_SERVICE_HOST").as_deref(), - fluxer_svc::config::optional_env("FLUXER_PUSH_SERVICE_PORT").as_deref(), - mode, - )?; - probe(addr).await -} - -fn target(host: Option<&str>, port: Option<&str>, mode: Mode) -> anyhow::Result { - let host = host.map(str::trim).unwrap_or("127.0.0.1"); - let ip = host - .parse::() - .with_context(|| format!("FLUXER_PUSH_SERVICE_HOST is not an IP address: {host}"))?; - let ip = match ip { - IpAddr::V4(ip) if ip.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST), - IpAddr::V6(ip) if ip.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST), - ip => ip, - }; - let port = match port.map(str::trim) { - Some(port) => port - .parse::() - .with_context(|| format!("FLUXER_PUSH_SERVICE_PORT is not a port number: {port}"))?, - None => mode.default_port(), - }; - Ok(SocketAddr::new(ip, port)) -} - -async fn probe(addr: SocketAddr) -> anyhow::Result<()> { - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_millis(500)) - .timeout(Duration::from_millis(2_000)) - .no_proxy() - .build()?; - let status = client - .get(format!("http://{addr}/_health")) - .send() - .await - .with_context(|| format!("health request to {addr} failed"))? - .status(); - anyhow::ensure!( - status == reqwest::StatusCode::OK, - "health returned {status}" - ); - Ok(()) +pub async fn run(mode: crate::config::Mode) -> anyhow::Result<()> { + fluxer_common::healthcheck::run( + fluxer_common::config::env_value("FLUXER_PUSH_SERVICE_HOST").as_deref(), + fluxer_common::config::env_value("FLUXER_PUSH_SERVICE_PORT").as_deref(), + "FLUXER_PUSH_SERVICE_HOST", + "FLUXER_PUSH_SERVICE_PORT", + mode.default_port(), + ) + .await } diff --git a/fluxer_push/src/lib.rs b/fluxer_push/src/lib.rs index c7f9862f5..2e0e3a638 100644 --- a/fluxer_push/src/lib.rs +++ b/fluxer_push/src/lib.rs @@ -15,7 +15,6 @@ mod relay_consent; mod resolver; mod retry; mod rpc; -mod secret; pub mod server; mod subscription; mod tokens; diff --git a/fluxer_push/src/metrics.rs b/fluxer_push/src/metrics.rs index 80899a44e..144139f69 100644 --- a/fluxer_push/src/metrics.rs +++ b/fluxer_push/src/metrics.rs @@ -2,7 +2,7 @@ use crate::relay::reject::{REASON_COUNT, Reason}; use crate::relay_consent::ConsentUpdate; -use fluxer_svc::metrics::now_ms; +use fluxer_common::metrics::{Histogram, now_ms, render_counter}; use std::fmt::{self, Write as _}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -12,65 +12,6 @@ pub fn elapsed_ms(started_ms: i64) -> u64 { u64::try_from(now_ms() - started_ms).unwrap_or(0) } -const HISTOGRAM_BUCKETS_MS: &[u64] = &[ - 1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, -]; - -struct Histogram { - buckets: [AtomicU64; 13], - inf: AtomicU64, - sum_ms: AtomicU64, - count: AtomicU64, -} - -impl Histogram { - const fn new() -> Self { - Self { - buckets: [const { AtomicU64::new(0) }; 13], - inf: AtomicU64::new(0), - sum_ms: AtomicU64::new(0), - count: AtomicU64::new(0), - } - } - - fn observe(&self, ms: u64) { - for (index, upper) in HISTOGRAM_BUCKETS_MS.iter().copied().enumerate() { - if ms <= upper { - self.buckets[index].fetch_add(1, ORDERING); - break; - } - } - self.inf.fetch_add(1, ORDERING); - self.sum_ms.fetch_add(ms, ORDERING); - self.count.fetch_add(1, ORDERING); - } - - fn render_series(&self, out: &mut String, name: &str, labels: &str) -> fmt::Result { - let mut cumulative = 0; - for (index, upper) in HISTOGRAM_BUCKETS_MS.iter().copied().enumerate() { - cumulative += self.buckets[index].load(ORDERING); - writeln!(out, "{name}_bucket{{{labels},le=\"{upper}\"}} {cumulative}")?; - } - writeln!( - out, - "{name}_bucket{{{labels},le=\"+Inf\"}} {}", - self.inf.load(ORDERING) - )?; - writeln!(out, "{name}_sum{{{labels}}} {}", self.sum_ms.load(ORDERING))?; - writeln!( - out, - "{name}_count{{{labels}}} {}", - self.count.load(ORDERING) - ) - } -} - -impl Default for Histogram { - fn default() -> Self { - Self::new() - } -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(usize)] pub enum JobKind { @@ -659,11 +600,6 @@ impl Default for Metrics { } } -fn render_counter(out: &mut String, name: &str, counter: &AtomicU64) -> fmt::Result { - writeln!(out, "# TYPE {name} counter")?; - writeln!(out, "{name} {}", counter.load(ORDERING)) -} - fn render_gauge(out: &mut String, name: &str, gauge: &AtomicU64) -> fmt::Result { writeln!(out, "# TYPE {name} gauge")?; writeln!(out, "{name} {}", gauge.load(ORDERING)) @@ -716,7 +652,7 @@ fn render_labelled_histogram( ) -> fmt::Result { writeln!(out, "# TYPE {name} histogram")?; for (index, value) in values.into_iter().enumerate() { - histograms[index].render_series(out, name, &format!("{label}=\"{value}\""))?; + histograms[index].render_series(out, name, Some(&format!("{label}=\"{value}\"")))?; } Ok(()) } diff --git a/fluxer_push/src/providers/own_relay.rs b/fluxer_push/src/providers/own_relay.rs index e54ed4ed1..98d5c68cd 100644 --- a/fluxer_push/src/providers/own_relay.rs +++ b/fluxer_push/src/providers/own_relay.rs @@ -155,17 +155,6 @@ mod tests { assert_eq!(hop.leg, Leg::ApnsVoip); } - #[test] - fn only_the_alert_legs_are_taken_in_process() { - let shortcut = |path: &str| { - parse(&format!("https://push.fluxer.com/relay/v1/{path}"), &ours()) - .filter(|hop| matches!(hop.leg, Leg::Apns | Leg::Fcm)) - }; - assert!(shortcut(&format!("apns/canary/production/{TOKEN}")).is_some()); - assert!(shortcut(&format!("apns-voip/canary/production/{TOKEN}")).is_none()); - assert!(shortcut("fcm/canary/dYC_x9gXTjyyrG8_Aw3nUM%3AAPA91bExample").is_some()); - } - #[test] fn a_relay_we_do_not_operate_is_left_on_the_network_path() { let endpoint = format!("https://push.example.org/relay/v1/apns/canary/production/{TOKEN}"); diff --git a/fluxer_push/src/providers/web_push.rs b/fluxer_push/src/providers/web_push.rs index 9b50cec9e..042c52e7f 100644 --- a/fluxer_push/src/providers/web_push.rs +++ b/fluxer_push/src/providers/web_push.rs @@ -295,24 +295,6 @@ fn retry_delay(attempt: u32) -> Duration { Duration::from_millis(MAX_RETRY_DELAY_MS.min(base + jitter - 1)) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_unified_push_topic_with_no_listener_is_permanent() { - assert_eq!( - classify(INSUFFICIENT_STORAGE), - SendOutcome::permanent("http_507") - ); - } - - #[test] - fn an_unavailable_push_service_stays_retryable() { - assert_eq!(classify(503), SendOutcome::transient("http_503")); - } -} - #[cfg(test)] mod retry_tests { use super::*; @@ -367,3 +349,21 @@ mod retry_tests { assert!(!should_retry(410, 0)); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_unified_push_topic_with_no_listener_is_permanent() { + assert_eq!( + classify(INSUFFICIENT_STORAGE), + SendOutcome::permanent("http_507") + ); + } + + #[test] + fn an_unavailable_push_service_stays_retryable() { + assert_eq!(classify(503), SendOutcome::transient("http_503")); + } +} diff --git a/fluxer_push/src/relay/mod.rs b/fluxer_push/src/relay/mod.rs index bced79f19..1ba79ffdc 100644 --- a/fluxer_push/src/relay/mod.rs +++ b/fluxer_push/src/relay/mod.rs @@ -7,7 +7,7 @@ pub mod reject; use crate::config::{FcmConfig, ProviderEnvironment, RelayConfig}; use crate::metrics::{Metrics, RelayLeg, RelayResult}; -use crate::server::{Sidecar, serve, sidecar_router}; +use crate::server::{Sidecar, serve}; use crate::tokens::{TokenCache, TokenError}; use crate::unix_seconds; use crate::vendor::{self, ApnsRequest, DeadToken, Refusal, VendorOutcome}; @@ -117,7 +117,7 @@ pub fn router(state: Arc) -> Router { .route(FCM_ROUTE, post(fcm_route)) .fallback(unmatched_route) .with_state(state) - .merge(sidecar_router(sidecar)) + .merge(sidecar.router()) } async fn unmatched_route(State(state): State>) -> Response { diff --git a/fluxer_push/src/resolver.rs b/fluxer_push/src/resolver.rs index 209a93370..e2a0b1498 100644 --- a/fluxer_push/src/resolver.rs +++ b/fluxer_push/src/resolver.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use reqwest::dns::{Addrs, Name, Resolve, Resolving}; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; use tokio::net::lookup_host; @@ -9,36 +9,6 @@ type ResolveError = Box; pub const BLOCKED_ADDRESS_ERROR: &str = "host resolved into blocked address space"; -const BLOCKED_V4: &[(Ipv4Addr, u32)] = &[ - (Ipv4Addr::new(0, 0, 0, 0), 8), - (Ipv4Addr::new(10, 0, 0, 0), 8), - (Ipv4Addr::new(100, 64, 0, 0), 10), - (Ipv4Addr::new(127, 0, 0, 0), 8), - (Ipv4Addr::new(169, 254, 0, 0), 16), - (Ipv4Addr::new(172, 16, 0, 0), 12), - (Ipv4Addr::new(192, 0, 0, 0), 24), - (Ipv4Addr::new(192, 0, 2, 0), 24), - (Ipv4Addr::new(192, 88, 99, 0), 24), - (Ipv4Addr::new(192, 168, 0, 0), 16), - (Ipv4Addr::new(198, 18, 0, 0), 15), - (Ipv4Addr::new(198, 51, 100, 0), 24), - (Ipv4Addr::new(203, 0, 113, 0), 24), - (Ipv4Addr::new(224, 0, 0, 0), 4), - (Ipv4Addr::new(240, 0, 0, 0), 4), -]; - -const BLOCKED_V6: &[(Ipv6Addr, u32)] = &[ - (Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 0), 128), - (Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1), 128), - (Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0), 32), - (Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0), 7), - (Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 0), 10), - (Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0), 8), -]; - -const NAT64_PREFIX: [u8; 4] = [0x00, 0x64, 0xff, 0x9b]; -const SIXTOFOUR_PREFIX: [u8; 2] = [0x20, 0x02]; - pub struct PublicOnlyResolver { private_hosts: Arc<[String]>, } @@ -80,66 +50,7 @@ fn screen(resolved: Vec, allow_private: bool) -> Result bool { - match ip { - IpAddr::V4(ip) => is_blocked_v4(ip), - IpAddr::V6(ip) => match embedded_v4(ip) { - Some(embedded) => is_blocked_v4(embedded), - None => is_blocked_v6(ip), - }, - } -} - -fn is_blocked_v4(ip: Ipv4Addr) -> bool { - let value = ip.to_bits(); - BLOCKED_V4 - .iter() - .any(|(network, prefix)| masked_v4(value, *prefix) == masked_v4(network.to_bits(), *prefix)) -} - -fn is_blocked_v6(ip: Ipv6Addr) -> bool { - let value = ip.to_bits(); - BLOCKED_V6 - .iter() - .any(|(network, prefix)| masked_v6(value, *prefix) == masked_v6(network.to_bits(), *prefix)) -} - -fn masked_v4(value: u32, prefix: u32) -> u32 { - match prefix { - 0 => 0, - _ => value & (u32::MAX << (u32::BITS - prefix)), - } -} - -fn masked_v6(value: u128, prefix: u32) -> u128 { - match prefix { - 0 => 0, - _ => value & (u128::MAX << (u128::BITS - prefix)), - } -} - -fn embedded_v4(ip: Ipv6Addr) -> Option { - let octets = ip.octets(); - let quad = |start: usize| { - Ipv4Addr::new( - octets[start], - octets[start + 1], - octets[start + 2], - octets[start + 3], - ) - }; - if octets[..10] == [0; 10] && octets[10] == 0xff && octets[11] == 0xff { - return Some(quad(12)); - } - if octets[..4] == NAT64_PREFIX && octets[4..12] == [0; 8] { - return Some(quad(12)); - } - if octets[..12] == [0; 12] { - return Some(quad(12)); - } - if octets[..2] == SIXTOFOUR_PREFIX { - return Some(quad(2)); - } - None + !fluxer_common::public_ip::is_public_ip(ip) } #[cfg(test)] diff --git a/fluxer_push/src/rpc.rs b/fluxer_push/src/rpc.rs index 88fddd784..aa4c9c363 100644 --- a/fluxer_push/src/rpc.rs +++ b/fluxer_push/src/rpc.rs @@ -2,8 +2,8 @@ use crate::config::{RPC_AUTH_HEADER, RpcConfig}; use crate::metrics::{Metrics, RpcMethod, RpcOutcome, elapsed_ms}; -use crate::secret::SecretString; use crate::subscription::Subscription; +use fluxer_common::secret::SecretString; use fluxer_svc::metrics::now_ms; use rand::RngExt as _; use serde::Deserialize; diff --git a/fluxer_push/src/secret.rs b/fluxer_push/src/secret.rs deleted file mode 100644 index b0b6645b0..000000000 --- a/fluxer_push/src/secret.rs +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::fmt; -use std::sync::atomic::{Ordering, compiler_fence}; - -const REDACTED: &str = "[REDACTED]"; - -fn zero(bytes: &mut [u8]) { - for byte in bytes.iter_mut() { - unsafe { std::ptr::write_volatile(byte, 0) }; - } - compiler_fence(Ordering::SeqCst); -} - -#[derive(Clone)] -pub struct SecretString(String); - -impl SecretString { - pub fn new(value: String) -> Self { - Self(value) - } - - pub fn expose(&self) -> &str { - self.0.as_str() - } -} - -impl Drop for SecretString { - fn drop(&mut self) { - zero(unsafe { self.0.as_mut_vec() }); - } -} - -impl fmt::Debug for SecretString { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(REDACTED) - } -} diff --git a/fluxer_push/src/server.rs b/fluxer_push/src/server.rs index 90744ff87..67d309942 100644 --- a/fluxer_push/src/server.rs +++ b/fluxer_push/src/server.rs @@ -6,14 +6,10 @@ use crate::metrics::Metrics; use crate::relay; use crate::relay_consent::{self, RelayConsentStore}; use crate::rpc::RpcClient; -use crate::secret::SecretString; use crate::tokens::TokenCache; use crate::vendor; use axum::Router; -use axum::extract::{ConnectInfo, State}; -use axum::http::{HeaderValue, StatusCode, header}; -use axum::response::{IntoResponse, Response}; -use axum::routing::get; +use fluxer_common::secret::SecretString; use fluxer_svc::shutdown::{DEFAULT_DRAIN_TIMEOUT, drain_with_timeout, wait_for_shutdown}; use fluxer_svc::transport::NatsTransport; use std::net::SocketAddr; @@ -29,30 +25,27 @@ const JOB_DRAIN_TIMEOUT: Duration = Duration::from_secs(25); pub struct Sidecar { metrics: Arc, - serving: AtomicBool, + serving: Arc, } impl Sidecar { pub fn new(metrics: Arc) -> Self { Self { metrics, - serving: AtomicBool::new(false), + serving: Arc::new(AtomicBool::new(false)), } } + pub fn router(&self) -> Router { + let metrics = self.metrics.clone(); + fluxer_svc::server::ops_router(self.serving.clone(), Arc::new(move || metrics.render())) + } + pub fn set_serving(&self, serving: bool) { self.serving.store(serving, Ordering::SeqCst); } } -pub fn sidecar_router(sidecar: Arc) -> Router { - Router::new() - .route("/_health", get(readiness)) - .route("/_healthz", get(async || "OK")) - .route("/_metrics", get(metrics_handler)) - .with_state(sidecar) -} - pub struct Serving { stop: oneshot::Sender<()>, served: JoinHandle>, @@ -131,7 +124,7 @@ pub async fn run(cfg: Config) -> anyhow::Result<()> { async fn run_delivery(cfg: DeliveryConfig) -> anyhow::Result<()> { let state = Arc::new(AppState::try_new(cfg)?); let addr = state.cfg.bind_addr; - let serving = serve(addr, sidecar_router(Arc::clone(&state.sidecar))).await?; + let serving = serve(addr, state.sidecar.router()).await?; info!(%addr, vapid_subject = state.cfg.vapid.email, "push sidecar listening"); let transport = NatsTransport::connect( @@ -178,32 +171,3 @@ async fn drain_jobs(state: &AppState, jobs: JoinHandle<()>, timeout: Duration) { .await; running.abort(); } - -async fn readiness(State(sidecar): State>) -> impl IntoResponse { - if sidecar.serving.load(Ordering::SeqCst) { - (StatusCode::OK, "OK") - } else { - (StatusCode::SERVICE_UNAVAILABLE, "NOT READY") - } -} - -async fn metrics_handler( - ConnectInfo(peer): ConnectInfo, - State(sidecar): State>, -) -> Response { - if !is_loopback_peer(&peer) { - return (StatusCode::FORBIDDEN, "FORBIDDEN").into_response(); - } - ( - [( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"), - )], - sidecar.metrics.render(), - ) - .into_response() -} - -fn is_loopback_peer(peer: &SocketAddr) -> bool { - peer.ip().to_canonical().is_loopback() -} diff --git a/fluxer_snowflakes/src/main.rs b/fluxer_snowflakes/src/main.rs index 890d8a61b..11a9b8217 100644 --- a/fluxer_snowflakes/src/main.rs +++ b/fluxer_snowflakes/src/main.rs @@ -25,7 +25,14 @@ async fn main() -> anyhow::Result<()> { "starting snowflakes service" ); match config.mode { - Mode::Router => router_impl::run_round_robin_router(&config, transport).await, + Mode::Router => { + fluxer_svc::router::run_router( + &config, + router_impl::SnowflakesRouter::new(config.shard_count), + transport, + ) + .await + } Mode::Shard => { let shard = SnowflakesShard::new(config.shard_id)?; fluxer_svc::shard::run_shard(&config, shard, transport).await diff --git a/fluxer_snowflakes/src/router_impl.rs b/fluxer_snowflakes/src/router_impl.rs index 82c21bb6b..9f965b8b2 100644 --- a/fluxer_snowflakes/src/router_impl.rs +++ b/fluxer_snowflakes/src/router_impl.rs @@ -1,18 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::types::{SERVICE_NAME, SnowflakeRequest, SnowflakeResponse}; -use fluxer_svc::config::ServiceConfig; use fluxer_svc::hash_ring::HashRing; -use fluxer_svc::metrics::ServiceMetrics; -use fluxer_svc::transport::{NatsMessage, NatsTransport, TransportMessage, TransportSubscriber}; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::time::{Duration, Instant}; -use tokio::task::JoinSet; -use tracing::{debug, info, warn}; - -const SHARD_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); -const MAX_ROUTER_REQUEST_BYTES: usize = 64 * 1024; +use fluxer_svc::router::RouterService; +use std::sync::atomic::{AtomicU32, Ordering}; pub struct RoundRobinShardPicker { next: AtomicU32, @@ -21,12 +12,8 @@ pub struct RoundRobinShardPicker { impl RoundRobinShardPicker { pub fn new(shard_count: u32) -> Self { - Self::with_start(shard_count, 0) - } - - fn with_start(shard_count: u32, start: u32) -> Self { Self { - next: AtomicU32::new(start), + next: AtomicU32::new(0), shard_count: shard_count.max(1), } } @@ -57,186 +44,30 @@ impl SnowflakeShardPicker { } } -pub async fn run_round_robin_router( - config: &ServiceConfig, - transport: NatsTransport, -) -> anyhow::Result<()> { - anyhow::ensure!( - config.max_concurrent_requests > 0, - "snowflake router request concurrency must be positive" - ); - let picker = Arc::new(SnowflakeShardPicker::new(config.shard_count)); - let mut tasks = JoinSet::new(); - let health_addr = config.listen_addr; - let router_serving = Arc::new(AtomicBool::new(true)); - let http_serving = router_serving.clone(); - let http_metrics = Arc::new(ServiceMetrics::default()); - http_metrics.init(); - tasks.spawn({ - let metrics = http_metrics.clone(); - async move { - fluxer_svc::server::run_http( - health_addr, - http_serving, - metrics, - SERVICE_NAME.to_owned(), - ) - .await - } - }); - tasks.spawn(serve_requests( - transport, - picker, - http_metrics, - config.max_concurrent_requests, - )); - tokio::select! { - result = tasks.join_next() => { - match result { - Some(Ok(Ok(()))) => Ok(()), - Some(Ok(Err(error))) => Err(error), - Some(Err(error)) => Err(error.into()), - None => Ok(()), - } - } - _ = fluxer_svc::shutdown::wait_for_shutdown() => { - info!("snowflake router shutting down"); - router_serving.store(false, Ordering::SeqCst); - Ok(()) +pub struct SnowflakesRouter { + picker: SnowflakeShardPicker, +} + +impl SnowflakesRouter { + pub fn new(shard_count: u32) -> Self { + Self { + picker: SnowflakeShardPicker::new(shard_count), } } } -async fn serve_requests( - transport: NatsTransport, - picker: Arc, - metrics: Arc, - max_concurrent_requests: usize, -) -> anyhow::Result<()> { - let request_subject = format!("svc.{SERVICE_NAME}"); - let queue_group = format!("{SERVICE_NAME}-router"); - let mut requests = JoinSet::new(); - loop { - let mut sub = transport - .subscribe_queue(&request_subject, &queue_group) - .await?; - info!( - subject = request_subject, - max_concurrent_requests, "snowflake router listening for requests" - ); - loop { - let msg = tokio::select! { - result = requests.join_next(), if !requests.is_empty() => { - if let Err(error) = result.expect("nonempty snowflake request set") { - warn!(error = %error, "snowflake request task failed"); - } - continue; - } - msg = sub.next() => { - let Some(msg) = msg else { - warn!("snowflake router request subscription stream ended, will re-subscribe"); - break; - }; - msg - } - }; - while let Some(result) = requests.try_join_next() { - if let Err(error) = result { - warn!(error = %error, "snowflake request task failed"); - } - } - metrics.record_request(); - if requests.len() >= max_concurrent_requests { - debug!( - max_concurrent_requests, - "shedding overloaded snowflake router request" - ); - metrics.record_request_error(); - reply_json_error(&msg, &transport, "overloaded").await; - continue; - } - requests.spawn(handle_request( - msg, - transport.clone(), - picker.clone(), - metrics.clone(), - )); - } +impl RouterService for SnowflakesRouter { + type Request = SnowflakeRequest; + type Response = SnowflakeResponse; + const CACHES_RESPONSES: bool = false; + fn service_name(&self) -> &str { + SERVICE_NAME } -} - -async fn handle_request( - msg: NatsMessage, - transport: NatsTransport, - picker: Arc, - metrics: Arc, -) { - let started = Instant::now(); - match forward_request(msg.payload(), &transport, &picker, &metrics).await { - Ok(response) => { - if msg.has_reply() { - let payload = serde_json::to_vec(&response) - .expect("snowflake responses contain only JSON-serializable strings"); - reply(&msg, &transport, &payload).await; - } - } - Err(error) => { - metrics.record_request_error(); - reply_json_error(&msg, &transport, error).await; - } + fn route_key(request: &Self::Request) -> String { + request.routing_key().unwrap_or_default().to_owned() } - metrics.record_request_duration(started.elapsed().as_millis() as u64); -} - -async fn forward_request( - payload: &[u8], - transport: &NatsTransport, - picker: &SnowflakeShardPicker, - metrics: &ServiceMetrics, -) -> Result { - if payload.len() > MAX_ROUTER_REQUEST_BYTES { - warn!( - payload_bytes = payload.len(), - max_payload_bytes = MAX_ROUTER_REQUEST_BYTES, - "rejecting oversized snowflake request" - ); - return Err("request_too_large"); - } - let request: SnowflakeRequest = serde_json::from_slice(payload).map_err(|error| { - warn!(error = %error, "failed to decode snowflake request"); - "decode_error" - })?; - let shard_id = picker.pick_shard(&request); - let shard_subject = format!("svc.{SERVICE_NAME}.shard.{shard_id}"); - let payload = rmp_serde::to_vec(&request).map_err(|error| { - warn!(error = %error, "failed to encode snowflake shard request"); - "encode_error" - })?; - metrics.record_shard_forward(); - let response_bytes = transport - .request(&shard_subject, &payload, SHARD_REQUEST_TIMEOUT) - .await - .map_err(|error| { - debug!(error = %error, shard_id, "snowflake shard request failed"); - "shard_unavailable" - })?; - rmp_serde::from_slice(&response_bytes).map_err(|error| { - debug!(error = %error, shard_id, "failed to decode snowflake shard response"); - "shard_decode_error" - }) -} - -async fn reply_json_error(msg: &NatsMessage, transport: &NatsTransport, error: &str) { - if msg.has_reply() { - let payload = serde_json::to_vec(&serde_json::json!({ "error": error })) - .expect("snowflake error responses contain only a JSON-serializable string"); - reply(msg, transport, &payload).await; - } -} - -async fn reply(msg: &NatsMessage, transport: &NatsTransport, payload: &[u8]) { - if let Err(error) = msg.reply(transport, payload).await { - warn!(error = %error, "failed to reply to snowflake request"); + fn shard_for(&self, request: &Self::Request, _ring: &HashRing) -> u32 { + self.picker.pick_shard(request) } } @@ -253,7 +84,8 @@ mod tests { #[test] fn picker_handles_atomic_wraparound() { - let picker = RoundRobinShardPicker::with_start(4, u32::MAX - 1); + let picker = RoundRobinShardPicker::new(4); + picker.next.store(u32::MAX - 1, Ordering::Relaxed); let shards = (0..4).map(|_| picker.next_shard()).collect::>(); assert_eq!(shards, vec![2, 3, 0, 1]); } diff --git a/fluxer_svc/Cargo.toml b/fluxer_svc/Cargo.toml index 4cce3c791..4caf06307 100644 --- a/fluxer_svc/Cargo.toml +++ b/fluxer_svc/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true license.workspace = true [dependencies] +fluxer_common = { path = "../fluxer_common", version = "0.1.0" } anyhow = "1.0.104" async-nats = "0.50" async-trait = "0.1" diff --git a/fluxer_svc/src/config.rs b/fluxer_svc/src/config.rs index 2e9f23cc6..1b1c76e1a 100644 --- a/fluxer_svc/src/config.rs +++ b/fluxer_svc/src/config.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use std::env; -use std::fs; +pub use fluxer_common::config::env_value as optional_env; + use std::net::{IpAddr, SocketAddr}; use std::time::Duration; @@ -53,7 +53,7 @@ pub enum DatabaseBackend { impl ServiceConfig { pub fn from_env() -> anyhow::Result { - Self::from_env_reader(env_var) + Self::from_env_reader(fluxer_common::config::env_value) } fn from_env_reader(get: F) -> anyhow::Result @@ -203,66 +203,6 @@ fn default_max_concurrent_requests(service_name: &str) -> usize { } } -pub fn optional_env(name: &str) -> Option { - env_var(name) -} - -fn env_var(name: &str) -> Option { - resolve_env_value(name, |key| env::var(key).ok()).unwrap_or_else(|error| panic!("{error}")) -} - -fn resolve_env_value(name: &str, get: F) -> Result, String> -where - F: Fn(&str) -> Option, -{ - let non_blank = |value: Option| value.filter(|value| !value.trim().is_empty()); - let value = non_blank(get(name)); - let Some(path) = non_blank(get(&format!("{name}_FILE"))) else { - return Ok(value); - }; - if value.is_some() { - return Err(format!("{name} and {name}_FILE are both set, set only one")); - } - let contents = fs::read_to_string(&path) - .map_err(|error| format!("{name}_FILE could not read {path} ({error})"))?; - let contents = contents - .strip_suffix('\n') - .map_or(contents.as_str(), |rest| { - rest.strip_suffix('\r').unwrap_or(rest) - }); - Ok(non_blank(Some(contents.to_owned()))) -} - -pub fn resolve_env_files(vars: I) -> Result, String> -where - I: IntoIterator, -{ - let vars: Vec<(String, String)> = vars.into_iter().collect(); - let get = |key: &str| { - vars.iter() - .find_map(|(name, value)| (name == key).then(|| value.clone())) - }; - let mut resolved = Vec::new(); - for (key, _) in &vars { - let Some(name) = key - .strip_suffix("_FILE") - .filter(|name| name.starts_with("FLUXER_")) - else { - continue; - }; - if let Some(value) = resolve_env_value(name, get)? { - resolved.push((name.to_owned(), value)); - } - } - let rest: Vec<(String, String)> = vars - .iter() - .filter(|(key, _)| !resolved.iter().any(|(name, _)| name == key)) - .cloned() - .collect(); - resolved.extend(rest); - Ok(resolved) -} - fn optional_from(get: &F, name: &str) -> Option where F: Fn(&str) -> Option, @@ -333,46 +273,6 @@ mod tests { .unwrap() } - #[test] - fn resolves_name_file_entries() { - let dir = env::temp_dir().join(format!("fluxer-svc-env-file-{}", std::process::id())); - fs::create_dir_all(&dir).unwrap(); - let path = dir.join("token"); - fs::write(&path, "from-file\r\n").unwrap(); - let path = path.to_string_lossy().into_owned(); - let missing = dir.join("missing").to_string_lossy().into_owned(); - let resolved = resolve_env_files(vec![ - ("FLUXER_NATS_AUTH_TOKEN".to_owned(), String::new()), - ("FLUXER_NATS_AUTH_TOKEN_FILE".to_owned(), path.clone()), - ]) - .unwrap(); - let both = resolve_env_files(vec![ - ("FLUXER_X".to_owned(), "direct".to_owned()), - ("FLUXER_X_FILE".to_owned(), path), - ]); - let unreadable = resolve_env_files(vec![("FLUXER_X_FILE".to_owned(), missing.clone())]); - let unrelated = resolve_env_files(vec![("SSL_CERT_FILE".to_owned(), missing.clone())]); - fs::remove_dir_all(&dir).unwrap(); - assert_eq!( - vec!["from-file"], - resolved - .iter() - .filter(|(name, _)| name == "FLUXER_NATS_AUTH_TOKEN") - .map(|(_, value)| value.as_str()) - .collect::>() - ); - assert_eq!( - Err("FLUXER_X and FLUXER_X_FILE are both set, set only one".to_owned()), - both - ); - assert!( - unreadable - .unwrap_err() - .starts_with(&format!("FLUXER_X_FILE could not read {missing} (")) - ); - assert_eq!(Ok(vec![("SSL_CERT_FILE".to_owned(), missing)]), unrelated); - } - #[test] fn extracts_statefulset_ordinal() { assert_eq!(shard_id_from_pod_name("my-service-0"), Some(0)); diff --git a/fluxer_svc/src/hash_ring.rs b/fluxer_svc/src/hash_ring.rs index 4412e9c78..4c5d50dac 100644 --- a/fluxer_svc/src/hash_ring.rs +++ b/fluxer_svc/src/hash_ring.rs @@ -54,52 +54,3 @@ fn rendezvous_score(key: &str, shard_id: u32) -> u64 { } hash } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn deterministic_routing() { - let ring = HashRing::new(8); - let key = "guild:123456"; - let first = ring.owner(key); - for _ in 0..100 { - assert_eq!(ring.owner(key), first); - } - } - - #[test] - fn single_shard_always_zero() { - let ring = HashRing::new(1); - assert_eq!(ring.owner("anything"), 0); - assert_eq!(ring.owner(""), 0); - } - - #[test] - fn distribution_across_shards() { - let shard_count = 8; - let ring = HashRing::new(shard_count); - let mut counts = vec![0u32; shard_count as usize]; - for i in 0..10_000 { - let key = format!("key:{i}"); - counts[ring.owner(&key) as usize] += 1; - } - for count in &counts { - assert!( - *count > 500, - "shard received only {count} of 10000 keys across {shard_count} shards" - ); - } - } - - #[test] - fn different_keys_can_route_to_different_shards() { - let ring = HashRing::new(4); - let mut seen = std::collections::HashSet::new(); - for i in 0..100 { - seen.insert(ring.owner(&format!("key:{i}"))); - } - assert!(seen.len() > 1, "all 100 keys routed to the same shard"); - } -} diff --git a/fluxer_svc/src/lib.rs b/fluxer_svc/src/lib.rs index acede77ed..1933fde35 100644 --- a/fluxer_svc/src/lib.rs +++ b/fluxer_svc/src/lib.rs @@ -13,16 +13,10 @@ pub mod transport; #[cfg(feature = "scylla")] pub mod scylla; -use tracing_subscriber::EnvFilter; - pub fn init_tracing() { tracing_subscriber::fmt() .json() - .with_env_filter( - config::optional_env("RUST_LOG") - .and_then(|filter| EnvFilter::try_new(filter).ok()) - .unwrap_or_else(|| EnvFilter::new("info")), - ) + .with_env_filter(fluxer_common::config::env_filter("info")) .try_init() .ok(); } diff --git a/fluxer_svc/src/metrics.rs b/fluxer_svc/src/metrics.rs index b725b4de4..cf367c7e5 100644 --- a/fluxer_svc/src/metrics.rs +++ b/fluxer_svc/src/metrics.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use libc::{CLOCK_MONOTONIC, clock_gettime, timespec}; +use fluxer_common::metrics::render_counter; +pub use fluxer_common::metrics::{Histogram, now_ms}; use std::fmt::Write; use std::sync::Arc; use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; @@ -8,63 +9,6 @@ use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; const ORDERING: Ordering = Ordering::Relaxed; pub(crate) type AdditionalMetricsRenderer = Arc; -const HISTOGRAM_BUCKETS_MS: &[u64] = &[ - 1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, -]; - -pub fn now_ms() -> i64 { - let mut ts = timespec { - tv_sec: 0, - tv_nsec: 0, - }; - let rc = unsafe { clock_gettime(CLOCK_MONOTONIC, &mut ts) }; - if rc != 0 { - return 0; - } - ts.tv_sec - .saturating_mul(1_000) - .saturating_add(ts.tv_nsec / 1_000_000) -} - -pub struct Histogram { - buckets: [AtomicU64; 13], - inf: AtomicU64, - sum_ms: AtomicU64, - count: AtomicU64, -} - -impl Histogram { - pub const fn new() -> Self { - Self { - buckets: [const { AtomicU64::new(0) }; 13], - inf: AtomicU64::new(0), - sum_ms: AtomicU64::new(0), - count: AtomicU64::new(0), - } - } - - pub fn observe(&self, ms: u64) { - for (i, upper) in HISTOGRAM_BUCKETS_MS.iter().copied().enumerate() { - if ms <= upper { - self.buckets[i].fetch_add(1, ORDERING); - self.inf.fetch_add(1, ORDERING); - self.sum_ms.fetch_add(ms, ORDERING); - self.count.fetch_add(1, ORDERING); - return; - } - } - self.inf.fetch_add(1, ORDERING); - self.sum_ms.fetch_add(ms, ORDERING); - self.count.fetch_add(1, ORDERING); - } -} - -impl Default for Histogram { - fn default() -> Self { - Self::new() - } -} - pub struct ServiceMetrics { pub requests_total: AtomicU64, pub request_errors_total: AtomicU64, @@ -127,40 +71,30 @@ impl ServiceMetrics { self.request_duration.observe(ms); } - pub fn snapshot(&self) -> MetricsSnapshot { - MetricsSnapshot { - requests_total: self.requests_total.load(ORDERING), - request_errors_total: self.request_errors_total.load(ORDERING), - cache_hits_total: self.cache_hits_total.load(ORDERING), - cache_misses_total: self.cache_misses_total.load(ORDERING), - shard_forwards_total: self.shard_forwards_total.load(ORDERING), - } - } - pub fn render_prometheus(&self, service_name: &str) -> String { let mut out = String::new(); - render_counter( + let _ = render_counter( &mut out, &format!("fluxer_{service_name}_requests_total"), &self.requests_total, ); - render_counter( + let _ = render_counter( &mut out, &format!("fluxer_{service_name}_request_errors_total"), &self.request_errors_total, ); - render_counter( + let _ = render_counter( &mut out, &format!("fluxer_{service_name}_cache_hits_total"), &self.cache_hits_total, ); - render_counter( + let _ = render_counter( &mut out, &format!("fluxer_{service_name}_cache_misses_total"), &self.cache_misses_total, ); - render_counter( + let _ = render_counter( &mut out, &format!("fluxer_{service_name}_shard_forwards_total"), &self.shard_forwards_total, @@ -186,20 +120,6 @@ impl ServiceMetrics { } } -#[derive(Clone, Debug, Default)] -pub struct MetricsSnapshot { - pub requests_total: u64, - pub request_errors_total: u64, - pub cache_hits_total: u64, - pub cache_misses_total: u64, - pub shard_forwards_total: u64, -} - -fn render_counter(out: &mut String, name: &str, counter: &AtomicU64) { - let _ = writeln!(out, "# TYPE {name} counter"); - let _ = writeln!(out, "{name} {}", counter.load(ORDERING)); -} - fn render_gauge(out: &mut String, name: &str, value: f64) { let _ = writeln!(out, "# TYPE {name} gauge"); let _ = writeln!(out, "{name} {value:.3}"); @@ -207,38 +127,13 @@ fn render_gauge(out: &mut String, name: &str, value: f64) { fn render_histogram(out: &mut String, name: &str, hist: &Histogram) { let _ = writeln!(out, "# TYPE {name} histogram"); - let mut cumulative = 0; - for (i, upper) in HISTOGRAM_BUCKETS_MS.iter().copied().enumerate() { - cumulative += hist.buckets[i].load(ORDERING); - let _ = writeln!(out, "{name}_bucket{{le=\"{upper}\"}} {cumulative}"); - } - let _ = writeln!( - out, - "{name}_bucket{{le=\"+Inf\"}} {}", - hist.inf.load(ORDERING) - ); - let _ = writeln!(out, "{name}_sum {}", hist.sum_ms.load(ORDERING)); - let _ = writeln!(out, "{name}_count {}", hist.count.load(ORDERING)); + let _ = hist.render_series(out, name, None); } #[cfg(test)] mod tests { use super::*; - #[test] - fn histogram_observes_into_correct_bucket() { - let h = Histogram::new(); - h.observe(0); - h.observe(3); - h.observe(50); - h.observe(100_000); - assert_eq!(1, h.buckets[0].load(ORDERING)); - assert_eq!(1, h.buckets[1].load(ORDERING)); - assert_eq!(1, h.buckets[4].load(ORDERING)); - assert_eq!(4, h.inf.load(ORDERING)); - assert_eq!(4, h.count.load(ORDERING)); - } - #[test] fn render_prometheus_produces_valid_output() { let m = ServiceMetrics::default(); @@ -260,14 +155,4 @@ mod tests { assert!(text.contains("fluxer_user_svc_request_duration_ms_bucket{le=\"50\"} 1")); assert!(text.contains("# TYPE fluxer_user_svc_uptime_seconds gauge")); } - - #[test] - fn snapshot_reflects_recorded_counters() { - let m = ServiceMetrics::default(); - m.record_request(); - m.record_shard_forward(); - let snap = m.snapshot(); - assert_eq!(snap.requests_total, 1); - assert_eq!(snap.shard_forwards_total, 1); - } } diff --git a/fluxer_svc/src/router.rs b/fluxer_svc/src/router.rs index 712612444..3509af089 100644 --- a/fluxer_svc/src/router.rs +++ b/fluxer_svc/src/router.rs @@ -20,7 +20,6 @@ use tracing::{debug, info, warn}; pub const SHARD_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); const MAX_BROADCAST_CONCURRENCY: usize = 32; const MAX_ROUTER_REQUEST_BYTES: usize = 2 * 1024 * 1024; -const LEGACY_SHARD_DECODE_ERROR: &[u8] = br#"{"error":"shard_request_decode_error"}"#; type InflightKey = (String, String); type InflightCall = Shared, Arc>>>; type Inflight = Arc>>; @@ -33,6 +32,9 @@ pub trait RouterService: Send + Sync + 'static { fn service_name(&self) -> &str; fn route_key(request: &Self::Request) -> String; + fn shard_for(&self, request: &Self::Request, ring: &HashRing) -> u32 { + ring.owner(&Self::route_key(request)) + } fn coalesce_key(_request: &Self::Request) -> Option { None } @@ -48,66 +50,33 @@ pub trait RouterService: Send + Sync + 'static { None } fn l1_insert(&self, _req: &Self::Request, _resp: &Self::Response) {} - fn l1_invalidate(&self, _key: &str) {} } -fn shard_subject(service: &S, ring: &HashRing, route_key: &str) -> String { - let shard_id = ring.owner(route_key); +fn shard_subject(service: &S, ring: &HashRing, request: &S::Request) -> String { + let shard_id = service.shard_for(request, ring); format!("svc.{}.shard.{shard_id}", service.service_name()) } async fn forward_to_shard( transport: &impl Transport, - service: &S, - ring: &HashRing, + subject: &str, request: &S::Request, - route_key: &str, ) -> anyhow::Result> { - let shard_subject = shard_subject(service, ring, route_key); - let msgpack_payload = rmp_serde::to_vec_named(request) .map_err(|e| anyhow::anyhow!("failed to encode request as msgpack: {e}"))?; - transport - .request(&shard_subject, &msgpack_payload, SHARD_REQUEST_TIMEOUT) + .request(subject, &msgpack_payload, SHARD_REQUEST_TIMEOUT) .await } -async fn forward_to_shard_verbatim( +async fn forward_to_shard_verbatim( transport: &impl Transport, - service: &S, - ring: &HashRing, - request: &S::Request, - route_key: &str, + subject: &str, payload: &[u8], ) -> anyhow::Result> { - let shard_subject = shard_subject(service, ring, route_key); - let response_bytes = transport - .request(&shard_subject, payload, SHARD_REQUEST_TIMEOUT) - .await?; - if response_bytes != LEGACY_SHARD_DECODE_ERROR { - return Ok(response_bytes); - } - - warn!( - subject = shard_subject, - "shard rejected a pass-through request, retrying with the legacy msgpack encoding" - ); - let legacy_bytes = forward_to_shard::(transport, service, ring, request, route_key).await?; - match rmp_serde::from_slice::(&legacy_bytes) { - Ok(response) => serde_json::to_vec(&response).map_err(|err| { - anyhow::anyhow!("failed to encode legacy shard response as json: {err}") - }), - Err(err) => { - if serde_json::from_slice::(&legacy_bytes).is_ok() { - Ok(legacy_bytes) - } else { - Err(anyhow::anyhow!( - "failed to decode legacy shard response: {err}" - )) - } - } - } + transport + .request(subject, payload, SHARD_REQUEST_TIMEOUT) + .await } async fn dispatch_to_shard( @@ -115,13 +84,13 @@ async fn dispatch_to_shard( service: &S, ring: &HashRing, request: &S::Request, - route_key: &str, payload: &[u8], ) -> anyhow::Result> { + let subject = shard_subject(service, ring, request); if S::CACHES_RESPONSES { - forward_to_shard::(transport, service, ring, request, route_key).await + forward_to_shard::(transport, &subject, request).await } else { - forward_to_shard_verbatim::(transport, service, ring, request, route_key, payload).await + forward_to_shard_verbatim(transport, &subject, payload).await } } @@ -285,7 +254,6 @@ async fn handle_router_request( let forward_transport = transport.clone(); let forward_service = service.clone(); let forward_ring = ring.clone(); - let forward_route_key = route_key.clone(); let forward_request = request.clone(); let forward_payload = if S::CACHES_RESPONSES { Vec::new() @@ -299,7 +267,6 @@ async fn handle_router_request( forward_service.as_ref(), forward_ring.as_ref(), &forward_request, - &forward_route_key, &forward_payload, ) .await @@ -311,7 +278,6 @@ async fn handle_router_request( service.as_ref(), ring.as_ref(), &request, - &route_key, msg.payload(), ) .await @@ -448,33 +414,6 @@ where } }); - if S::CACHES_RESPONSES { - let inv_transport = transport.clone(); - let inv_service = service.clone(); - let invalidate_prefix = format!("svc.{name}.invalidate."); - let invalidate_subject = format!("{invalidate_prefix}>"); - tasks.spawn(async move { - loop { - let mut sub = inv_transport.subscribe(&invalidate_subject).await?; - info!( - subject = invalidate_subject, - "router listening for cache invalidations" - ); - - while let Some(msg) = sub.next().await { - if let Some(key) = msg - .subject() - .strip_prefix(&invalidate_prefix) - .filter(|key| !key.is_empty()) - { - inv_service.l1_invalidate(key); - } - } - warn!("router invalidation subscription stream ended, will re-subscribe"); - } - }); - } - tokio::select! { result = tasks.join_next() => { match result { @@ -968,62 +907,6 @@ mod tests { router_task.abort(); } - #[tokio::test] - async fn router_retries_pass_through_requests_that_legacy_shards_reject() { - let transport = InMemoryTransport::new(); - let mut shard_sub = transport - .subscribe("svc.passthrough-mock.shard.0") - .await - .unwrap(); - - let shard_transport = transport.clone(); - let shard_task = tokio::spawn(async move { - while let Some(msg) = shard_sub.next().await { - let response_bytes = match rmp_serde::from_slice::(msg.payload()) { - Ok(_) => rmp_serde::to_vec_named(&MockResponse { - key: "legacy".to_owned(), - }) - .unwrap(), - Err(_) => serde_json::to_vec( - &serde_json::json!({"error": "shard_request_decode_error"}), - ) - .unwrap(), - }; - reply_message(&msg, &shard_transport, &response_bytes) - .await - .unwrap(); - } - }); - - let router_config = test_config(4); - let router_transport = transport.clone(); - let router_task = tokio::spawn(async move { - run_router(&router_config, PassThroughRouter, router_transport).await - }); - - tokio::time::sleep(Duration::from_millis(25)).await; - - let request = serde_json::to_vec(&MockRequest { - key: "a".to_owned(), - }) - .unwrap(); - - let response = transport - .request("svc.passthrough-mock", &request, Duration::from_secs(1)) - .await - .unwrap(); - - assert_eq!( - serde_json::from_slice::(&response).unwrap(), - MockResponse { - key: "legacy".to_owned() - } - ); - - shard_task.abort(); - router_task.abort(); - } - #[tokio::test] async fn router_sheds_requests_when_permits_are_exhausted() { let transport = InMemoryTransport::new(); diff --git a/fluxer_svc/src/server.rs b/fluxer_svc/src/server.rs index cd93b63bb..116fa3c4c 100644 --- a/fluxer_svc/src/server.rs +++ b/fluxer_svc/src/server.rs @@ -8,6 +8,7 @@ use axum::http::{HeaderValue, StatusCode, header}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::get; +use fluxer_common::metrics::is_loopback_peer; use std::net::SocketAddr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, OnceLock}; @@ -16,8 +17,7 @@ use tokio::net::TcpListener; #[derive(Clone)] pub struct HttpState { pub is_serving: Arc, - pub metrics: Arc, - pub service_name: String, + pub render: Arc String + Send + Sync>, } pub async fn run_http( @@ -26,17 +26,8 @@ pub async fn run_http( metrics: Arc, service_name: String, ) -> anyhow::Result<()> { - let state = HttpState { - is_serving, - metrics, - service_name, - }; - let app = Router::new() - .route("/_health", get(readiness_check)) - .route("/_healthz", get(|| async { "OK" })) - .route("/_metrics", get(metrics_handler)) - .with_state(state) - .layer(middleware::from_fn(add_version_header)); + let render = Arc::new(move || metrics.render_prometheus(&service_name)); + let app = ops_router(is_serving, render).layer(middleware::from_fn(add_version_header)); let listener = TcpListener::bind(addr).await?; tracing::info!(addr = %addr, "health HTTP server listening"); axum::serve( @@ -47,6 +38,17 @@ pub async fn run_http( Ok(()) } +pub fn ops_router( + is_serving: Arc, + render: Arc String + Send + Sync>, +) -> Router { + Router::new() + .route("/_health", get(readiness_check)) + .route("/_healthz", get(|| async { "OK" })) + .route("/_metrics", get(metrics_handler)) + .with_state(HttpState { is_serving, render }) +} + async fn readiness_check(State(state): State) -> impl IntoResponse { if state.is_serving.load(Ordering::SeqCst) { (StatusCode::OK, "OK") @@ -55,10 +57,6 @@ async fn readiness_check(State(state): State) -> impl IntoResponse { } } -fn is_loopback_peer(peer: &SocketAddr) -> bool { - peer.ip().to_canonical().is_loopback() -} - async fn metrics_handler( ConnectInfo(peer): ConnectInfo, State(state): State, @@ -66,7 +64,7 @@ async fn metrics_handler( if !is_loopback_peer(&peer) { return (StatusCode::FORBIDDEN, "FORBIDDEN").into_response(); } - let body = state.metrics.render_prometheus(&state.service_name); + let body = (state.render)(); ( [( header::CONTENT_TYPE, @@ -93,30 +91,3 @@ async fn add_version_header(request: Request, next: Next) -> Response { } response } - -#[cfg(test)] -mod tests { - use super::is_loopback_peer; - use std::net::SocketAddr; - - fn peer(value: &str) -> SocketAddr { - value.parse().expect("valid socket address") - } - - #[test] - fn accepts_loopback_peers() { - assert!(is_loopback_peer(&peer("127.0.0.1:5000"))); - assert!(is_loopback_peer(&peer("127.0.0.2:5000"))); - assert!(is_loopback_peer(&peer("[::1]:5000"))); - assert!(is_loopback_peer(&peer("[::ffff:127.0.0.1]:5000"))); - } - - #[test] - fn rejects_remote_peers() { - assert!(!is_loopback_peer(&peer("8.8.8.8:5000"))); - assert!(!is_loopback_peer(&peer("10.0.0.5:5000"))); - assert!(!is_loopback_peer(&peer("172.18.0.4:5000"))); - assert!(!is_loopback_peer(&peer("[fe80::1]:5000"))); - assert!(!is_loopback_peer(&peer("[::ffff:8.8.8.8]:5000"))); - } -} diff --git a/fluxer_svc/src/shutdown.rs b/fluxer_svc/src/shutdown.rs index feedf70e4..0e948027b 100644 --- a/fluxer_svc/src/shutdown.rs +++ b/fluxer_svc/src/shutdown.rs @@ -4,30 +4,7 @@ use std::time::Duration; pub const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(20); -pub async fn wait_for_shutdown() { - let ctrl_c = tokio::signal::ctrl_c(); - - #[cfg(unix)] - { - use tokio::signal::unix::{SignalKind, signal}; - let mut sigterm = - signal(SignalKind::terminate()).expect("failed to install SIGTERM handler"); - tokio::select! { - _ = ctrl_c => { - tracing::info!("received SIGINT, beginning shutdown"); - } - _ = sigterm.recv() => { - tracing::info!("received SIGTERM, beginning shutdown"); - } - } - } - - #[cfg(not(unix))] - { - ctrl_c.await.ok(); - tracing::info!("received SIGINT, beginning shutdown"); - } -} +pub use fluxer_common::shutdown::wait_for_shutdown; pub async fn drain_with_timeout>( quiesce: F, @@ -45,28 +22,3 @@ pub async fn drain_with_timeout>( } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn drain_completes_immediately_when_quiesce_is_instant() { - drain_with_timeout(async {}, Duration::from_secs(5)).await; - } - - #[tokio::test] - async fn drain_times_out_when_quiesce_never_finishes() { - let start = tokio::time::Instant::now(); - drain_with_timeout(futures::future::pending(), Duration::from_millis(50)).await; - let elapsed = start.elapsed(); - assert!( - elapsed >= Duration::from_millis(50), - "drain returned before timeout: {elapsed:?}" - ); - assert!( - elapsed < Duration::from_millis(200), - "drain took too long after timeout: {elapsed:?}" - ); - } -} diff --git a/fluxer_svc/src/transport.rs b/fluxer_svc/src/transport.rs index eb63dacf8..46f6a8751 100644 --- a/fluxer_svc/src/transport.rs +++ b/fluxer_svc/src/transport.rs @@ -31,7 +31,6 @@ pub trait Transport: Clone + Send + Sync + 'static { ) -> anyhow::Result; async fn publish(&self, subject: &str, payload: &[u8]) -> anyhow::Result<()>; async fn wait_for_reconnect(&self); - fn is_connected(&self) -> bool; } #[async_trait] @@ -161,10 +160,6 @@ impl NatsTransport { ::request(self, subject, payload, timeout).await } - pub async fn subscribe(&self, subject: &str) -> anyhow::Result { - ::subscribe(self, subject).await - } - pub async fn subscribe_queue( &self, subject: &str, @@ -172,18 +167,6 @@ impl NatsTransport { ) -> anyhow::Result { ::subscribe_queue(self, subject, queue_group).await } - - pub async fn publish(&self, subject: &str, payload: &[u8]) -> anyhow::Result<()> { - ::publish(self, subject, payload).await - } - - pub async fn wait_for_reconnect(&self) { - ::wait_for_reconnect(self).await; - } - - pub fn is_connected(&self) -> bool { - ::is_connected(self) - } } fn should_log_slow_consumer(now_ms: u64, last_log_ms: &AtomicU64) -> bool { @@ -252,13 +235,6 @@ impl Transport for NatsTransport { async fn wait_for_reconnect(&self) { self.reconnect_notify.notified().await; } - - fn is_connected(&self) -> bool { - matches!( - self.client.connection_state(), - async_nats::connection::State::Connected - ) - } } #[async_trait] @@ -291,40 +267,6 @@ impl NatsMessage { } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn slow_consumer_logs_initial_event_then_throttles() { - let last_log_ms = AtomicU64::new(SLOW_CONSUMER_LOG_NEVER_MS); - - assert!(should_log_slow_consumer(10, &last_log_ms)); - assert!(!should_log_slow_consumer(1_009, &last_log_ms)); - assert!(should_log_slow_consumer(1_010, &last_log_ms)); - } - - #[test] - fn slow_consumer_throttle_allows_one_racing_logger() { - let last_log_ms = Arc::new(AtomicU64::new(SLOW_CONSUMER_LOG_NEVER_MS)); - let logged = Arc::new(AtomicU64::new(0)); - - std::thread::scope(|scope| { - for _ in 0..8 { - let last_log_ms = last_log_ms.clone(); - let logged = logged.clone(); - scope.spawn(move || { - if should_log_slow_consumer(42, &last_log_ms) { - logged.fetch_add(1, Ordering::Relaxed); - } - }); - } - }); - - assert_eq!(logged.load(Ordering::Relaxed), 1); - } -} - #[cfg(debug_assertions)] mod in_memory { use super::{Transport, TransportMessage, TransportSubscriber}; @@ -394,14 +336,14 @@ mod in_memory { let mut subscribers = self.bus.subscribers.lock().await; for (pattern, senders) in subscribers.plain.iter_mut() { - if !matches_subject(pattern, subject) { + if pattern != subject { continue; } senders.retain(|sender| sender.send(message.clone()).is_ok()); } for (key, queue) in subscribers.queue.iter_mut() { - if !matches_subject(&key.subject, subject) { + if key.subject != subject { continue; } send_one_queue_subscriber(queue, message.clone()); @@ -476,10 +418,6 @@ mod in_memory { async fn wait_for_reconnect(&self) { std::future::pending::<()>().await; } - - fn is_connected(&self) -> bool { - true - } } #[async_trait] @@ -518,15 +456,6 @@ mod in_memory { } } } - - fn matches_subject(pattern: &str, subject: &str) -> bool { - if pattern == subject { - return true; - } - pattern - .strip_suffix(".>") - .is_some_and(|prefix| subject.starts_with(&format!("{prefix}."))) - } } #[cfg(debug_assertions)] diff --git a/fluxer_unfurl/src/activity_pub/formatter.rs b/fluxer_unfurl/src/activity_pub/formatter.rs index ca827af6e..7663fbf9e 100644 --- a/fluxer_unfurl/src/activity_pub/formatter.rs +++ b/fluxer_unfurl/src/activity_pub/formatter.rs @@ -4,10 +4,9 @@ use super::types::{ ActivityPubActor, ActivityPubCollectionCount, ActivityPubContext, ActivityPubPost, MastodonMediaAttachment, MastodonPost, }; -use crate::media_proxy::{MediaMetadata, MediaProxyClient, embed_media_flags}; +use crate::media_proxy::{MediaMetadata, MediaProxyClient}; use crate::text_limits; use crate::types::{EmbedAuthor, EmbedField, EmbedFooter, EmbedMedia, MessageEmbed, NsfwMode}; -use chrono::{DateTime, SecondsFormat, Utc}; use url::Url; const AP_COLOR: u32 = 0x6364FF; @@ -402,20 +401,14 @@ fn build_media_from_metadata( description: Option<&str>, ) -> EmbedMedia { EmbedMedia { - url: Some(url.to_owned()), - content_type: Some(metadata.content_type.clone()), - content_hash: Some(metadata.content_hash.clone()), width, height, - duration: metadata.duration.map(|duration| duration as u32), - placeholder: metadata.placeholder.clone(), - flags: embed_media_flags(metadata), description: description .filter(|description| !description.is_empty()) .map(|description| { text_limits::truncate(description, text_limits::MEDIA_DESCRIPTION_MAX) }), - ..Default::default() + ..metadata.to_embed_media(url) } } @@ -662,15 +655,6 @@ fn decode_html_entities(input: &str) -> String { .into_owned() } -fn sanitize_optional_absolute_url(value: &str) -> Option { - Url::parse(value.trim()).ok().map(|url| url.to_string()) -} - -fn normalize_timestamp(value: &str) -> String { - DateTime::parse_from_rfc3339(value) - .map(|dt| { - dt.with_timezone(&Utc) - .to_rfc3339_opts(SecondsFormat::Millis, true) - }) - .unwrap_or_else(|_| value.to_owned()) -} +use crate::resolvers::default_helpers::{ + normalize_timestamp, sanitize_absolute_url as sanitize_optional_absolute_url, +}; diff --git a/fluxer_unfurl/src/cache_policy.rs b/fluxer_unfurl/src/cache_policy.rs index 479f6467e..36e67b16a 100644 --- a/fluxer_unfurl/src/cache_policy.rs +++ b/fluxer_unfurl/src/cache_policy.rs @@ -9,8 +9,6 @@ pub const EMPTY_UNFURL_CACHE_TTL_SECS: u64 = 60; const SCAN_TOKENS: [&str; 2] = ["scanned", "unscanned"]; -const PROVIDER_CAPABILITIES: [&str; 4] = ["none", "youtube", "klipy", "youtube+klipy"]; - pub fn nsfw_scan_token(nsfw_mode: NsfwMode) -> &'static str { match nsfw_mode { NsfwMode::Block | NsfwMode::Flag => SCAN_TOKENS[0], @@ -32,16 +30,6 @@ pub fn unfurl_cache_key(url: &str, nsfw_mode: NsfwMode, capability: &str) -> Str format!("{scan}:{capability}:{url}") } -pub fn every_unfurl_cache_key(url: &str) -> Vec { - let mut keys = Vec::with_capacity(SCAN_TOKENS.len() * PROVIDER_CAPABILITIES.len()); - for scan in SCAN_TOKENS { - for capability in PROVIDER_CAPABILITIES { - keys.push(format!("{scan}:{capability}:{url}")); - } - } - keys -} - pub fn default_empty_entry_lifetime() -> Duration { Duration::from_secs(EMPTY_UNFURL_CACHE_TTL_SECS) } @@ -133,22 +121,6 @@ mod tests { })) } - #[test] - fn every_key_covers_the_full_scan_and_capability_matrix() { - let keys = every_unfurl_cache_key(URL); - assert_eq!(keys.len(), 8); - let unique: std::collections::HashSet<&String> = keys.iter().collect(); - assert_eq!(unique.len(), keys.len(), "invalidation keys must be unique"); - for nsfw_mode in [NsfwMode::Block, NsfwMode::Flag, NsfwMode::Allow] { - for capability in PROVIDER_CAPABILITIES { - assert!( - keys.contains(&unfurl_cache_key(URL, nsfw_mode, capability)), - "invalidation must cover {nsfw_mode:?}/{capability}" - ); - } - } - } - #[test] fn scanning_and_unscanned_requests_do_not_share_an_entry() { assert_ne!( diff --git a/fluxer_unfurl/src/embed_normalizer.rs b/fluxer_unfurl/src/embed_normalizer.rs index 19f76607d..d01cdceae 100644 --- a/fluxer_unfurl/src/embed_normalizer.rs +++ b/fluxer_unfurl/src/embed_normalizer.rs @@ -135,34 +135,6 @@ mod tests { assert!(result[0].title.as_ref().unwrap().chars().count() <= TITLE_MAX); } - #[test] - fn short_titles_are_unchanged() { - let result = normalize_embeds(vec![embed_with_title("Short")], &test_proxy()); - assert_eq!(result[0].title.as_deref(), Some("Short")); - } - - #[test] - fn empty_author_name_strips_author() { - let mut e = MessageEmbed::new("rich"); - e.author = Some(EmbedAuthor { - name: "".into(), - ..Default::default() - }); - let result = normalize_embeds(vec![e], &test_proxy()); - assert!(result[0].author.is_none()); - } - - #[test] - fn empty_footer_text_strips_footer() { - let mut e = MessageEmbed::new("rich"); - e.footer = Some(EmbedFooter { - text: "".into(), - ..Default::default() - }); - let result = normalize_embeds(vec![e], &test_proxy()); - assert!(result[0].footer.is_none()); - } - #[test] fn fields_limited_to_max() { let mut e = MessageEmbed::new("rich"); @@ -178,36 +150,6 @@ mod tests { assert!(result[0].fields.as_ref().unwrap().len() <= MAX_FIELDS); } - #[test] - fn empty_field_names_are_removed() { - let mut e = MessageEmbed::new("rich"); - e.fields = Some(vec![ - EmbedField { - name: "".into(), - value: "v".into(), - inline: false, - }, - EmbedField { - name: "ok".into(), - value: "v".into(), - inline: false, - }, - ]); - let result = normalize_embeds(vec![e], &test_proxy()); - assert_eq!(result[0].fields.as_ref().unwrap().len(), 1); - } - - #[test] - fn provider_with_empty_name_string_is_removed() { - let mut e = MessageEmbed::new("rich"); - e.provider = Some(EmbedProvider { - name: String::new(), - url: Some("https://example.com".to_owned()), - }); - let result = normalize_embeds(vec![e], &test_proxy()); - assert!(result[0].provider.is_none()); - } - #[test] fn provider_with_long_name_is_truncated() { let mut e = MessageEmbed::new("rich"); @@ -300,19 +242,6 @@ mod tests { assert!(val.chars().count() <= FIELD_VALUE_MAX); } - #[test] - fn none_fields_remain_none() { - let e = MessageEmbed::new("rich"); - let result = normalize_embeds(vec![e], &test_proxy()); - assert!(result[0].title.is_none()); - assert!(result[0].description.is_none()); - assert!(result[0].author.is_none()); - assert!(result[0].provider.is_none()); - assert!(result[0].footer.is_none()); - assert!(result[0].fields.is_none()); - assert!(result[0].children.is_none()); - } - #[test] fn missing_proxy_url_on_media_is_populated() { let mut e = MessageEmbed::new("rich"); diff --git a/fluxer_unfurl/src/http_fetch.rs b/fluxer_unfurl/src/http_fetch.rs index eafa37620..b7ac92929 100644 --- a/fluxer_unfurl/src/http_fetch.rs +++ b/fluxer_unfurl/src/http_fetch.rs @@ -199,21 +199,6 @@ async fn read_body(resp: reqwest::Response, max_bytes: usize) -> anyhow::Result< mod tests { use super::*; - #[test] - fn default_max_bytes_is_8mib() { - assert_eq!(DEFAULT_MAX_BYTES, 8 * 1024 * 1024); - } - - #[test] - fn default_html_max_bytes_matches_old_unfurl_document_limit() { - assert_eq!(DEFAULT_HTML_MAX_BYTES, 8 * 1024 * 1024); - } - - #[test] - fn default_timeout_is_10s() { - assert_eq!(DEFAULT_TIMEOUT, Duration::from_secs(10)); - } - #[tokio::test] async fn rejects_blocked_ip_literal_before_fetch() { let client = reqwest::Client::builder() @@ -250,27 +235,4 @@ mod tests { ); } } - - #[tokio::test] - async fn fetch_url_maybe_body_skips_body_when_policy_returns_false() { - let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .unwrap(); - let result = fetch_url_maybe_body( - &client, - "http://127.0.0.1/", - DEFAULT_MAX_BYTES, - Duration::from_secs(1), - |_| false, - ) - .await; - assert!(result.is_err()); - } - - #[test] - #[allow(clippy::assertions_on_constants)] - fn default_max_redirects_is_reasonable() { - assert!(DEFAULT_MAX_REDIRECTS >= 3 && DEFAULT_MAX_REDIRECTS <= 10); - } } diff --git a/fluxer_unfurl/src/media_proxy.rs b/fluxer_unfurl/src/media_proxy.rs index bee5b602b..a9c1137ca 100644 --- a/fluxer_unfurl/src/media_proxy.rs +++ b/fluxer_unfurl/src/media_proxy.rs @@ -34,7 +34,7 @@ pub struct MediaMetadata { pub nsfw: bool, } -pub fn embed_media_flags(meta: &MediaMetadata) -> u32 { +fn embed_media_flags(meta: &MediaMetadata) -> u32 { let mut flags = 0; if meta.nsfw { flags |= 1 << 4; @@ -45,6 +45,22 @@ pub fn embed_media_flags(meta: &MediaMetadata) -> u32 { flags } +impl MediaMetadata { + pub fn to_embed_media(&self, url: &str) -> crate::types::EmbedMedia { + crate::types::EmbedMedia { + url: Some(url.to_owned()), + content_type: Some(self.content_type.clone()), + content_hash: Some(self.content_hash.clone()), + width: self.width, + height: self.height, + duration: self.duration.map(|duration| duration as u32), + placeholder: self.placeholder.clone(), + flags: embed_media_flags(self), + ..Default::default() + } + } +} + impl MediaProxyClient { pub fn new_with_public_endpoint( endpoint: &str, @@ -109,8 +125,9 @@ impl MediaProxyClient { } pub fn is_own_url(&self, input_url: &str) -> bool { - input_url == self.public_endpoint - || input_url.starts_with(&format!("{}/", self.public_endpoint)) + Url::parse(input_url).is_ok_and(|url| { + fluxer_common::external_media_path::is_own_media_url(&self.public_endpoint, &url) + }) } pub fn external_proxy_url(&self, input_url: &str) -> Option { @@ -130,19 +147,6 @@ impl MediaProxyClient { mod tests { use super::*; - #[test] - fn endpoint_trailing_slash_stripped() { - let client = reqwest::Client::new(); - let mp = MediaProxyClient::new_with_public_endpoint( - "http://localhost:8000/", - "secret", - None, - client, - ); - assert_eq!(mp.endpoint, "http://localhost:8000"); - assert_eq!(mp.public_endpoint, "http://localhost:8000"); - } - #[test] fn nsfw_mode_strings() { assert_eq!( @@ -159,20 +163,6 @@ mod tests { ); } - #[test] - fn own_urls_are_recognised_by_their_public_endpoint() { - let mp = MediaProxyClient::new_with_public_endpoint( - "http://media-proxy:8080", - "secret", - Some("https://chat.example.test/media/"), - reqwest::Client::new(), - ); - assert!(mp.is_own_url("https://chat.example.test/media")); - assert!(mp.is_own_url("https://chat.example.test/media/attachments/1/2/cat.gif")); - assert!(!mp.is_own_url("https://chat.example.test/mediafiles/1.gif")); - assert!(!mp.is_own_url("https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp")); - } - #[test] fn external_proxy_url_uses_public_endpoint_and_plain_path() { let client = reqwest::Client::new(); diff --git a/fluxer_unfurl/src/network_policy.rs b/fluxer_unfurl/src/network_policy.rs index 957417524..f18daff8f 100644 --- a/fluxer_unfurl/src/network_policy.rs +++ b/fluxer_unfurl/src/network_policy.rs @@ -1,8 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use fluxer_common::public_ip::{is_public_ip, is_valid_public_hostname}; use reqwest::dns::{Addrs, Name, Resolve, Resolving}; use std::fmt; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::net::{IpAddr, SocketAddr}; use tokio::net::lookup_host; use url::Url; @@ -104,10 +105,10 @@ fn normalize_host(raw: &str) -> Result { } fn validate_ip(ip: IpAddr) -> Result<(), Error> { - match ip { - IpAddr::V4(ip) if blocked_ipv4(ip) => Err(Error::BlockedUrl), - IpAddr::V6(ip) if blocked_ipv6(ip) => Err(Error::BlockedUrl), - _ => Ok(()), + if is_public_ip(ip) { + Ok(()) + } else { + Err(Error::BlockedUrl) } } @@ -130,105 +131,6 @@ impl Resolve for PinnedDnsResolver { } } -fn ipv4_to_u32(ip: Ipv4Addr) -> u32 { - u32::from_be_bytes(ip.octets()) -} - -fn ipv4_in(ip: Ipv4Addr, prefix: Ipv4Addr, bits: u32) -> bool { - let mask = if bits == 0 { - 0 - } else { - u32::MAX << (32 - bits) - }; - (ipv4_to_u32(ip) & mask) == (ipv4_to_u32(prefix) & mask) -} - -fn ipv6_to_u128(ip: Ipv6Addr) -> u128 { - u128::from_be_bytes(ip.octets()) -} - -fn ipv6_in(ip: Ipv6Addr, prefix: Ipv6Addr, bits: u32) -> bool { - let mask = if bits == 0 { - 0 - } else { - u128::MAX << (128 - bits) - }; - (ipv6_to_u128(ip) & mask) == (ipv6_to_u128(prefix) & mask) -} - -fn blocked_ipv4(ip: Ipv4Addr) -> bool { - ipv4_in(ip, Ipv4Addr::new(0, 0, 0, 0), 8) - || ipv4_in(ip, Ipv4Addr::new(10, 0, 0, 0), 8) - || ipv4_in(ip, Ipv4Addr::new(100, 64, 0, 0), 10) - || ipv4_in(ip, Ipv4Addr::new(127, 0, 0, 0), 8) - || ipv4_in(ip, Ipv4Addr::new(169, 254, 0, 0), 16) - || ipv4_in(ip, Ipv4Addr::new(172, 16, 0, 0), 12) - || ipv4_in(ip, Ipv4Addr::new(192, 0, 0, 0), 24) - || ipv4_in(ip, Ipv4Addr::new(192, 0, 2, 0), 24) - || ipv4_in(ip, Ipv4Addr::new(192, 88, 99, 0), 24) - || ipv4_in(ip, Ipv4Addr::new(192, 168, 0, 0), 16) - || ipv4_in(ip, Ipv4Addr::new(198, 18, 0, 0), 15) - || ipv4_in(ip, Ipv4Addr::new(198, 51, 100, 0), 24) - || ipv4_in(ip, Ipv4Addr::new(203, 0, 113, 0), 24) - || ipv4_in(ip, Ipv4Addr::new(224, 0, 0, 0), 4) - || ipv4_in(ip, Ipv4Addr::new(240, 0, 0, 0), 4) - || ip == Ipv4Addr::new(255, 255, 255, 255) -} - -fn embedded_ipv4(ip: Ipv6Addr) -> Option { - let octets = ip.octets(); - if ipv6_in(ip, Ipv6Addr::new(0x0064, 0xff9b, 0, 0, 0, 0, 0, 0), 96) - || ipv6_in(ip, Ipv6Addr::UNSPECIFIED, 96) - { - return Some(Ipv4Addr::new( - octets[12], octets[13], octets[14], octets[15], - )); - } - if ipv6_in(ip, Ipv6Addr::new(0x2002, 0, 0, 0, 0, 0, 0, 0), 16) { - return Some(Ipv4Addr::new(octets[2], octets[3], octets[4], octets[5])); - } - None -} - -fn blocked_ipv6(ip: Ipv6Addr) -> bool { - if let Some(mapped) = ip.to_ipv4_mapped() { - return blocked_ipv4(mapped); - } - if let Some(embedded) = embedded_ipv4(ip) { - return blocked_ipv4(embedded); - } - ip.is_unspecified() - || ip.is_loopback() - || ipv6_in(ip, Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0), 32) - || ipv6_in(ip, Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0), 7) - || ipv6_in(ip, Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 0), 10) - || ipv6_in(ip, Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0), 8) -} - -fn is_valid_public_hostname(host: &str) -> bool { - if host.is_empty() || host.len() > 253 || !host.contains('.') { - return false; - } - let mut last = ""; - for label in host.split('.') { - if label.is_empty() || label.len() > 63 { - return false; - } - let bytes = label.as_bytes(); - if !bytes[0].is_ascii_alphanumeric() || !bytes[bytes.len() - 1].is_ascii_alphanumeric() { - return false; - } - if !bytes - .iter() - .all(|b| b.is_ascii_alphanumeric() || *b == b'-') - { - return false; - } - last = label; - } - !last.bytes().all(|b| b.is_ascii_digit()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/fluxer_unfurl/src/resolvers/bluesky.rs b/fluxer_unfurl/src/resolvers/bluesky.rs index 18bd3069b..3d03e80ed 100644 --- a/fluxer_unfurl/src/resolvers/bluesky.rs +++ b/fluxer_unfurl/src/resolvers/bluesky.rs @@ -2,7 +2,7 @@ use super::{ResolveContext, Resolver, ResolverResult}; use crate::http_fetch; -use crate::media_proxy::{MediaProxyClient, embed_media_flags}; +use crate::media_proxy::MediaProxyClient; use crate::network_policy; use crate::text_limits; use crate::types::{EmbedAuthor, EmbedField, EmbedFooter, EmbedMedia, MessageEmbed}; @@ -405,7 +405,7 @@ fn build_bluesky_embed(input: BlueskyEmbedBuildInput<'_>) -> MessageEmbed { url: Some(input.post_url.clone()), icon_url: input .author - .and_then(|a| sanitize_optional_absolute_url(a.avatar.as_deref())), + .and_then(|a| a.avatar.as_deref().and_then(sanitize_absolute_url)), ..Default::default() }); @@ -654,9 +654,6 @@ async fn process_image( .await .ok()?; Some(EmbedMedia { - url: Some(image_url.to_owned()), - content_type: Some(meta.content_type.clone()), - content_hash: Some(meta.content_hash.clone()), width: aspect_ratio .and_then(|aspect_ratio| aspect_ratio.width) .or(meta.width), @@ -666,10 +663,7 @@ async fn process_image( description: alt_text .filter(|alt| !alt.is_empty()) .map(|alt| text_limits::truncate(alt, text_limits::MEDIA_DESCRIPTION_MAX)), - placeholder: meta.placeholder.clone(), - duration: meta.duration.map(|duration| duration as u32), - flags: embed_media_flags(&meta), - ..Default::default() + ..meta.to_embed_media(image_url) }) } @@ -713,15 +707,9 @@ async fn process_video_embed( }; let video = EmbedMedia { - url: Some(direct_url), - content_type: Some(meta.content_type.clone()), - content_hash: Some(meta.content_hash.clone()), width: meta.width.or(thumbnail.width), height: meta.height.or(thumbnail.height), - placeholder: meta.placeholder.clone(), - duration: meta.duration.map(|duration| duration as u32), - flags: embed_media_flags(&meta), - ..Default::default() + ..meta.to_embed_media(&direct_url) }; (Some(thumbnail), Some(video)) @@ -939,16 +927,6 @@ fn at_uri_to_post_url(at_uri: &str, fallback_handle: &str) -> String { } } -fn normalize_url(url: &str) -> Option { - Url::parse(url) - .ok() - .map(|url| url.to_string().trim_end_matches('/').to_owned()) -} - -fn sanitize_optional_absolute_url(url: Option<&str>) -> Option { - Url::parse(url?.trim()).ok().map(|url| url.to_string()) -} - #[derive(Debug, Deserialize)] struct DidResponse { did: Option, @@ -1041,15 +1019,6 @@ fn service_endpoint_cache() -> &'static moka::future::Cache { }) } -fn normalize_timestamp(value: &str) -> String { - chrono::DateTime::parse_from_rfc3339(value) - .map(|dt| { - dt.with_timezone(&chrono::Utc) - .to_rfc3339_opts(chrono::SecondsFormat::Millis, true) - }) - .unwrap_or_else(|_| value.to_owned()) -} - fn format_count(count: u64) -> String { if count < 1000 { return count.to_string(); @@ -1062,6 +1031,8 @@ fn format_count(count: u64) -> String { format!("{thousands}K") } +use super::default_helpers::{normalize_timestamp, normalize_url, sanitize_absolute_url}; + #[cfg(test)] mod tests { use super::*; @@ -1116,26 +1087,8 @@ mod tests { assert_eq!(embed_links_in_text(text, Some(&facets)), "hi"); } - #[test] - fn sanitize_optional_url_accepts_valid() { - assert_eq!( - sanitize_optional_absolute_url(Some("https://e.com/img.png")), - Some("https://e.com/img.png".to_owned()) - ); - } - #[test] fn sanitize_optional_url_rejects_relative() { - assert!(sanitize_optional_absolute_url(Some("/relative/path")).is_none()); - } - - #[test] - fn sanitize_optional_url_trims_whitespace() { - assert!(sanitize_optional_absolute_url(Some(" https://e.com ")).is_some()); - } - - #[test] - fn sanitize_optional_url_none_input() { - assert!(sanitize_optional_absolute_url(None).is_none()); + assert!(sanitize_absolute_url("/relative/path").is_none()); } } diff --git a/fluxer_unfurl/src/resolvers/default_helpers.rs b/fluxer_unfurl/src/resolvers/default_helpers.rs index 098e77e93..3010d33f8 100644 --- a/fluxer_unfurl/src/resolvers/default_helpers.rs +++ b/fluxer_unfurl/src/resolvers/default_helpers.rs @@ -101,7 +101,7 @@ pub fn resolve_media_url(base_url: &Url, value: &str) -> Option { matches!(url.scheme(), "http" | "https").then(|| url.to_string()) } -fn normalize_url(url: &str) -> Option { +pub fn normalize_url(url: &str) -> Option { Url::parse(url) .ok() .map(|u| u.as_str().trim_end_matches('/').to_owned()) @@ -206,6 +206,41 @@ pub fn parse_hex_color(s: &str) -> Option { } } +use crate::text_limits; + +pub fn parse_text(value: &str, max_len: usize) -> String { + text_limits::truncate(decode_html_text(value).trim(), max_len) +} + +pub fn decode_html_text(input: &str) -> String { + scraper::Html::parse_fragment(input) + .root_element() + .text() + .collect() +} + +pub fn resolve_http_url(base_url: &Url, media_url: &str) -> Option { + let url = base_url.join(media_url).ok()?; + if matches!(url.scheme(), "http" | "https") { + Some(url.to_string()) + } else { + None + } +} + +pub fn normalize_timestamp(value: &str) -> String { + chrono::DateTime::parse_from_rfc3339(value) + .map(|dt| { + dt.with_timezone(&chrono::Utc) + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true) + }) + .unwrap_or_else(|_| value.to_owned()) +} + +pub fn sanitize_absolute_url(value: &str) -> Option { + Url::parse(value.trim()).ok().map(|url| url.to_string()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/fluxer_unfurl/src/resolvers/default_resolver.rs b/fluxer_unfurl/src/resolvers/default_resolver.rs index 1bfb359f6..84854ed7a 100644 --- a/fluxer_unfurl/src/resolvers/default_resolver.rs +++ b/fluxer_unfurl/src/resolvers/default_resolver.rs @@ -13,7 +13,6 @@ use crate::charset; use crate::direct_media::MediaKind; use crate::html_parser::{self, OgMetadata, TwitterCardMetadata}; use crate::http_fetch; -use crate::media_proxy::embed_media_flags; use crate::oembed; use crate::sanitizer; use crate::text_limits; @@ -447,15 +446,9 @@ async fn resolve_media_with_metadata_and_size( None } Ok(m) => Some(EmbedMedia { - url: Some(url.to_owned()), width: width.or(m.width), height: height.or(m.height), - content_type: Some(m.content_type.clone()), - content_hash: Some(m.content_hash.clone()), - placeholder: m.placeholder.clone(), - duration: m.duration.map(|duration| duration as u32), - flags: embed_media_flags(&m), - ..Default::default() + ..m.to_embed_media(url) }), Err(err) => { tracing::warn!(error = %err, url, "failed to resolve default embed media metadata"); diff --git a/fluxer_unfurl/src/resolvers/fxtwitter.rs b/fluxer_unfurl/src/resolvers/fxtwitter.rs index 07de9050c..64c4b60aa 100644 --- a/fluxer_unfurl/src/resolvers/fxtwitter.rs +++ b/fluxer_unfurl/src/resolvers/fxtwitter.rs @@ -2,7 +2,6 @@ use super::{ResolveContext, Resolver, ResolverResult}; use crate::http_fetch; -use crate::media_proxy::embed_media_flags; use crate::text_limits; use crate::types::{EmbedAuthor, EmbedFooter, EmbedMedia, MessageEmbed}; use serde::{Deserialize, Deserializer}; @@ -757,16 +756,10 @@ async fn build_image_media( let nsfw = crate::media_proxy::MediaProxyClient::nsfw_mode_str(ctx.nsfw_mode); let meta = ctx.media_proxy.get_metadata(url, nsfw).await.ok()?; Some(EmbedMedia { - url: Some(url.to_owned()), proxy_url: ctx.media_proxy.external_proxy_url(url), - content_type: Some(meta.content_type.clone()), - content_hash: Some(meta.content_hash.clone()), width: width.or(meta.width), height: height.or(meta.height), - placeholder: meta.placeholder.clone(), - duration: meta.duration.map(|duration| duration as u32), - flags: embed_media_flags(&meta), - ..Default::default() + ..meta.to_embed_media(url) }) } @@ -798,15 +791,9 @@ async fn build_video_pair_from_parts( let thumbnail = match thumbnail_url { Some(thumb_url) => match ctx.media_proxy.get_metadata(thumb_url, nsfw).await { Ok(meta) => Some(EmbedMedia { - url: Some(thumb_url.to_owned()), proxy_url: ctx.media_proxy.external_proxy_url(thumb_url), - content_type: Some(meta.content_type.clone()), - content_hash: Some(meta.content_hash.clone()), - width: meta.width, - height: meta.height, - placeholder: meta.placeholder.clone(), - flags: embed_media_flags(&meta), - ..Default::default() + duration: None, + ..meta.to_embed_media(thumb_url) }), Err(_) => None, }, @@ -820,16 +807,11 @@ async fn build_video_pair_from_parts( return (thumbnail, None); }; let video_media = EmbedMedia { - url: Some(video_url.to_owned()), proxy_url: ctx.media_proxy.external_proxy_url(video_url), - content_type: Some(meta.content_type.clone()), - content_hash: Some(meta.content_hash.clone()), width: width.or(meta.width), height: height.or(meta.height), - placeholder: meta.placeholder.clone(), duration: duration.or(meta.duration).map(|duration| duration as u32), - flags: embed_media_flags(&meta), - ..Default::default() + ..meta.to_embed_media(video_url) }; (thumbnail, Some(video_media)) } diff --git a/fluxer_unfurl/src/resolvers/hacker_news.rs b/fluxer_unfurl/src/resolvers/hacker_news.rs index 532768e31..053018d5e 100644 --- a/fluxer_unfurl/src/resolvers/hacker_news.rs +++ b/fluxer_unfurl/src/resolvers/hacker_news.rs @@ -157,17 +157,8 @@ fn days_to_ymd(days: u64) -> (u64, u64, u64) { (y, m, d) } -fn parse_text(value: &str, max_len: usize) -> String { - text_limits::truncate(decode_html_entities(value).trim(), max_len) -} - fn html_to_markdown(html: &str) -> String { - crate::html_markdown::to_markdown(html, decode_html_entities) + crate::html_markdown::to_markdown(html, decode_html_text) } -fn decode_html_entities(input: &str) -> String { - scraper::Html::parse_fragment(input) - .root_element() - .text() - .collect() -} +use super::default_helpers::{decode_html_text, parse_text}; diff --git a/fluxer_unfurl/src/resolvers/klipy.rs b/fluxer_unfurl/src/resolvers/klipy.rs index 50cbb47ba..122390621 100644 --- a/fluxer_unfurl/src/resolvers/klipy.rs +++ b/fluxer_unfurl/src/resolvers/klipy.rs @@ -2,14 +2,13 @@ use super::{ResolveContext, Resolver, ResolverResult}; use crate::http_fetch; -use crate::media_proxy::{MediaMetadata, embed_media_flags}; +use crate::media_proxy::MediaMetadata; use crate::types::{EmbedMedia, EmbedProvider, MessageEmbed}; use std::future::Future; use std::pin::Pin; use std::time::Duration; use url::Url; -const KLIPY_API_V1_BASE_URL: &str = "https://api.klipy.com/api/v1"; const CURATED_PROVIDER_NSFW_MODE: &str = "allow"; const KLIPY_API_MAX_BYTES: usize = 512 * 1024; const KLIPY_API_TIMEOUT: Duration = Duration::from_secs(10); @@ -38,8 +37,9 @@ impl Resolver for KlipyResolver { } fn transform_url(&self, url: &Url) -> Option { - let (kind, slug) = klipy_path(url)?; - let resource = klipy_resource(&kind); + let path = fluxer_common::klipy::parse_share_path(url)?; + let resource = path.kind.resource(); + let slug = path.slug; Url::parse(&format!("https://klipy.com/{resource}/{slug}/player")).ok() } @@ -97,7 +97,7 @@ async fn resolve_klipy_media( nsfw_mode: &str, ) -> Option { let url = format.url.as_deref()?; - let resolved_url = resolve_relative_url(&ctx.original_url, url)?; + let resolved_url = resolve_http_url(&ctx.original_url, url)?; let meta = match ctx.media_proxy.get_metadata(&resolved_url, nsfw_mode).await { Ok(meta) => meta, Err(err) => { @@ -113,37 +113,14 @@ async fn resolve_klipy_media( )) } -fn klipy_path(url: &Url) -> Option<(String, String)> { - if !is_klipy_host(url) { - return None; - } - static PATH_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { - regex::Regex::new(r"^/(gif|gifs|clip|clips)/([^/]+)").expect("valid regex") - }); - let caps = PATH_RE.captures(url.path())?; - Some(( - caps.get(1)?.as_str().to_owned(), - caps.get(2)?.as_str().to_owned(), - )) -} - -fn klipy_resource(kind: &str) -> &'static str { - if kind.starts_with("clip") { - "clips" - } else { - "gifs" - } -} - async fn resolve_media_via_api( ctx: &ResolveContext<'_>, api_key: &str, ) -> anyhow::Result> { - let Some((kind, slug)) = klipy_path(&ctx.original_url) else { + let Some(path) = fluxer_common::klipy::parse_share_path(&ctx.original_url) else { return Ok(None); }; - let resource = klipy_resource(&kind); - let url = klipy_direct_url(api_key, resource, &slug)?; + let url = fluxer_common::klipy::direct_items_url(api_key, path.kind, &path.slug)?; let response = http_fetch::fetch_url( &ctx.http_client, url.as_str(), @@ -166,17 +143,6 @@ async fn resolve_media_via_api( .and_then(extract_klipy_api_media)) } -fn klipy_direct_url(api_key: &str, resource: &str, slug: &str) -> anyhow::Result { - let mut url = Url::parse(KLIPY_API_V1_BASE_URL)?; - url.path_segments_mut() - .map_err(|_| anyhow::anyhow!("KLIPY API base URL cannot be a base"))? - .push(api_key) - .push(resource) - .push("items"); - url.query_pairs_mut().append_pair("slugs", slug); - Ok(url) -} - fn extract_klipy_api_media(item: &serde_json::Value) -> Option { let file = item.get("file")?; let file_meta = item.get("file_meta"); @@ -230,15 +196,6 @@ fn klipy_meta_dimension( .and_then(|value| u32::try_from(value).ok()) } -fn resolve_relative_url(base_url: &Url, media_url: &str) -> Option { - let url = base_url.join(media_url).ok()?; - if matches!(url.scheme(), "http" | "https") { - Some(url.to_string()) - } else { - None - } -} - fn build_embed_media_payload( url: &str, metadata: &MediaMetadata, @@ -246,15 +203,9 @@ fn build_embed_media_payload( height: Option, ) -> EmbedMedia { EmbedMedia { - url: Some(url.to_owned()), width: width.or(metadata.width), height: height.or(metadata.height), - placeholder: metadata.placeholder.clone(), - flags: embed_media_flags(metadata), - content_hash: Some(metadata.content_hash.clone()), - content_type: Some(metadata.content_type.clone()), - duration: metadata.duration.map(|duration| duration as u32), - ..Default::default() + ..metadata.to_embed_media(url) } } @@ -284,13 +235,15 @@ fn extract_media_format(value: Option<&serde_json::Value>) -> Option MessageEmbed { let url_str = url.to_string(); let media = EmbedMedia { - url: Some(url_str.clone()), - content_type: Some(meta.content_type.clone()), - content_hash: Some(meta.content_hash.clone()), - width: meta.width, - height: meta.height, - placeholder: meta.placeholder.clone(), - duration: meta.duration.map(|duration| duration as u32), - flags: embed_media_flags(meta), - ..Default::default() + ..meta.to_embed_media(&url_str) }; let mut embed = match kind { diff --git a/fluxer_unfurl/src/resolvers/mod.rs b/fluxer_unfurl/src/resolvers/mod.rs index 08d66b02d..9e897006a 100644 --- a/fluxer_unfurl/src/resolvers/mod.rs +++ b/fluxer_unfurl/src/resolvers/mod.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pub mod bluesky; -mod default_helpers; +pub(crate) mod default_helpers; pub mod default_resolver; pub mod fxtwitter; pub mod hacker_news; diff --git a/fluxer_unfurl/src/resolvers/tenor.rs b/fluxer_unfurl/src/resolvers/tenor.rs index 185b66393..331de09ee 100644 --- a/fluxer_unfurl/src/resolvers/tenor.rs +++ b/fluxer_unfurl/src/resolvers/tenor.rs @@ -4,7 +4,7 @@ use super::{ResolveContext, Resolver, ResolverResult}; use crate::charset; use crate::html_parser; use crate::http_fetch; -use crate::media_proxy::{MediaMetadata, MediaProxyClient, embed_media_flags}; +use crate::media_proxy::MediaProxyClient; use crate::types::{EmbedMedia, EmbedProvider, MessageEmbed}; use scraper::{Html, Selector}; use std::future::Future; @@ -134,21 +134,7 @@ async fn resolve_media_url( return None; } }; - Some(build_embed_media_payload(&resolved_url, &meta)) -} - -fn build_embed_media_payload(url: &str, metadata: &MediaMetadata) -> EmbedMedia { - EmbedMedia { - url: Some(url.to_owned()), - width: metadata.width, - height: metadata.height, - placeholder: metadata.placeholder.clone(), - flags: embed_media_flags(metadata), - content_hash: Some(metadata.content_hash.clone()), - content_type: Some(metadata.content_type.clone()), - duration: metadata.duration.map(|duration| duration as u32), - ..Default::default() - } + Some(meta.to_embed_media(&resolved_url)) } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/fluxer_unfurl/src/resolvers/wikipedia.rs b/fluxer_unfurl/src/resolvers/wikipedia.rs index 0c5038ca5..d019e33ed 100644 --- a/fluxer_unfurl/src/resolvers/wikipedia.rs +++ b/fluxer_unfurl/src/resolvers/wikipedia.rs @@ -2,7 +2,7 @@ use super::{ResolveContext, Resolver, ResolverResult}; use crate::http_fetch; -use crate::media_proxy::{MediaMetadata, MediaProxyClient, embed_media_flags}; +use crate::media_proxy::{MediaMetadata, MediaProxyClient}; use crate::text_limits; use crate::types::{EmbedMedia, MessageEmbed}; use serde::Deserialize; @@ -156,29 +156,12 @@ fn build_embed_media_payload( height: Option, ) -> EmbedMedia { EmbedMedia { - url: Some(url.to_owned()), width: width.or(metadata.width), height: height.or(metadata.height), - placeholder: metadata.placeholder.clone(), - flags: embed_media_flags(metadata), - content_hash: Some(metadata.content_hash.clone()), - content_type: Some(metadata.content_type.clone()), - duration: metadata.duration.map(|duration| duration as u32), - ..Default::default() + ..metadata.to_embed_media(url) } } -fn parse_text(value: &str, max_len: usize) -> String { - text_limits::truncate(decode_html_entities(value).trim(), max_len) -} - -fn decode_html_entities(input: &str) -> String { - scraper::Html::parse_fragment(input) - .root_element() - .text() - .collect() -} - fn deduplicate_thumbnails(images: Vec>) -> Vec { let mut seen = std::collections::HashSet::new(); let mut unique = Vec::new(); @@ -237,3 +220,5 @@ fn normalize_wiki_url(url_str: &str) -> String { url_str.trim_end_matches('/').to_owned() } } + +use super::default_helpers::parse_text; diff --git a/fluxer_unfurl/src/resolvers/xkcd.rs b/fluxer_unfurl/src/resolvers/xkcd.rs index 6a5f850b5..9c1acfe65 100644 --- a/fluxer_unfurl/src/resolvers/xkcd.rs +++ b/fluxer_unfurl/src/resolvers/xkcd.rs @@ -4,8 +4,7 @@ use super::{ResolveContext, Resolver, ResolverResult}; use crate::charset; use crate::html_parser; use crate::http_fetch; -use crate::media_proxy::{MediaMetadata, MediaProxyClient, embed_media_flags}; -use crate::text_limits; +use crate::media_proxy::{MediaMetadata, MediaProxyClient}; use crate::types::{EmbedFooter, EmbedMedia, MessageEmbed}; use scraper::{Html, Selector}; use std::future::Future; @@ -93,7 +92,7 @@ async fn resolve_media_url( media_url: &str, description: Option, ) -> anyhow::Result> { - let Some(resolved_url) = resolve_relative_url(&ctx.url, media_url) else { + let Some(resolved_url) = resolve_http_url(&ctx.url, media_url) else { return Ok(None); }; let nsfw_str = MediaProxyClient::nsfw_mode_str(ctx.nsfw_mode); @@ -110,15 +109,6 @@ async fn resolve_media_url( ))) } -fn resolve_relative_url(base_url: &Url, media_url: &str) -> Option { - let url = base_url.join(media_url).ok()?; - if matches!(url.scheme(), "http" | "https") { - Some(url.to_string()) - } else { - None - } -} - fn build_embed_media_payload( url: &str, metadata: &MediaMetadata, @@ -127,30 +117,13 @@ fn build_embed_media_payload( description: Option, ) -> EmbedMedia { EmbedMedia { - url: Some(url.to_owned()), width: width.or(metadata.width), height: height.or(metadata.height), description, - placeholder: metadata.placeholder.clone(), - flags: embed_media_flags(metadata), - content_hash: Some(metadata.content_hash.clone()), - content_type: Some(metadata.content_type.clone()), - duration: metadata.duration.map(|duration| duration as u32), - ..Default::default() + ..metadata.to_embed_media(url) } } -fn parse_text(value: &str, max_len: usize) -> String { - text_limits::truncate(decode_html_entities(value).trim(), max_len) -} - -fn decode_html_entities(input: &str) -> String { - scraper::Html::parse_fragment(input) - .root_element() - .text() - .collect() -} - fn extract_comic_alt(doc: &Html) -> Option { let sel = Selector::parse("#comic img").ok()?; doc.select(&sel) @@ -159,13 +132,6 @@ fn extract_comic_alt(doc: &Html) -> Option { .map(|s| s.to_owned()) } -#[cfg(test)] -mod tests { - use super::*; +use super::default_helpers::parse_text; - #[test] - fn resolve_relative_url_rejects_non_http() { - let base = Url::parse("https://xkcd.com/1/").unwrap(); - assert!(resolve_relative_url(&base, "ftp://evil.com/img.png").is_none()); - } -} +use super::default_helpers::resolve_http_url; diff --git a/fluxer_unfurl/src/resolvers/youtube.rs b/fluxer_unfurl/src/resolvers/youtube.rs index 9ce8c318a..b15fa60ea 100644 --- a/fluxer_unfurl/src/resolvers/youtube.rs +++ b/fluxer_unfurl/src/resolvers/youtube.rs @@ -2,7 +2,6 @@ use super::{ResolveContext, Resolver, ResolverResult}; use crate::http_fetch; -use crate::media_proxy::embed_media_flags; use crate::text_limits; use crate::types::{EmbedAuthor, EmbedMedia, EmbedProvider, MessageEmbed}; use serde::Deserialize; @@ -150,32 +149,18 @@ impl Resolver for YouTubeResolver { None } }; + let media = thumbnail_meta + .as_ref() + .map(|meta| meta.to_embed_media(&thumbnail.url)) + .unwrap_or_else(|| EmbedMedia { + url: Some(thumbnail.url.clone()), + content_type: Some("image/jpeg".to_owned()), + ..Default::default() + }); embed.thumbnail = Some(EmbedMedia { - url: Some(thumbnail.url.clone()), - content_type: thumbnail_meta - .as_ref() - .map(|meta| meta.content_type.clone()) - .or_else(|| Some("image/jpeg".to_owned())), - content_hash: thumbnail_meta - .as_ref() - .map(|meta| meta.content_hash.clone()), - width: thumbnail - .width - .or_else(|| thumbnail_meta.as_ref().and_then(|meta| meta.width)), - height: thumbnail - .height - .or_else(|| thumbnail_meta.as_ref().and_then(|meta| meta.height)), - placeholder: thumbnail_meta - .as_ref() - .and_then(|meta| meta.placeholder.clone()), - duration: thumbnail_meta - .as_ref() - .and_then(|meta| meta.duration.map(|duration| duration as u32)), - flags: thumbnail_meta - .as_ref() - .map(embed_media_flags) - .unwrap_or_default(), - ..Default::default() + width: thumbnail.width.or(media.width), + height: thumbnail.height.or(media.height), + ..media }); } diff --git a/fluxer_unfurl/src/router_impl.rs b/fluxer_unfurl/src/router_impl.rs index c1b2ee747..91444dd69 100644 --- a/fluxer_unfurl/src/router_impl.rs +++ b/fluxer_unfurl/src/router_impl.rs @@ -1,8 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::cache_policy::{ - UnfurlEntryExpiry, default_empty_entry_lifetime, every_unfurl_cache_key, provider_capability, - unfurl_cache_key, + UnfurlEntryExpiry, default_empty_entry_lifetime, provider_capability, unfurl_cache_key, }; use crate::types::{UnfurlRequest, UnfurlResponse}; use fluxer_svc::router::RouterService; @@ -31,12 +30,6 @@ impl UnfurlRouter { .build(), } } - - fn invalidate_all_variants(&self, url: &str) { - for key in every_unfurl_cache_key(url) { - self.l1.invalidate(&key); - } - } } impl RouterService for UnfurlRouter { @@ -50,7 +43,6 @@ impl RouterService for UnfurlRouter { fn route_key(req: &UnfurlRequest) -> String { match req { UnfurlRequest::Unfurl { url, .. } => url.clone(), - UnfurlRequest::Invalidate { url } => url.clone(), } } @@ -77,7 +69,6 @@ impl RouterService for UnfurlRouter { ) )) } - UnfurlRequest::Invalidate { .. } => None, } } @@ -102,7 +93,6 @@ impl RouterService for UnfurlRouter { self.l1.get(&key) } } - UnfurlRequest::Invalidate { .. } => None, } } @@ -129,15 +119,8 @@ impl RouterService for UnfurlRouter { ); } } - UnfurlRequest::Invalidate { url } => { - self.invalidate_all_variants(url); - } } } - - fn l1_invalidate(&self, key: &str) { - self.invalidate_all_variants(key); - } } #[cfg(test)] @@ -233,22 +216,6 @@ mod tests { ); } - #[test] - fn invalidate_clears_every_provider_capability_variant() { - let router = UnfurlRouter::new(16, test_lifetime()); - let url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; - router.l1_insert(&unfurl(url, None), &resolved_response()); - router.l1_insert(&unfurl(url, Some("configured")), &resolved_response()); - router.l1_insert( - &UnfurlRequest::Invalidate { - url: url.to_owned(), - }, - &resolved_response(), - ); - assert!(router.l1_lookup(&unfurl(url, None)).is_none()); - assert!(router.l1_lookup(&unfurl(url, Some("configured"))).is_none()); - } - #[test] fn coalesce_key_separates_cache_only_from_full_unfurls() { assert_ne!( @@ -315,21 +282,6 @@ mod tests { assert!(router.l1_lookup(&unfurl_allowing_nsfw(url)).is_some()); } - #[test] - fn invalidate_clears_both_scanning_and_unscanned_entries() { - let router = UnfurlRouter::new(16, test_lifetime()); - let url = "https://example.com/both"; - router.l1_insert(&unfurl(url, None), &resolved_response()); - router.l1_insert(&unfurl_allowing_nsfw(url), &resolved_response()); - router.l1_insert( - &UnfurlRequest::Invalidate { - url: url.to_owned(), - }, - &resolved_response(), - ); - assert!(router.l1_lookup(&unfurl(url, None)).is_none()); - assert!(router.l1_lookup(&unfurl_allowing_nsfw(url)).is_none()); - } #[tokio::test] async fn an_empty_upper_cache_entry_lapses_long_before_a_populated_one() { let router = diff --git a/fluxer_unfurl/src/shard_impl.rs b/fluxer_unfurl/src/shard_impl.rs index 2a07f0bad..90faad1d9 100644 --- a/fluxer_unfurl/src/shard_impl.rs +++ b/fluxer_unfurl/src/shard_impl.rs @@ -1,13 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::cache_policy::{ - UnfurlEntryExpiry, default_empty_entry_lifetime, every_unfurl_cache_key, provider_capability, - unfurl_cache_key, + UnfurlEntryExpiry, default_empty_entry_lifetime, provider_capability, unfurl_cache_key, }; use crate::embed_normalizer::normalize_embeds; use crate::media_proxy::MediaProxyClient; use crate::resolvers::{self, ResolveContext, ResolverResult}; -use crate::types::{InvalidatedResponse, NsfwMode, UnfurlRequest, UnfurlResponse, UnfurlResult}; +use crate::types::{NsfwMode, UnfurlRequest, UnfurlResponse, UnfurlResult}; use fluxer_svc::config::optional_env; use fluxer_svc::shard::ShardService; use moka::future::Cache; @@ -314,14 +313,6 @@ impl ShardService for UnfurlShard { self.cache.insert(cache_key, result.clone()).await; Ok(UnfurlResponse::Resolved(result)) } - UnfurlRequest::Invalidate { ref url } => { - for key in every_unfurl_cache_key(url) { - self.cache.invalidate(&key).await; - } - Ok(UnfurlResponse::Invalidated(InvalidatedResponse { - invalidated: true, - })) - } } } } @@ -489,23 +480,6 @@ mod tests { ); } - #[tokio::test] - async fn invalidate_clears_the_shard_entry_for_every_provider_capability() { - let shard = UnfurlShard::for_test(); - seed_keyless_entry(&shard).await; - - shard - .handle(UnfurlRequest::Invalidate { - url: BLOCKED_URL.to_owned(), - }) - .await - .unwrap(); - - assert_eq!( - 0, - embed_count(&shard.handle(unfurl(BLOCKED_URL, None)).await.unwrap()) - ); - } #[tokio::test] async fn a_url_that_produced_no_embed_is_written_to_the_shard_cache() { let shard = UnfurlShard::for_test(); diff --git a/fluxer_unfurl/src/text_limits.rs b/fluxer_unfurl/src/text_limits.rs index da13c563e..d0375e816 100644 --- a/fluxer_unfurl/src/text_limits.rs +++ b/fluxer_unfurl/src/text_limits.rs @@ -46,28 +46,6 @@ pub fn clamp_lines(s: &str, max_lines: usize) -> String { mod tests { use super::*; - #[test] - fn truncate_short_string() { - assert_eq!(truncate("hello", 10), "hello"); - } - - #[test] - fn truncate_long_string() { - let result = truncate("hello world", 6); - assert_eq!(result, "hello\u{2026}"); - } - - #[test] - fn clamp_lines_within_limit() { - assert_eq!(clamp_lines("a\nb\nc", 5), "a\nb\nc"); - } - - #[test] - fn clamp_lines_over_limit() { - let result = clamp_lines("a\nb\nc\nd", 2); - assert_eq!(result, "a\nb\n\u{2026}"); - } - #[test] fn truncate_preserves_multibyte_boundaries() { let s = "hello 🎉🎊🎁 world"; @@ -75,52 +53,4 @@ mod tests { assert!(result.chars().count() <= 10); assert!(result.ends_with('\u{2026}')); } - - #[test] - fn truncate_at_exact_limit() { - assert_eq!(truncate("12345", 5), "12345"); - assert_eq!(truncate("123456", 5), "1234\u{2026}"); - } - - #[test] - fn truncate_empty_string() { - assert_eq!(truncate("", 10), ""); - } - - #[test] - fn truncate_limit_one() { - assert_eq!(truncate("ab", 1), "\u{2026}"); - } - - #[test] - fn clamp_lines_single_line() { - assert_eq!(clamp_lines("hello", 1), "hello"); - } - - #[test] - fn clamp_lines_empty() { - assert_eq!(clamp_lines("", 5), ""); - } - - #[test] - fn clamp_lines_at_exact_limit() { - assert_eq!(clamp_lines("a\nb", 2), "a\nb"); - assert_eq!(clamp_lines("a\nb\nc", 2), "a\nb\n\u{2026}"); - } - - #[test] - fn field_limits_match_old_ts_unfurl_embed_text_limits() { - assert_eq!(TITLE_MAX, 256); - assert_eq!(DESCRIPTION_MAX, 4096); - assert_eq!(DESCRIPTION_LINES_MAX, 20); - assert_eq!(AUTHOR_NAME_MAX, 256); - assert_eq!(PROVIDER_NAME_MAX, 256); - assert_eq!(FOOTER_TEXT_MAX, 2048); - assert_eq!(MEDIA_DESCRIPTION_MAX, 4096); - assert_eq!(FIELD_NAME_MAX, 256); - assert_eq!(FIELD_VALUE_MAX, 1024); - assert_eq!(HTML_MAX, 12000); - assert_eq!(MAX_FIELDS, 25); - assert_eq!(MAX_CHILDREN, 1); - } } diff --git a/fluxer_unfurl/src/types.rs b/fluxer_unfurl/src/types.rs index f59d3e941..d17b3c2bf 100644 --- a/fluxer_unfurl/src/types.rs +++ b/fluxer_unfurl/src/types.rs @@ -18,9 +18,6 @@ pub enum UnfurlRequest { #[serde(default)] klipy_api_key: Option, }, - Invalidate { - url: String, - }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -35,15 +32,9 @@ pub enum NsfwMode { #[derive(Debug, Clone, Serialize, Deserialize)] pub enum UnfurlResponse { Resolved(Arc), - Invalidated(InvalidatedResponse), Failed { message: String }, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InvalidatedResponse { - pub invalidated: bool, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UnfurlResult { pub embeds: Vec, @@ -213,7 +204,6 @@ mod tests { assert!(youtube_api_key.is_none()); assert!(klipy_api_key.is_none()); } - UnfurlRequest::Invalidate { .. } => panic!("expected unfurl request"), } } diff --git a/fluxer_users/src/shard_impl.rs b/fluxer_users/src/shard_impl.rs index 0c73dc977..77864e66f 100644 --- a/fluxer_users/src/shard_impl.rs +++ b/fluxer_users/src/shard_impl.rs @@ -1,9 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::types::{ApiUserPartial, User, UserPartial, UserRequest, UserResponse, now_ms}; +use crate::types::{ApiUserPartial, UserPartial, UserRequest, UserResponse}; #[cfg(feature = "scylla")] use chrono::{DateTime, Utc}; -use fluxer_common::user_flags::AccountStanding; +use fluxer_common::time::unix_millis; +use fluxer_common::user_flags::{AccountStanding, USER_FLAG_STAFF}; +use fluxer_common::user_partial::{ + FLUXER_SYSTEM_DISCRIMINATOR, FLUXER_SYSTEM_USER_ID, FLUXER_SYSTEM_USERNAME, +}; use fluxer_svc::shard::ShardService; use fluxer_svc::{postgres, postgres::KeyPart}; use futures::stream::{self, StreamExt}; @@ -39,10 +43,6 @@ const USER_BATCH_SIZE: usize = 128; const USER_BATCH_CONCURRENCY: usize = 8; const USER_CACHE_MIN_GENERATION_STRIPES: usize = 4096; const USER_CACHE_MAX_GENERATION_STRIPES: usize = 1 << 20; -const FLUXER_SYSTEM_USER_ID: i64 = 0; -const FLUXER_SYSTEM_USERNAME: &str = "Fluxer"; -const FLUXER_SYSTEM_DISCRIMINATOR: i32 = 0; -const USER_FLAG_STAFF: i64 = 1; pub struct UsersShard { storage: UsersStorage, @@ -80,7 +80,7 @@ struct ScyllaUsersStorage { stmt_partial_batch: PreparedStatement, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] struct PartialUserDbRow { user_id: i64, username: String, @@ -264,7 +264,7 @@ impl UsersShard { async fn get_partial_user(&self, user_id: i64) -> anyhow::Result> { if user_id == FLUXER_SYSTEM_USER_ID { - return Ok(Some(fluxer_system_user().to_partial())); + return Ok(Some(system_user_partial())); } self.caches .get_or_fetch_partial(user_id, self.storage.fetch_partial_user(user_id)) @@ -278,7 +278,7 @@ impl UsersShard { let mut misses = Vec::new(); for user_id in user_ids { if user_id == FLUXER_SYSTEM_USER_ID { - partials.push(fluxer_system_user().to_partial()); + partials.push(system_user_partial()); continue; } match self.caches.get_partial(user_id).await { @@ -452,64 +452,21 @@ fn decode_postgres_user_partial(row: serde_json::Value) -> anyhow::Result User { - User { +fn system_user_partial() -> UserPartial { + UserPartial { user_id: FLUXER_SYSTEM_USER_ID, username: FLUXER_SYSTEM_USERNAME.to_owned(), discriminator: FLUXER_SYSTEM_DISCRIMINATOR, + global_name: None, + avatar_hash: None, bot: Some(true), system: Some(true), - email: None, - email_verified: None, - email_bounced: None, - authenticator_types: Vec::new(), - avatar_hash: None, - avatar_color: None, + flags: Some(USER_FLAG_STAFF), banner_hash: None, banner_color: None, - bio: None, accent_color: None, - date_of_birth: None, - locale: None, - flags: Some(USER_FLAG_STAFF), - premium_flags: None, - global_name: None, - pronouns: None, - traits: Vec::new(), - premium_type: None, - premium_since: None, - premium_until: None, - premium_gift_extension_ends_at: None, - premium_lifetime_sequence: None, - premium_billing_cycle: None, - premium_will_cancel: None, - premium_onboarding_dismissed_at: None, - has_ever_purchased: None, - stripe_subscription_id: None, - stripe_customer_id: None, - gift_inventory_server_seq: None, - gift_inventory_client_seq: None, - terms_agreed_at: None, - privacy_agreed_at: None, - last_active_at: None, - last_active_ip: None, - temp_banned_until: None, - pending_deletion_at: None, - pending_bulk_message_deletion_at: None, - pending_bulk_message_deletion_channel_count: None, - pending_bulk_message_deletion_message_count: None, - password_last_changed_at: None, - acls: Vec::new(), - deletion_reason_code: None, - deletion_public_reason: None, - deletion_audit_log_reason: None, - first_refund_at: None, - version: 1, - premium_grace_ends_at: None, + avatar_color: None, mention_flags: None, - last_voice_activity_sharing_change_at: None, - timezone: None, - timezone_privacy_flags: None, content_hidden_since: None, } } @@ -597,7 +554,7 @@ impl From for UserPartial { mention_flags: row.mention_flags, content_hidden_since: row.content_hidden_since, } - .visible_to_others(&standing, now_ms()) + .visible_to_others(&standing, unix_millis()) } } @@ -606,54 +563,43 @@ mod tests { use super::*; use serde_json::json; - fn test_user(user_id: i64) -> User { - let mut user = fluxer_system_user(); - user.user_id = user_id; - user.username = "Ada".to_owned(); - user.discriminator = 7; - user.global_name = Some("Ada Lovelace".to_owned()); - user.avatar_hash = Some("avatar_hash".to_owned()); - user.bot = Some(false); - user.system = Some(false); - user.email = Some("ada@example.com".to_owned()); - user.bio = Some("analytical engine enjoyer".to_owned()); - user.stripe_customer_id = Some("cus_123".to_owned()); - user + fn test_user(user_id: i64) -> PartialUserDbRow { + PartialUserDbRow { + user_id, + username: "Ada".to_owned(), + discriminator: 7, + global_name: Some("Ada Lovelace".to_owned()), + avatar_hash: Some("avatar_hash".to_owned()), + bot: Some(false), + system: Some(false), + flags: Some(USER_FLAG_STAFF), + banner_hash: None, + banner_color: None, + accent_color: None, + avatar_color: None, + mention_flags: None, + temp_banned_until: None, + pending_deletion_at: None, + deletion_reason_code: None, + content_hidden_since: None, + } } fn caches() -> UserCaches { UserCaches::new(16, Duration::from_secs(60)) } - #[tokio::test] - async fn partial_reads_populate_only_the_partial_cache() { - let caches = caches(); - let partial = test_user(42).to_partial(); - - let fetched = caches - .get_or_fetch_partial(42, async { Ok(Some(partial)) }) - .await - .unwrap() - .unwrap(); - - assert_eq!(fetched.username, "Ada"); - assert_eq!( - caches.get_partial(42).await.unwrap().unwrap().username, - "Ada" - ); - } - #[tokio::test] async fn partial_cache_hits_do_not_reach_storage_again() { let caches = caches(); - let partial = test_user(42).to_partial(); + let partial = UserPartial::from(test_user(42)); caches .get_or_fetch_partial(42, async { Ok(Some(partial)) }) .await .unwrap(); let fetched = caches - .get_or_fetch_partial(42, async { Ok(Some(test_user(7).to_partial())) }) + .get_or_fetch_partial(42, async { Ok(Some(UserPartial::from(test_user(7)))) }) .await .unwrap() .unwrap(); @@ -665,7 +611,7 @@ mod tests { async fn invalidation_clears_a_partial_only_entry() { let caches = caches(); caches - .get_or_fetch_partial(42, async { Ok(Some(test_user(42).to_partial())) }) + .get_or_fetch_partial(42, async { Ok(Some(UserPartial::from(test_user(42)))) }) .await .unwrap(); @@ -674,27 +620,6 @@ mod tests { assert!(caches.get_partial(42).await.is_none()); } - #[tokio::test] - async fn invalidation_counts_generation_bumps() { - let caches = caches(); - assert_eq!(caches.generation_bumps.load(Ordering::Relaxed), 0); - - caches.invalidate(42).await; - caches.invalidate(43).await; - - assert_eq!(caches.generation_bumps.load(Ordering::Relaxed), 2); - } - - #[test] - fn generation_stripes_follow_the_configured_capacity() { - assert_eq!(generation_stripes(16), USER_CACHE_MIN_GENERATION_STRIPES); - assert_eq!(generation_stripes(100_000), 131_072); - assert_eq!( - generation_stripes(u64::MAX), - USER_CACHE_MAX_GENERATION_STRIPES - ); - } - #[cfg(feature = "scylla")] #[test] fn partial_columns_match_the_user_partial_fields_exactly() { @@ -706,7 +631,7 @@ mod tests { .collect::>(); let mut user = test_user(42); user.content_hidden_since = Some(1); - let partial = serde_json::to_value(user.to_partial()).unwrap(); + let partial = serde_json::to_value(UserPartial::from(user.clone())).unwrap(); let mut fields = partial .as_object() .unwrap() @@ -724,7 +649,7 @@ mod tests { #[test] fn fluxer_system_user_partial_is_virtual_id_zero() { - let partial = fluxer_system_user().to_partial(); + let partial = system_user_partial(); assert_eq!(partial.user_id, 0); assert_eq!(partial.username, "Fluxer"); @@ -741,7 +666,7 @@ mod tests { const DISABLED: i64 = 1 << 38; const PROFILE_HIDDEN: i64 = 1 << 7; - fn styled(user_id: i64, flags: i64) -> User { + fn styled(user_id: i64, flags: i64) -> PartialUserDbRow { let mut user = test_user(user_id); user.flags = Some(flags); user.banner_hash = Some("banner_hash".to_owned()); @@ -770,9 +695,7 @@ mod tests { #[test] fn a_masked_partial_matches_the_shared_development_vector() { - let api = styled(1_174_109_840_998_400_001, SPAMMER) - .to_partial() - .to_api_partial(); + let api = UserPartial::from(styled(1_174_109_840_998_400_001, SPAMMER)).to_api_partial(); assert_eq!( (api.username.as_str(), api.discriminator.as_str()), ("MambaEgret", "6542") @@ -782,19 +705,9 @@ mod tests { assert_eq!(api.avatar, None); } - #[test] - fn a_masked_partial_is_stable_across_reads() { - let first = styled(53, PROFILE_HIDDEN).to_partial(); - let second = styled(53, PROFILE_HIDDEN).to_partial(); - assert_eq!( - (first.username, first.discriminator), - (second.username, second.discriminator) - ); - } - #[test] fn hidden_and_enforced_accounts_get_a_neutral_partial_and_keep_their_stored_profile() { - let now = now_ms(); + let now = unix_millis(); let mut banned = styled(43, DISABLED); banned.temp_banned_until = Some(now + 60_000); let mut deleting = styled(44, 0); @@ -806,7 +719,7 @@ mod tests { banned, deleting, ] { - assert_hidden(&user.to_partial()); + assert_hidden(&UserPartial::from(user.clone())); assert_eq!(user.username, "Ada"); assert_eq!(user.avatar_hash.as_deref(), Some("avatar_hash")); } @@ -814,14 +727,14 @@ mod tests { #[test] fn lifted_bans_self_disables_and_normal_accounts_keep_their_partial() { - let now = now_ms(); + let now = unix_millis(); let mut expired = styled(45, DISABLED); expired.temp_banned_until = Some(now - 60_000); let mut self_deleting = styled(46, 0); self_deleting.pending_deletion_at = Some(now + 60_000); self_deleting.deletion_reason_code = Some(1); for user in [styled(47, 0), styled(48, DISABLED), expired, self_deleting] { - let partial = user.to_partial(); + let partial = UserPartial::from(user.clone()); assert_eq!(partial.username, "Ada", "{}", user.user_id); assert_eq!(partial.global_name.as_deref(), Some("Ada Lovelace")); assert_eq!(partial.banner_hash.as_deref(), Some("banner_hash")); @@ -847,7 +760,7 @@ mod tests { "temp_banned_until": until(ms), }) }; - let now = now_ms(); + let now = unix_millis(); assert_hidden(&decode_postgres_user_partial(row(now + 60_000)).unwrap()); let lifted = decode_postgres_user_partial(row(now - 60_000)).unwrap(); assert_eq!(lifted.username, "ada"); @@ -859,22 +772,22 @@ mod tests { let since = 1_790_000_000_000; let mut hidden = styled(49, 0); hidden.content_hidden_since = Some(since); - let partial = hidden.to_partial(); + let partial = UserPartial::from(hidden.clone()); assert_eq!(partial.content_hidden_since, Some(since)); assert_eq!(partial.username, "Ada"); let mut masked = styled(50, PROFILE_HIDDEN); masked.content_hidden_since = Some(since); - let partial = masked.to_partial(); + let partial = UserPartial::from(masked.clone()); assert_hidden(&partial); assert_eq!(partial.content_hidden_since, Some(since)); assert_eq!( - styled(51, PROFILE_HIDDEN).to_partial().content_hidden_since, + UserPartial::from(styled(51, PROFILE_HIDDEN)).content_hidden_since, None ); - let wire = serde_json::to_value(styled(52, 0).to_partial()).unwrap(); + let wire = serde_json::to_value(UserPartial::from(styled(52, 0))).unwrap(); assert!(wire.get("content_hidden_since").is_none()); assert!( - serde_json::to_value(hidden.to_partial().to_api_partial()) + serde_json::to_value(UserPartial::from(hidden.clone()).to_api_partial()) .unwrap() .get("content_hidden_since") .is_none() diff --git a/fluxer_users/src/types.rs b/fluxer_users/src/types.rs index 6fd64ad41..1d848b4d7 100644 --- a/fluxer_users/src/types.rs +++ b/fluxer_users/src/types.rs @@ -1,9 +1,15 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::pseudonym::pseudonym; -use fluxer_common::user_flags::{AccountStanding, USER_FLAG_STAFF, visible_user_flags}; +use fluxer_common::user_flags::AccountStanding; +#[cfg(test)] +use fluxer_common::user_flags::USER_FLAG_STAFF; #[cfg(test)] use fluxer_common::user_flags::{USER_FLAG_PARTNER, USER_FLAG_STAFF_HIDDEN}; +pub use fluxer_common::user_partial::ApiUserPartial; +#[cfg(test)] +use fluxer_common::user_partial::FLUXER_SYSTEM_USERNAME; +use fluxer_common::user_partial::{FLUXER_SYSTEM_USER_ID, UserPartialFields}; use serde::{Deserialize, Deserializer, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -26,72 +32,9 @@ pub enum UserRequest { pub enum UserResponse { FoundPartials(Vec), FoundApiPartials(Vec), - NotFound, Invalidated, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct User { - pub user_id: i64, - pub username: String, - pub discriminator: i32, - pub bot: Option, - pub system: Option, - pub email: Option, - pub email_verified: Option, - pub email_bounced: Option, - pub authenticator_types: Vec, - pub avatar_hash: Option, - pub avatar_color: Option, - pub banner_hash: Option, - pub banner_color: Option, - pub bio: Option, - pub accent_color: Option, - pub date_of_birth: Option, - pub locale: Option, - pub flags: Option, - pub premium_flags: Option, - pub global_name: Option, - pub pronouns: Option, - pub traits: Vec, - pub premium_type: Option, - pub premium_since: Option, - pub premium_until: Option, - pub premium_gift_extension_ends_at: Option, - pub premium_lifetime_sequence: Option, - pub premium_billing_cycle: Option, - pub premium_will_cancel: Option, - pub premium_onboarding_dismissed_at: Option, - pub has_ever_purchased: Option, - pub stripe_subscription_id: Option, - pub stripe_customer_id: Option, - pub gift_inventory_server_seq: Option, - pub gift_inventory_client_seq: Option, - pub terms_agreed_at: Option, - pub privacy_agreed_at: Option, - pub last_active_at: Option, - pub last_active_ip: Option, - pub temp_banned_until: Option, - pub pending_deletion_at: Option, - pub pending_bulk_message_deletion_at: Option, - pub pending_bulk_message_deletion_channel_count: Option, - pub pending_bulk_message_deletion_message_count: Option, - pub password_last_changed_at: Option, - pub acls: Vec, - pub deletion_reason_code: Option, - pub deletion_public_reason: Option, - pub deletion_audit_log_reason: Option, - pub first_refund_at: Option, - pub version: i32, - pub premium_grace_ends_at: Option, - pub mention_flags: Option, - pub last_voice_activity_sharing_change_at: Option, - pub timezone: Option, - pub timezone_privacy_flags: Option, - #[serde(default)] - pub content_hidden_since: Option, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserPartial { pub user_id: i64, @@ -111,68 +54,6 @@ pub struct UserPartial { pub content_hidden_since: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ApiUserPartial { - pub id: String, - pub username: String, - pub discriminator: String, - pub global_name: Option, - pub avatar: Option, - pub avatar_color: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub bot: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub system: Option, - pub flags: i32, - #[serde(skip_serializing_if = "Option::is_none")] - pub mention_flags: Option, -} - -const FLUXER_SYSTEM_USER_ID: i64 = 0; -const FLUXER_SYSTEM_USERNAME: &str = "Fluxer"; -const FLUXER_SYSTEM_DISCRIMINATOR: &str = "0000"; - -pub fn now_ms() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_millis() as i64) -} - -impl User { - pub fn standing(&self) -> AccountStanding { - AccountStanding { - flags: self.flags.unwrap_or_default(), - temp_banned_until_ms: self.temp_banned_until, - pending_deletion_at_ms: self.pending_deletion_at, - deletion_reason_code: self.deletion_reason_code, - } - } - - pub fn to_partial(&self) -> UserPartial { - self.to_own_partial() - .visible_to_others(&self.standing(), now_ms()) - } - - fn to_own_partial(&self) -> UserPartial { - UserPartial { - user_id: self.user_id, - username: self.username.clone(), - discriminator: self.discriminator, - global_name: self.global_name.clone(), - avatar_hash: self.avatar_hash.clone(), - bot: self.bot, - system: self.system, - flags: self.flags, - banner_hash: self.banner_hash.clone(), - banner_color: self.banner_color, - accent_color: self.accent_color, - avatar_color: self.avatar_color, - mention_flags: self.mention_flags, - content_hidden_since: self.content_hidden_since, - } - } -} - impl UserPartial { pub fn visible_to_others(self, standing: &AccountStanding, now_ms: i64) -> UserPartial { if self.user_id == FLUXER_SYSTEM_USER_ID || !standing.profile_hidden(now_ms) { @@ -193,36 +74,18 @@ impl UserPartial { } pub fn to_api_partial(&self) -> ApiUserPartial { - if self.user_id == FLUXER_SYSTEM_USER_ID { - return fluxer_system_user(); - } - ApiUserPartial { - id: self.user_id.to_string(), + ApiUserPartial::from(UserPartialFields { + user_id: self.user_id, username: self.username.clone(), - discriminator: format!("{:04}", self.discriminator), + discriminator: self.discriminator, global_name: self.global_name.clone(), - avatar: self.avatar_hash.clone(), + avatar_hash: self.avatar_hash.clone(), avatar_color: self.avatar_color, - bot: self.bot.filter(|bot| *bot), - system: self.system.filter(|system| *system), - flags: visible_user_flags(self.flags.unwrap_or_default()), - mention_flags: self.mention_flags.filter(|flags| *flags != 0), - } - } -} - -fn fluxer_system_user() -> ApiUserPartial { - ApiUserPartial { - id: FLUXER_SYSTEM_USER_ID.to_string(), - username: FLUXER_SYSTEM_USERNAME.to_owned(), - discriminator: FLUXER_SYSTEM_DISCRIMINATOR.to_owned(), - global_name: None, - avatar: None, - avatar_color: None, - bot: Some(true), - system: Some(true), - flags: USER_FLAG_STAFF as i32, - mention_flags: None, + bot: self.bot, + system: self.system, + flags: self.flags, + mention_flags: self.mention_flags, + }) } } @@ -269,86 +132,6 @@ mod tests { } } - fn user_with_flags(user_id: i64, flags: i64) -> User { - User { - user_id, - username: "Ada".to_owned(), - discriminator: 7, - bot: Some(false), - system: Some(false), - email: Some("ada@example.com".to_owned()), - email_verified: Some(true), - email_bounced: Some(false), - authenticator_types: vec![1], - avatar_hash: Some("avatar_hash".to_owned()), - avatar_color: Some(0x336699), - banner_hash: Some("banner_hash".to_owned()), - banner_color: Some(0x112233), - bio: Some("analytical engine enjoyer".to_owned()), - accent_color: Some(0x445566), - date_of_birth: Some("1815-12-10".to_owned()), - locale: Some("en-US".to_owned()), - flags: Some(flags), - premium_flags: Some(1), - global_name: Some("Ada Lovelace".to_owned()), - pronouns: Some("she/her".to_owned()), - traits: vec!["founder".to_owned()], - premium_type: Some(2), - premium_since: Some(1_781_526_896_789), - premium_until: Some(1_781_526_896_789), - premium_gift_extension_ends_at: None, - premium_lifetime_sequence: None, - premium_billing_cycle: Some("monthly".to_owned()), - premium_will_cancel: Some(false), - premium_onboarding_dismissed_at: None, - has_ever_purchased: Some(true), - stripe_subscription_id: Some("sub_123".to_owned()), - stripe_customer_id: Some("cus_123".to_owned()), - gift_inventory_server_seq: Some(3), - gift_inventory_client_seq: Some(3), - terms_agreed_at: Some(1_781_526_896_789), - privacy_agreed_at: Some(1_781_526_896_789), - last_active_at: Some(1_781_526_896_789), - last_active_ip: Some("203.0.113.7".to_owned()), - temp_banned_until: None, - pending_deletion_at: None, - pending_bulk_message_deletion_at: None, - pending_bulk_message_deletion_channel_count: None, - pending_bulk_message_deletion_message_count: None, - password_last_changed_at: Some(1_781_526_896_789), - acls: vec!["admin".to_owned()], - deletion_reason_code: None, - deletion_public_reason: None, - deletion_audit_log_reason: None, - first_refund_at: None, - version: 3, - premium_grace_ends_at: None, - mention_flags: Some(2), - last_voice_activity_sharing_change_at: None, - timezone: Some("Europe/London".to_owned()), - timezone_privacy_flags: Some(1), - content_hidden_since: None, - } - } - - #[test] - fn api_partial_ignores_fields_outside_the_partial() { - let mut user = user_with_flags(123, USER_FLAG_STAFF); - user.mention_flags = Some(0); - let api_partial = user.to_partial().to_api_partial(); - - assert_eq!(api_partial.id, "123"); - assert_eq!(api_partial.username, "Ada"); - assert_eq!(api_partial.discriminator, "0007"); - assert_eq!(api_partial.global_name, Some("Ada Lovelace".to_owned())); - assert_eq!(api_partial.avatar, Some("avatar_hash".to_owned())); - assert_eq!(api_partial.avatar_color, Some(0x336699)); - assert_eq!(api_partial.bot, None); - assert_eq!(api_partial.system, None); - assert_eq!(api_partial.flags, USER_FLAG_STAFF as i32); - assert_eq!(api_partial.mention_flags, None); - } - #[test] fn api_partial_requests_use_string_snowflakes() { let request: UserRequest = serde_json::from_value(json!({ diff --git a/knip.json b/knip.json index 682ea6dbd..01ba72c80 100644 --- a/knip.json +++ b/knip.json @@ -15,16 +15,9 @@ ], "ignoreIssues": { "fluxer_api/src/api/infrastructure/activity/Contract.generated.ts": ["exports", "types"], - "fluxer_app/scripts/build/rspack/static-files.mjs": ["exports"], - "fluxer_app/src/features/notification/utils/EntranceSoundScopes.ts": ["exports"], "fluxer_app/src/features/platform/state/PersistentStorage.ts": ["exports"], "fluxer_app/src/features/voice/utils/CodecCapabilityDetector.ts": ["exports"], - "fluxer_app/src/index.tsx": ["unlisted"], - "packages/schema/src/domains/admin/AdminUserSchemas.ts": ["exports"], - "packages/schema/src/domains/download/DownloadSchemas.ts": ["exports"], - "packages/schema/src/domains/geolocation/GeolocationSchemas.ts": ["exports"], - "packages/schema/src/domains/guild/GuildAuditLogSchemas.ts": ["exports"], - "pnpm-workspace.yaml": ["catalog"] + "fluxer_app/src/index.tsx": ["unlisted"] }, "workspaces": { ".": { @@ -61,7 +54,6 @@ "src/preload/index.ts", "src/preload/LegacyHarvestPreload.ts", "src/main/**/*.test.{ts,mjs}", - "scripts/release-check.mjs", "native/*/index.{js,d.ts}", "native/*/loader-diagnostics.cjs" ], diff --git a/package.json b/package.json index 6b93a736b..fe55cda50 100644 --- a/package.json +++ b/package.json @@ -29,27 +29,16 @@ "docs:typecheck": "pnpm --filter fluxer_docs typecheck", "i18n:compile": "pnpm --filter fluxer_app i18n:compile && pnpm --filter @fluxer/i18n static:sync && pnpm --filter @fluxer/i18n generate:types", "i18n:refresh": "pnpm --filter fluxer_app i18n:refresh", - "i18n:generate": "pnpm --filter @fluxer/i18n generate:types", "i18n:source-sync": "pnpm i18n:refresh && pnpm i18n:compile", - "i18n:typecheck": "pnpm --filter @fluxer/errors --filter @pkgs/email typecheck", - "i18n:weblate-compile": "pnpm i18n:compile", - "knip": "cargo run -p fluxer-dev -- knip", - "lint": "cargo run -p fluxer-dev -- lint", + "knip": "tools/ci/run.sh ci --step knip", + "lint": "biome ci && eslint . --max-warnings 0 && pnpm --filter fluxer_app theming:data-flx:check", "openapi:generate": "pnpm --filter @fluxer/openapi generate", "openapi:validate": "pnpm --filter @fluxer/openapi validate", - "test": "cargo run -p fluxer-dev -- test", - "typecheck": "cargo run -p fluxer-dev -- typecheck" - }, - "prettier": { - "bracketSpacing": false, - "printWidth": 120, - "singleQuote": true, - "tabWidth": 2, - "useTabs": true + "test": "tools/ci/run.sh ci --step test", + "typecheck": "tools/ci/run.sh ci --step typecheck" }, "devDependencies": { "@biomejs/biome": "catalog:", - "@vitest/coverage-v8": "catalog:", "eslint": "catalog:", "eslint-plugin-react-google-translate": "catalog:", "knip": "catalog:", diff --git a/packages/config/package.json b/packages/config/package.json index dd4fb802f..a887e3d50 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -13,7 +13,6 @@ }, "devDependencies": { "@types/node": "catalog:", - "@vitest/coverage-v8": "catalog:", "typescript": "catalog:ts7", "vitest": "catalog:", "yaml": "catalog:" diff --git a/packages/config/src/__tests__/ConfigLoader.test.ts b/packages/config/src/__tests__/ConfigLoader.test.ts index 93637e12d..a70288460 100644 --- a/packages/config/src/__tests__/ConfigLoader.test.ts +++ b/packages/config/src/__tests__/ConfigLoader.test.ts @@ -1,9 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {generateKeyPairSync} from 'node:crypto'; -import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; -import {tmpdir} from 'node:os'; -import {join} from 'node:path'; import {loadConfig, resetConfig} from '@fluxer/config/src/ConfigLoader'; import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; @@ -83,24 +80,6 @@ describe('ConfigLoader', () => { expect((await loadConfig()).domain.base_domain).toBe('localhost'); }); - test('loadConfig reads secrets from NAME_FILE', async () => { - const dir = mkdtempSync(join(tmpdir(), 'fluxer-config-file-')); - try { - const path = join(dir, 'postgres_password'); - writeFileSync(path, 'from-secret-file\n'); - stubMinimalEnv({FLUXER_POSTGRES_PASSWORD: '', FLUXER_POSTGRES_PASSWORD_FILE: path}); - const config = await loadConfig(); - expect(config.database.postgres.password).toBe('from-secret-file'); - } finally { - rmSync(dir, {recursive: true, force: true}); - } - }); - - test('loadConfig rejects NAME and NAME_FILE together', async () => { - stubMinimalEnv({FLUXER_SUDO_MODE_SECRET_FILE: '/run/secrets/sudo'}); - await expect(loadConfig()).rejects.toThrow('FLUXER_SUDO_MODE_SECRET and FLUXER_SUDO_MODE_SECRET_FILE are both set'); - }); - test('endpoint overrides take precedence over derived endpoints', async () => { stubMinimalEnv({ FLUXER_API_ENDPOINT: 'https://custom-api.example.com', @@ -369,16 +348,6 @@ describe('ConfigLoader', () => { await expect(loadConfig()).rejects.toThrow('FLUXER_POSTGRES_PORT'); }); - test('rejects a non-integer port', async () => { - stubMinimalEnv({FLUXER_API_PORT: '80a'}); - await expect(loadConfig()).rejects.toThrow('FLUXER_API_PORT must be an integer, got "80a"'); - }); - - test('rejects malformed JSON for a JSON-shaped variable', async () => { - stubMinimalEnv({FLUXER_LIVEKIT_DEFAULT_REGION: '{bad'}); - await expect(loadConfig()).rejects.toThrow('FLUXER_LIVEKIT_DEFAULT_REGION must be valid JSON'); - }); - test('rejects a non-boolean Postgres prepared statements value', async () => { stubMinimalEnv({FLUXER_POSTGRES_PREPARED_STATEMENTS: 'maybe'}); await expect(loadConfig()).rejects.toThrow('FLUXER_POSTGRES_PREPARED_STATEMENTS'); diff --git a/packages/config/src/__tests__/EndpointDerivation.test.ts b/packages/config/src/__tests__/EndpointDerivation.test.ts index c0970bcbe..24a8f4dbd 100644 --- a/packages/config/src/__tests__/EndpointDerivation.test.ts +++ b/packages/config/src/__tests__/EndpointDerivation.test.ts @@ -107,23 +107,3 @@ describe('parsePublicOrigin', () => { expect(parsePublicOrigin('https://user:pw@chat.example.com')).toBeNull(); }); }); - -describe('endpoints derived from a public origin', () => { - test('an origin with a non-standard port ports every derived endpoint', () => { - const origin = parsePublicOrigin('https://chat.example.com:29080'); - assert.ok(origin); - const endpoints = deriveEndpointsFromDomain(origin); - expect(endpoints.api_client).toBe('https://chat.example.com:29080/api'); - expect(endpoints.app).toBe('https://chat.example.com:29080'); - expect(endpoints.gateway).toBe('wss://chat.example.com:29080/gateway'); - expect(endpoints.admin).toBe('https://chat.example.com:29080/admin'); - }); - test('an origin written with an explicit :443 derives portless endpoints', () => { - const origin = parsePublicOrigin('https://chat.example.com:443'); - assert.ok(origin); - const endpoints = deriveEndpointsFromDomain(origin); - expect(endpoints.admin).toBe('https://chat.example.com/admin'); - expect(endpoints.app).toBe('https://chat.example.com'); - expect(endpoints.gateway).toBe('wss://chat.example.com/gateway'); - }); -}); diff --git a/packages/config/src/__tests__/NonDefaultPortCompose.test.ts b/packages/config/src/__tests__/NonDefaultPortCompose.test.ts index 0e0cc25c3..958d1df9a 100644 --- a/packages/config/src/__tests__/NonDefaultPortCompose.test.ts +++ b/packages/config/src/__tests__/NonDefaultPortCompose.test.ts @@ -176,25 +176,6 @@ async function loadApiConfig(env: Record): Promise return loadConfig(); } -describe('Compose environment interpolation', () => { - test.each([ - [`\${VALUE}`, {VALUE: 'set'}, 'set'], - ['$VALUE/path', {VALUE: 'set'}, 'set/path'], - [`\${VALUE:-fallback}`, {VALUE: ''}, 'fallback'], - [`\${VALUE:-\${OTHER:-fallback}}`, {OTHER: 'nested'}, 'nested'], - [`\${VALUE:+alternate}`, {VALUE: 'set'}, 'alternate'], - [`\${VALUE:+alternate}`, {}, ''], - [`\${VALUE:?required}`, {VALUE: 'set'}, 'set'], - ] as const)('expands %s with %j to %s', (source, environment, expected) => { - expect(expand(source, environment)).toBe(expected); - }); - - test('rejects a missing required value and unbalanced interpolation', () => { - expect(() => expand(`\${VALUE:?required}`, {})).toThrow('VALUE is required: required'); - expect(() => expand('${VALUE:-fallback', {})).toThrow('unbalanced interpolation'); - }); -}); - describe('the shipped compose stack expanded on a non-default port', () => { test('every service handed a public URL is handed the base domain and port that repair it', () => { const starved = serviceNames diff --git a/packages/constants/src/ApiErrorCodes.ts b/packages/constants/src/ApiErrorCodes.ts index 6ec8db6e1..7ae4b0d01 100644 --- a/packages/constants/src/ApiErrorCodes.ts +++ b/packages/constants/src/ApiErrorCodes.ts @@ -16,7 +16,6 @@ export const APIErrorCodes = { ACCOUNT_TOO_NEW_FOR_GUILD: 'ACCOUNT_TOO_NEW_FOR_GUILD', ACLS_MUST_BE_NON_EMPTY: 'ACLS_MUST_BE_NON_EMPTY', ADMIN_API_KEY_NOT_FOUND: 'ADMIN_API_KEY_NOT_FOUND', - APPLICATION_NOT_OWNED: 'APPLICATION_NOT_OWNED', ALREADY_FRIENDS: 'ALREADY_FRIENDS', BOTS_CANNOT_CREATE_GUILDS: 'BOTS_CANNOT_CREATE_GUILDS', BOTS_CANNOT_SEND_FRIEND_REQUESTS: 'BOTS_CANNOT_SEND_FRIEND_REQUESTS', diff --git a/packages/constants/src/LimitTierPerks.ts b/packages/constants/src/LimitTierPerks.ts index 247db0b17..881b5bca0 100644 --- a/packages/constants/src/LimitTierPerks.ts +++ b/packages/constants/src/LimitTierPerks.ts @@ -44,10 +44,6 @@ export function isNumericTierPerk(perk: LimitTierPerk): perk is NumericTierPerk return perk.type === 'numeric'; } -export function isTextTierPerk(perk: LimitTierPerk): perk is TextTierPerk { - return perk.type === 'text'; -} - export const LIMIT_TIER_PERKS: ReadonlyArray = [ { id: 'custom_discriminator', diff --git a/packages/constants/src/ThreadConstants.test.ts b/packages/constants/src/ThreadConstants.test.ts index b66c53e03..6af8d216f 100644 --- a/packages/constants/src/ThreadConstants.test.ts +++ b/packages/constants/src/ThreadConstants.test.ts @@ -11,7 +11,6 @@ import { import * as ThreadConstants from '@fluxer/constants/src/ThreadConstants'; import { ChannelFlags, - isThreadAutoArchiveDuration, isValidThreadMemberSettingsFlags, publicThreadTypeFor, resolveTextThreadType, @@ -96,15 +95,6 @@ describe('thread constants', () => { } }); - it('accepts only the supported auto archive durations', () => { - for (const duration of [60, 1440, 4320, 10080]) { - expect(isThreadAutoArchiveDuration(duration)).toBe(true); - } - for (const duration of [0, 59, 61, 10081]) { - expect(isThreadAutoArchiveDuration(duration)).toBe(false); - } - }); - it('allows at most one notification bit on thread member settings', () => { expect(isValidThreadMemberSettingsFlags(0)).toBe(true); expect(isValidThreadMemberSettingsFlags(ThreadMemberFlags.ALL_MESSAGES)).toBe(true); diff --git a/packages/constants/src/ThreadConstants.ts b/packages/constants/src/ThreadConstants.ts index 389a1f79c..86f7d2cfe 100644 --- a/packages/constants/src/ThreadConstants.ts +++ b/packages/constants/src/ThreadConstants.ts @@ -48,8 +48,6 @@ export const ChannelFlags = { HIDE_MEDIA_DOWNLOAD_OPTIONS: 1 << 15, } as const; -export type ChannelFlag = ValueOf; - export function settableChannelFlags(type: number, parentType?: number | null): number { switch (type) { case ChannelTypes.PUBLIC_THREAD: @@ -121,10 +119,6 @@ export type ThreadAutoArchiveDuration = (typeof THREAD_AUTO_ARCHIVE_DURATIONS)[n export const DEFAULT_THREAD_AUTO_ARCHIVE_DURATION: ThreadAutoArchiveDuration = 4320; -export function isThreadAutoArchiveDuration(value: number): value is ThreadAutoArchiveDuration { - return (THREAD_AUTO_ARCHIVE_DURATIONS as ReadonlyArray).includes(value); -} - export const THREAD_NAME_MIN_LENGTH = 1; export const THREAD_NAME_MAX_LENGTH = 100; export const FORUM_TOPIC_MAX_LENGTH = 4096; diff --git a/packages/date_utils/src/DateTimestampStyle.ts b/packages/date_utils/src/DateTimestampStyle.ts index ca13b5a2e..42e4da8f5 100644 --- a/packages/date_utils/src/DateTimestampStyle.ts +++ b/packages/date_utils/src/DateTimestampStyle.ts @@ -3,7 +3,7 @@ import {getDateFormatter} from '@fluxer/date_utils/src/DateFormatterCache'; import {localeUses12Hour} from '@fluxer/date_utils/src/DateHourCycle'; -const TimestampStyle = { +export const TimestampStyle = { ShortTime: 'ShortTime', LongTime: 'LongTime', ShortDate: 'ShortDate', @@ -14,7 +14,7 @@ const TimestampStyle = { ShortDateMediumTime: 'ShortDateMediumTime', RelativeTime: 'RelativeTime', } as const; -type TimestampStyle = (typeof TimestampStyle)[keyof typeof TimestampStyle]; +export type TimestampStyle = (typeof TimestampStyle)[keyof typeof TimestampStyle]; const TIMESTAMP_STYLE_OPTIONS: Record = { [TimestampStyle.ShortTime]: {hour: 'numeric', minute: 'numeric'}, diff --git a/packages/desktop_ipc/package.json b/packages/desktop_ipc/package.json index 3191465ea..bc82a1a42 100644 --- a/packages/desktop_ipc/package.json +++ b/packages/desktop_ipc/package.json @@ -12,5 +12,8 @@ "devDependencies": { "@types/node": "catalog:", "typescript": "catalog:ts7" + }, + "dependencies": { + "@fluxer/record_utils": "workspace:*" } } diff --git a/packages/desktop_ipc/src/DesktopBridgeTypes.ts b/packages/desktop_ipc/src/DesktopBridgeTypes.ts new file mode 100644 index 000000000..3b68ef16b --- /dev/null +++ b/packages/desktop_ipc/src/DesktopBridgeTypes.ts @@ -0,0 +1,394 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export interface LinuxAppearanceSnapshot { + colorScheme: 'no-preference' | 'prefer-dark' | 'prefer-light'; + contrast: 'no-preference' | 'high'; + accent: {r: number; g: number; b: number} | null; +} + +export interface ChromiumRuntimeInfo { + enableFeatures: Array; + disableFeatures: Array; + switches: Array; +} + +export interface GpuDeviceInfo { + active: boolean; + vendorId: number; + deviceId: number; + vendorName?: string; + deviceString?: string; + driverVendor?: string; + driverVersion?: string; + dedicatedVideoMemory?: number; + sharedSystemMemory?: number; + subsystemVendorId?: number; + subsystemDeviceId?: number; + registryId?: string; + adapterLuid?: string; + pciPath?: string; + integrated?: boolean; + removable?: boolean; + headless?: boolean; + source?: 'metal' | 'dxgi' | 'linux-sysfs' | 'electron'; +} + +export interface GpuInfo { + devices: ReadonlyArray; + glRenderer?: string; + glVendor?: string; + machineModelName?: string; + machineModelVersion?: string; + nativeSource?: 'metal' | 'dxgi' | 'linux-sysfs'; +} + +export type StreamingPriorityDiagnostics = Record; + +export interface CpuInfo { + model: string; + speed: number; + cores: number; + physicalCores: number; +} + +export interface ProcessMetrics { + cpu: { + percentCPUUsage: number; + }; + memory: { + workingSetSize: number; + peakWorkingSetSize: number; + privateBytes?: number; + }; + pid: number; + type: string; + name?: string; +} + +export interface AppMetricsSnapshot { + cpuInfo: CpuInfo; + processes: Array; + totalMemoryMB: number; + freeMemoryMB: number; +} + +export interface ThemeLocalFileReference { + id: string; + name: string; + path: string; + mimeType: string; + size: number; +} + +export interface ThemeLocalFileReadResult { + path: string; + dataUrl?: string; + error?: string; +} + +export interface ThemeDirectoryCssFile { + fileName: string; + path: string; + css: string; +} + +export type ThemeLinkedFileError = 'not_allowed' | 'missing' | 'not_file' | 'too_large' | 'too_many' | 'read_failed'; + +export interface ThemeLinkedFileChange { + path: string; + css?: string; + error?: ThemeLinkedFileError; +} + +export type VoiceBackgroundMediaKind = 'static' | 'animated' | 'video'; + +export interface VoiceBackgroundMediaCacheRequest { + id: string; + mimeType: string; + fileName?: string; + data: ArrayBuffer; +} + +export interface VoiceBackgroundMediaCacheResult { + path: string; + mediaKind: VoiceBackgroundMediaKind; +} + +export interface VoiceBackgroundMediaReadResult { + path: string; + mediaKind: VoiceBackgroundMediaKind; + dataUrl: string; +} + +export interface DesktopTroubleshootingSettings { + disableHardwareAcceleration: boolean; +} + +export interface SetDesktopTroubleshootingDisableHardwareAccelerationOptions { + disable: boolean; + restart?: boolean; +} + +export interface DesktopVoiceDebugEventSinkEntry { + sequence: number; + line: string; +} + +export type ClipboardWriteFileMediaType = 'image' | 'gif' | 'video' | 'audio'; + +export interface ClipboardWriteFileOptions { + url: string; + suggestedName?: string; + mediaType: ClipboardWriteFileMediaType; +} + +export interface ClipboardWriteFileResult { + success: boolean; + path?: string; + error?: string; +} + +export type UpdaterDownloadFormat = 'setup' | 'dmg' | 'zip' | 'appimage' | 'deb' | 'rpm' | 'tar_gz'; + +export interface UpdaterDownloadOption { + format: UpdaterDownloadFormat; + label: string; + url: string; + suggestedName?: string; + sha256?: string | null; +} + +export type MediaAccessType = 'microphone' | 'camera' | 'screen' | 'audio-capture'; + +export type MediaAccessStatus = 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown'; + +export type InputMonitoringPermissionStatus = 'granted' | 'denied' | 'not-determined' | 'unsupported'; + +export type VirtmicBackend = 'pipewire'; + +export type VirtmicNode = Record; + +export interface VirtmicLinkOptions { + ignoreDevices?: boolean; + ignoreInputMedia?: boolean; + ignoreVirtual?: boolean; + workaround?: boolean; +} + +export type NativeAudioBackend = 'macos-sck' | 'macos-coreaudio' | 'windows-wasapi-loopback' | 'linux-pipewire'; + +export interface NativeAudioAvailability { + available: boolean; + backend?: NativeAudioBackend; + capabilities?: { + process: boolean; + system: boolean; + systemExcludesSelf?: boolean; + processInclude?: boolean; + processExclude?: boolean; + sessionMixer?: boolean; + systemLoopbackMode?: 'process-exclude' | 'session-mixer' | 'unavailable'; + }; + reason?: + | 'unsupported-platform' + | 'addon-not-installed' + | 'load-failed' + | 'os-version-too-old' + | 'permission-denied' + | 'no-pipewire' + | 'disabled-by-launch'; + detail?: string; +} + +export interface NativeAudioApplication { + pid: number; + identifier: string; + name: string; + audible?: boolean; +} + +export interface NativeAudioStartOptions { + targetPid?: number; + includeProcessTree?: boolean; + macBackend?: 'sck' | 'coreaudio' | 'auto'; + macCaptureScope?: 'process' | 'system'; + winCaptureScope?: 'process' | 'system' | 'session-mixer'; + linuxRule?: VirtmicLinkOptions & { + include?: Array; + exclude?: Array; + onlySpeakers?: boolean; + onlyDefaultSpeakers?: boolean; + }; +} + +export interface NativeAudioStartResult { + captureId: string; + sampleRate: number; + channels: number; +} + +export interface NativeAudioFrameMessage { + captureId: string; + sampleRate: number; + channels: number; + timestampUs: number; + samples: ArrayBuffer; +} + +export type NativeAudioEndReason = 'stopped' | 'target-exited' | 'addon-error'; + +export interface NativeAudioEndMessage { + captureId: string; + reason: NativeAudioEndReason; + detail?: string; +} + +export type NativeScreenCaptureLifecycleEventKind = 'error' | 'closed' | 'closed-clean' | 'stalled' | 'diagnostic'; + +export type NativeScreenCaptureLifecycleSource = 'delegate' | 'programmatic'; + +export interface NativeScreenCaptureLifecycleMessage { + captureId: string; + kind: NativeScreenCaptureLifecycleEventKind; + message: string; + source?: NativeScreenCaptureLifecycleSource; +} + +export type DisplayMediaPortalSurfacePreference = 'window' | 'monitor'; + +export interface StreamerModeCaptureProcess { + name: string; + pid?: number; +} + +export interface StreamerModeCaptureAppStatus { + detected: boolean; + processes: Array; +} + +export type TrayPresenceStatus = 'online' | 'idle' | 'dnd' | 'invisible'; + +export interface TrayRuntimeStatePayload { + voiceConnected: boolean; + voiceChannelLabel: string | null; + selfMute: boolean; + selfDeaf: boolean; + presenceStatus: TrayPresenceStatus | null; + buildInfo: string | null; +} + +export type GlobalShortcutsBackend = 'portal' | 'x11' | 'evdev' | 'windows' | 'macos' | 'none'; + +export type GlobalShortcutsPortalState = + | 'unknown' + | 'probing' + | 'unsupported' + | 'not-set-up' + | 'binding' + | 'bound' + | 'declined' + | 'error'; + +export interface GlobalShortcutsPortalShortcut { + action: string; + triggerDescription: string | null; +} + +export interface GlobalShortcutCombo { + code?: string; + key: string; + ctrl: boolean; + alt: boolean; + shift: boolean; + meta: boolean; + mouseButton?: number; + modifierOnly?: boolean; + bothSides?: boolean; +} + +export interface GlobalShortcutBinding { + sourceId: string; + action: string; + combo: GlobalShortcutCombo; +} + +export interface GlobalShortcutActionDefinition { + action: string; + description: string; + preferredCombo: GlobalShortcutCombo | null; +} + +export interface GlobalShortcutsSyncPayload { + bindings: Array; + actions: Array; +} + +export interface GlobalShortcutEvent { + action: string; + sourceId: string; + phase: 'press' | 'release'; +} + +export interface GlobalShortcutCaptureEvent { + type: 'keydown' | 'keyup' | 'mousedown' | 'mouseup'; + code: string | null; + key: string | null; + button: number | null; + ctrl: boolean; + alt: boolean; + shift: boolean; + meta: boolean; +} + +export interface GlobalKeyHookRegisterOptions { + id: string; + description?: string; + keycode?: number; + keyName?: string; + physicalKeyName?: string; + mouseButton?: number; + ctrl?: boolean; + alt?: boolean; + shift?: boolean; + meta?: boolean; +} + +export interface GlobalKeyEvent { + type: 'keydown' | 'keyup'; + keycode: number; + keyName: string; + backend?: 'evdev' | 'native' | null; + altKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + metaKey: boolean; +} + +export interface GlobalMouseEvent { + type: 'mousedown' | 'mouseup'; + button: number; + altKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + metaKey: boolean; +} + +export interface GlobalKeybindTriggeredEvent { + id: string; + type: 'keydown' | 'keyup'; +} + +export type SpellcheckEngine = 'auto' | 'hunspell' | 'system'; + +export interface SpellcheckBundledDictionary { + tag: string; + package: string; + displayName: string; + nativeName: string; +} + +export interface SpellcheckResolvedEngineInfo { + mode: 'hunspell' | 'system' | 'off'; + hunspellLangs: Array; + systemLangs: Array; +} diff --git a/packages/desktop_ipc/src/DesktopLabels.ts b/packages/desktop_ipc/src/DesktopLabels.ts new file mode 100644 index 000000000..f08fca6f9 --- /dev/null +++ b/packages/desktop_ipc/src/DesktopLabels.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export function formatReleaseChannelLabel(value: string): string { + const normalized = value.trim().toLowerCase(); + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +} + +export const ARCHITECTURE_PATTERNS: ReadonlyArray<{ + pattern: RegExp; + label: string; +}> = [ + {pattern: /\barm64\b|\baarch64\b|\barmv8\b|\barm64e\b/i, label: 'arm64'}, + {pattern: /\barm\b|\barmv7\b|\barmv6\b/i, label: 'arm'}, + {pattern: /MacIntel/i, label: 'x64'}, + {pattern: /\bx86_64\b|\bx64\b|\bamd64\b|\bwin64\b|\bwow64\b/i, label: 'x64'}, + {pattern: /\bx86\b|\bi[3-6]86\b/i, label: 'x86'}, +]; + +export function normalizeArchitectureValue(value: string | null | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + for (const entry of ARCHITECTURE_PATTERNS) { + if (entry.pattern.test(trimmed)) { + return entry.label; + } + } + return trimmed || undefined; +} + +export function getWindowsVersionName(osVersion: string): string { + const parts = osVersion.split('.'); + const majorVersion = parseInt(parts[0], 10); + const buildNumber = parseInt(parts[2], 10); + if (majorVersion === 10) { + if (buildNumber >= 22000) { + return 'Windows 11'; + } + return 'Windows 10'; + } + return 'Windows'; +} + +export function formatDesktopOsName(desktopInfo: {os: string; osVersion?: string}): string { + switch (desktopInfo.os) { + case 'darwin': + return 'macOS'; + case 'win32': + return desktopInfo.osVersion ? getWindowsVersionName(desktopInfo.osVersion) : 'Windows'; + case 'linux': + return 'Linux'; + default: + return desktopInfo.os; + } +} diff --git a/packages/desktop_ipc/src/GlobalShortcutKeyNames.ts b/packages/desktop_ipc/src/GlobalShortcutKeyNames.ts new file mode 100644 index 000000000..96cf514ee --- /dev/null +++ b/packages/desktop_ipc/src/GlobalShortcutKeyNames.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export const PUNCTUATION_KEY_CODES: Readonly> = { + ' ': 'Space', + '-': 'Minus', + _: 'Minus', + '=': 'Equal', + '+': 'Equal', + ',': 'Comma', + '<': 'Comma', + '.': 'Period', + '>': 'Period', + ';': 'Semicolon', + ':': 'Semicolon', + "'": 'Quote', + '"': 'Quote', + '/': 'Slash', + '?': 'Slash', + '\\': 'Backslash', + '|': 'Backslash', + '[': 'BracketLeft', + '{': 'BracketLeft', + ']': 'BracketRight', + '}': 'BracketRight', + '`': 'Backquote', + '~': 'Backquote', +}; + +export function isPrintableShortcutKey(key: string | null | undefined): key is string { + if (!key) return false; + if (key === 'Dead' || key === 'Unidentified' || key === 'Process') return false; + return key.length === 1; +} + +export function shouldPreferLayoutKey(combo: {key?: string | null; code?: string}): boolean { + if (!isPrintableShortcutKey(combo.key)) return false; + return !(combo.code && /^Numpad/.test(combo.code)); +} + +export function hookKeyNameForLayoutKey(key: string): string | null { + if (key.length !== 1) return null; + const punctuation = PUNCTUATION_KEY_CODES[key]; + if (punctuation) return punctuation; + const upper = key.toUpperCase(); + return /^[A-Z0-9]$/.test(upper) ? upper : null; +} diff --git a/packages/desktop_ipc/src/LegacyShellHintSeed.ts b/packages/desktop_ipc/src/LegacyShellHintSeed.ts index 683a0ec1c..80645b69a 100644 --- a/packages/desktop_ipc/src/LegacyShellHintSeed.ts +++ b/packages/desktop_ipc/src/LegacyShellHintSeed.ts @@ -1,3 +1,5 @@ +import {isRecord} from '@fluxer/record_utils/src/RecordGuards'; + // SPDX-License-Identifier: AGPL-3.0-or-later const LEGACY_SKELETON_LAYOUT_MEMORY_KEY = 'SkeletonLayoutMemory'; @@ -59,10 +61,6 @@ const MAX_SIDEBAR_WIDTH_PX = 480; const MAX_ACCOUNT_KEY_LENGTH = 256; const NO_SELECTED_ROW_INDEX = -1; -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - function parseRecord(value: unknown): Record | null { if (typeof value !== 'string') return null; try { diff --git a/packages/errors/package.json b/packages/errors/package.json index c985519be..f57a67c2e 100644 --- a/packages/errors/package.json +++ b/packages/errors/package.json @@ -15,6 +15,7 @@ "@fluxer/constants": "workspace:*", "@fluxer/i18n": "workspace:*", "@fluxer/logger": "workspace:*", + "@fluxer/record_utils": "workspace:*", "@fluxer/schema": "workspace:*", "hono": "catalog:" }, diff --git a/packages/errors/src/FluxerError.ts b/packages/errors/src/FluxerError.ts index 1062039ab..a9919ebfd 100644 --- a/packages/errors/src/FluxerError.ts +++ b/packages/errors/src/FluxerError.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import type {APIErrorCode} from '@fluxer/constants/src/ApiErrorCodes'; import type {ErrorResponse} from '@fluxer/schema/src/domains/common/ErrorSchemas'; import {HTTPException} from 'hono/http-exception'; @@ -54,3 +55,31 @@ export class FluxerError extends HTTPException { }; } } + +type StatusErrorOptions = Omit; +type RequiredStatusErrorOptions = {code: APIErrorCode} & (TAllowMessage extends true + ? StatusErrorOptions + : Omit); + +export function statusError( + status: FluxerErrorStatus, + allowMessage: TAllowMessage = true as TAllowMessage, +) { + return class extends FluxerError { + constructor(options: RequiredStatusErrorOptions) { + const {message, ...rest} = options as StatusErrorOptions & {code: APIErrorCode}; + super({...rest, status, message: allowMessage ? message : undefined}); + } + }; +} + +export function defaultCodeStatusError( + status: FluxerErrorStatus, + defaultCode: NoInfer, +) { + return class extends FluxerError { + constructor({code = defaultCode, ...options}: StatusErrorOptions & {code?: TCode} = {}) { + super({...options, code, status}); + } + }; +} diff --git a/packages/errors/src/HttpErrors.ts b/packages/errors/src/HttpErrors.ts deleted file mode 100644 index e9dd6ad07..000000000 --- a/packages/errors/src/HttpErrors.ts +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; -import {HttpStatus} from '@fluxer/constants/src/HttpConstants'; -import {FluxerError, type FluxerErrorData} from '@fluxer/errors/src/FluxerError'; - -interface HttpErrorOptions { - code?: string; - message?: string; - data?: FluxerErrorData; - headers?: Record; - cause?: Error; -} - -export class ServiceUnavailableError extends FluxerError { - constructor(options: HttpErrorOptions = {}) { - super({ - ...options, - code: options.code ?? APIErrorCodes.SERVICE_UNAVAILABLE, - message: options.message ?? 'Service Unavailable', - status: HttpStatus.SERVICE_UNAVAILABLE, - }); - this.name = 'ServiceUnavailableError'; - } -} diff --git a/packages/errors/src/__tests__/AppErrorHandlerLogging.test.ts b/packages/errors/src/__tests__/AppErrorHandlerLogging.test.ts index 9181c8a22..bfef604d2 100644 --- a/packages/errors/src/__tests__/AppErrorHandlerLogging.test.ts +++ b/packages/errors/src/__tests__/AppErrorHandlerLogging.test.ts @@ -2,7 +2,7 @@ import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; import {AppErrorHandler, type BaseHonoEnv} from '@fluxer/errors/src/domains/core/ErrorHandlers'; -import {ServiceUnavailableError} from '@fluxer/errors/src/HttpErrors'; +import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError'; import {Hono} from 'hono'; import {beforeEach, describe, expect, it, vi} from 'vitest'; diff --git a/packages/errors/src/domains/core/BadGatewayError.ts b/packages/errors/src/domains/core/BadGatewayError.ts index ac029db12..d0ab0cb23 100644 --- a/packages/errors/src/domains/core/BadGatewayError.ts +++ b/packages/errors/src/domains/core/BadGatewayError.ts @@ -1,23 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {APIErrorCode} from '@fluxer/constants/src/ApiErrorCodes'; import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; -import {FluxerError, type FluxerErrorData} from '@fluxer/errors/src/FluxerError'; +import {defaultCodeStatusError} from '@fluxer/errors/src/FluxerError'; -export class BadGatewayError extends FluxerError { - constructor({ - code = APIErrorCodes.BAD_GATEWAY, - message, - data, - headers, - messageVariables, - }: { - code?: APIErrorCode; - message?: string; - data?: FluxerErrorData; - headers?: Record; - messageVariables?: Record; - } = {}) { - super({code, message, status: 502, data, headers, messageVariables}); - } -} +export class BadGatewayError extends defaultCodeStatusError(502, APIErrorCodes.BAD_GATEWAY) {} diff --git a/packages/errors/src/domains/core/BadRequestError.ts b/packages/errors/src/domains/core/BadRequestError.ts index 7412a6a16..75f656db1 100644 --- a/packages/errors/src/domains/core/BadRequestError.ts +++ b/packages/errors/src/domains/core/BadRequestError.ts @@ -1,22 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {APIErrorCode} from '@fluxer/constants/src/ApiErrorCodes'; -import {FluxerError, type FluxerErrorData} from '@fluxer/errors/src/FluxerError'; +import {statusError} from '@fluxer/errors/src/FluxerError'; -export class BadRequestError extends FluxerError { - constructor({ - code, - message, - headers, - data, - messageVariables, - }: { - code: APIErrorCode; - message?: string; - data?: FluxerErrorData; - headers?: Record; - messageVariables?: Record; - }) { - super({code, message, status: 400, data, headers, messageVariables}); - } -} +export class BadRequestError extends statusError(400) {} diff --git a/packages/errors/src/domains/core/ConflictError.ts b/packages/errors/src/domains/core/ConflictError.ts index f7e88f806..3f8fdc5d6 100644 --- a/packages/errors/src/domains/core/ConflictError.ts +++ b/packages/errors/src/domains/core/ConflictError.ts @@ -1,22 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {APIErrorCode} from '@fluxer/constants/src/ApiErrorCodes'; -import {FluxerError, type FluxerErrorData} from '@fluxer/errors/src/FluxerError'; +import {statusError} from '@fluxer/errors/src/FluxerError'; -export class ConflictError extends FluxerError { - constructor({ - code, - message, - data, - headers, - messageVariables, - }: { - code: APIErrorCode; - message?: string; - data?: FluxerErrorData; - headers?: Record; - messageVariables?: Record; - }) { - super({code, message, status: 409, data, headers, messageVariables}); - } -} +export class ConflictError extends statusError(409) {} diff --git a/packages/errors/src/domains/core/ErrorHandlers.ts b/packages/errors/src/domains/core/ErrorHandlers.ts index d350f047b..ebffb161b 100644 --- a/packages/errors/src/domains/core/ErrorHandlers.ts +++ b/packages/errors/src/domains/core/ErrorHandlers.ts @@ -1,7 +1,7 @@ +import {isObjectLike as isRecord} from '@fluxer/record_utils/src/RecordGuards'; // SPDX-License-Identifier: AGPL-3.0-or-later import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; -import {Locales} from '@fluxer/constants/src/Locales'; import {OAuth2Error} from '@fluxer/errors/src/domains/auth/OAuth2Error'; import { getApiErrorCodeForStatus, @@ -20,6 +20,7 @@ import {FluxerError} from '@fluxer/errors/src/FluxerError'; import {ErrorCodeToI18nKey} from '@fluxer/errors/src/i18n/ErrorCodeMappings'; import {getErrorMessageUnsafe} from '@fluxer/errors/src/i18n/ErrorI18n'; import type {ErrorI18nKey} from '@fluxer/errors/src/i18n/ErrorI18nMessages'; +import {parseAcceptLanguage} from '@fluxer/i18n/src/locale/resolution/AcceptLanguageNegotiation'; import {createLogger} from '@fluxer/logger/src/Logger'; import type {Context} from 'hono'; import {HTTPException} from 'hono/http-exception'; @@ -42,7 +43,6 @@ export interface BaseHonoEnv { } const logger = createLogger('errors'); -const LOCALE_LOOKUP = new Map(Object.values(Locales).map((locale) => [locale.toLowerCase(), locale])); interface LocalizedValidationErrorEntry { path: string; @@ -50,10 +50,6 @@ interface LocalizedValidationErrorEntry { variables?: Record; } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object'; -} - function isLocalizedValidationErrorEntry(value: unknown): value is LocalizedValidationErrorEntry { if (!isRecord(value)) { return false; @@ -82,34 +78,6 @@ function isExpectedError(err: Error): boolean { return 'isExpected' in err && Boolean(err.isExpected); } -function resolveLocaleFromAcceptLanguage(acceptLanguageHeader: string | undefined): string | undefined { - if (!acceptLanguageHeader) { - return undefined; - } - for (const entry of acceptLanguageHeader.split(',')) { - const localeToken = entry.split(';')[0]?.trim().toLowerCase(); - if (!localeToken) { - continue; - } - const exactMatch = LOCALE_LOOKUP.get(localeToken); - if (exactMatch) { - return exactMatch; - } - const baseToken = localeToken.split('-')[0]; - if (!baseToken) { - continue; - } - if (baseToken === 'en') { - return Locales.EN_US; - } - const baseMatch = LOCALE_LOOKUP.get(baseToken); - if (baseMatch) { - return baseMatch; - } - } - return undefined; -} - function getI18nContext( ctx: Context, ): { @@ -117,7 +85,7 @@ function getI18nContext( locale: string; } { const requestLocale = ctx.get('requestLocale'); - const locale = requestLocale ?? resolveLocaleFromAcceptLanguage(ctx.req.header('accept-language')) ?? Locales.EN_US; + const locale = requestLocale ?? parseAcceptLanguage(ctx.req.header('accept-language')); return { errorI18nService: ctx.get('errorI18nService'), locale, diff --git a/packages/errors/src/domains/core/ForbiddenError.ts b/packages/errors/src/domains/core/ForbiddenError.ts index 3c827c3de..936946d4d 100644 --- a/packages/errors/src/domains/core/ForbiddenError.ts +++ b/packages/errors/src/domains/core/ForbiddenError.ts @@ -1,22 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {APIErrorCode} from '@fluxer/constants/src/ApiErrorCodes'; -import {FluxerError, type FluxerErrorData} from '@fluxer/errors/src/FluxerError'; +import {statusError} from '@fluxer/errors/src/FluxerError'; -export class ForbiddenError extends FluxerError { - constructor({ - code, - message, - headers, - data, - messageVariables, - }: { - code: APIErrorCode; - message?: string; - data?: FluxerErrorData; - headers?: Record; - messageVariables?: Record; - }) { - super({code, message, status: 403, data, headers, messageVariables}); - } -} +export class ForbiddenError extends statusError(403) {} diff --git a/packages/errors/src/domains/core/GatewayTimeoutError.ts b/packages/errors/src/domains/core/GatewayTimeoutError.ts index 6cd300c63..1fcd0216d 100644 --- a/packages/errors/src/domains/core/GatewayTimeoutError.ts +++ b/packages/errors/src/domains/core/GatewayTimeoutError.ts @@ -1,23 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {APIErrorCode} from '@fluxer/constants/src/ApiErrorCodes'; import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; -import {FluxerError, type FluxerErrorData} from '@fluxer/errors/src/FluxerError'; +import {defaultCodeStatusError} from '@fluxer/errors/src/FluxerError'; -export class GatewayTimeoutError extends FluxerError { - constructor({ - code = APIErrorCodes.GATEWAY_TIMEOUT, - message, - data, - headers, - messageVariables, - }: { - code?: APIErrorCode; - message?: string; - data?: FluxerErrorData; - headers?: Record; - messageVariables?: Record; - } = {}) { - super({code, message, status: 504, data, headers, messageVariables}); - } -} +export class GatewayTimeoutError extends defaultCodeStatusError(504, APIErrorCodes.GATEWAY_TIMEOUT) {} diff --git a/packages/errors/src/domains/core/InternalServerError.ts b/packages/errors/src/domains/core/InternalServerError.ts index 6ee15dcd3..1fe223498 100644 --- a/packages/errors/src/domains/core/InternalServerError.ts +++ b/packages/errors/src/domains/core/InternalServerError.ts @@ -1,20 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {APIErrorCode} from '@fluxer/constants/src/ApiErrorCodes'; -import {FluxerError, type FluxerErrorData} from '@fluxer/errors/src/FluxerError'; +import {statusError} from '@fluxer/errors/src/FluxerError'; -export class InternalServerError extends FluxerError { - constructor({ - code, - data, - headers, - messageVariables, - }: { - code: APIErrorCode; - data?: FluxerErrorData; - headers?: Record; - messageVariables?: Record; - }) { - super({code, status: 500, data, headers, messageVariables}); - } -} +export class InternalServerError extends statusError(500, false) {} diff --git a/packages/errors/src/domains/core/LockedError.ts b/packages/errors/src/domains/core/LockedError.ts index 643762db8..067d67ecd 100644 --- a/packages/errors/src/domains/core/LockedError.ts +++ b/packages/errors/src/domains/core/LockedError.ts @@ -1,20 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {APIErrorCode} from '@fluxer/constants/src/ApiErrorCodes'; -import {FluxerError, type FluxerErrorData} from '@fluxer/errors/src/FluxerError'; +import {statusError} from '@fluxer/errors/src/FluxerError'; -export class LockedError extends FluxerError { - constructor({ - code, - headers, - data, - messageVariables, - }: { - code: APIErrorCode; - data?: FluxerErrorData; - headers?: Record; - messageVariables?: Record; - }) { - super({code, status: 423, data, headers, messageVariables}); - } -} +export class LockedError extends statusError(423, false) {} diff --git a/packages/errors/src/domains/core/NotFoundError.ts b/packages/errors/src/domains/core/NotFoundError.ts index 0d6a35a01..6b1e11210 100644 --- a/packages/errors/src/domains/core/NotFoundError.ts +++ b/packages/errors/src/domains/core/NotFoundError.ts @@ -1,22 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {APIErrorCode} from '@fluxer/constants/src/ApiErrorCodes'; -import {FluxerError, type FluxerErrorData} from '@fluxer/errors/src/FluxerError'; +import {statusError} from '@fluxer/errors/src/FluxerError'; -export class NotFoundError extends FluxerError { - constructor({ - code, - message, - headers, - data, - messageVariables, - }: { - code: APIErrorCode; - message?: string; - data?: FluxerErrorData; - headers?: Record; - messageVariables?: Record; - }) { - super({code, message, status: 404, data, headers, messageVariables}); - } -} +export class NotFoundError extends statusError(404) {} diff --git a/packages/errors/src/domains/core/ServiceUnavailableError.ts b/packages/errors/src/domains/core/ServiceUnavailableError.ts index e62ac9fbd..b4a5f7ab6 100644 --- a/packages/errors/src/domains/core/ServiceUnavailableError.ts +++ b/packages/errors/src/domains/core/ServiceUnavailableError.ts @@ -1,22 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; -import {FluxerError, type FluxerErrorData} from '@fluxer/errors/src/FluxerError'; +import {defaultCodeStatusError} from '@fluxer/errors/src/FluxerError'; -export class ServiceUnavailableError extends FluxerError { - constructor({ - code = APIErrorCodes.SERVICE_UNAVAILABLE, - message, - data, - headers, - messageVariables, - }: { - code?: string; - message?: string; - data?: FluxerErrorData; - headers?: Record; - messageVariables?: Record; - } = {}) { - super({code, message, status: 503, data, headers, messageVariables}); - } -} +export class ServiceUnavailableError extends defaultCodeStatusError(503, APIErrorCodes.SERVICE_UNAVAILABLE) {} diff --git a/packages/errors/src/domains/core/UnauthorizedError.ts b/packages/errors/src/domains/core/UnauthorizedError.ts index 0f906ac29..e2b51b2e6 100644 --- a/packages/errors/src/domains/core/UnauthorizedError.ts +++ b/packages/errors/src/domains/core/UnauthorizedError.ts @@ -1,23 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {APIErrorCode} from '@fluxer/constants/src/ApiErrorCodes'; import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; -import {FluxerError, type FluxerErrorData} from '@fluxer/errors/src/FluxerError'; +import {defaultCodeStatusError} from '@fluxer/errors/src/FluxerError'; -export class UnauthorizedError extends FluxerError { - constructor({ - code = APIErrorCodes.UNAUTHORIZED, - message, - headers, - data, - messageVariables, - }: { - code?: APIErrorCode; - message?: string; - data?: FluxerErrorData; - headers?: Record; - messageVariables?: Record; - } = {}) { - super({code, message, status: 401, data, headers, messageVariables}); - } -} +export class UnauthorizedError extends defaultCodeStatusError(401, APIErrorCodes.UNAUTHORIZED) {} diff --git a/packages/errors/src/error_handling/ErrorIntrospection.ts b/packages/errors/src/error_handling/ErrorIntrospection.ts index 3dae99566..eafdb8342 100644 --- a/packages/errors/src/error_handling/ErrorIntrospection.ts +++ b/packages/errors/src/error_handling/ErrorIntrospection.ts @@ -1,3 +1,4 @@ +import {isObjectLike as isRecord} from '@fluxer/record_utils/src/RecordGuards'; // SPDX-License-Identifier: AGPL-3.0-or-later import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; @@ -24,10 +25,6 @@ export function getApiErrorCodeForStatus(status: number): string { return HTTP_STATUS_TO_ERROR_CODE[status] ?? APIErrorCodes.GENERAL_ERROR; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - function isApiErrorCode(value: unknown): value is string { return typeof value === 'string' && apiErrorCodeSet.has(value); } diff --git a/packages/errors/src/i18n/ErrorCodeMappings.ts b/packages/errors/src/i18n/ErrorCodeMappings.ts index e8574c3c5..f3ee3443f 100644 --- a/packages/errors/src/i18n/ErrorCodeMappings.ts +++ b/packages/errors/src/i18n/ErrorCodeMappings.ts @@ -18,7 +18,6 @@ export const ErrorCodeToI18nKey = { [APIErrorCodes.ACLS_MUST_BE_NON_EMPTY]: 'admin_and_system.acls_must_be_non_empty', [APIErrorCodes.ADMIN_API_KEY_NOT_FOUND]: 'admin_and_system.admin_api_key_not_found', [APIErrorCodes.ANNOUNCEMENT_CHANNEL_REQUIRED]: 'channels_and_guilds.announcement_channel_required', - [APIErrorCodes.APPLICATION_NOT_OWNED]: 'auth_and_oauth.application_not_owned', [APIErrorCodes.ALREADY_FRIENDS]: 'friends_and_dms.already_friends', [APIErrorCodes.CHANNEL_ALREADY_FOLLOWED]: 'channels_and_guilds.channel_already_followed', [APIErrorCodes.CHANNEL_HAS_FOLLOWED_CHANNELS]: 'channels_and_guilds.channel_has_followed_channels', diff --git a/packages/errors/src/i18n/ErrorI18n.test.ts b/packages/errors/src/i18n/ErrorI18n.test.ts index f29cfd467..b9eb14496 100644 --- a/packages/errors/src/i18n/ErrorI18n.test.ts +++ b/packages/errors/src/i18n/ErrorI18n.test.ts @@ -1,8 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {getErrorMessageResult, getErrorMessageUnsafe} from '@fluxer/errors/src/i18n/ErrorI18n'; +import {getErrorMessageUnsafe} from '@fluxer/errors/src/i18n/ErrorI18n'; import {ERROR_I18N_LOCALE_MESSAGES} from '@fluxer/errors/src/i18n/ErrorI18nLocales'; -import type {ErrorI18nKey} from '@fluxer/errors/src/i18n/ErrorI18nMessages'; import {beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest'; describe('ErrorI18n', () => { @@ -11,45 +10,6 @@ describe('ErrorI18n', () => { consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); consoleWarnSpy.mockClear(); }); - describe('constructor and initialization', () => { - it('handles missing default bundle gracefully', () => { - const message = getErrorMessageUnsafe('nonexistent.key', 'en-US', undefined, 'Fallback message'); - expect(message).toBe('Fallback message'); - }); - }); - describe('getMessage() - basic retrieval', () => { - it('returns key when translation missing', () => { - const message = getErrorMessageUnsafe('nonexistent.key', 'en-US'); - expect(message).toBe('nonexistent.key'); - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Missing translation for error message: nonexistent.key (locale: en-US)', - ); - }); - }); - describe('getMessage() - locale handling', () => { - it('falls back to en-US for unsupported locales', () => { - const message = getErrorMessageUnsafe('rate_limits.rate_limited', 'de-DE'); - expect(message).toBe("You're being rate limited."); - expect(consoleWarnSpy).toHaveBeenCalledWith('Unsupported locale, falling back to en-US: de-DE'); - }); - it('handles null locale by defaulting to en-US', () => { - const message = getErrorMessageUnsafe('rate_limits.rate_limited', null); - expect(message).toBe("You're being rate limited."); - }); - it('handles undefined locale by defaulting to en-US', () => { - const message = getErrorMessageUnsafe('rate_limits.rate_limited', undefined); - expect(message).toBe("You're being rate limited."); - }); - }); - describe('getMessageResult()', () => { - it('returns error result for missing template', () => { - const result = getErrorMessageResult('missing.key' as ErrorI18nKey, 'en-US'); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.kind).toBe('missing-template'); - } - }); - }); describe('global IP block messages', () => { const HOSTED = {ipAddress: '203.0.113.20', appealEmail: 'support@fluxer.com', product_name: 'Fluxer'}; const SELF_HOSTED = {ipAddress: '203.0.113.20', appealEmail: null, product_name: 'Example Chat'}; diff --git a/packages/errors/src/i18n/ErrorI18n.ts b/packages/errors/src/i18n/ErrorI18n.ts index 7ce60ee38..4713ab90e 100644 --- a/packages/errors/src/i18n/ErrorI18n.ts +++ b/packages/errors/src/i18n/ErrorI18n.ts @@ -3,7 +3,6 @@ import {ErrorCodeToI18nKey} from '@fluxer/errors/src/i18n/ErrorCodeMappings'; import {ERROR_I18N_LOCALE_MESSAGES} from '@fluxer/errors/src/i18n/ErrorI18nLocales'; import {ERROR_I18N_MESSAGES, type ErrorI18nCatalog, type ErrorI18nKey} from '@fluxer/errors/src/i18n/ErrorI18nMessages'; -import {identityLocale} from '@fluxer/i18n/src/normalization/IdentityLocale'; import {compileMessage} from '@fluxer/i18n/src/runtime/CompileMessage'; import {createStaticI18n} from '@fluxer/i18n/src/runtime/CreateStaticI18n'; import type {I18nResult} from '@fluxer/i18n/src/runtime/I18nTypes'; @@ -22,7 +21,6 @@ const errorI18n = createStaticI18n identityLocale(locale), onWarning: (message) => { console.warn(message); }, diff --git a/packages/errors/src/i18n/ErrorI18nCatalogIntegrity.test.ts b/packages/errors/src/i18n/ErrorI18nCatalogIntegrity.test.ts index 6d5dde27e..a435078dc 100644 --- a/packages/errors/src/i18n/ErrorI18nCatalogIntegrity.test.ts +++ b/packages/errors/src/i18n/ErrorI18nCatalogIntegrity.test.ts @@ -14,10 +14,6 @@ import {describe, expect, it} from 'vitest'; const WEBLATE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'weblate'); const SOURCE_SYNC_COMMAND = 'pnpm i18n:source-sync'; -const ENGLISH_VARIANT_LOCALES = new Set(['en-GB']); - -const ENGLISH_IS_THE_ADJUDICATED_TRANSLATION = new Set(['nl:http.conflict', 'ro:http.conflict']); - type FlatCatalog = Record; const SOURCE_CATALOG = ERROR_I18N_MESSAGES as FlatCatalog; @@ -163,29 +159,6 @@ describe('error i18n catalog integrity', () => { expect(STATIC_LOCALES).toEqual(weblateLocales); }); - it('ships no translated value that is still byte-identical to the English source', () => { - const problems: Array = []; - for (const locale of STATIC_LOCALES) { - if (ENGLISH_VARIANT_LOCALES.has(locale)) { - continue; - } - const translated = LOCALE_CATALOGS[locale]; - for (const key of Object.keys(translated).sort()) { - const source = SOURCE_CATALOG[key]; - if (source === undefined || translated[key] !== source) { - continue; - } - if (ENGLISH_IS_THE_ADJUDICATED_TRANSLATION.has(`${locale}:${key}`)) { - continue; - } - problems.push( - `${locale} / ${key}: ships the English source ${quoteMessage(source)} verbatim, so ${locale} users read English. Translate it, or add '${locale}:${key}' to ENGLISH_IS_THE_ADJUDICATED_TRANSLATION if English really is the right ${locale} wording.`, - ); - } - } - expect(problems).toEqual([]); - }); - it('keeps every catalog message compilable with the placeholders its English source declares', () => { const problems: Array = []; for (const key of Object.keys(SOURCE_CATALOG).sort()) { diff --git a/packages/errors/src/i18n/ErrorI18nMessages.ts b/packages/errors/src/i18n/ErrorI18nMessages.ts index f543f8969..788422425 100644 --- a/packages/errors/src/i18n/ErrorI18nMessages.ts +++ b/packages/errors/src/i18n/ErrorI18nMessages.ts @@ -83,7 +83,6 @@ export const ERROR_I18N_MESSAGES = { 'attachments_and_uploads.unresolved_attachment_url': 'Unresolved `attachment://` URL detected. This should have been resolved before embed processing.', 'attachments_and_uploads.uploaded_attachment_not_found': "Uploaded attachment {filename} wasn't found.", - 'auth_and_oauth.application_not_owned': "You don't own this application.", 'auth_and_oauth.bot_already_in_guild': 'This bot is already in the community.', 'auth_and_oauth.bot_users_cannot_create_auth_sessions': "Bot users can't create auth sessions.", 'auth_and_oauth.bot_users_cannot_use_auth_endpoints': "Bot users can't use auth endpoints.", diff --git a/packages/errors/src/i18n/weblate/locales/ar.json b/packages/errors/src/i18n/weblate/locales/ar.json index 73d72656c..e53aab080 100644 --- a/packages/errors/src/i18n/weblate/locales/ar.json +++ b/packages/errors/src/i18n/weblate/locales/ar.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "عدد كبير جدًا من الملفات. الحد الأقصى المسموح به هو {maxFiles, plural, zero {# ملف} one {ملف واحد} two {ملفان} few {# ملفات} many {# ملفًا} other {# ملف}}.", "attachments_and_uploads.unresolved_attachment_url": "تم اكتشاف عنوان URL بصيغة `attachment://` لم يتم حله. كان ينبغي حله قبل معالجة التضمين.", "attachments_and_uploads.uploaded_attachment_not_found": "لم يتم العثور على المرفق المرفوع {filename}.", - "auth_and_oauth.application_not_owned": "أنت لا تملك هذا التطبيق.", "auth_and_oauth.bot_already_in_guild": "هذا البوت موجود بالفعل في المجتمع.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "لا يمكن لمستخدمي البوت إنشاء جلسات مصادقة.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "لا يمكن لمستخدمي البوت استخدام نقاط نهاية المصادقة.", diff --git a/packages/errors/src/i18n/weblate/locales/bg.json b/packages/errors/src/i18n/weblate/locales/bg.json index dd2bcfcfc..5d449c9d4 100644 --- a/packages/errors/src/i18n/weblate/locales/bg.json +++ b/packages/errors/src/i18n/weblate/locales/bg.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Твърде много файлове. Разрешени са максимум {maxFiles} {maxFiles, plural, one {файл} other {файла}}.", "attachments_and_uploads.unresolved_attachment_url": "Открит е неразрешен `attachment://` URL. Това трябваше да бъде разрешено преди обработката на вградените елементи.", "attachments_and_uploads.uploaded_attachment_not_found": "Каченият прикачен файл {filename} не е намерен.", - "auth_and_oauth.application_not_owned": "Не притежаваш това приложение.", "auth_and_oauth.bot_already_in_guild": "Този бот вече е част от общността.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Бот потребителите не могат да създават сесии за удостоверяване.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Бот потребителите не могат да използват ендпойнти за удостоверяване.", diff --git a/packages/errors/src/i18n/weblate/locales/cs.json b/packages/errors/src/i18n/weblate/locales/cs.json index bd6160c87..45a7433bd 100644 --- a/packages/errors/src/i18n/weblate/locales/cs.json +++ b/packages/errors/src/i18n/weblate/locales/cs.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Příliš mnoho souborů. Lze nahrát nejvýše {maxFiles, plural, one {# soubor} few {# soubory} many {# souboru} other {# souborů}}.", "attachments_and_uploads.unresolved_attachment_url": "Nalezena nevyřešená adresa URL `attachment://`. Odkaz měl být vyřešen před zpracováním vloženého obsahu.", "attachments_and_uploads.uploaded_attachment_not_found": "Nahraná příloha {filename} nebyla nalezena.", - "auth_and_oauth.application_not_owned": "Nevlastníte tuto aplikaci.", "auth_and_oauth.bot_already_in_guild": "Tento bot je již v komunitě.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Boti nemohou vytvářet ověřovací relace.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Boti nemohou používat ověřovací koncové body.", diff --git a/packages/errors/src/i18n/weblate/locales/da.json b/packages/errors/src/i18n/weblate/locales/da.json index 6ca02179c..488fd762f 100644 --- a/packages/errors/src/i18n/weblate/locales/da.json +++ b/packages/errors/src/i18n/weblate/locales/da.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "For mange filer. Maksimalt {maxFiles} {maxFiles, plural, one {fil} other {filer}} tilladt.", "attachments_and_uploads.unresolved_attachment_url": "Der blev fundet en uafklaret `attachment://`-URL. Den skulle have været afklaret før behandlingen af indlejringer.", "attachments_and_uploads.uploaded_attachment_not_found": "Den uploadede vedhæftede fil {filename} blev ikke fundet.", - "auth_and_oauth.application_not_owned": "Du ejer ikke denne applikation.", "auth_and_oauth.bot_already_in_guild": "Denne bot er allerede i fællesskabet.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Botbrugere kan ikke oprette godkendelsessessioner.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Botbrugere kan ikke bruge godkendelsesendepunkter.", diff --git a/packages/errors/src/i18n/weblate/locales/de.json b/packages/errors/src/i18n/weblate/locales/de.json index 2306fea36..af0593894 100644 --- a/packages/errors/src/i18n/weblate/locales/de.json +++ b/packages/errors/src/i18n/weblate/locales/de.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Zu viele Dateien. Maximal {maxFiles, plural, one {# Datei} other {# Dateien}} erlaubt.", "attachments_and_uploads.unresolved_attachment_url": "Unaufgelöste `attachment://`-URL erkannt. Sie sollte vor der Einbettungsverarbeitung aufgelöst werden.", "attachments_and_uploads.uploaded_attachment_not_found": "Der hochgeladene Anhang {filename} wurde nicht gefunden.", - "auth_and_oauth.application_not_owned": "Du bist nicht der Eigentümer dieser Anwendung.", "auth_and_oauth.bot_already_in_guild": "Dieser Bot ist bereits in der Community.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Bot-Benutzer können keine Authentifizierungssitzungen erstellen.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Bot-Benutzer können keine Authentifizierungs-Endpunkte verwenden.", diff --git a/packages/errors/src/i18n/weblate/locales/el.json b/packages/errors/src/i18n/weblate/locales/el.json index 614255f77..ef172b68f 100644 --- a/packages/errors/src/i18n/weblate/locales/el.json +++ b/packages/errors/src/i18n/weblate/locales/el.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Πάρα πολλά αρχεία. Επιτρέπονται έως και {maxFiles, plural, one {# αρχείο} other {# αρχεία}}.", "attachments_and_uploads.unresolved_attachment_url": "Βρέθηκε ανεπίλυτη διεύθυνση URL `attachment://`. Θα έπρεπε να είχε επιλυθεί πριν από την επεξεργασία των ενσωματώσεων.", "attachments_and_uploads.uploaded_attachment_not_found": "Το ανεβασμένο συνημμένο {filename} δεν βρέθηκε.", - "auth_and_oauth.application_not_owned": "Δεν είσαι ο ιδιοκτήτης αυτής της εφαρμογής.", "auth_and_oauth.bot_already_in_guild": "Αυτό το bot βρίσκεται ήδη στην κοινότητα.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Οι χρήστες bot δεν μπορούν να δημιουργήσουν συνεδρίες ελέγχου ταυτότητας.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Οι χρήστες bot δεν μπορούν να χρησιμοποιήσουν τα auth endpoints.", diff --git a/packages/errors/src/i18n/weblate/locales/en-GB.json b/packages/errors/src/i18n/weblate/locales/en-GB.json index 814701264..ac88c52ba 100644 --- a/packages/errors/src/i18n/weblate/locales/en-GB.json +++ b/packages/errors/src/i18n/weblate/locales/en-GB.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Too many files. Maximum {maxFiles, plural, one {# file} other {# files}} allowed.", "attachments_and_uploads.unresolved_attachment_url": "Unresolved `attachment://` URL detected. This should have been resolved before embed processing.", "attachments_and_uploads.uploaded_attachment_not_found": "Uploaded attachment {filename} wasn't found.", - "auth_and_oauth.application_not_owned": "You don't own this application.", "auth_and_oauth.bot_already_in_guild": "This bot is already in the community.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Bot users can't create auth sessions.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Bot users can't use auth endpoints.", diff --git a/packages/errors/src/i18n/weblate/locales/es-419.json b/packages/errors/src/i18n/weblate/locales/es-419.json index f0ffa38c7..af31c78b3 100644 --- a/packages/errors/src/i18n/weblate/locales/es-419.json +++ b/packages/errors/src/i18n/weblate/locales/es-419.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Demasiados archivos. Se permite un máximo de {maxFiles, plural, one {# archivo} other {# archivos}}.", "attachments_and_uploads.unresolved_attachment_url": "Se detectó una URL `attachment://` sin resolver. Debería haberse resuelto antes de procesar el contenido incrustado.", "attachments_and_uploads.uploaded_attachment_not_found": "No se encontró el archivo adjunto subido {filename}.", - "auth_and_oauth.application_not_owned": "No eres el propietario de esta aplicación.", "auth_and_oauth.bot_already_in_guild": "Este bot ya está en la comunidad.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Los bots no pueden crear sesiones de autenticación.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Los bots no pueden usar los puntos de conexión de autenticación.", diff --git a/packages/errors/src/i18n/weblate/locales/es-ES.json b/packages/errors/src/i18n/weblate/locales/es-ES.json index 8e1af060b..cbfde8a3d 100644 --- a/packages/errors/src/i18n/weblate/locales/es-ES.json +++ b/packages/errors/src/i18n/weblate/locales/es-ES.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Demasiados archivos. Se permite un máximo de {maxFiles} {maxFiles, plural, one {archivo} other {archivos}}.", "attachments_and_uploads.unresolved_attachment_url": "Se detectó una URL `attachment://` sin resolver. Debería haberse resuelto antes de procesar el contenido incrustado.", "attachments_and_uploads.uploaded_attachment_not_found": "No se encontró el archivo adjunto subido con el nombre {filename}.", - "auth_and_oauth.application_not_owned": "No eres el propietario de esta aplicación.", "auth_and_oauth.bot_already_in_guild": "Este bot ya está en la comunidad.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Los bots no pueden crear sesiones de autenticación.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Los bots no pueden usar los puntos de conexión de autenticación.", diff --git a/packages/errors/src/i18n/weblate/locales/fi.json b/packages/errors/src/i18n/weblate/locales/fi.json index 1bb329166..50b556dfe 100644 --- a/packages/errors/src/i18n/weblate/locales/fi.json +++ b/packages/errors/src/i18n/weblate/locales/fi.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Liian monta tiedostoa. Sallittu enimmäismäärä on {maxFiles, plural, one {# tiedosto} other {# tiedostoa}}.", "attachments_and_uploads.unresolved_attachment_url": "Ratkaisematon `attachment://`-URL havaittu. Se olisi tullut ratkaista ennen upotusten käsittelyä.", "attachments_and_uploads.uploaded_attachment_not_found": "Ladattua liitettä {filename} ei löytynyt.", - "auth_and_oauth.application_not_owned": "Tämä sovellus ei ole sinun.", "auth_and_oauth.bot_already_in_guild": "Tämä botti on jo yhteisössä.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Bottikäyttäjät eivät voi luoda todennusistuntoja.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Bottikäyttäjät eivät voi käyttää todennusrajapintoja.", diff --git a/packages/errors/src/i18n/weblate/locales/fr.json b/packages/errors/src/i18n/weblate/locales/fr.json index 4d8552446..52e3bbd05 100644 --- a/packages/errors/src/i18n/weblate/locales/fr.json +++ b/packages/errors/src/i18n/weblate/locales/fr.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Trop de fichiers. {maxFiles, plural, =1 {Un seul fichier est autorisé.} other {Le nombre maximal de fichiers autorisés est de #.}}", "attachments_and_uploads.unresolved_attachment_url": "Une URL `attachment://` non résolue a été détectée. Elle aurait dû être résolue avant le traitement du contenu intégré.", "attachments_and_uploads.uploaded_attachment_not_found": "La pièce jointe envoyée {filename} n'a pas été trouvée.", - "auth_and_oauth.application_not_owned": "Vous n’êtes pas propriétaire de cette application.", "auth_and_oauth.bot_already_in_guild": "Ce bot est déjà dans la communauté.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Les comptes de bots ne peuvent pas créer de sessions d’authentification.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Les comptes de bots ne peuvent pas utiliser les points d’accès d’authentification.", diff --git a/packages/errors/src/i18n/weblate/locales/he.json b/packages/errors/src/i18n/weblate/locales/he.json index 24d239402..99c5626f0 100644 --- a/packages/errors/src/i18n/weblate/locales/he.json +++ b/packages/errors/src/i18n/weblate/locales/he.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "יותר מדי קבצים. מותרים לכל היותר {maxFiles} {maxFiles, plural, one {קובץ} other {קבצים}}.", "attachments_and_uploads.unresolved_attachment_url": "זוהתה כתובת URL של `attachment://` שלא נפתרה. היה עליה להיפתר לפני עיבוד ההטמעה.", "attachments_and_uploads.uploaded_attachment_not_found": "הקובץ המצורף שהועלה {filename} לא נמצא.", - "auth_and_oauth.application_not_owned": "אינך הבעלים של האפליקציה הזו.", "auth_and_oauth.bot_already_in_guild": "הבוט כבר חבר בקהילה.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "משתמשי בוט אינם יכולים ליצור סשנים של אימות.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "משתמשי בוט אינם יכולים להשתמש בנקודות קצה של אימות.", diff --git a/packages/errors/src/i18n/weblate/locales/hi.json b/packages/errors/src/i18n/weblate/locales/hi.json index 666a3ff1b..252052541 100644 --- a/packages/errors/src/i18n/weblate/locales/hi.json +++ b/packages/errors/src/i18n/weblate/locales/hi.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "बहुत ज़्यादा फ़ाइलें। अधिकतम {maxFiles} {maxFiles, plural, one {फ़ाइल} other {फ़ाइल}} की अनुमति है।", "attachments_and_uploads.unresolved_attachment_url": "अनसुलझा `attachment://` URL पाया गया। इसे एम्बेड प्रोसेसिंग से पहले हल किया जाना चाहिए था।", "attachments_and_uploads.uploaded_attachment_not_found": "अपलोड किया गया अटैचमेंट {filename} नहीं मिला।", - "auth_and_oauth.application_not_owned": "आप इस एप्लीकेशन के मालिक नहीं हैं।", "auth_and_oauth.bot_already_in_guild": "यह बॉट पहले से ही कम्युनिटी में है।", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "बॉट यूज़र ऑथेंटिकेशन सेशन नहीं बना सकते हैं।", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "बॉट यूज़र ऑथेंटिकेशन एंडपॉइंट का इस्तेमाल नहीं कर सकते।", diff --git a/packages/errors/src/i18n/weblate/locales/hr.json b/packages/errors/src/i18n/weblate/locales/hr.json index edcc96148..4ec510a26 100644 --- a/packages/errors/src/i18n/weblate/locales/hr.json +++ b/packages/errors/src/i18n/weblate/locales/hr.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Previše datoteka. Dopušteno je najviše {maxFiles, plural, one {# datoteka} few {# datoteke} other {# datoteka}}.", "attachments_and_uploads.unresolved_attachment_url": "Otkriven je nerazriješeni `attachment://` URL. Trebao je biti razriješen prije obrade ugrađenog sadržaja.", "attachments_and_uploads.uploaded_attachment_not_found": "Preneseni privitak {filename} nije pronađen.", - "auth_and_oauth.application_not_owned": "Nisi vlasnik ove aplikacije.", "auth_and_oauth.bot_already_in_guild": "Ovaj je bot već u zajednici.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Botovi ne mogu stvarati sesije autentifikacije.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Botovi ne mogu koristiti krajnje točke za autentifikaciju.", diff --git a/packages/errors/src/i18n/weblate/locales/hu.json b/packages/errors/src/i18n/weblate/locales/hu.json index be7f4fd23..f9fbf59d5 100644 --- a/packages/errors/src/i18n/weblate/locales/hu.json +++ b/packages/errors/src/i18n/weblate/locales/hu.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Túl sok fájl. Legfeljebb {maxFiles, plural, one {# fájl} other {# fájl}} engedélyezett.", "attachments_and_uploads.unresolved_attachment_url": "Feloldatlan `attachment://` URL található. Ezt a beágyazott tartalom feldolgozása előtt fel kellett volna oldani.", "attachments_and_uploads.uploaded_attachment_not_found": "A feltöltött \"{filename}\" melléklet nem található.", - "auth_and_oauth.application_not_owned": "Ez az alkalmazás nem a tiéd.", "auth_and_oauth.bot_already_in_guild": "Ez a bot már tagja a közösségnek.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "A botfelhasználók nem hozhatnak létre hitelesítési munkameneteket.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "A botfelhasználók nem használhatják a hitelesítési végpontokat.", diff --git a/packages/errors/src/i18n/weblate/locales/id.json b/packages/errors/src/i18n/weblate/locales/id.json index ea4bf3149..421449dfb 100644 --- a/packages/errors/src/i18n/weblate/locales/id.json +++ b/packages/errors/src/i18n/weblate/locales/id.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Terlalu banyak file. Maksimal {maxFiles} {maxFiles, plural, other {file}} diizinkan.", "attachments_and_uploads.unresolved_attachment_url": "Terdeteksi URL `attachment://` yang belum diselesaikan. Ini seharusnya sudah diselesaikan sebelum pemrosesan sematan.", "attachments_and_uploads.uploaded_attachment_not_found": "Lampiran {filename} yang diunggah tidak ditemukan.", - "auth_and_oauth.application_not_owned": "Kamu tidak memiliki aplikasi ini.", "auth_and_oauth.bot_already_in_guild": "Bot ini sudah ada di komunitas.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Pengguna bot tidak bisa membuat sesi autentikasi.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Pengguna bot tidak bisa menggunakan endpoint autentikasi.", diff --git a/packages/errors/src/i18n/weblate/locales/it.json b/packages/errors/src/i18n/weblate/locales/it.json index 8dda99fed..c842f987a 100644 --- a/packages/errors/src/i18n/weblate/locales/it.json +++ b/packages/errors/src/i18n/weblate/locales/it.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Troppi file. Il limite è di {maxFiles, plural, one {# file} other {# file}}.", "attachments_and_uploads.unresolved_attachment_url": "Rilevato URL `attachment://` non risolto. Avrebbe dovuto essere risolto prima dell’elaborazione del contenuto incorporato.", "attachments_and_uploads.uploaded_attachment_not_found": "L'allegato caricato {filename} non è stato trovato.", - "auth_and_oauth.application_not_owned": "Non possiedi questa applicazione.", "auth_and_oauth.bot_already_in_guild": "Questo bot è già nella community.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Gli utenti bot non possono creare sessioni di autenticazione.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Gli utenti bot non possono usare gli endpoint di autenticazione.", diff --git a/packages/errors/src/i18n/weblate/locales/ja.json b/packages/errors/src/i18n/weblate/locales/ja.json index 6ea8512a8..99a54803c 100644 --- a/packages/errors/src/i18n/weblate/locales/ja.json +++ b/packages/errors/src/i18n/weblate/locales/ja.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "ファイルが多すぎます。最大{maxFiles, plural, other {#個}}のファイルまでです。", "attachments_and_uploads.unresolved_attachment_url": "未解決の`attachment://` URLが検出されました。これは埋め込み処理の前に解決されているはずです。", "attachments_and_uploads.uploaded_attachment_not_found": "アップロードされた添付ファイル「{filename}」が見つかりませんでした。", - "auth_and_oauth.application_not_owned": "このアプリケーションの所有者ではありません。", "auth_and_oauth.bot_already_in_guild": "このボットはすでにコミュニティに参加しています。", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "ボットユーザーは認証セッションを作成できません。", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "ボットユーザーは認証エンドポイントを使用できません。", diff --git a/packages/errors/src/i18n/weblate/locales/ko.json b/packages/errors/src/i18n/weblate/locales/ko.json index 402f02aaf..dfe3c99c3 100644 --- a/packages/errors/src/i18n/weblate/locales/ko.json +++ b/packages/errors/src/i18n/weblate/locales/ko.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "파일이 너무 많아요. 최대 {maxFiles, plural, other {#개}}까지 허용돼요.", "attachments_and_uploads.unresolved_attachment_url": "미해결 `attachment://` URL이 감지되었어요. 임베드 처리 전에 해결되었어야 해요.", "attachments_and_uploads.uploaded_attachment_not_found": "업로드한 첨부 파일 {filename}을 찾을 수 없어요.", - "auth_and_oauth.application_not_owned": "이 애플리케이션의 소유자가 아니에요.", "auth_and_oauth.bot_already_in_guild": "이 봇은 이미 커뮤니티에 있어요.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "봇 사용자는 인증 세션을 생성할 수 없어요.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "봇 사용자는 인증 엔드포인트를 사용할 수 없어요.", diff --git a/packages/errors/src/i18n/weblate/locales/lt.json b/packages/errors/src/i18n/weblate/locales/lt.json index 8b5f60cf4..57ebd6ad7 100644 --- a/packages/errors/src/i18n/weblate/locales/lt.json +++ b/packages/errors/src/i18n/weblate/locales/lt.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Per daug failų. Leidžiama daugiausia {maxFiles, plural, one {# failas} few {# failai} many {# failo} other {# failų}}.", "attachments_and_uploads.unresolved_attachment_url": "Aptiktas neišspręstas `attachment://` URL. Jis turėjo būti išspręstas prieš apdorojant įterptuosius elementus.", "attachments_and_uploads.uploaded_attachment_not_found": "Įkeltas priedas {filename} nerastas.", - "auth_and_oauth.application_not_owned": "Tu nesi šios programos savininkas.", "auth_and_oauth.bot_already_in_guild": "Šis botas jau priklauso bendruomenei.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Botai negali kurti prisijungimo seansų.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Botai negali naudoti prisijungimo galinių taškų.", diff --git a/packages/errors/src/i18n/weblate/locales/nl.json b/packages/errors/src/i18n/weblate/locales/nl.json index 35dd4be9f..f6073e6a0 100644 --- a/packages/errors/src/i18n/weblate/locales/nl.json +++ b/packages/errors/src/i18n/weblate/locales/nl.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Te veel bestanden. Maximaal {maxFiles, plural, one {# bestand} other {# bestanden}} toegestaan.", "attachments_and_uploads.unresolved_attachment_url": "Onopgeloste `attachment://`-URL gedetecteerd. Dit had moeten worden opgelost vóór het verwerken van de ingesloten inhoud.", "attachments_and_uploads.uploaded_attachment_not_found": "De geüploade bijlage {filename} is niet gevonden.", - "auth_and_oauth.application_not_owned": "Deze app is niet van jou.", "auth_and_oauth.bot_already_in_guild": "Deze bot is al lid van de community.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Botgebruikers kunnen geen authenticatiesessies aanmaken.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Botgebruikers kunnen geen authenticatie-eindpunten gebruiken.", diff --git a/packages/errors/src/i18n/weblate/locales/no.json b/packages/errors/src/i18n/weblate/locales/no.json index 3a1838420..cde7311b2 100644 --- a/packages/errors/src/i18n/weblate/locales/no.json +++ b/packages/errors/src/i18n/weblate/locales/no.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "For mange filer. Maksimalt {maxFiles, plural, one {# fil} other {# filer}} er tillatt.", "attachments_and_uploads.unresolved_attachment_url": "En uløst `attachment://`-URL ble oppdaget. Denne skulle ha vært løst før behandling av innebygd innhold.", "attachments_and_uploads.uploaded_attachment_not_found": "Det opplastede vedlegget {filename} ble ikke funnet.", - "auth_and_oauth.application_not_owned": "Du eier ikke denne appen.", "auth_and_oauth.bot_already_in_guild": "Denne boten er allerede i fellesskapet.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Botbrukere kan ikke opprette autentiseringsøkter.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Botbrukere kan ikke bruke autentiseringsendepunkter.", diff --git a/packages/errors/src/i18n/weblate/locales/pl.json b/packages/errors/src/i18n/weblate/locales/pl.json index 50c5d36b9..9fde1ecdd 100644 --- a/packages/errors/src/i18n/weblate/locales/pl.json +++ b/packages/errors/src/i18n/weblate/locales/pl.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Zbyt wiele plików. {maxFiles, plural, one {Dozwolony jest maksymalnie # plik.} few {Dozwolone są maksymalnie # pliki.} many {Dozwolonych jest maksymalnie # plików.} other {Dozwolone jest maksymalnie # pliku.}}", "attachments_and_uploads.unresolved_attachment_url": "Wykryto nierozwiązany adres URL `attachment://`. Powinien zostać rozwiązany przed przetwarzaniem elementów osadzonych.", "attachments_and_uploads.uploaded_attachment_not_found": "Nie znaleziono przesłanego załącznika {filename}.", - "auth_and_oauth.application_not_owned": "Nie jesteś właścicielem tej aplikacji.", "auth_and_oauth.bot_already_in_guild": "Ten bot już należy do społeczności.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Konta botów nie mogą tworzyć sesji uwierzytelniania.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Konta botów nie mogą korzystać z punktów końcowych uwierzytelniania.", diff --git a/packages/errors/src/i18n/weblate/locales/pt-BR.json b/packages/errors/src/i18n/weblate/locales/pt-BR.json index 3501d6efe..e116014b1 100644 --- a/packages/errors/src/i18n/weblate/locales/pt-BR.json +++ b/packages/errors/src/i18n/weblate/locales/pt-BR.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Há arquivos demais. O limite é de {maxFiles, plural, =1 {# arquivo} other {# arquivos}}.", "attachments_and_uploads.unresolved_attachment_url": "URL `attachment://` não resolvida detectada. Isso deveria ter sido resolvido antes do processamento de incorporação.", "attachments_and_uploads.uploaded_attachment_not_found": "O anexo enviado {filename} não foi encontrado.", - "auth_and_oauth.application_not_owned": "Você não é o dono deste aplicativo.", "auth_and_oauth.bot_already_in_guild": "Este bot já está nesta comunidade.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Usuários de bot não podem criar sessões de autenticação.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Bots não podem usar endpoints de autenticação.", diff --git a/packages/errors/src/i18n/weblate/locales/ro.json b/packages/errors/src/i18n/weblate/locales/ro.json index fdd51026c..c56e2c0c2 100644 --- a/packages/errors/src/i18n/weblate/locales/ro.json +++ b/packages/errors/src/i18n/weblate/locales/ro.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Prea multe fișiere. Sunt permise maximum {maxFiles, plural, one {# fișier} few {# fișiere} other {# de fișiere}}.", "attachments_and_uploads.unresolved_attachment_url": "S-a detectat o adresă URL `attachment://` nerezolvată. Aceasta ar fi trebuit rezolvată înainte de procesarea încorporărilor.", "attachments_and_uploads.uploaded_attachment_not_found": "Atașamentul încărcat {filename} nu a fost găsit.", - "auth_and_oauth.application_not_owned": "Nu deții această aplicație.", "auth_and_oauth.bot_already_in_guild": "Acest bot este deja în comunitate.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Utilizatorii bot nu pot crea sesiuni de autentificare.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Utilizatorii bot nu pot utiliza endpoint-uri de autentificare.", diff --git a/packages/errors/src/i18n/weblate/locales/ru.json b/packages/errors/src/i18n/weblate/locales/ru.json index 722937d09..03cbfd8d0 100644 --- a/packages/errors/src/i18n/weblate/locales/ru.json +++ b/packages/errors/src/i18n/weblate/locales/ru.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Слишком много файлов. Разрешено максимум {maxFiles} {maxFiles, plural, one {файл} few {файла} many {файлов} other {файла}}.", "attachments_and_uploads.unresolved_attachment_url": "Обнаружен неразрешённый URL `attachment://`. Он должен был быть разрешён до обработки встраивания.", "attachments_and_uploads.uploaded_attachment_not_found": "Не удалось найти загруженное вложение {filename}.", - "auth_and_oauth.application_not_owned": "Ты не владелец этого приложения.", "auth_and_oauth.bot_already_in_guild": "Этот бот уже находится в сообществе.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Пользователи-боты не могут создавать сессии авторизации.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Пользователи-боты не могут использовать эндпоинты авторизации.", diff --git a/packages/errors/src/i18n/weblate/locales/sv-SE.json b/packages/errors/src/i18n/weblate/locales/sv-SE.json index 532875a0d..89a48469d 100644 --- a/packages/errors/src/i18n/weblate/locales/sv-SE.json +++ b/packages/errors/src/i18n/weblate/locales/sv-SE.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "För många filer. Högst {maxFiles, plural, one {# fil} other {# filer}} tillåts.", "attachments_and_uploads.unresolved_attachment_url": "En olöst `attachment://`-URL upptäcktes. Detta skulle ha lösts innan inbäddningarna behandlades.", "attachments_and_uploads.uploaded_attachment_not_found": "Den uppladdade bilagan {filename} hittades inte.", - "auth_and_oauth.application_not_owned": "Du äger inte denna applikation.", "auth_and_oauth.bot_already_in_guild": "Denna bot finns redan i communityn.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Botanvändare kan inte skapa inloggningssessioner.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Botanvändare kan inte använda autentiseringsslutpunkter.", diff --git a/packages/errors/src/i18n/weblate/locales/th.json b/packages/errors/src/i18n/weblate/locales/th.json index 9e52fa843..8bb984159 100644 --- a/packages/errors/src/i18n/weblate/locales/th.json +++ b/packages/errors/src/i18n/weblate/locales/th.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "ไฟล์มากเกินไป อนุญาตสูงสุด {maxFiles, plural, other {# ไฟล์}}", "attachments_and_uploads.unresolved_attachment_url": "ตรวจพบ URL `attachment://` ที่ยังไม่ได้แปลงค่า ควรแปลงค่าก่อนประมวลผลเอมเบด", "attachments_and_uploads.uploaded_attachment_not_found": "ไม่พบไฟล์แนบที่อัปโหลด {filename}", - "auth_and_oauth.application_not_owned": "คุณไม่ได้เป็นเจ้าของแอปพลิเคชันนี้", "auth_and_oauth.bot_already_in_guild": "บอทนี้อยู่ในคอมมูนิตี้แล้ว", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "ผู้ใช้บอทไม่สามารถสร้างเซสชันยืนยันตัวตนได้", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "ผู้ใช้บอทไม่สามารถใช้ปลายทางยืนยันตัวตนได้", diff --git a/packages/errors/src/i18n/weblate/locales/tr.json b/packages/errors/src/i18n/weblate/locales/tr.json index 28fdbf368..3a21630ba 100644 --- a/packages/errors/src/i18n/weblate/locales/tr.json +++ b/packages/errors/src/i18n/weblate/locales/tr.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Çok fazla dosya. En fazla {maxFiles, plural, one {# dosya} other {# dosya}} yükleyebilirsin.", "attachments_and_uploads.unresolved_attachment_url": "Çözülmemiş `attachment://` URL'si algılandı. Bu, gömülü içerik işlenmeden önce çözülmeliydi.", "attachments_and_uploads.uploaded_attachment_not_found": "Yüklenen {filename} eki bulunamadı.", - "auth_and_oauth.application_not_owned": "Bu uygulamanın sahibi değilsin.", "auth_and_oauth.bot_already_in_guild": "Bu bot zaten toplulukta.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Bot kullanıcıları kimlik doğrulama oturumu oluşturamaz.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Bot kullanıcıları kimlik doğrulama uç noktalarını kullanamaz.", diff --git a/packages/errors/src/i18n/weblate/locales/uk.json b/packages/errors/src/i18n/weblate/locales/uk.json index 32b1aaeb2..62360f77b 100644 --- a/packages/errors/src/i18n/weblate/locales/uk.json +++ b/packages/errors/src/i18n/weblate/locales/uk.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Забагато файлів. Дозволено максимум {maxFiles} {maxFiles, plural, one {файл} few {файли} many {файлів} other {файла}}.", "attachments_and_uploads.unresolved_attachment_url": "Виявлено нерозв'язану URL-адресу `attachment://`. Її потрібно було розв'язати до обробки ембедів.", "attachments_and_uploads.uploaded_attachment_not_found": "Завантажене вкладення {filename} не знайдено.", - "auth_and_oauth.application_not_owned": "Ти не власник цього застосунку.", "auth_and_oauth.bot_already_in_guild": "Цей бот уже є в спільноті.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Боти не можуть створювати сеанси авторизації.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Боти не можуть використовувати кінцеві точки авторизації.", diff --git a/packages/errors/src/i18n/weblate/locales/vi.json b/packages/errors/src/i18n/weblate/locales/vi.json index 262ed55de..4d080acc4 100644 --- a/packages/errors/src/i18n/weblate/locales/vi.json +++ b/packages/errors/src/i18n/weblate/locales/vi.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Quá nhiều tệp. Chỉ cho phép tối đa {maxFiles} {maxFiles, plural, other {tệp}}.", "attachments_and_uploads.unresolved_attachment_url": "Phát hiện URL `attachment://` chưa được phân giải. URL này lẽ ra phải được phân giải trước khi xử lý embed.", "attachments_and_uploads.uploaded_attachment_not_found": "Không tìm thấy tệp đính kèm đã tải lên {filename}.", - "auth_and_oauth.application_not_owned": "Bạn không phải chủ sở hữu của ứng dụng này.", "auth_and_oauth.bot_already_in_guild": "Bot này đã có trong cộng đồng.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Người dùng bot không thể tạo phiên xác thực.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Người dùng bot không thể sử dụng điểm cuối xác thực.", diff --git a/packages/errors/src/i18n/weblate/locales/zh-CN.json b/packages/errors/src/i18n/weblate/locales/zh-CN.json index ae5341a99..9cf772978 100644 --- a/packages/errors/src/i18n/weblate/locales/zh-CN.json +++ b/packages/errors/src/i18n/weblate/locales/zh-CN.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "文件过多。最多允许 {maxFiles, plural, other {# 个文件}}。", "attachments_and_uploads.unresolved_attachment_url": "检测到未解析的 `attachment://` URL。此 URL 应在嵌入处理前解析。", "attachments_and_uploads.uploaded_attachment_not_found": "未找到上传的附件 {filename}。", - "auth_and_oauth.application_not_owned": "你不是此应用的拥有者。", "auth_and_oauth.bot_already_in_guild": "此机器人已在社区中。", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "机器人用户无法创建身份验证会话。", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "机器人用户不能使用身份验证端点。", diff --git a/packages/errors/src/i18n/weblate/locales/zh-TW.json b/packages/errors/src/i18n/weblate/locales/zh-TW.json index 651ecbd87..7d7e77091 100644 --- a/packages/errors/src/i18n/weblate/locales/zh-TW.json +++ b/packages/errors/src/i18n/weblate/locales/zh-TW.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "檔案過多。最多允許 {maxFiles, plural, other {# 個檔案}}。", "attachments_and_uploads.unresolved_attachment_url": "偵測到未解析的 `attachment://` URL。這應該在嵌入處理之前完成解析。", "attachments_and_uploads.uploaded_attachment_not_found": "找不到已上傳的附件 {filename}。", - "auth_and_oauth.application_not_owned": "你沒有此應用程式的擁有權。", "auth_and_oauth.bot_already_in_guild": "此機器人已在社群中。", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "機器人無法建立驗證工作階段。", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "機器人無法使用驗證端點。", diff --git a/packages/errors/src/i18n/weblate/messages.json b/packages/errors/src/i18n/weblate/messages.json index 95615ade4..1dbd6cdf8 100644 --- a/packages/errors/src/i18n/weblate/messages.json +++ b/packages/errors/src/i18n/weblate/messages.json @@ -67,7 +67,6 @@ "attachments_and_uploads.too_many_files": "Too many files. Maximum {maxFiles, plural, one {# file} other {# files}} allowed.", "attachments_and_uploads.unresolved_attachment_url": "Unresolved `attachment://` URL detected. This should have been resolved before embed processing.", "attachments_and_uploads.uploaded_attachment_not_found": "Uploaded attachment {filename} wasn't found.", - "auth_and_oauth.application_not_owned": "You don't own this application.", "auth_and_oauth.bot_already_in_guild": "This bot is already in the community.", "auth_and_oauth.bot_users_cannot_create_auth_sessions": "Bot users can't create auth sessions.", "auth_and_oauth.bot_users_cannot_use_auth_endpoints": "Bot users can't use auth endpoints.", diff --git a/packages/fonts/NOTICE.md b/packages/fonts/NOTICE.md index ac2b094ea..1c2d3a9c6 100644 --- a/packages/fonts/NOTICE.md +++ b/packages/fonts/NOTICE.md @@ -20,7 +20,6 @@ were changed: | IBM Plex Sans Arabic | Fluxer Sans Arabic | | IBM Plex Sans Devanagari | Fluxer Sans Devanagari | | IBM Plex Sans Hebrew | Fluxer Sans Hebrew | -| IBM Plex Sans Thai | Fluxer Sans Thai | | IBM Plex Sans Thai Looped | Fluxer Sans Thai Looped | | IBM Plex Sans JP | Fluxer Sans JP | | IBM Plex Sans KR | Fluxer Sans KR | diff --git a/packages/fonts/css/script-thai.css b/packages/fonts/css/script-thai.css deleted file mode 100644 index bc879bfcd..000000000 --- a/packages/fonts/css/script-thai.css +++ /dev/null @@ -1,67 +0,0 @@ -/* SPDX-License-Identifier: AGPL-3.0-or-later */ -/* Generated by tools/fonts/build_fonts.py. Do not edit by hand. */ - -@font-face { - font-family: 'Fluxer Sans Thai'; - src: url('../files/FluxerSansThai/FluxerSansThai-Thin.woff2') format('woff2'); - font-weight: 100; - font-style: normal; - font-display: swap; - unicode-range: U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0110, U+0131-0133, U+0135, U+0140-0142, U+0152-0153, U+0160-0161, U+0178, U+017D-017E, U+0192, U+0237, U+02BB-02BC, U+02C6-02C7, U+02D7-02DD, U+0303, U+0331, U+034F, U+03C0, U+0E01-0E3A, U+0E3F-0E5B, U+1E9E, U+200B-200D, U+2013-2014, U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+2060, U+2070, U+2074-2079, U+2080-2089, U+20A1, U+20A4, U+20A6, U+20A8-20AE, U+20B1-20B2, U+20B4-20B5, U+20B8-20BA, U+20BD, U+20BF, U+2113, U+2116, U+2122, U+2126, U+212E, U+2150-2151, U+2153-215E, U+2190-2199, U+21A9-21AA, U+21B0-21B3, U+21B6-21B7, U+21BA-21BB, U+21C4, U+21C6, U+2202, U+2206, U+220F, U+2211-2212, U+2215, U+221A, U+221E, U+222B, U+2248, U+2260, U+2264-2265, U+25CA, U+25CC, U+2638, U+2713, U+274C, U+2B0E-2B11, U+EC00-EC0B, U+EC10-EC12, U+ECE0, U+EFCC, U+FB01-FB02; -} -@font-face { - font-family: 'Fluxer Sans Thai'; - src: url('../files/FluxerSansThai/FluxerSansThai-ExtraLight.woff2') format('woff2'); - font-weight: 200; - font-style: normal; - font-display: swap; - unicode-range: U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0110, U+0131-0133, U+0135, U+0140-0142, U+0152-0153, U+0160-0161, U+0178, U+017D-017E, U+0192, U+0237, U+02BB-02BC, U+02C6-02C7, U+02D7-02DD, U+0303, U+0331, U+034F, U+03C0, U+0E01-0E3A, U+0E3F-0E5B, U+1E9E, U+200B-200D, U+2013-2014, U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+2060, U+2070, U+2074-2079, U+2080-2089, U+20A1, U+20A4, U+20A6, U+20A8-20AE, U+20B1-20B2, U+20B4-20B5, U+20B8-20BA, U+20BD, U+20BF, U+2113, U+2116, U+2122, U+2126, U+212E, U+2150-2151, U+2153-215E, U+2190-2199, U+21A9-21AA, U+21B0-21B3, U+21B6-21B7, U+21BA-21BB, U+21C4, U+21C6, U+2202, U+2206, U+220F, U+2211-2212, U+2215, U+221A, U+221E, U+222B, U+2248, U+2260, U+2264-2265, U+25CA, U+25CC, U+2638, U+2713, U+274C, U+2B0E-2B11, U+EC00-EC0B, U+EC10-EC12, U+ECE0, U+EFCC, U+FB01-FB02; -} -@font-face { - font-family: 'Fluxer Sans Thai'; - src: url('../files/FluxerSansThai/FluxerSansThai-Light.woff2') format('woff2'); - font-weight: 300; - font-style: normal; - font-display: swap; - unicode-range: U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0110, U+0131-0133, U+0135, U+0140-0142, U+0152-0153, U+0160-0161, U+0178, U+017D-017E, U+0192, U+0237, U+02BB-02BC, U+02C6-02C7, U+02D7-02DD, U+0303, U+0331, U+034F, U+03C0, U+0E01-0E3A, U+0E3F-0E5B, U+1E9E, U+200B-200D, U+2013-2014, U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+2060, U+2070, U+2074-2079, U+2080-2089, U+20A1, U+20A4, U+20A6, U+20A8-20AE, U+20B1-20B2, U+20B4-20B5, U+20B8-20BA, U+20BD, U+20BF, U+2113, U+2116, U+2122, U+2126, U+212E, U+2150-2151, U+2153-215E, U+2190-2199, U+21A9-21AA, U+21B0-21B3, U+21B6-21B7, U+21BA-21BB, U+21C4, U+21C6, U+2202, U+2206, U+220F, U+2211-2212, U+2215, U+221A, U+221E, U+222B, U+2248, U+2260, U+2264-2265, U+25CA, U+25CC, U+2638, U+2713, U+274C, U+2B0E-2B11, U+EC00-EC0B, U+EC10-EC12, U+ECE0, U+EFCC, U+FB01-FB02; -} -@font-face { - font-family: 'Fluxer Sans Thai'; - src: url('../files/FluxerSansThai/FluxerSansThai-Regular.woff2') format('woff2'); - font-weight: 400; - font-style: normal; - font-display: swap; - unicode-range: U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0110, U+0131-0133, U+0135, U+0140-0142, U+0152-0153, U+0160-0161, U+0178, U+017D-017E, U+0192, U+0237, U+02BB-02BC, U+02C6-02C7, U+02D7-02DD, U+0303, U+0331, U+034F, U+03C0, U+0E01-0E3A, U+0E3F-0E5B, U+1E9E, U+200B-200D, U+2013-2014, U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+2060, U+2070, U+2074-2079, U+2080-2089, U+20A1, U+20A4, U+20A6, U+20A8-20AE, U+20B1-20B2, U+20B4-20B5, U+20B8-20BA, U+20BD, U+20BF, U+2113, U+2116, U+2122, U+2126, U+212E, U+2150-2151, U+2153-215E, U+2190-2199, U+21A9-21AA, U+21B0-21B3, U+21B6-21B7, U+21BA-21BB, U+21C4, U+21C6, U+2202, U+2206, U+220F, U+2211-2212, U+2215, U+221A, U+221E, U+222B, U+2248, U+2260, U+2264-2265, U+25CA, U+25CC, U+2638, U+2713, U+274C, U+2B0E-2B11, U+EC00-EC0B, U+EC10-EC12, U+ECE0, U+EFCC, U+FB01-FB02; -} -@font-face { - font-family: 'Fluxer Sans Thai'; - src: url('../files/FluxerSansThai/FluxerSansThai-Text.woff2') format('woff2'); - font-weight: 450; - font-style: normal; - font-display: swap; - unicode-range: U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0110, U+0131-0133, U+0135, U+0140-0142, U+0152-0153, U+0160-0161, U+0178, U+017D-017E, U+0192, U+0237, U+02BB-02BC, U+02C6-02C7, U+02D7-02DD, U+0303, U+0331, U+034F, U+03C0, U+0E01-0E3A, U+0E3F-0E5B, U+1E9E, U+200B-200D, U+2013-2014, U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+2060, U+2070, U+2074-2079, U+2080-2089, U+20A1, U+20A4, U+20A6, U+20A8-20AE, U+20B1-20B2, U+20B4-20B5, U+20B8-20BA, U+20BD, U+20BF, U+2113, U+2116, U+2122, U+2126, U+212E, U+2150-2151, U+2153-215E, U+2190-2199, U+21A9-21AA, U+21B0-21B3, U+21B6-21B7, U+21BA-21BB, U+21C4, U+21C6, U+2202, U+2206, U+220F, U+2211-2212, U+2215, U+221A, U+221E, U+222B, U+2248, U+2260, U+2264-2265, U+25CA, U+25CC, U+2638, U+2713, U+274C, U+2B0E-2B11, U+EC00-EC0B, U+EC10-EC12, U+ECE0, U+EFCC, U+FB01-FB02; -} -@font-face { - font-family: 'Fluxer Sans Thai'; - src: url('../files/FluxerSansThai/FluxerSansThai-Medium.woff2') format('woff2'); - font-weight: 500; - font-style: normal; - font-display: swap; - unicode-range: U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0110, U+0131-0133, U+0135, U+0140-0142, U+0152-0153, U+0160-0161, U+0178, U+017D-017E, U+0192, U+0237, U+02BB-02BC, U+02C6-02C7, U+02D7-02DD, U+0303, U+0331, U+034F, U+03C0, U+0E01-0E3A, U+0E3F-0E5B, U+1E9E, U+200B-200D, U+2013-2014, U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+2060, U+2070, U+2074-2079, U+2080-2089, U+20A1, U+20A4, U+20A6, U+20A8-20AE, U+20B1-20B2, U+20B4-20B5, U+20B8-20BA, U+20BD, U+20BF, U+2113, U+2116, U+2122, U+2126, U+212E, U+2150-2151, U+2153-215E, U+2190-2199, U+21A9-21AA, U+21B0-21B3, U+21B6-21B7, U+21BA-21BB, U+21C4, U+21C6, U+2202, U+2206, U+220F, U+2211-2212, U+2215, U+221A, U+221E, U+222B, U+2248, U+2260, U+2264-2265, U+25CA, U+25CC, U+2638, U+2713, U+274C, U+2B0E-2B11, U+EC00-EC0B, U+EC10-EC12, U+ECE0, U+EFCC, U+FB01-FB02; -} -@font-face { - font-family: 'Fluxer Sans Thai'; - src: url('../files/FluxerSansThai/FluxerSansThai-SemiBold.woff2') format('woff2'); - font-weight: 600; - font-style: normal; - font-display: swap; - unicode-range: U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0110, U+0131-0133, U+0135, U+0140-0142, U+0152-0153, U+0160-0161, U+0178, U+017D-017E, U+0192, U+0237, U+02BB-02BC, U+02C6-02C7, U+02D7-02DD, U+0303, U+0331, U+034F, U+03C0, U+0E01-0E3A, U+0E3F-0E5B, U+1E9E, U+200B-200D, U+2013-2014, U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+2060, U+2070, U+2074-2079, U+2080-2089, U+20A1, U+20A4, U+20A6, U+20A8-20AE, U+20B1-20B2, U+20B4-20B5, U+20B8-20BA, U+20BD, U+20BF, U+2113, U+2116, U+2122, U+2126, U+212E, U+2150-2151, U+2153-215E, U+2190-2199, U+21A9-21AA, U+21B0-21B3, U+21B6-21B7, U+21BA-21BB, U+21C4, U+21C6, U+2202, U+2206, U+220F, U+2211-2212, U+2215, U+221A, U+221E, U+222B, U+2248, U+2260, U+2264-2265, U+25CA, U+25CC, U+2638, U+2713, U+274C, U+2B0E-2B11, U+EC00-EC0B, U+EC10-EC12, U+ECE0, U+EFCC, U+FB01-FB02; -} -@font-face { - font-family: 'Fluxer Sans Thai'; - src: url('../files/FluxerSansThai/FluxerSansThai-Bold.woff2') format('woff2'); - font-weight: 700; - font-style: normal; - font-display: swap; - unicode-range: U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0110, U+0131-0133, U+0135, U+0140-0142, U+0152-0153, U+0160-0161, U+0178, U+017D-017E, U+0192, U+0237, U+02BB-02BC, U+02C6-02C7, U+02D7-02DD, U+0303, U+0331, U+034F, U+03C0, U+0E01-0E3A, U+0E3F-0E5B, U+1E9E, U+200B-200D, U+2013-2014, U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+2060, U+2070, U+2074-2079, U+2080-2089, U+20A1, U+20A4, U+20A6, U+20A8-20AE, U+20B1-20B2, U+20B4-20B5, U+20B8-20BA, U+20BD, U+20BF, U+2113, U+2116, U+2122, U+2126, U+212E, U+2150-2151, U+2153-215E, U+2190-2199, U+21A9-21AA, U+21B0-21B3, U+21B6-21B7, U+21BA-21BB, U+21C4, U+21C6, U+2202, U+2206, U+220F, U+2211-2212, U+2215, U+221A, U+221E, U+222B, U+2248, U+2260, U+2264-2265, U+25CA, U+25CC, U+2638, U+2713, U+274C, U+2B0E-2B11, U+EC00-EC0B, U+EC10-EC12, U+ECE0, U+EFCC, U+FB01-FB02; -} diff --git a/packages/fonts/files/FluxerSansThai/FluxerSansThai-Bold.woff2 b/packages/fonts/files/FluxerSansThai/FluxerSansThai-Bold.woff2 deleted file mode 100644 index 3b5bb1ddd..000000000 Binary files a/packages/fonts/files/FluxerSansThai/FluxerSansThai-Bold.woff2 and /dev/null differ diff --git a/packages/fonts/files/FluxerSansThai/FluxerSansThai-ExtraLight.woff2 b/packages/fonts/files/FluxerSansThai/FluxerSansThai-ExtraLight.woff2 deleted file mode 100644 index 11bcecdca..000000000 Binary files a/packages/fonts/files/FluxerSansThai/FluxerSansThai-ExtraLight.woff2 and /dev/null differ diff --git a/packages/fonts/files/FluxerSansThai/FluxerSansThai-Light.woff2 b/packages/fonts/files/FluxerSansThai/FluxerSansThai-Light.woff2 deleted file mode 100644 index 055e27abc..000000000 Binary files a/packages/fonts/files/FluxerSansThai/FluxerSansThai-Light.woff2 and /dev/null differ diff --git a/packages/fonts/files/FluxerSansThai/FluxerSansThai-Medium.woff2 b/packages/fonts/files/FluxerSansThai/FluxerSansThai-Medium.woff2 deleted file mode 100644 index 026646b29..000000000 Binary files a/packages/fonts/files/FluxerSansThai/FluxerSansThai-Medium.woff2 and /dev/null differ diff --git a/packages/fonts/files/FluxerSansThai/FluxerSansThai-Regular.woff2 b/packages/fonts/files/FluxerSansThai/FluxerSansThai-Regular.woff2 deleted file mode 100644 index 8b7ce8a2d..000000000 Binary files a/packages/fonts/files/FluxerSansThai/FluxerSansThai-Regular.woff2 and /dev/null differ diff --git a/packages/fonts/files/FluxerSansThai/FluxerSansThai-SemiBold.woff2 b/packages/fonts/files/FluxerSansThai/FluxerSansThai-SemiBold.woff2 deleted file mode 100644 index 341455f75..000000000 Binary files a/packages/fonts/files/FluxerSansThai/FluxerSansThai-SemiBold.woff2 and /dev/null differ diff --git a/packages/fonts/files/FluxerSansThai/FluxerSansThai-Text.woff2 b/packages/fonts/files/FluxerSansThai/FluxerSansThai-Text.woff2 deleted file mode 100644 index 10a2a2ec2..000000000 Binary files a/packages/fonts/files/FluxerSansThai/FluxerSansThai-Text.woff2 and /dev/null differ diff --git a/packages/fonts/files/FluxerSansThai/FluxerSansThai-Thin.woff2 b/packages/fonts/files/FluxerSansThai/FluxerSansThai-Thin.woff2 deleted file mode 100644 index a23e47c66..000000000 Binary files a/packages/fonts/files/FluxerSansThai/FluxerSansThai-Thin.woff2 and /dev/null differ diff --git a/packages/fonts/manifest.json b/packages/fonts/manifest.json index 5bb8e5c87..d136d534b 100644 --- a/packages/fonts/manifest.json +++ b/packages/fonts/manifest.json @@ -11,7 +11,6 @@ "Fluxer Sans Arabic": "css/script-arabic.css", "Fluxer Sans Devanagari": "css/script-devanagari.css", "Fluxer Sans Hebrew": "css/script-hebrew.css", - "Fluxer Sans Thai": "css/script-thai.css", "Fluxer Sans Thai Looped": "css/script-thai-looped.css", "Fluxer Sans JP": "css/script-jp.css", "Fluxer Sans SC": "css/script-sc.css", @@ -462,72 +461,6 @@ } ] }, - { - "id": "FluxerSansThai", - "cssFamily": "Fluxer Sans Thai", - "stylesheet": "css/script-thai.css", - "mode": "rebuild", - "latinCore": false, - "chunks": 1, - "faces": [ - { - "file": "FluxerSansThai/FluxerSansThai-Thin.woff2", - "weight": 100, - "style": "normal", - "unicodeRange": "U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0110, U+0131-0133, U+0135, U+0140-0142, U+0152-0153, U+0160-0161, U+0178, U+017D-017E, U+0192, U+0237, U+02BB-02BC, U+02C6-02C7, U+02D7-02DD, U+0303, U+0331, U+034F, U+03C0, U+0E01-0E3A, U+0E3F-0E5B, U+1E9E, U+200B-200D, U+2013-2014, U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+2060, U+2070, U+2074-2079, U+2080-2089, U+20A1, U+20A4, U+20A6, U+20A8-20AE, U+20B1-20B2, U+20B4-20B5, U+20B8-20BA, U+20BD, U+20BF, U+2113, U+2116, U+2122, U+2126, U+212E, U+2150-2151, U+2153-215E, U+2190-2199, U+21A9-21AA, U+21B0-21B3, U+21B6-21B7, U+21BA-21BB, U+21C4, U+21C6, U+2202, U+2206, U+220F, U+2211-2212, U+2215, U+221A, U+221E, U+222B, U+2248, U+2260, U+2264-2265, U+25CA, U+25CC, U+2638, U+2713, U+274C, U+2B0E-2B11, U+EC00-EC0B, U+EC10-EC12, U+ECE0, U+EFCC, U+FB01-FB02", - "bytes": 40916 - }, - { - "file": "FluxerSansThai/FluxerSansThai-ExtraLight.woff2", - "weight": 200, - "style": "normal", - "unicodeRange": "U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0110, U+0131-0133, U+0135, U+0140-0142, U+0152-0153, U+0160-0161, U+0178, U+017D-017E, U+0192, U+0237, U+02BB-02BC, U+02C6-02C7, U+02D7-02DD, U+0303, U+0331, U+034F, U+03C0, U+0E01-0E3A, U+0E3F-0E5B, U+1E9E, U+200B-200D, U+2013-2014, U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+2060, U+2070, U+2074-2079, U+2080-2089, U+20A1, U+20A4, U+20A6, U+20A8-20AE, U+20B1-20B2, U+20B4-20B5, U+20B8-20BA, U+20BD, U+20BF, U+2113, U+2116, U+2122, U+2126, U+212E, U+2150-2151, U+2153-215E, U+2190-2199, U+21A9-21AA, U+21B0-21B3, U+21B6-21B7, U+21BA-21BB, U+21C4, U+21C6, U+2202, U+2206, U+220F, U+2211-2212, U+2215, U+221A, U+221E, U+222B, U+2248, U+2260, U+2264-2265, U+25CA, U+25CC, U+2638, U+2713, U+274C, U+2B0E-2B11, U+EC00-EC0B, U+EC10-EC12, U+ECE0, U+EFCC, U+FB01-FB02", - "bytes": 42184 - }, - { - "file": "FluxerSansThai/FluxerSansThai-Light.woff2", - "weight": 300, - "style": "normal", - "unicodeRange": "U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0110, U+0131-0133, U+0135, U+0140-0142, U+0152-0153, U+0160-0161, U+0178, U+017D-017E, U+0192, U+0237, U+02BB-02BC, U+02C6-02C7, U+02D7-02DD, U+0303, U+0331, U+034F, U+03C0, U+0E01-0E3A, U+0E3F-0E5B, U+1E9E, U+200B-200D, U+2013-2014, U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+2060, U+2070, U+2074-2079, U+2080-2089, U+20A1, U+20A4, U+20A6, U+20A8-20AE, U+20B1-20B2, U+20B4-20B5, U+20B8-20BA, U+20BD, U+20BF, U+2113, U+2116, U+2122, U+2126, U+212E, U+2150-2151, U+2153-215E, U+2190-2199, U+21A9-21AA, U+21B0-21B3, U+21B6-21B7, U+21BA-21BB, U+21C4, U+21C6, U+2202, U+2206, U+220F, U+2211-2212, U+2215, U+221A, U+221E, U+222B, U+2248, U+2260, U+2264-2265, U+25CA, U+25CC, U+2638, U+2713, U+274C, U+2B0E-2B11, U+EC00-EC0B, U+EC10-EC12, U+ECE0, U+EFCC, U+FB01-FB02", - "bytes": 41652 - }, - { - "file": "FluxerSansThai/FluxerSansThai-Regular.woff2", - "weight": 400, - "style": "normal", - "unicodeRange": "U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0110, U+0131-0133, U+0135, U+0140-0142, U+0152-0153, U+0160-0161, U+0178, U+017D-017E, U+0192, U+0237, U+02BB-02BC, U+02C6-02C7, U+02D7-02DD, U+0303, U+0331, U+034F, U+03C0, U+0E01-0E3A, U+0E3F-0E5B, U+1E9E, U+200B-200D, U+2013-2014, U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+2060, U+2070, U+2074-2079, U+2080-2089, U+20A1, U+20A4, U+20A6, U+20A8-20AE, U+20B1-20B2, U+20B4-20B5, U+20B8-20BA, U+20BD, U+20BF, U+2113, U+2116, U+2122, U+2126, U+212E, U+2150-2151, U+2153-215E, U+2190-2199, U+21A9-21AA, U+21B0-21B3, U+21B6-21B7, U+21BA-21BB, U+21C4, U+21C6, U+2202, U+2206, U+220F, U+2211-2212, U+2215, U+221A, U+221E, U+222B, U+2248, U+2260, U+2264-2265, U+25CA, U+25CC, U+2638, U+2713, U+274C, U+2B0E-2B11, U+EC00-EC0B, U+EC10-EC12, U+ECE0, U+EFCC, U+FB01-FB02", - "bytes": 39840 - }, - { - "file": "FluxerSansThai/FluxerSansThai-Text.woff2", - "weight": 450, - "style": "normal", - "unicodeRange": "U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0110, U+0131-0133, U+0135, U+0140-0142, U+0152-0153, U+0160-0161, U+0178, U+017D-017E, U+0192, U+0237, U+02BB-02BC, U+02C6-02C7, U+02D7-02DD, U+0303, U+0331, U+034F, U+03C0, U+0E01-0E3A, U+0E3F-0E5B, U+1E9E, U+200B-200D, U+2013-2014, U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+2060, U+2070, U+2074-2079, U+2080-2089, U+20A1, U+20A4, U+20A6, U+20A8-20AE, U+20B1-20B2, U+20B4-20B5, U+20B8-20BA, U+20BD, U+20BF, U+2113, U+2116, U+2122, U+2126, U+212E, U+2150-2151, U+2153-215E, U+2190-2199, U+21A9-21AA, U+21B0-21B3, U+21B6-21B7, U+21BA-21BB, U+21C4, U+21C6, U+2202, U+2206, U+220F, U+2211-2212, U+2215, U+221A, U+221E, U+222B, U+2248, U+2260, U+2264-2265, U+25CA, U+25CC, U+2638, U+2713, U+274C, U+2B0E-2B11, U+EC00-EC0B, U+EC10-EC12, U+ECE0, U+EFCC, U+FB01-FB02", - "bytes": 41008 - }, - { - "file": "FluxerSansThai/FluxerSansThai-Medium.woff2", - "weight": 500, - "style": "normal", - "unicodeRange": "U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0110, U+0131-0133, U+0135, U+0140-0142, U+0152-0153, U+0160-0161, U+0178, U+017D-017E, U+0192, U+0237, U+02BB-02BC, U+02C6-02C7, U+02D7-02DD, U+0303, U+0331, U+034F, U+03C0, U+0E01-0E3A, U+0E3F-0E5B, U+1E9E, U+200B-200D, U+2013-2014, U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+2060, U+2070, U+2074-2079, U+2080-2089, U+20A1, U+20A4, U+20A6, U+20A8-20AE, U+20B1-20B2, U+20B4-20B5, U+20B8-20BA, U+20BD, U+20BF, U+2113, U+2116, U+2122, U+2126, U+212E, U+2150-2151, U+2153-215E, U+2190-2199, U+21A9-21AA, U+21B0-21B3, U+21B6-21B7, U+21BA-21BB, U+21C4, U+21C6, U+2202, U+2206, U+220F, U+2211-2212, U+2215, U+221A, U+221E, U+222B, U+2248, U+2260, U+2264-2265, U+25CA, U+25CC, U+2638, U+2713, U+274C, U+2B0E-2B11, U+EC00-EC0B, U+EC10-EC12, U+ECE0, U+EFCC, U+FB01-FB02", - "bytes": 41068 - }, - { - "file": "FluxerSansThai/FluxerSansThai-SemiBold.woff2", - "weight": 600, - "style": "normal", - "unicodeRange": "U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0110, U+0131-0133, U+0135, U+0140-0142, U+0152-0153, U+0160-0161, U+0178, U+017D-017E, U+0192, U+0237, U+02BB-02BC, U+02C6-02C7, U+02D7-02DD, U+0303, U+0331, U+034F, U+03C0, U+0E01-0E3A, U+0E3F-0E5B, U+1E9E, U+200B-200D, U+2013-2014, U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+2060, U+2070, U+2074-2079, U+2080-2089, U+20A1, U+20A4, U+20A6, U+20A8-20AE, U+20B1-20B2, U+20B4-20B5, U+20B8-20BA, U+20BD, U+20BF, U+2113, U+2116, U+2122, U+2126, U+212E, U+2150-2151, U+2153-215E, U+2190-2199, U+21A9-21AA, U+21B0-21B3, U+21B6-21B7, U+21BA-21BB, U+21C4, U+21C6, U+2202, U+2206, U+220F, U+2211-2212, U+2215, U+221A, U+221E, U+222B, U+2248, U+2260, U+2264-2265, U+25CA, U+25CC, U+2638, U+2713, U+274C, U+2B0E-2B11, U+EC00-EC0B, U+EC10-EC12, U+ECE0, U+EFCC, U+FB01-FB02", - "bytes": 41416 - }, - { - "file": "FluxerSansThai/FluxerSansThai-Bold.woff2", - "weight": 700, - "style": "normal", - "unicodeRange": "U+0000, U+000D, U+0020-007E, U+00A0-00FF, U+0110, U+0131-0133, U+0135, U+0140-0142, U+0152-0153, U+0160-0161, U+0178, U+017D-017E, U+0192, U+0237, U+02BB-02BC, U+02C6-02C7, U+02D7-02DD, U+0303, U+0331, U+034F, U+03C0, U+0E01-0E3A, U+0E3F-0E5B, U+1E9E, U+200B-200D, U+2013-2014, U+2018-201A, U+201C-201E, U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+2060, U+2070, U+2074-2079, U+2080-2089, U+20A1, U+20A4, U+20A6, U+20A8-20AE, U+20B1-20B2, U+20B4-20B5, U+20B8-20BA, U+20BD, U+20BF, U+2113, U+2116, U+2122, U+2126, U+212E, U+2150-2151, U+2153-215E, U+2190-2199, U+21A9-21AA, U+21B0-21B3, U+21B6-21B7, U+21BA-21BB, U+21C4, U+21C6, U+2202, U+2206, U+220F, U+2211-2212, U+2215, U+221A, U+221E, U+222B, U+2248, U+2260, U+2264-2265, U+25CA, U+25CC, U+2638, U+2713, U+274C, U+2B0E-2B11, U+EC00-EC0B, U+EC10-EC12, U+ECE0, U+EFCC, U+FB01-FB02", - "bytes": 40300 - } - ] - }, { "id": "FluxerSansThaiLooped", "cssFamily": "Fluxer Sans Thai Looped", diff --git a/packages/hono/src/tests/ServerLimits.test.ts b/packages/hono/src/tests/ServerLimits.test.ts index fb51ebec9..95a20b194 100644 --- a/packages/hono/src/tests/ServerLimits.test.ts +++ b/packages/hono/src/tests/ServerLimits.test.ts @@ -45,20 +45,29 @@ describe('Server limits', () => { expect(httpServer.requestTimeout).toBe(120_000); expect(httpServer.keepAliveTimeout).toBe(125_000); expect(httpServer.maxRequestsPerSocket).toBe(1_000); - expect(httpServer.headersTimeout).toBeLessThanOrEqual(httpServer.requestTimeout); + expect(httpServer.headersTimeout).toBe(30_000); }); test('honours explicit limit overrides', async () => { const listening = await listen({ port: 0, requestTimeoutMs: 15_000, + headersTimeoutMs: 10_000, keepAliveTimeoutMs: 61_000, maxRequestsPerSocket: 250, }); server = listening.server; const httpServer = server as Server; expect(httpServer.requestTimeout).toBe(15_000); + expect(httpServer.headersTimeout).toBe(10_000); expect(httpServer.keepAliveTimeout).toBe(61_000); expect(httpServer.maxRequestsPerSocket).toBe(250); }); + + test('clamps the header timeout to the request timeout', async () => { + const listening = await listen({port: 0, requestTimeoutMs: 5_000}); + server = listening.server; + const httpServer = server as Server; + expect(httpServer.headersTimeout).toBe(5_000); + }); }); diff --git a/packages/hono/src/tests/ServerTimeouts.test.ts b/packages/hono/src/tests/ServerTimeouts.test.ts deleted file mode 100644 index 95ecee2c3..000000000 --- a/packages/hono/src/tests/ServerTimeouts.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {createServer} from '@fluxer/hono/src/Server'; -import {Hono} from 'hono'; -import {afterEach, describe, expect, it} from 'vitest'; - -const servers: Array<{close: (cb: () => void) => void}> = []; - -afterEach(async () => { - await Promise.all(servers.splice(0).map((s) => new Promise((r) => s.close(() => r())))); -}); - -function listen(options: Parameters[1]) { - const app = new Hono(); - app.post('/echo', async (ctx) => ctx.text(String((await ctx.req.text()).length))); - const server = createServer(app, options) as never as { - close: (cb: () => void) => void; - address: () => {port: number}; - headersTimeout: number; - requestTimeout: number; - }; - servers.push(server); - return server; -} - -describe('createServer timeouts', () => { - it('separates the header timeout from the total request timeout', () => { - const server = listen({port: 0}); - expect(server.headersTimeout).toBe(30_000); - expect(server.requestTimeout).toBe(120_000); - }); - - it('lets both be overridden', () => { - const server = listen({port: 0, headersTimeoutMs: 1_000, requestTimeoutMs: 2_000}); - expect(server.headersTimeout).toBe(1_000); - expect(server.requestTimeout).toBe(2_000); - }); - - it('never lets the header timeout exceed the request timeout', () => { - const server = listen({port: 0, requestTimeoutMs: 5_000}); - expect(server.requestTimeout).toBe(5_000); - expect(server.headersTimeout).toBeLessThanOrEqual(server.requestTimeout); - }); -}); diff --git a/packages/hono/vitest.config.ts b/packages/hono/vitest.config.ts index e51465aa0..36579a641 100644 --- a/packages/hono/vitest.config.ts +++ b/packages/hono/vitest.config.ts @@ -17,11 +17,5 @@ export default defineConfig({ isolate: false, reporters: ['default', 'json'], outputFile: './test-results.json', - coverage: { - provider: 'v8', - reporter: ['text', 'text-summary', 'json', 'html'], - reportsDirectory: './coverage', - exclude: ['**/node_modules/tests/test*.test.tsx', '**/*.test.ts'], - }, }, }); diff --git a/packages/i18n/package.json b/packages/i18n/package.json index b6f180ec7..834efaefd 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -14,6 +14,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@fluxer/constants": "workspace:*", + "@fluxer/record_utils": "workspace:*", "@messageformat/core": "catalog:", "@messageformat/parser": "catalog:" }, diff --git a/packages/i18n/scripts/SyncStaticI18nCatalogs.ts b/packages/i18n/scripts/SyncStaticI18nCatalogs.ts index a960a37af..09697b3c6 100644 --- a/packages/i18n/scripts/SyncStaticI18nCatalogs.ts +++ b/packages/i18n/scripts/SyncStaticI18nCatalogs.ts @@ -1,3 +1,4 @@ +import {isRecord} from '@fluxer/record_utils/src/RecordGuards'; // SPDX-License-Identifier: AGPL-3.0-or-later import * as fs from 'node:fs'; @@ -159,10 +160,6 @@ function normalizeEmailCatalog(source: EmailCatalog, existing: unknown): EmailCa return next; } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - function validateCatalog(kind: CatalogKind, catalog: unknown, label: string): Catalog { if (!isRecord(catalog)) { throw new Error(`${label} must be an object`); diff --git a/fluxer_api/pkgs/locale/src/catalog/LocaleCatalog.ts b/packages/i18n/src/locale/catalog/LocaleCatalog.ts similarity index 100% rename from fluxer_api/pkgs/locale/src/catalog/LocaleCatalog.ts rename to packages/i18n/src/locale/catalog/LocaleCatalog.ts diff --git a/fluxer_api/pkgs/locale/src/resolution/AcceptLanguageNegotiation.ts b/packages/i18n/src/locale/resolution/AcceptLanguageNegotiation.ts similarity index 98% rename from fluxer_api/pkgs/locale/src/resolution/AcceptLanguageNegotiation.ts rename to packages/i18n/src/locale/resolution/AcceptLanguageNegotiation.ts index 7cfea443a..2c80f81d9 100644 --- a/fluxer_api/pkgs/locale/src/resolution/AcceptLanguageNegotiation.ts +++ b/packages/i18n/src/locale/resolution/AcceptLanguageNegotiation.ts @@ -7,7 +7,7 @@ import { getLocaleByCode, getPreferredLocaleForLanguageCode, normalizeLocaleCode, -} from '@pkgs/locale/src/catalog/LocaleCatalog'; +} from '@fluxer/i18n/src/locale/catalog/LocaleCatalog'; interface AcceptLanguagePreference { locale: string; diff --git a/packages/i18n/src/normalization/IdentityLocale.ts b/packages/i18n/src/normalization/IdentityLocale.ts deleted file mode 100644 index 208b508e3..000000000 --- a/packages/i18n/src/normalization/IdentityLocale.ts +++ /dev/null @@ -1,5 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -export function identityLocale(locale: string): string { - return locale; -} diff --git a/packages/i18n/src/runtime/CreateStaticI18n.ts b/packages/i18n/src/runtime/CreateStaticI18n.ts index f76260ef2..fbf2b5467 100644 --- a/packages/i18n/src/runtime/CreateStaticI18n.ts +++ b/packages/i18n/src/runtime/CreateStaticI18n.ts @@ -11,7 +11,6 @@ export interface StaticI18nConfig { defaultLocale: string; defaultMessages: Record; localeMessages: Record>; - normalizeLocale?: (locale: string) => string; onWarning?: (message: string) => void; validateVariables?: (key: TKey, template: TValue, variables: TVariables) => string | null; messageFormatOptions?: MessageFormatOptions<'string'>; diff --git a/packages/i18n/src/runtime/StaticLocale.ts b/packages/i18n/src/runtime/StaticLocale.ts index d8bc9dad9..7234d6c6b 100644 --- a/packages/i18n/src/runtime/StaticLocale.ts +++ b/packages/i18n/src/runtime/StaticLocale.ts @@ -19,7 +19,7 @@ export function getEffectiveStaticLocale | null = null; + const lastColonIndex = head.lastIndexOf(':'); + const trailing = head.slice(lastColonIndex + 1); + if (trailing.includes('.')) { + if (!isIPv4(trailing)) { + return null; + } + embeddedIpv4Octets = trailing.split('.').map((part) => Number.parseInt(part, 10)); + head = `${head.slice(0, lastColonIndex + 1)}0:0`; + } + let groups: Array; + if (head.indexOf('::') === -1) { + groups = head.split(':'); + if (groups.length !== 8) { + return null; + } + } else { + const [beforePart, afterPart] = head.split('::'); + const before = beforePart.length > 0 ? beforePart.split(':') : []; + const after = afterPart.length > 0 ? afterPart.split(':') : []; + const missing = 8 - before.length - after.length; + if (missing < 1) { + return null; + } + groups = [...before, ...new Array(missing).fill('0'), ...after]; + } + const bytes = new Uint8Array(16); + for (let index = 0; index < 8; index += 1) { + const value = parseHexGroup(groups[index]); + if (value === null) { + return null; + } + bytes[index * 2] = (value >> 8) & 0xff; + bytes[index * 2 + 1] = value & 0xff; + } + if (embeddedIpv4Octets) { + bytes[12] = embeddedIpv4Octets[0]; + bytes[13] = embeddedIpv4Octets[1]; + bytes[14] = embeddedIpv4Octets[2]; + bytes[15] = embeddedIpv4Octets[3]; + } + return {family: 'ipv6', bytes}; +} + +function parseHexGroup(value: string | undefined): number | null { + if (!value || !/^[0-9a-f]{1,4}$/.test(value)) { + return null; + } + return Number.parseInt(value, 16); +} diff --git a/packages/ip_utils/src/__tests__/ClientIp.test.ts b/packages/ip_utils/src/__tests__/ClientIp.test.ts index fb844d22e..6757aed8a 100644 --- a/packages/ip_utils/src/__tests__/ClientIp.test.ts +++ b/packages/ip_utils/src/__tests__/ClientIp.test.ts @@ -2,7 +2,6 @@ import { extractClientIp, - extractClientIpDetails, MissingClientIpError, requireClientIp, resolveClientIpHeaderName, @@ -109,25 +108,6 @@ describe('resolveClientIpHeaderName', () => { }); }); -describe('extractClientIpDetails', () => { - it('returns extracted ip and source', () => { - const request = new Request('http://example.com', { - headers: {'X-Forwarded-For': '203.0.113.50'}, - }); - expect(extractClientIpDetails(request, {trustClientIpHeader: true})).toEqual({ - ip: '203.0.113.50', - source: 'client-ip-header', - ipVersion: 'ipv4', - }); - }); - it('returns null when trust is disabled', () => { - const request = new Request('http://example.com', { - headers: {'X-Forwarded-For': '203.0.113.50'}, - }); - expect(extractClientIpDetails(request, {trustClientIpHeader: false})).toBeNull(); - }); -}); - describe('requireClientIp', () => { it('returns ip when present and trusted', () => { const request = new Request('http://example.com', { diff --git a/packages/limits/src/LimitDefaults.ts b/packages/limits/src/LimitDefaults.ts index ec07d513d..1de67171c 100644 --- a/packages/limits/src/LimitDefaults.ts +++ b/packages/limits/src/LimitDefaults.ts @@ -108,6 +108,3 @@ export const DEFAULT_STOCK_LIMITS: Record = { max_guilds: 200, max_message_length: MAX_MESSAGE_LENGTH_PREMIUM, }; - -export const DEFAULT_FREE_LIMITS = DEFAULT_RESTRICTED_LIMITS; -export const DEFAULT_PREMIUM_LIMITS = DEFAULT_STOCK_LIMITS; diff --git a/packages/limits/src/LimitDiffer.ts b/packages/limits/src/LimitDiffer.ts index f1f59c826..93b69cec2 100644 --- a/packages/limits/src/LimitDiffer.ts +++ b/packages/limits/src/LimitDiffer.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {LIMIT_KEYS, type LimitKey} from '@fluxer/constants/src/LimitConfigMetadata'; -import {DEFAULT_FREE_LIMITS} from '@fluxer/limits/src/LimitDefaults'; +import {DEFAULT_RESTRICTED_LIMITS} from '@fluxer/limits/src/LimitDefaults'; import {computeDefaultsHash} from '@fluxer/limits/src/LimitHashing'; import type {LimitConfigSnapshot, LimitConfigWireFormat} from '@fluxer/limits/src/LimitTypes'; @@ -35,7 +35,7 @@ export function computeOverrides( export function computeWireFormat(config: LimitConfigSnapshot): LimitConfigWireFormat { const defaultsHash = computeDefaultsHash(); const rules = config.rules.map((rule) => { - const overrides = computeOverrides(rule.limits, DEFAULT_FREE_LIMITS); + const overrides = computeOverrides(rule.limits, DEFAULT_RESTRICTED_LIMITS); for (const key of WIRE_COMPATIBILITY_LIMIT_KEYS) { const value = rule.limits[key]; if (value !== undefined) { @@ -59,7 +59,7 @@ export function computeWireFormat(config: LimitConfigSnapshot): LimitConfigWireF export function expandWireFormat(wireFormat: LimitConfigWireFormat): LimitConfigSnapshot { const rules = wireFormat.rules.map((rule) => { const limits: Partial> = { - ...DEFAULT_FREE_LIMITS, + ...DEFAULT_RESTRICTED_LIMITS, ...rule.overrides, }; return { diff --git a/packages/limits/src/LimitResolver.ts b/packages/limits/src/LimitResolver.ts index e6240b753..06daca605 100644 --- a/packages/limits/src/LimitResolver.ts +++ b/packages/limits/src/LimitResolver.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata'; -import {DEFAULT_FREE_LIMITS} from '@fluxer/limits/src/LimitDefaults'; +import {DEFAULT_RESTRICTED_LIMITS} from '@fluxer/limits/src/LimitDefaults'; import {applyRuleToResolvedLimits, ruleMatches, sortRulesBySpecificity} from '@fluxer/limits/src/LimitRuleRuntime'; import type { LimitConfigSnapshot, @@ -16,7 +16,7 @@ export function resolveLimits( options?: LimitEvaluationOptions, ): LimitEvaluationResult { const evaluationContext = options?.evaluationContext ?? 'user'; - const resolvedLimits = {...(options?.baseLimits ?? DEFAULT_FREE_LIMITS)}; + const resolvedLimits = {...(options?.baseLimits ?? DEFAULT_RESTRICTED_LIMITS)}; for (const rule of sortRulesBySpecificity(snapshot.rules)) { if (ruleMatches(rule.filters, ctx)) { applyRuleToResolvedLimits(resolvedLimits, rule, evaluationContext); diff --git a/packages/limits/src/__tests__/LimitDiffer.test.ts b/packages/limits/src/__tests__/LimitDiffer.test.ts index fa2f6cb36..7a95dc7af 100644 --- a/packages/limits/src/__tests__/LimitDiffer.test.ts +++ b/packages/limits/src/__tests__/LimitDiffer.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {DEFAULT_FREE_LIMITS, DEFAULT_PREMIUM_LIMITS} from '@fluxer/limits/src/LimitDefaults'; +import {DEFAULT_RESTRICTED_LIMITS, DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults'; import {computeOverrides, computeWireFormat, expandWireFormat} from '@fluxer/limits/src/LimitDiffer'; import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes'; import {describe, expect, test} from 'vitest'; @@ -16,8 +16,8 @@ const WIRE_COMPATIBILITY_LIMIT_KEYS = [ ] as const; function expectWireCompatibilityOverrides( - overrides: Partial, - limits: typeof DEFAULT_FREE_LIMITS, + overrides: Partial, + limits: typeof DEFAULT_RESTRICTED_LIMITS, ): void { expect(Object.keys(overrides).sort()).toEqual([...WIRE_COMPATIBILITY_LIMIT_KEYS].sort()); for (const key of WIRE_COMPATIBILITY_LIMIT_KEYS) { @@ -27,16 +27,16 @@ function expectWireCompatibilityOverrides( describe('LimitDiffer', () => { test('computeOverrides returns empty object when limits match defaults', () => { - const overrides = computeOverrides(DEFAULT_FREE_LIMITS, DEFAULT_FREE_LIMITS); + const overrides = computeOverrides(DEFAULT_RESTRICTED_LIMITS, DEFAULT_RESTRICTED_LIMITS); expect(overrides).toEqual({}); }); test('computeOverrides extracts differences from defaults', () => { const customLimits = { - ...DEFAULT_FREE_LIMITS, + ...DEFAULT_RESTRICTED_LIMITS, max_guilds: 150, max_message_length: 3000, }; - const overrides = computeOverrides(customLimits, DEFAULT_FREE_LIMITS); + const overrides = computeOverrides(customLimits, DEFAULT_RESTRICTED_LIMITS); expect(overrides).toEqual({ max_guilds: 150, max_message_length: 3000, @@ -48,12 +48,12 @@ describe('LimitDiffer', () => { rules: [ { id: 'default', - limits: {...DEFAULT_FREE_LIMITS}, + limits: {...DEFAULT_RESTRICTED_LIMITS}, }, { id: 'premium', filters: {traits: ['premium']}, - limits: {...DEFAULT_PREMIUM_LIMITS}, + limits: {...DEFAULT_STOCK_LIMITS}, }, ], }; @@ -63,7 +63,7 @@ describe('LimitDiffer', () => { expect(wireFormat.rules).toHaveLength(2); expect(wireFormat.defaultsHash).toBeTruthy(); expect(wireFormat.rules[0].id).toBe('default'); - expectWireCompatibilityOverrides(wireFormat.rules[0].overrides, DEFAULT_FREE_LIMITS); + expectWireCompatibilityOverrides(wireFormat.rules[0].overrides, DEFAULT_RESTRICTED_LIMITS); expect(wireFormat.rules[1].id).toBe('premium'); expect(Object.keys(wireFormat.rules[1].overrides).length).toBeGreaterThan(0); expect(wireFormat.rules[1].overrides.max_guilds).toBe(200); @@ -74,12 +74,12 @@ describe('LimitDiffer', () => { rules: [ { id: 'default', - limits: {...DEFAULT_FREE_LIMITS}, + limits: {...DEFAULT_RESTRICTED_LIMITS}, }, { id: 'premium', filters: {traits: ['premium']}, - limits: {...DEFAULT_PREMIUM_LIMITS}, + limits: {...DEFAULT_STOCK_LIMITS}, }, ], }; @@ -98,13 +98,13 @@ describe('LimitDiffer', () => { rules: [ { id: 'default', - limits: {...DEFAULT_FREE_LIMITS}, + limits: {...DEFAULT_RESTRICTED_LIMITS}, }, { id: 'premium', filters: {traits: ['premium']}, limits: { - ...DEFAULT_PREMIUM_LIMITS, + ...DEFAULT_STOCK_LIMITS, max_guilds: 250, }, }, diff --git a/packages/limits/src/__tests__/LimitResolver.test.ts b/packages/limits/src/__tests__/LimitResolver.test.ts index c527783d7..f736d2e5f 100644 --- a/packages/limits/src/__tests__/LimitResolver.test.ts +++ b/packages/limits/src/__tests__/LimitResolver.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {DEFAULT_FREE_LIMITS, DEFAULT_PREMIUM_LIMITS} from '@fluxer/limits/src/LimitDefaults'; +import {DEFAULT_RESTRICTED_LIMITS, DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults'; import {resolveLimit, resolveLimits} from '@fluxer/limits/src/LimitResolver'; import type {LimitConfigSnapshot, LimitMatchContext} from '@fluxer/limits/src/LimitTypes'; import {describe, expect, test} from 'vitest'; @@ -16,7 +16,7 @@ describe('LimitResolver', () => { guildFeatures: new Set(), }; const result = resolveLimits(snapshot, ctx); - expect(result.limits).toEqual(DEFAULT_FREE_LIMITS); + expect(result.limits).toEqual(DEFAULT_RESTRICTED_LIMITS); }); test('resolveLimits applies premium limits for premium trait', () => { const snapshot: LimitConfigSnapshot = { @@ -24,12 +24,12 @@ describe('LimitResolver', () => { rules: [ { id: 'default', - limits: {...DEFAULT_FREE_LIMITS}, + limits: {...DEFAULT_RESTRICTED_LIMITS}, }, { id: 'premium', filters: {traits: ['premium']}, - limits: {...DEFAULT_PREMIUM_LIMITS}, + limits: {...DEFAULT_STOCK_LIMITS}, }, ], }; diff --git a/packages/logger/src/LoggerInterface.ts b/packages/logger/src/LoggerInterface.ts index 5ed5cc742..25c0b9e6c 100644 --- a/packages/logger/src/LoggerInterface.ts +++ b/packages/logger/src/LoggerInterface.ts @@ -1,15 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0-or-later export interface LoggerInterface { - trace(obj: Record, msg?: string): void; trace(msg: string): void; - debug(obj: Record, msg?: string): void; + trace(obj: object, msg?: string): void; debug(msg: string): void; - info(obj: Record, msg?: string): void; + debug(obj: object, msg?: string): void; info(msg: string): void; - warn(obj: Record, msg?: string): void; + info(obj: object, msg?: string): void; warn(msg: string): void; - error(obj: Record, msg?: string): void; + warn(obj: object, msg?: string): void; error(msg: string): void; + error(obj: object, msg?: string): void; + fatal(msg: string): void; + fatal(obj: object, msg?: string): void; child(bindings: Record): LoggerInterface; } diff --git a/packages/openapi/src/OpenAPIGenerator.ts b/packages/openapi/src/OpenAPIGenerator.ts index 9bbc3f968..f48a4bd3f 100644 --- a/packages/openapi/src/OpenAPIGenerator.ts +++ b/packages/openapi/src/OpenAPIGenerator.ts @@ -141,7 +141,7 @@ export class OpenAPIGenerator { continue; } if (!route.responseSchemaName && !route.hasNoContent) { - skippedRoutes.push(describeRoute(route, 'no responseSchema and no NoContent()')); + skippedRoutes.push(describeRoute(route, 'no responseSchema')); continue; } if (!hasOpenAPIPathTemplate(route.path)) { diff --git a/packages/openapi/src/extractors/RouteExtractor.ts b/packages/openapi/src/extractors/RouteExtractor.ts index 4d2c647da..722df0dad 100644 --- a/packages/openapi/src/extractors/RouteExtractor.ts +++ b/packages/openapi/src/extractors/RouteExtractor.ts @@ -64,9 +64,6 @@ const RouteMetadata = z.strictObject({ .regex(/^[a-z][a-z0-9_]*$/) .optional(), }); -const RouteOptionsMetadata = RouteMetadata.pick({description: true, responseContentType: true}).extend({ - description: z.string().min(1), -}); function isHttpMethod(method: string): method is HttpMethod { return HTTP_METHODS.has(method); } @@ -184,20 +181,12 @@ interface MiddlewareInfo { explicitExperiment?: string; } function extractOpenAPIMetadata(args: ReadonlyArray, context: MetadataContext): MiddlewareInfo { - const [first, summary, responseSchema, options] = args; + const [first] = args; if (!first) throw new Error('OpenAPI requires route metadata'); - if (options !== undefined && !Node.isObjectLiteralExpression(options)) { - throw new Error(`OpenAPI options must be an object literal: ${options.getText()}`); + if (!Node.isObjectLiteralExpression(first)) { + throw new Error(`OpenAPI metadata must be an object literal: ${first.getText()}`); } - const value = Node.isObjectLiteralExpression(first) - ? parseObjectLiteralMetadata(first, context) - : { - ...(options ? RouteOptionsMetadata.parse(parseObjectLiteralMetadata(options, context)) : {}), - operationId: resolveMetadataValue(first, context), - summary: summary ? resolveMetadataValue(summary, context) : undefined, - responseSchema: responseSchema ? extractSchemaName(responseSchema, context) : undefined, - tags: [], - }; + const value = parseObjectLiteralMetadata(first, context); const parsed = RouteMetadata.safeParse(value); if (!parsed.success) { throw new Error(`Invalid OpenAPI metadata at line ${first.getStartLineNumber()}`, {cause: parsed.error}); @@ -233,10 +222,6 @@ function extractMiddlewareInfo(callExpr: CallExpression, context: MetadataContex switch (name) { case 'RateLimitMiddleware': return {middlewareName: name, rateLimitConfig: args[0]?.getText()}; - case 'ResponseType': - return {middlewareName: name, responseSchemaName: args[0]?.getText()}; - case 'NoContent': - return {middlewareName: name, hasNoContent: true}; case 'OpenAPI': return extractOpenAPIMetadata(args, context); case 'requireOAuth2Scope': diff --git a/fluxer_api/pkgs/locale/package.json b/packages/record_utils/package.json similarity index 51% rename from fluxer_api/pkgs/locale/package.json rename to packages/record_utils/package.json index 406997617..946df704a 100644 --- a/fluxer_api/pkgs/locale/package.json +++ b/packages/record_utils/package.json @@ -1,5 +1,5 @@ { - "name": "@pkgs/locale", + "name": "@fluxer/record_utils", "version": "0.0.0", "private": true, "type": "module", @@ -7,16 +7,10 @@ "./*": "./*" }, "scripts": { - "test": "vitest run", - "test:watch": "vitest", "typecheck": "tsc --noEmit" }, - "dependencies": { - "@fluxer/constants": "workspace:*" - }, "devDependencies": { "@types/node": "catalog:", - "typescript": "catalog:ts7", - "vitest": "catalog:" + "typescript": "catalog:ts7" } } diff --git a/packages/record_utils/src/RecordGuards.ts b/packages/record_utils/src/RecordGuards.ts new file mode 100644 index 000000000..6f5a18e9d --- /dev/null +++ b/packages/record_utils/src/RecordGuards.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export function isObjectLike(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function isRecord(value: unknown): value is Record { + return isObjectLike(value) && !Array.isArray(value); +} diff --git a/packages/record_utils/tsconfig.json b/packages/record_utils/tsconfig.json new file mode 100644 index 000000000..a8426e011 --- /dev/null +++ b/packages/record_utils/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfigs/package.json", + "compilerOptions": {}, + "include": ["src/**/*"] +} diff --git a/packages/schema/package.json b/packages/schema/package.json index 518009d38..df4129907 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -8,7 +8,6 @@ }, "scripts": { "test": "vitest run", - "test:coverage": "vitest run --coverage", "test:watch": "vitest", "typecheck": "tsc --noEmit", "generate": "cd proto && buf generate && ../../../tools/ci/run.sh clean-schema-generated-files --root ../src/gen && cd .. && pnpm exec biome check --write --unsafe --no-errors-on-unmatched src/gen" diff --git a/packages/schema/proto/fluxer/read_state/v1/read_state.proto b/packages/schema/proto/fluxer/read_state/v1/read_state.proto deleted file mode 100644 index 73f8b032e..000000000 --- a/packages/schema/proto/fluxer/read_state/v1/read_state.proto +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -syntax = "proto3"; - -package fluxer.read_state.v1; - -import "google/protobuf/timestamp.proto"; - -message ReadStateEntry { - uint64 channel_id = 1; - optional uint64 last_message_id = 2; - uint32 mention_count = 3; - google.protobuf.Timestamp last_pin_timestamp = 4; - optional uint64 version = 5; -} - -message ReadStateBundle { - repeated ReadStateEntry read_states = 1; -} diff --git a/packages/schema/src/domains/admin/AdminUserSchemas.ts b/packages/schema/src/domains/admin/AdminUserSchemas.ts index 95c74bafa..fc82de9aa 100644 --- a/packages/schema/src/domains/admin/AdminUserSchemas.ts +++ b/packages/schema/src/domains/admin/AdminUserSchemas.ts @@ -153,6 +153,8 @@ export const ListUserDmChannelsRequest = z export type ListUserDmChannelsRequest = z.infer; +export type AdminResolvedUser = z.infer; + const AdminResolvedUserSchema = z.object({ id: SnowflakeStringType, username: z.string(), diff --git a/packages/schema/src/domains/admin/ChannelThreadsSchemas.test.ts b/packages/schema/src/domains/admin/ChannelThreadsSchemas.test.ts index cbe280204..136cd69b2 100644 --- a/packages/schema/src/domains/admin/ChannelThreadsSchemas.test.ts +++ b/packages/schema/src/domains/admin/ChannelThreadsSchemas.test.ts @@ -1,154 +1,32 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { - type ChannelThreadsConfig, + ChannelThreadsAssignmentResponse, ChannelThreadsConfigSchema, - channelThreadsGuildActive, - channelThreadsUserActive, - channelThreadsUserExcluded, - compileChannelThreadsConfig, - DEFAULT_CHANNEL_THREADS_CONFIG, everyoneChannelThreadsConfig, - resolveChannelThreadsAssignment, } from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas'; -import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket'; -import vectors from '@fluxer/schema/src/domains/experiment/ExperimentBucketVectors.json' with {type: 'json'}; import {describe, expect, test} from 'vitest'; -const GUILD_ID = '1000000000000000001'; -const OTHER_GUILD_ID = '1000000000000000002'; -const USER_ID = '1100000000000000001'; -const OTHER_USER_ID = '1100000000000000002'; - -function config(overrides: Partial = {}): ChannelThreadsConfig { - return {...DEFAULT_CHANNEL_THREADS_CONFIG, ...overrides}; -} - -function compiled(overrides: Partial = {}) { - return compileChannelThreadsConfig(config(overrides)); -} - -function syntheticIds(count: number): Array { - return Array.from({length: count}, (_, index) => (1400000000000000000n + BigInt(index)).toString()); -} - describe('channel threads configuration', () => { - test('defaults to fully off', () => { - expect(DEFAULT_CHANNEL_THREADS_CONFIG).toEqual({ - enabled: false, - config_version: 0, - ever_enabled: false, - guild_basis_points: 0, - guild_salt: 'channel-threads-guild-v1', - enabled_guild_ids: [], - disabled_guild_ids: [], - user_basis_points: 0, - user_salt: 'channel-threads-user-v1', - included_user_ids: [], - excluded_user_ids: [], - }); - expect(channelThreadsGuildActive(compiled(), GUILD_ID)).toBe(false); - expect(channelThreadsUserActive(compiled(), USER_ID)).toBe(false); - }); - - test.each([ - {guild_basis_points: -1}, - {guild_basis_points: 10001}, - {user_basis_points: 1.5}, - {guild_salt: ' '}, - {user_salt: 'channel-threads-é'}, - {guild_salt: 'x'.repeat(65)}, - {enabled_guild_ids: ['guild']}, - {excluded_user_ids: ['123456789012345678901']}, - ])('rejects invalid values: %j', (value) => { - expect(ChannelThreadsConfigSchema.safeParse(value).success).toBe(false); - }); - - test('caps every targeted id list at a thousand', () => { - const ids = syntheticIds(1001); - for (const key of ['enabled_guild_ids', 'disabled_guild_ids', 'included_user_ids', 'excluded_user_ids']) { - expect(ChannelThreadsConfigSchema.safeParse({[key]: ids}).success).toBe(false); - expect(ChannelThreadsConfigSchema.safeParse({[key]: ids.slice(0, 1000)}).success).toBe(true); - } - }); - test('the everyone config serves every guild and user and keeps the given version', () => { const everyone = everyoneChannelThreadsConfig(42); expect(everyone).toEqual({ - ...DEFAULT_CHANNEL_THREADS_CONFIG, enabled: true, config_version: 42, ever_enabled: true, guild_basis_points: 10000, + guild_salt: 'channel-threads-guild-v1', + enabled_guild_ids: [], + disabled_guild_ids: [], user_basis_points: 10000, + user_salt: 'channel-threads-user-v1', + included_user_ids: [], + excluded_user_ids: [], }); expect(ChannelThreadsConfigSchema.parse(everyone)).toEqual(everyone); - const cfg = compileChannelThreadsConfig(everyone); - for (const id of syntheticIds(200)) { - expect(channelThreadsGuildActive(cfg, id)).toBe(true); - expect(channelThreadsUserActive(cfg, id)).toBe(true); - } - expect(resolveChannelThreadsAssignment(cfg, USER_ID)).toEqual({active: true, config_version: 42}); - }); -}); - -describe('channel threads predicates', () => { - test('the master switch overrides every list', () => { - const cfg = compiled({ - enabled_guild_ids: [GUILD_ID], - included_user_ids: [USER_ID], - guild_basis_points: 10000, - user_basis_points: 10000, + expect(ChannelThreadsAssignmentResponse.parse({active: true, config_version: everyone.config_version})).toEqual({ + active: true, + config_version: 42, }); - expect(channelThreadsGuildActive(cfg, GUILD_ID)).toBe(false); - expect(channelThreadsUserActive(cfg, USER_ID)).toBe(false); - expect(resolveChannelThreadsAssignment(cfg, USER_ID)).toBeUndefined(); - }); - - test('exclusion beats inclusion and inclusion beats the bucket', () => { - const cfg = compiled({ - enabled: true, - enabled_guild_ids: [GUILD_ID], - disabled_guild_ids: [GUILD_ID], - included_user_ids: [USER_ID, OTHER_USER_ID], - excluded_user_ids: [USER_ID], - }); - expect(channelThreadsGuildActive(cfg, GUILD_ID)).toBe(false); - expect(channelThreadsGuildActive(cfg, OTHER_GUILD_ID)).toBe(false); - expect(channelThreadsUserActive(cfg, USER_ID)).toBe(false); - expect(channelThreadsUserActive(cfg, OTHER_USER_ID)).toBe(true); - expect(channelThreadsUserExcluded(cfg, USER_ID)).toBe(true); - expect(channelThreadsUserExcluded(cfg, OTHER_USER_ID)).toBe(false); - }); - - test('the guild and user buckets use their own salts', () => { - const guildBucket = experimentBucket(GUILD_ID, 'channel-threads-guild-v1'); - const userBucket = experimentBucket(USER_ID, 'channel-threads-user-v1'); - expect(channelThreadsGuildActive(compiled({enabled: true, guild_basis_points: guildBucket}), GUILD_ID)).toBe(false); - expect(channelThreadsGuildActive(compiled({enabled: true, guild_basis_points: guildBucket + 1}), GUILD_ID)).toBe( - true, - ); - expect(channelThreadsUserActive(compiled({enabled: true, user_basis_points: userBucket}), USER_ID)).toBe(false); - expect(channelThreadsUserActive(compiled({enabled: true, user_basis_points: userBucket + 1}), USER_ID)).toBe(true); - }); - - test('a full rollout covers everyone who is not excluded', () => { - const cfg = compiled({enabled: true, guild_basis_points: 10000, user_basis_points: 10000}); - for (const id of syntheticIds(200)) { - expect(channelThreadsGuildActive(cfg, id)).toBe(true); - expect(channelThreadsUserActive(cfg, id)).toBe(true); - } - }); - - test('the assignment is present only for active users and reports the version', () => { - const cfg = compiled({enabled: true, config_version: 7, included_user_ids: [USER_ID]}); - expect(resolveChannelThreadsAssignment(cfg, USER_ID)).toEqual({active: true, config_version: 7}); - expect(resolveChannelThreadsAssignment(cfg, OTHER_USER_ID)).toBeUndefined(); - }); -}); - -describe('shared experiment bucket vectors', () => { - test.each(vectors as Array<[string, string, number]>)('bucket(%s, %s) is %i', (salt, id, bucket) => { - expect(experimentBucket(id, salt)).toBe(bucket); }); }); diff --git a/packages/schema/src/domains/admin/ChannelThreadsSchemas.ts b/packages/schema/src/domains/admin/ChannelThreadsSchemas.ts index cea56ae5b..0b3061cbe 100644 --- a/packages/schema/src/domains/admin/ChannelThreadsSchemas.ts +++ b/packages/schema/src/domains/admin/ChannelThreadsSchemas.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket'; +import {EXPERIMENT_BUCKET_RESOLUTION} from '@fluxer/schema/src/domains/experiment/ExperimentBucket'; import {z} from 'zod'; const CHANNEL_THREADS_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION; @@ -66,55 +66,9 @@ export function everyoneChannelThreadsConfig(configVersion: number): ChannelThre }; } -export interface CompiledChannelThreadsConfig { - config: ChannelThreadsConfig; - enabledGuildIds: ReadonlySet; - disabledGuildIds: ReadonlySet; - includedUserIds: ReadonlySet; - excludedUserIds: ReadonlySet; -} - -export function compileChannelThreadsConfig(config: ChannelThreadsConfig): CompiledChannelThreadsConfig { - return { - config, - enabledGuildIds: new Set(config.enabled_guild_ids), - disabledGuildIds: new Set(config.disabled_guild_ids), - includedUserIds: new Set(config.included_user_ids), - excludedUserIds: new Set(config.excluded_user_ids), - }; -} - -export function channelThreadsGuildActive(compiled: CompiledChannelThreadsConfig, guildId: string): boolean { - const {config} = compiled; - if (!config.enabled) return false; - if (compiled.disabledGuildIds.has(guildId)) return false; - if (compiled.enabledGuildIds.has(guildId)) return true; - return config.guild_basis_points > 0 && experimentBucket(guildId, config.guild_salt) < config.guild_basis_points; -} - -export function channelThreadsUserExcluded(compiled: CompiledChannelThreadsConfig, userId: string): boolean { - return compiled.excludedUserIds.has(userId); -} - -export function channelThreadsUserActive(compiled: CompiledChannelThreadsConfig, userId: string): boolean { - const {config} = compiled; - if (!config.enabled) return false; - if (compiled.excludedUserIds.has(userId)) return false; - if (compiled.includedUserIds.has(userId)) return true; - return config.user_basis_points > 0 && experimentBucket(userId, config.user_salt) < config.user_basis_points; -} - export const ChannelThreadsAssignmentResponse = z.object({ active: z.literal(true), config_version: z.number().int().min(0), }); export type ChannelThreadsAssignmentResponse = z.infer; - -export function resolveChannelThreadsAssignment( - compiled: CompiledChannelThreadsConfig, - userId: string, -): ChannelThreadsAssignmentResponse | undefined { - if (!channelThreadsUserActive(compiled, userId)) return undefined; - return {active: true, config_version: compiled.config.config_version}; -} diff --git a/packages/schema/src/domains/admin/PlutoniumPageSchemas.ts b/packages/schema/src/domains/admin/PlutoniumPageSchemas.ts index 29d538e30..72f8987cc 100644 --- a/packages/schema/src/domains/admin/PlutoniumPageSchemas.ts +++ b/packages/schema/src/domains/admin/PlutoniumPageSchemas.ts @@ -7,7 +7,3 @@ export const PlutoniumPageAssignmentResponse = z.object({ }); export type PlutoniumPageAssignmentResponse = z.infer; - -export const INERT_PLUTONIUM_PAGE_ASSIGNMENT: PlutoniumPageAssignmentResponse = { - enabled: false, -}; diff --git a/packages/schema/src/domains/channel/GuildChannelOrdering.ts b/packages/schema/src/domains/channel/GuildChannelOrdering.ts index e30a2ca6c..82c71898a 100644 --- a/packages/schema/src/domains/channel/GuildChannelOrdering.ts +++ b/packages/schema/src/domains/channel/GuildChannelOrdering.ts @@ -150,27 +150,6 @@ function findCategorySpanInOrderedList, ->({ - channels, - targetId, - desiredParentId, - position, -}: { - channels: ReadonlyArray; - targetId: Id; - desiredParentId: Id | null; - position: number; -}): Id | null { - const siblings = sortChannelsForOrdering(channels).filter((ch) => (ch.parentId ?? null) === desiredParentId); - const blockIds = computeChannelMoveBlockIds({channels, targetId}); - const siblingsWithoutBlock = siblings.filter((ch) => !blockIds.has(ch.id)); - const clamped = Math.min(Math.max(position, 0), siblingsWithoutBlock.length); - return clamped === 0 ? null : siblingsWithoutBlock[clamped - 1]!.id; -} - export function computePositionFromPrecedingSiblingId< Id extends string | bigint, Channel extends ChannelOrderingChannel, diff --git a/packages/schema/src/domains/experiment/ExperimentBucket.test.ts b/packages/schema/src/domains/experiment/ExperimentBucket.test.ts index 5e189ba24..c1fe48b9f 100644 --- a/packages/schema/src/domains/experiment/ExperimentBucket.test.ts +++ b/packages/schema/src/domains/experiment/ExperimentBucket.test.ts @@ -3,8 +3,10 @@ import { ExperimentRolloutCountryCodesSchema, type ExperimentTargeting, + experimentBucket, experimentRolloutCountryIncludes, } from '@fluxer/schema/src/domains/experiment/ExperimentBucket'; +import vectors from '@fluxer/schema/src/domains/experiment/ExperimentBucketVectors.json' with {type: 'json'}; import {describe, expect, test} from 'vitest'; describe('ExperimentRolloutCountryCodesSchema', () => { @@ -48,3 +50,9 @@ describe('experimentRolloutCountryIncludes', () => { expect(experimentRolloutCountryIncludes(rules, targeting(null))).toBe(false); }); }); + +describe('shared experiment bucket vectors', () => { + test.each(vectors as Array<[string, string, number]>)('bucket(%s, %s) is %i', (salt, id, bucket) => { + expect(experimentBucket(id, salt)).toBe(bucket); + }); +}); diff --git a/packages/schema/src/domains/experiment/ExperimentSchemas.ts b/packages/schema/src/domains/experiment/ExperimentSchemas.ts index 72e25215c..5266f6a62 100644 --- a/packages/schema/src/domains/experiment/ExperimentSchemas.ts +++ b/packages/schema/src/domains/experiment/ExperimentSchemas.ts @@ -5,10 +5,7 @@ import { DomainMigrationAssignmentResponse, INERT_DOMAIN_MIGRATION_ASSIGNMENT, } from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas'; -import { - INERT_PLUTONIUM_PAGE_ASSIGNMENT, - PlutoniumPageAssignmentResponse, -} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas'; +import {PlutoniumPageAssignmentResponse} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas'; import {INERT_VOICE_P2P_ASSIGNMENT, VoiceP2pAssignmentResponse} from '@fluxer/schema/src/domains/admin/VoiceP2pSchemas'; import {z} from 'zod'; @@ -73,16 +70,6 @@ export function readDomainMigrationAssignment( return response.assignments.domain_migration ?? INERT_DOMAIN_MIGRATION_ASSIGNMENT; } -export function readChannelThreadsAssignment( - response: ExperimentAssignmentsResponse, -): ChannelThreadsAssignmentResponse | null { - return response.assignments.channel_threads ?? null; -} - -export function readPlutoniumPageAssignment(response: ExperimentAssignmentsResponse): PlutoniumPageAssignmentResponse { - return response.assignments.plutonium_page ?? INERT_PLUTONIUM_PAGE_ASSIGNMENT; -} - export function readVoiceP2pAssignment(response: ExperimentAssignmentsResponse): VoiceP2pAssignmentResponse { return response.assignments.voice_p2p ?? INERT_VOICE_P2P_ASSIGNMENT; } diff --git a/packages/schema/src/domains/read_state/ReadStateProtoCodec.ts b/packages/schema/src/domains/read_state/ReadStateProtoCodec.ts deleted file mode 100644 index 27b96f34b..000000000 --- a/packages/schema/src/domains/read_state/ReadStateProtoCodec.ts +++ /dev/null @@ -1,114 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {create, fromBinary, toBinary} from '@bufbuild/protobuf'; -import {timestampDate, timestampFromDate} from '@bufbuild/protobuf/wkt'; -import {type ReadStateBundle, ReadStateBundleSchema} from '@fluxer/schema/src/gen/fluxer/read_state/v1/read_state_pb'; -import {base64ToUint8Array, uint8ArrayToBase64} from 'uint8array-extras'; - -interface ReadStateProtoEntryInput { - id: string; - mention_count?: number | null; - last_message_id?: string | null; - last_pin_timestamp?: string | null; - version?: string | null; -} - -interface ReadStateProtoEntry { - id: string; - mention_count: number; - last_message_id: string | null; - last_pin_timestamp: string | null; - version?: string; -} - -export const EMPTY_READ_STATE_PROTO = ''; -const MAX_UINT32 = 0xffffffff; -const MAX_UINT64 = 0xffffffffffffffffn; - -export function encodeReadStateProto(readStates: ReadonlyArray): string { - const bundle = create(ReadStateBundleSchema, { - readStates: readStates.map((readState) => ({ - channelId: parseUint64(readState.id, 'id'), - lastMessageId: optionalUint64(readState.last_message_id, 'last_message_id'), - mentionCount: normalizeMentionCount(readState.mention_count), - lastPinTimestamp: optionalTimestamp(readState.last_pin_timestamp), - version: optionalUint64(readState.version, 'version'), - })), - }); - const bytes = toBinary(ReadStateBundleSchema, bundle); - if (bytes.byteLength === 0) return EMPTY_READ_STATE_PROTO; - return uint8ArrayToBase64(bytes); -} - -export function decodeReadStateProto(encoded: string | null | undefined): Array { - const bundle = decodeReadStateProtoBundle(encoded); - return bundle.readStates.map((readState) => ({ - id: readState.channelId.toString(), - mention_count: normalizeMentionCount(readState.mentionCount), - last_message_id: readState.lastMessageId?.toString() ?? null, - last_pin_timestamp: readState.lastPinTimestamp ? timestampDate(readState.lastPinTimestamp).toISOString() : null, - version: readState.version?.toString(), - })); -} - -export function decodeReadStateProtoBundle(encoded: string | null | undefined): ReadStateBundle { - if (!encoded) return create(ReadStateBundleSchema); - let bytes: Uint8Array; - try { - bytes = base64ToUint8Array(encoded); - } catch (error) { - throw new ReadStateProtoDecodeError(error instanceof Error ? `invalid base64: ${error.message}` : 'invalid base64'); - } - if (bytes.byteLength === 0) return create(ReadStateBundleSchema); - try { - return fromBinary(ReadStateBundleSchema, bytes); - } catch (error) { - throw new ReadStateProtoDecodeError(error instanceof Error ? error.message : 'invalid read-state protobuf'); - } -} - -export class ReadStateProtoDecodeError extends Error { - constructor(message: string) { - super(`failed to decode read_state_proto: ${message}`); - this.name = 'ReadStateProtoDecodeError'; - } -} - -class ReadStateProtoEncodeError extends Error { - constructor(message: string) { - super(`failed to encode read_state_proto: ${message}`); - this.name = 'ReadStateProtoEncodeError'; - } -} - -function optionalUint64(value: string | null | undefined, field: string): bigint | undefined { - return value == null ? undefined : parseUint64(value, field); -} - -function parseUint64(value: string, field: string): bigint { - try { - const parsed = BigInt(value); - if (parsed < 0n || parsed > MAX_UINT64) { - throw new Error('out of uint64 range'); - } - return parsed; - } catch (error) { - throw new ReadStateProtoEncodeError( - error instanceof Error ? `${field}: ${error.message}` : `${field}: invalid uint64`, - ); - } -} - -function optionalTimestamp(value: string | null | undefined) { - if (value == null) return undefined; - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - throw new ReadStateProtoEncodeError('last_pin_timestamp: invalid timestamp'); - } - return timestampFromDate(date); -} - -function normalizeMentionCount(value: number | null | undefined): number { - if (value == null || !Number.isFinite(value)) return 0; - return Math.min(MAX_UINT32, Math.max(0, Math.floor(value))); -} diff --git a/packages/schema/src/domains/rpc/RpcSchemas.ts b/packages/schema/src/domains/rpc/RpcSchemas.ts index bc328f44c..48bde58b8 100644 --- a/packages/schema/src/domains/rpc/RpcSchemas.ts +++ b/packages/schema/src/domains/rpc/RpcSchemas.ts @@ -95,11 +95,6 @@ export const RpcRequest = z.discriminatedUnion('type', [ guild_id: SnowflakeType.describe('ID of the guild for this voice state'), connection_id: createStringType(1, 255).describe('Connection ID to remove from the snapshot'), }), - z.object({ - type: z.literal('log_guild_crash').describe('Request type for logging guild crashes'), - guild_id: SnowflakeType.describe('ID of the guild that crashed'), - stacktrace: z.string().describe('Error stacktrace from the crash'), - }), z.object({ type: z.literal('get_user_guild_settings').describe('Request type for fetching user guild settings'), user_ids: z.array(SnowflakeType).max(RPC_USER_BATCH_MAX).describe('IDs of users to fetch settings for'), @@ -113,10 +108,6 @@ export const RpcRequest = z.discriminatedUnion('type', [ type: z.literal('get_badge_counts').describe('Request type for fetching notification badge counts'), user_ids: z.array(SnowflakeType).describe('IDs of users to fetch badge counts for'), }), - z.object({ - type: z.literal('geoip_lookup').describe('Request type for IP geolocation lookup'), - ip: createStringType(1, 45).describe('IP address to lookup'), - }), z.object({ type: z.literal('delete_push_subscriptions').describe('Request type for deleting push notification subscriptions'), subscriptions: z @@ -189,20 +180,6 @@ export const RpcRequest = z.discriminatedUnion('type', [ can_stream: z.boolean().optional().describe('Whether the user can stream'), can_video: z.boolean().optional().describe('Whether the user can use video'), }), - z.object({ - type: z - .literal('voice_force_disconnect_channel') - .describe('Request type for force disconnecting all participants from a channel'), - guild_id: SnowflakeType.optional().describe('ID of the guild'), - channel_id: SnowflakeType.describe('ID of the voice channel to clear'), - }), - z.object({ - type: z.literal('voice_list_participants').describe('Request type for listing voice participants on one server'), - guild_id: SnowflakeType.optional().describe('ID of the guild'), - channel_id: SnowflakeType.describe('ID of the voice channel'), - region_id: createStringType(1, 64).describe('Voice region that hosts the room'), - server_id: createStringType(1, 128).describe('Voice server that hosts the room'), - }), z.object({ type: z .literal('voice_update_participant_permissions') @@ -249,11 +226,6 @@ export const RpcRequest = z.discriminatedUnion('type', [ z.object({ type: z.literal('get_channel_threads_config').describe('Request type for fetching channel threads configuration'), }), - z.object({ - type: z.literal('get_thread_memberships').describe('Request type for fetching the thread memberships of a user'), - guild_id: SnowflakeType.describe('ID of the guild'), - user_id: SnowflakeType.describe('ID of the user'), - }), z.object({ type: z.literal('list_thread_members').describe('Request type for listing the members of a thread'), guild_id: SnowflakeType.describe('ID of the guild'), @@ -417,14 +389,6 @@ export const RpcResponse = z.discriminatedUnion('type', [ type: z.literal('session').describe('Response type for session initialization'), data: RpcResponseSessionData.describe('Session initialization data'), }), - z.object({ - type: z.literal('log_guild_crash').describe('Response type for guild crash logging'), - data: z - .object({ - success: z.boolean().describe('Whether the crash was logged successfully'), - }) - .describe('Crash logging result'), - }), z.object({ type: z.literal('guild_collection').describe('Response type for guild collection chunks'), data: RpcResponseGuildCollectionData.describe('Guild collection chunk data'), @@ -544,35 +508,6 @@ export const RpcResponse = z.discriminatedUnion('type', [ }) .describe('Update result'), }), - z.object({ - type: z.literal('voice_force_disconnect_channel').describe('Response type for force disconnect channel'), - data: z - .object({ - success: z.boolean().describe('Whether the operation was successful'), - disconnected_count: z.number().optional().describe('Number of participants disconnected'), - }) - .describe('Channel disconnect result'), - }), - z.object({ - type: z.literal('voice_list_participants').describe('Response type for voice participant listing'), - data: z - .object({ - status: z.enum(['ok', 'error']).describe('Whether the participant snapshot is authoritative'), - participants: z - .array( - z.object({ - identity: z.string().describe('LiveKit participant identity'), - user_id: SnowflakeStringType.describe('Participant user ID'), - connection_id: createStringType(1, 255).describe('Participant voice connection ID'), - }), - ) - .describe('Participants currently present on this voice server'), - error_code: z.string().optional().describe('Voice server lookup error code'), - retryable: z.boolean().optional().describe('Whether the lookup can be retried'), - server_missing: z.boolean().optional().describe('Whether the pinned server no longer exists'), - }) - .describe('Voice participant listing result'), - }), z.object({ type: z.literal('voice_update_participant_permissions').describe('Response type for voice permissions update'), data: z @@ -593,16 +528,6 @@ export const RpcResponse = z.discriminatedUnion('type', [ type: z.literal('validate_custom_status').describe('Response type for custom status validation'), data: RpcResponseValidateCustomStatus.describe('Custom status validation result'), }), - z.object({ - type: z.literal('geoip_lookup').describe('Response type for IP geolocation lookup'), - data: z - .object({ - country_code: z.string().describe('Two-letter country code'), - latitude: createStringType(1, 32).optional().describe('Latitude from IP geolocation'), - longitude: createStringType(1, 32).optional().describe('Longitude from IP geolocation'), - }) - .describe('Geolocation result'), - }), z.object({ type: z.literal('get_dm_channel').describe('Response type for DM channel fetch'), data: z @@ -645,14 +570,6 @@ export const RpcResponse = z.discriminatedUnion('type', [ }) .describe('Channel threads config result'), }), - z.object({ - type: z.literal('get_thread_memberships').describe('Response type for the thread memberships of a user'), - data: z - .object({ - members: z.array(ThreadMemberRpcResponse).describe('Thread memberships of the user in the guild'), - }) - .describe('Thread memberships result'), - }), z.object({ type: z.literal('list_thread_members').describe('Response type for a page of thread members'), data: z diff --git a/packages/schema/src/domains/tests/GuildChannelOrdering.test.ts b/packages/schema/src/domains/tests/GuildChannelOrdering.test.ts deleted file mode 100644 index c2e6a03e3..000000000 --- a/packages/schema/src/domains/tests/GuildChannelOrdering.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; -import { - type ChannelOrderingChannel, - computeGuildChannelReorderPlan, - computePositionFromPrecedingSiblingId, - computePrecedingSiblingIdFromPosition, - sortChannelsForOrdering, -} from '@fluxer/schema/src/domains/channel/GuildChannelOrdering'; -import {describe, expect, it} from 'vitest'; - -type Ch = ChannelOrderingChannel; - -const createFixture = (): Array => [ - {id: 'cat1', parentId: null, type: ChannelTypes.GUILD_CATEGORY, position: 1}, - {id: 'c1_text1', parentId: 'cat1', type: ChannelTypes.GUILD_TEXT, position: 2}, - {id: 'c1_voice1', parentId: 'cat1', type: ChannelTypes.GUILD_VOICE, position: 3}, - {id: 'cat2', parentId: null, type: ChannelTypes.GUILD_CATEGORY, position: 4}, - {id: 'c2_text1', parentId: 'cat2', type: ChannelTypes.GUILD_TEXT, position: 5}, - {id: 'c2_voice1', parentId: 'cat2', type: ChannelTypes.GUILD_VOICE, position: 6}, - {id: 'top_text', parentId: null, type: ChannelTypes.GUILD_TEXT, position: 7}, -]; - -describe('GuildChannelOrdering', () => { - it('round-trips position <-> preceding sibling for a parent', () => { - const channels = createFixture(); - const preceding = computePrecedingSiblingIdFromPosition({ - channels, - targetId: 'c2_voice1', - desiredParentId: 'cat2', - position: 1, - }); - expect(preceding).toBe('c2_text1'); - const position = computePositionFromPrecedingSiblingId({ - channels, - targetId: 'c2_voice1', - desiredParentId: 'cat2', - precedingSiblingId: preceding, - }); - expect(position).toBe(1); - }); - it('inserts after a preceding category block (category + children)', () => { - const channels = createFixture(); - const result = computeGuildChannelReorderPlan({ - channels, - operation: {channelId: 'cat1', parentId: null, precedingSiblingId: 'cat2'}, - }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.plan.finalChannels.map((c) => c.id)).toEqual([ - 'cat2', - 'c2_text1', - 'c2_voice1', - 'cat1', - 'c1_text1', - 'c1_voice1', - 'top_text', - ]); - }); - it('rejects positioning relative to a channel inside the moved category block', () => { - const channels = createFixture(); - const result = computeGuildChannelReorderPlan({ - channels, - operation: {channelId: 'cat1', parentId: null, precedingSiblingId: 'c1_text1'}, - }); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.code).toBe('CANNOT_POSITION_RELATIVE_TO_SELF_BLOCK'); - }); - it('computes sibling positions against the list with the moved block removed', () => { - const channels = createFixture(); - const position = computePositionFromPrecedingSiblingId({ - channels, - targetId: 'c1_text1', - desiredParentId: 'cat1', - precedingSiblingId: 'c1_voice1', - }); - expect(position).toBe(1); - }); - it('moves a channel across categories via preceding sibling', () => { - const channels = createFixture(); - const result = computeGuildChannelReorderPlan({ - channels, - operation: {channelId: 'c1_text1', parentId: 'cat2', precedingSiblingId: 'c2_text1'}, - }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.plan.desiredParentById.get('c1_text1')).toBe('cat2'); - const ids = result.plan.finalChannels.map((c) => c.id); - const c2TextIdx = ids.indexOf('c2_text1'); - const c1TextIdx = ids.indexOf('c1_text1'); - expect(c1TextIdx).toBe(c2TextIdx + 1); - }); - it('moves a channel to the first position inside a category (no preceding sibling)', () => { - const channels = createFixture(); - const result = computeGuildChannelReorderPlan({ - channels, - operation: {channelId: 'top_text', parentId: 'cat1', precedingSiblingId: null}, - }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.plan.desiredParentById.get('top_text')).toBe('cat1'); - const ids = result.plan.finalChannels.map((c) => c.id); - const cat1Idx = ids.indexOf('cat1'); - const topTextIdx = ids.indexOf('top_text'); - expect(topTextIdx).toBe(cat1Idx + 1); - }); - it('returns orderUnchanged when the channel is already in the requested position', () => { - const channels = createFixture(); - const result = computeGuildChannelReorderPlan({ - channels, - operation: {channelId: 'c1_text1', parentId: 'cat1', precedingSiblingId: null}, - }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.plan.orderUnchanged).toBe(true); - }); - it('rejects PRECEDING_PARENT_MISMATCH when sibling is in wrong parent', () => { - const channels = createFixture(); - const result = computeGuildChannelReorderPlan({ - channels, - operation: {channelId: 'c1_text1', parentId: 'cat1', precedingSiblingId: 'c2_text1'}, - }); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.code).toBe('PRECEDING_PARENT_MISMATCH'); - }); - it('ranks announcement channels with text channels ahead of voice channels', () => { - const channels: Array = [ - {id: 'cat', parentId: null, type: ChannelTypes.GUILD_CATEGORY, position: 0}, - {id: 'voice', parentId: 'cat', type: ChannelTypes.GUILD_VOICE, position: 1}, - {id: 'announcements', parentId: 'cat', type: ChannelTypes.GUILD_ANNOUNCEMENT, position: 3}, - {id: 'text', parentId: 'cat', type: ChannelTypes.GUILD_TEXT, position: 2}, - ]; - expect(sortChannelsForOrdering(channels).map((c) => c.id)).toEqual([ - 'cat', - 'text', - 'announcements', - 'voice', - ]); - }); -}); diff --git a/packages/schema/src/domains/tests/ReadStateProtoCodec.test.ts b/packages/schema/src/domains/tests/ReadStateProtoCodec.test.ts deleted file mode 100644 index 54ce21716..000000000 --- a/packages/schema/src/domains/tests/ReadStateProtoCodec.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import { - decodeReadStateProto, - decodeReadStateProtoBundle, - EMPTY_READ_STATE_PROTO, - encodeReadStateProto, - ReadStateProtoDecodeError, -} from '@fluxer/schema/src/domains/read_state/ReadStateProtoCodec'; -import {describe, expect, it} from 'vitest'; - -describe('ReadStateProtoCodec', () => { - it('encodes the empty bundle to the empty string', () => { - expect(encodeReadStateProto([])).toBe(EMPTY_READ_STATE_PROTO); - expect(EMPTY_READ_STATE_PROTO).toBe(''); - }); - it('decodes empty/null/undefined to an empty array', () => { - expect(decodeReadStateProto('')).toEqual([]); - expect(decodeReadStateProto(null)).toEqual([]); - expect(decodeReadStateProto(undefined)).toEqual([]); - }); - it('round-trips read states without losing optional nulls', () => { - const encoded = encodeReadStateProto([ - { - id: '100', - mention_count: 3, - last_message_id: '200', - last_pin_timestamp: '2026-05-15T12:00:00.000Z', - version: '7', - }, - { - id: '101', - mention_count: 0, - last_message_id: null, - last_pin_timestamp: null, - }, - ]); - expect(encoded).not.toBe(''); - expect(decodeReadStateProto(encoded)).toEqual([ - { - id: '100', - mention_count: 3, - last_message_id: '200', - last_pin_timestamp: '2026-05-15T12:00:00.000Z', - version: '7', - }, - { - id: '101', - mention_count: 0, - last_message_id: null, - last_pin_timestamp: null, - version: undefined, - }, - ]); - }); - it('encodes snowflakes and versions as uint64 and pin times as protobuf timestamps', () => { - const encoded = encodeReadStateProto([ - { - id: '100', - last_message_id: '200', - last_pin_timestamp: '2026-05-15T12:00:00.000Z', - version: '7', - }, - ]); - const bundle = decodeReadStateProtoBundle(encoded); - expect(bundle.readStates[0]?.channelId).toBe(100n); - expect(bundle.readStates[0]?.lastMessageId).toBe(200n); - expect(bundle.readStates[0]?.version).toBe(7n); - expect(bundle.readStates[0]?.lastPinTimestamp?.seconds).toBe(1778846400n); - }); - it('rejects invalid base64 with a typed error', () => { - expect(() => decodeReadStateProto('not_base64!!!')).toThrow(ReadStateProtoDecodeError); - }); -}); diff --git a/packages/schema/src/gen/fluxer/read_state/v1/read_state_pb.ts b/packages/schema/src/gen/fluxer/read_state/v1/read_state_pb.ts deleted file mode 100644 index a6d718d35..000000000 --- a/packages/schema/src/gen/fluxer/read_state/v1/read_state_pb.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; -import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; -import type { Timestamp } from "@bufbuild/protobuf/wkt"; -import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; -import type { Message } from "@bufbuild/protobuf"; - -/** - * Describes the file fluxer/read_state/v1/read_state.proto. - */ -export const file_fluxer_read_state_v1_read_state: GenFile = /*@__PURE__*/ - fileDesc("CiVmbHV4ZXIvcmVhZF9zdGF0ZS92MS9yZWFkX3N0YXRlLnByb3RvEhRmbHV4ZXIucmVhZF9zdGF0ZS52MSLHAQoOUmVhZFN0YXRlRW50cnkSEgoKY2hhbm5lbF9pZBgBIAEoBBIcCg9sYXN0X21lc3NhZ2VfaWQYAiABKARIAIgBARIVCg1tZW50aW9uX2NvdW50GAMgASgNEjYKEmxhc3RfcGluX3RpbWVzdGFtcBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFAoHdmVyc2lvbhgFIAEoBEgBiAEBQhIKEF9sYXN0X21lc3NhZ2VfaWRCCgoIX3ZlcnNpb24iTAoPUmVhZFN0YXRlQnVuZGxlEjkKC3JlYWRfc3RhdGVzGAEgAygLMiQuZmx1eGVyLnJlYWRfc3RhdGUudjEuUmVhZFN0YXRlRW50cnliBnByb3RvMw", [file_google_protobuf_timestamp]); - -/** - * @generated from message fluxer.read_state.v1.ReadStateEntry - */ -export type ReadStateEntry = Message<"fluxer.read_state.v1.ReadStateEntry"> & { - /** - * @generated from field: uint64 channel_id = 1; - */ - channelId: bigint; - - /** - * @generated from field: optional uint64 last_message_id = 2; - */ - lastMessageId?: bigint | undefined; - - /** - * @generated from field: uint32 mention_count = 3; - */ - mentionCount: number; - - /** - * @generated from field: google.protobuf.Timestamp last_pin_timestamp = 4; - */ - lastPinTimestamp?: Timestamp | undefined; - - /** - * @generated from field: optional uint64 version = 5; - */ - version?: bigint | undefined; -}; - -/** - * Describes the message fluxer.read_state.v1.ReadStateEntry. - * Use `create(ReadStateEntrySchema)` to create a new message. - */ -export const ReadStateEntrySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_fluxer_read_state_v1_read_state, 0); - -/** - * @generated from message fluxer.read_state.v1.ReadStateBundle - */ -export type ReadStateBundle = Message<"fluxer.read_state.v1.ReadStateBundle"> & { - /** - * @generated from field: repeated fluxer.read_state.v1.ReadStateEntry read_states = 1; - */ - readStates: ReadStateEntry[]; -}; - -/** - * Describes the message fluxer.read_state.v1.ReadStateBundle. - * Use `create(ReadStateBundleSchema)` to create a new message. - */ -export const ReadStateBundleSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_fluxer_read_state_v1_read_state, 1); diff --git a/packages/schema/src/primitives/MessageValidators.ts b/packages/schema/src/primitives/MessageValidators.ts index edc542358..dc5cb052d 100644 --- a/packages/schema/src/primitives/MessageValidators.ts +++ b/packages/schema/src/primitives/MessageValidators.ts @@ -55,3 +55,40 @@ export const AllowedMentionParseTypeSchema = createNamedStringLiteralUnion( ], 'Types of mentions to parse from content', ); + +function isInvisibleContentCodePoint(codePoint: number): boolean { + return ( + codePoint === 0x0000 || + codePoint === 0x00ad || + codePoint === 0x034f || + codePoint === 0x061c || + codePoint === 0x115f || + codePoint === 0x1160 || + codePoint === 0x17b4 || + codePoint === 0x17b5 || + codePoint === 0x180e || + (codePoint >= 0x200b && codePoint <= 0x200f) || + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2060 && codePoint <= 0x2069) || + codePoint === 0x2800 || + codePoint === 0x3164 || + (codePoint >= 0xfe00 && codePoint <= 0xfe0f) || + codePoint === 0xfeff || + codePoint === 0xffa0 || + (codePoint >= 0xe0100 && codePoint <= 0xe01ef) + ); +} + +export function hasVisibleContent(value: string): boolean { + for (const char of value) { + if (char.trim().length === 0) { + continue; + } + const codePoint = char.codePointAt(0); + if (codePoint == null || isInvisibleContentCodePoint(codePoint)) { + continue; + } + return true; + } + return false; +} diff --git a/packages/schema/src/primitives/tests/ChannelValidators.test.ts b/packages/schema/src/primitives/tests/ChannelValidators.test.ts index 8ab890fa4..c37c9ca7d 100644 --- a/packages/schema/src/primitives/tests/ChannelValidators.test.ts +++ b/packages/schema/src/primitives/tests/ChannelValidators.test.ts @@ -2,14 +2,7 @@ import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; -import { - AuditLogReasonType, - ChannelNameType, - ChannelTypeSchema, - GeneralChannelNameType, - VanityURLCodeType, -} from '@fluxer/schema/src/primitives/ChannelValidators'; -import {MAX_STRING_PROCESSING_LENGTH} from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import {ChannelTypeSchema, VanityURLCodeType} from '@fluxer/schema/src/primitives/ChannelValidators'; import {describe, expect, it} from 'vitest'; describe('ChannelTypeSchema', () => { @@ -22,61 +15,6 @@ describe('ChannelTypeSchema', () => { }); }); -describe('ChannelNameType', () => { - it.each([ - ['general', 'general'], - ['GENERAL', 'general'], - ['my channel', 'my-channel'], - ['my#channel@name', 'mychannelname'], - ['日本語-ÉTÉ', '日本語-été'], - ['###', '-'], - ['', '-'], - [' my-channel ', 'my-channel'], - ['hello\u202Eworld', 'helloworld'], - ])('normalizes %j to %j', (input, expected) => { - expect(ChannelNameType.parse(input)).toBe(expected); - }); - - it('accepts the normalized length boundary', () => { - const name = 'a'.repeat(100); - expect(ChannelNameType.parse(` ${name} `)).toBe(name); - }); - - it.each([101, MAX_STRING_PROCESSING_LENGTH + 1])('returns a validation issue for %i characters', (length) => { - expect(ChannelNameType.safeParse('a'.repeat(length))).toMatchObject({ - success: false, - error: {issues: [{message: ValidationErrorCodes.STRING_LENGTH_INVALID}]}, - }); - }); -}); - -describe('GeneralChannelNameType', () => { - it.each([ - ['General Chat', 'General Chat'], - ['My Channel', 'My Channel'], - ['My Channel', 'My Channel'], - [' My Channel ', 'My Channel'], - ['My\u00A0Channel', 'My Channel'], - ['My\u200DChannel', 'MyChannel'], - ])('preserves display text while normalizing %j', (input, expected) => { - expect(GeneralChannelNameType.parse(input)).toBe(expected); - }); - - it.each(['', ' ', '\u200D'])('rejects empty display names %j', (input) => { - expect(GeneralChannelNameType.safeParse(input)).toMatchObject({ - success: false, - error: {issues: [{message: ValidationErrorCodes.NAME_EMPTY_AFTER_NORMALIZATION}]}, - }); - }); - - it.each([101, MAX_STRING_PROCESSING_LENGTH + 1])('rejects %i characters without throwing', (length) => { - expect(GeneralChannelNameType.safeParse('a'.repeat(length))).toMatchObject({ - success: false, - error: {issues: [{message: ValidationErrorCodes.STRING_LENGTH_INVALID}]}, - }); - }); -}); - describe('VanityURLCodeType', () => { it.each([ ['myserver', 'myserver'], @@ -106,18 +44,3 @@ describe('VanityURLCodeType', () => { }); }); }); - -describe('AuditLogReasonType', () => { - it.each([ - ['User was spamming', 'User was spamming'], - [' Reason here ', 'Reason here'], - ['a'.repeat(512), 'a'.repeat(512)], - [null, null], - [undefined, null], - ['', null], - [' ', null], - ['a'.repeat(513), null], - ])('normalizes %j', (input, expected) => { - expect(AuditLogReasonType.parse(input)).toBe(expected); - }); -}); diff --git a/packages/schema/src/primitives/tests/QueryValidators.test.ts b/packages/schema/src/primitives/tests/QueryValidators.test.ts index b996b5b4d..dfdc99cb7 100644 --- a/packages/schema/src/primitives/tests/QueryValidators.test.ts +++ b/packages/schema/src/primitives/tests/QueryValidators.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; -import {createQueryIntegerType, DateTimeType, QueryBooleanType} from '@fluxer/schema/src/primitives/QueryValidators'; +import {createQueryIntegerType, QueryBooleanType} from '@fluxer/schema/src/primitives/QueryValidators'; import {describe, expect, it} from 'vitest'; describe('QueryBooleanType', () => { @@ -77,42 +77,3 @@ describe('createQueryIntegerType', () => { expect(createQueryIntegerType({maxValue: Number.MAX_VALUE}).safeParse('9007199254740993').success).toBe(false); }); }); - -describe('DateTimeType', () => { - it.each([ - ['2024-01-15T12:30:00Z', '2024-01-15T12:30:00.000Z'], - ['2024-01-15T12:30:00.123Z', '2024-01-15T12:30:00.123Z'], - ['2024-01-15T12:30:00+05:00', '2024-01-15T07:30:00.000Z'], - ['2024-01-15T12:30:00-0800', '2024-01-15T20:30:00.000Z'], - ['2024-02-29T00:00:00Z', '2024-02-29T00:00:00.000Z'], - ['2000-02-29T00:00:00Z', '2000-02-29T00:00:00.000Z'], - [1705323000000, '2024-01-15T12:50:00.000Z'], - [0, '1970-01-01T00:00:00.000Z'], - ])('parses %j to the exact instant', (input, expected) => { - expect(DateTimeType.parse(input).toISOString()).toBe(expected); - }); - - it('preserves timezone-free timestamps as local time', () => { - expect(DateTimeType.parse('2024-01-15T12:30:00')).toEqual(new Date(2024, 0, 15, 12, 30)); - }); - - it.each([ - '2024-01-15 12:30:00', - 'not-a-date', - '2023-02-29T00:00:00Z', - '1900-02-29T00:00:00Z', - '2024-04-31T00:00:00Z', - '2024-01-15T25:00:00Z', - -1, - 8640000000000001, - 1705323000000.5, - Number.NaN, - Number.POSITIVE_INFINITY, - ])('rejects invalid calendar dates or timestamps: %j', (input) => { - expect(DateTimeType.safeParse(input).success).toBe(false); - }); - - it('accepts the maximum JavaScript date', () => { - expect(DateTimeType.parse(8640000000000000).getTime()).toBe(8640000000000000); - }); -}); diff --git a/packages/schema/src/primitives/tests/SchemaPrimitives.test.ts b/packages/schema/src/primitives/tests/SchemaPrimitives.test.ts index dec2f36d8..1ecb717b8 100644 --- a/packages/schema/src/primitives/tests/SchemaPrimitives.test.ts +++ b/packages/schema/src/primitives/tests/SchemaPrimitives.test.ts @@ -2,11 +2,9 @@ import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import { - ColorType, coerceNumberFromString, createInt32EnumType, createStringType, - createUnboundedStringType, Int32Type, Int64StringType, Int64Type, @@ -123,21 +121,6 @@ describe('SnowflakeType', () => { }); }); -describe('ColorType', () => { - it.each([0xff5500, 0x000000, 0xffffff])('preserves valid color %i', (value) => { - expect(ColorType.parse(value)).toBe(value); - }); - it.each([ - [-1, ValidationErrorCodes.COLOR_VALUE_TOO_LOW], - [0x1000000, ValidationErrorCodes.COLOR_VALUE_TOO_HIGH], - ])('reports the color-specific boundary error for %i', (input, message) => { - expect(ColorType.safeParse(input)).toMatchObject({success: false, error: {issues: [{message}]}}); - }); - it('rejects fractional colors', () => { - expect(ColorType.safeParse(123.45).success).toBe(false); - }); -}); - describe.each([ {name: 'Int32Type', schema: Int32Type, maximum: 2147483647}, {name: 'NonNegativeSafeIntegerType', schema: NonNegativeSafeIntegerType, maximum: Number.MAX_SAFE_INTEGER}, @@ -205,19 +188,6 @@ describe('createStringType', () => { }); }); -describe('createUnboundedStringType', () => { - it('normalizes string without length validation', () => { - const UnboundedStringType = createUnboundedStringType(); - const result = UnboundedStringType.parse(' hello\x00world '); - expect(result).toBe('hello\x00world'); - }); - it('accepts empty strings', () => { - const UnboundedStringType = createUnboundedStringType(); - const result = UnboundedStringType.parse(''); - expect(result).toBe(''); - }); -}); - describe('removeStandaloneSurrogates', () => { it('preserves valid characters', () => { expect(removeStandaloneSurrogates('hello')).toBe('hello'); @@ -237,18 +207,6 @@ describe('removeStandaloneSurrogates', () => { }); describe('normalizeWhitespace', () => { - it('collapses multiple spaces to single space', () => { - expect(normalizeWhitespace('hello world')).toBe('hello world'); - }); - it('normalizes unicode spaces', () => { - expect(normalizeWhitespace('hello\u00A0world')).toBe('hello world'); - }); - it('trims leading and trailing whitespace', () => { - expect(normalizeWhitespace(' hello world ')).toBe('hello world'); - }); - it('handles strings with only whitespace', () => { - expect(normalizeWhitespace(' ')).toBe(''); - }); it('throws on excessively long strings', () => { const longString = 'a'.repeat(10001); expect(() => normalizeWhitespace(longString)).toThrow(ValidationErrorCodes.STRING_LENGTH_INVALID); @@ -278,15 +236,6 @@ describe('stripInvisibles', () => { }); describe('stripVariationSelectors', () => { - it('removes basic variation selectors', () => { - expect(stripVariationSelectors('hello\uFE0Fworld')).toBe('helloworld'); - }); - it('removes ideographic variation selectors', () => { - expect(stripVariationSelectors('hello\u{E0100}world')).toBe('helloworld'); - }); - it('preserves normal text', () => { - expect(stripVariationSelectors('Hello, World!')).toBe('Hello, World!'); - }); it('throws on excessively long strings', () => { const longString = 'a'.repeat(10001); expect(() => stripVariationSelectors(longString)).toThrow(ValidationErrorCodes.STRING_LENGTH_INVALID); diff --git a/packages/schema/src/primitives/tests/UrlValidators.test.ts b/packages/schema/src/primitives/tests/UrlValidators.test.ts index 9fd9d7594..e991126f6 100644 --- a/packages/schema/src/primitives/tests/UrlValidators.test.ts +++ b/packages/schema/src/primitives/tests/UrlValidators.test.ts @@ -2,33 +2,9 @@ import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {AttachmentURLType, setIsDevelopment, URLType} from '@fluxer/schema/src/primitives/UrlValidators'; -import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {beforeEach, describe, expect, it} from 'vitest'; describe('URLType', () => { - it('accepts valid HTTPS URLs', () => { - const result = URLType.parse('https://example.com'); - expect(result).toBe('https://example.com'); - }); - it('accepts valid HTTP URLs', () => { - const result = URLType.safeParse('http://example.com'); - expect(result.success).toBe(true); - }); - it('accepts URLs with paths', () => { - const result = URLType.safeParse('https://example.com/path/to/resource'); - expect(result.success).toBe(true); - }); - it('accepts URLs with query parameters', () => { - const result = URLType.safeParse('https://example.com/page?param=value'); - expect(result.success).toBe(true); - }); - it('accepts URLs with subdomains', () => { - const result = URLType.safeParse('https://sub.domain.example.com'); - expect(result.success).toBe(true); - }); - it('accepts URLs with ports', () => { - const result = URLType.safeParse('https://example.com:8080'); - expect(result.success).toBe(true); - }); it('rejects URLs without protocol', () => { const result = URLType.safeParse('example.com'); expect(result.success).toBe(false); @@ -36,14 +12,6 @@ describe('URLType', () => { expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_URL_FORMAT); } }); - it('accepts URLs with fragments', () => { - const result = URLType.parse('https://example.com#section'); - expect(result).toBe('https://example.com#section'); - }); - it('accepts URLs with complex fragments', () => { - const result = URLType.safeParse('https://example.com/page#section-1.2'); - expect(result.success).toBe(true); - }); it('rejects FTP URLs', () => { const result = URLType.safeParse('ftp://example.com'); expect(result.success).toBe(false); @@ -64,33 +32,9 @@ describe('URLType', () => { const result = URLType.safeParse(''); expect(result.success).toBe(false); }); - it('trims whitespace', () => { - const result = URLType.parse(' https://example.com '); - expect(result).toBe('https://example.com'); - }); }); describe('AttachmentURLType', () => { - it('accepts valid HTTPS URLs', () => { - const result = AttachmentURLType.safeParse('https://example.com/image.png'); - expect(result.success).toBe(true); - }); - it('accepts valid attachment:// URLs', () => { - const result = AttachmentURLType.parse('attachment://image.png'); - expect(result).toBe('attachment://image.png'); - }); - it('accepts attachment URLs with unicode filenames', () => { - const result = AttachmentURLType.safeParse('attachment://archivo.png'); - expect(result.success).toBe(true); - }); - it('accepts attachment URLs with numbers', () => { - const result = AttachmentURLType.safeParse('attachment://file123.txt'); - expect(result.success).toBe(true); - }); - it('accepts attachment URLs with underscores and hyphens', () => { - const result = AttachmentURLType.safeParse('attachment://my_file-name.txt'); - expect(result.success).toBe(true); - }); it('rejects attachment URLs with empty filename', () => { const result = AttachmentURLType.safeParse('attachment://'); expect(result.success).toBe(false); @@ -115,31 +59,6 @@ describe('AttachmentURLType', () => { const result = AttachmentURLType.safeParse(`https://example.com/${longFilename}`); expect(result.success).toBe(false); }); - it('trims and normalizes input', () => { - const result = AttachmentURLType.parse(' attachment://file.txt '); - expect(result).toBe('attachment://file.txt'); - }); -}); - -describe('URL validators in development mode', () => { - beforeEach(() => { - setIsDevelopment(true); - }); - afterEach(() => { - setIsDevelopment(false); - }); - it('accepts localhost URLs in development mode', () => { - const result = URLType.safeParse('http://localhost:3000'); - expect(result.success).toBe(true); - }); - it('accepts URLs without TLD in development mode', () => { - const result = URLType.safeParse('http://myservice:8080'); - expect(result.success).toBe(true); - }); - it('accepts attachment URLs to local services', () => { - const result = AttachmentURLType.safeParse('http://localhost:3000/image.png'); - expect(result.success).toBe(true); - }); }); describe('URL validators in production mode', () => { diff --git a/packages/schema/src/primitives/tests/UserValidators.test.ts b/packages/schema/src/primitives/tests/UserValidators.test.ts index 9ec70f847..567d996b4 100644 --- a/packages/schema/src/primitives/tests/UserValidators.test.ts +++ b/packages/schema/src/primitives/tests/UserValidators.test.ts @@ -1,86 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; -import { - DiscriminatorType, - EmailType, - GlobalNameType, - PasswordType, - UsernameType, - WebhookNameType, -} from '@fluxer/schema/src/primitives/UserValidators'; +import {GlobalNameType, PasswordType, UsernameType} from '@fluxer/schema/src/primitives/UserValidators'; import {describe, expect, it} from 'vitest'; -describe('EmailType', () => { - it('accepts valid email addresses', () => { - const result = EmailType.parse('user@example.com'); - expect(result).toBe('user@example.com'); - }); - it('accepts emails with subdomains', () => { - const result = EmailType.safeParse('user@mail.example.com'); - expect(result.success).toBe(true); - }); - it('accepts emails with plus addressing', () => { - const result = EmailType.safeParse('user+tag@example.com'); - expect(result.success).toBe(true); - }); - it('rejects emails without @ symbol', () => { - const result = EmailType.safeParse('userexample.com'); - expect(result.success).toBe(false); - }); - it('rejects emails without domain', () => { - const result = EmailType.safeParse('user@'); - expect(result.success).toBe(false); - }); - it('rejects emails with invalid local part characters', () => { - const result = EmailType.safeParse('user name@example.com'); - expect(result.success).toBe(false); - }); - it('rejects emails exceeding max length', () => { - const longLocal = 'a'.repeat(250); - const result = EmailType.safeParse(`${longLocal}@example.com`); - expect(result.success).toBe(false); - }); - it('trims emails with leading/trailing whitespace', () => { - const result = EmailType.parse(' user@example.com '); - expect(result).toBe('user@example.com'); - }); -}); - -describe('DiscriminatorType', () => { - it('accepts valid single digit discriminators', () => { - const result = DiscriminatorType.parse('1'); - expect(result).toBe(1); - }); - it('accepts valid four digit discriminators', () => { - const result = DiscriminatorType.parse('1234'); - expect(result).toBe(1234); - }); - it('accepts zero as discriminator', () => { - const result = DiscriminatorType.parse('0'); - expect(result).toBe(0); - }); - it('accepts discriminators with leading zeros', () => { - const result = DiscriminatorType.parse('0001'); - expect(result).toBe(1); - }); - it('rejects discriminators with more than 4 digits', () => { - const result = DiscriminatorType.safeParse('12345'); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues[0].message).toBe(ValidationErrorCodes.DISCRIMINATOR_INVALID_FORMAT); - } - }); - it('rejects non-numeric discriminators', () => { - const result = DiscriminatorType.safeParse('abc'); - expect(result.success).toBe(false); - }); - it('rejects negative discriminators', () => { - const result = DiscriminatorType.safeParse('-1'); - expect(result.success).toBe(false); - }); -}); - describe('UsernameType', () => { it('accepts valid alphanumeric usernames', () => { const result = UsernameType.parse('testuser'); @@ -144,29 +67,6 @@ describe('UsernameType', () => { }); describe('GlobalNameType', () => { - it('accepts valid display names', () => { - const result = GlobalNameType.parse('Test User'); - expect(result).toBe('Test User'); - }); - it('accepts names with unicode characters', () => { - const result = GlobalNameType.safeParse('Test User'); - expect(result.success).toBe(true); - }); - it('sanitizes and normalizes input', () => { - const result = GlobalNameType.parse(' Test User '); - expect(result).toBe('Test User'); - }); - it('rejects empty names', () => { - const result = GlobalNameType.safeParse(''); - expect(result.success).toBe(false); - }); - it('rejects names exceeding max length', () => { - const result = GlobalNameType.safeParse('a'.repeat(33)); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues[0].message).toBe(ValidationErrorCodes.GLOBAL_NAME_LENGTH_INVALID); - } - }); it('rejects "everyone" as global name', () => { const result = GlobalNameType.safeParse('everyone'); expect(result.success).toBe(false); @@ -215,33 +115,3 @@ describe('PasswordType', () => { expect(result).toBe('password123'); }); }); - -describe('WebhookNameType', () => { - it('accepts valid webhook names', () => { - const result = WebhookNameType.parse('My Webhook'); - expect(result).toBe('My Webhook'); - }); - it('accepts single character names', () => { - const result = WebhookNameType.safeParse('A'); - expect(result.success).toBe(true); - }); - it('accepts names at maximum length', () => { - const result = WebhookNameType.safeParse('a'.repeat(80)); - expect(result.success).toBe(true); - }); - it('rejects empty names', () => { - const result = WebhookNameType.safeParse(''); - expect(result.success).toBe(false); - }); - it('rejects names exceeding maximum length', () => { - const result = WebhookNameType.safeParse('a'.repeat(81)); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues[0].message).toBe(ValidationErrorCodes.WEBHOOK_NAME_LENGTH_INVALID); - } - }); - it('trims and normalizes webhook names', () => { - const result = WebhookNameType.parse(' My Webhook '); - expect(result).toBe('My Webhook'); - }); -}); diff --git a/packages/snowflake/src/Snowflake.ts b/packages/snowflake/src/Snowflake.ts index 9453ee406..a4a3814df 100644 --- a/packages/snowflake/src/Snowflake.ts +++ b/packages/snowflake/src/Snowflake.ts @@ -164,10 +164,6 @@ export function createSnowflake(options: CreateSnowflakeOptions): bigint { return createSnowflakeBigInt(relativeTimestamp, workerId, sequence); } -export function createSnowflakeFromTimestamp(timestamp: number | bigint, workerId = 0): bigint { - return createSnowflake({timestamp, workerId}); -} - export function snowflakeToDate(snowflake: bigint): Date { return new Date(Number(toEpochTimestamp(snowflake >> TIMESTAMP_SHIFT))); } @@ -194,3 +190,7 @@ export function isValidSnowflake(value: unknown): value is bigint { } return true; } + +export function createSnowflakeFromTimestamp(timestamp: number | bigint, workerId = 0): bigint { + return createSnowflake({timestamp, workerId}); +} diff --git a/packages/voice_engine_v2/fixtures/event_logs/app_voice_session.json b/packages/voice_engine_v2/fixtures/event_logs/app_voice_session.json index a7cf3caad..06f48ad13 100644 --- a/packages/voice_engine_v2/fixtures/event_logs/app_voice_session.json +++ b/packages/voice_engine_v2/fixtures/event_logs/app_voice_session.json @@ -74,18 +74,6 @@ }, "commands": [] }, - { - "name": "gateway voice server arrives", - "event": { - "type": "gateway.voiceServerUpdated", - "voiceServer": { - "guildId": "guild-1", - "endpoint": "voice-gateway.example.test", - "token": "gateway-token-1" - } - }, - "commands": [] - }, { "name": "enumerate devices", "event": { @@ -240,19 +228,6 @@ } ] }, - { - "name": "livekit enters connecting", - "event": { - "type": "livekit.roomStateChanged", - "room": { - "connectionState": "connecting", - "roomSid": null, - "roomName": "guild-1/channel-1", - "serverRegion": null - } - }, - "commands": [] - }, { "name": "voice connection succeeds and queued media publishes", "event": { @@ -304,19 +279,6 @@ } ] }, - { - "name": "livekit room connects", - "event": { - "type": "livekit.roomStateChanged", - "room": { - "connectionState": "connected", - "roomSid": "room-1", - "roomName": "guild-1/channel-1", - "serverRegion": "us-east" - } - }, - "commands": [] - }, { "name": "microphone publish succeeds", "event": { @@ -643,19 +605,14 @@ "suppress": false, "requestToSpeakTimestamp": null }, - "voiceServer": { - "guildId": "guild-1", - "endpoint": "voice-gateway.example.test", - "token": "gateway-token-1" - }, "operationId": null, "failure": null }, "liveKit": { - "connectionState": "connected", - "roomSid": "room-1", - "roomName": "guild-1/channel-1", - "serverRegion": "us-east", + "connectionState": "disconnected", + "roomSid": null, + "roomName": null, + "serverRegion": null, "failure": null }, "microphone": { @@ -810,11 +767,6 @@ }, "statsOperationId": null, "statsFailure": null, - "hardwareEncoder": { - "capabilities": null, - "operationId": null, - "failure": null - }, "permissions": { "results": { "microphone": { @@ -943,18 +895,13 @@ "selfStream": true, "suppress": false, "requestToSpeakTimestamp": null - }, - "voiceServer": { - "guildId": "guild-1", - "endpoint": "voice-gateway.example.test", - "token": "gateway-token-1" } }, "liveKit": { - "connectionState": "connected", - "roomSid": "room-1", - "roomName": "guild-1/channel-1", - "serverRegion": "us-east" + "connectionState": "disconnected", + "roomSid": null, + "roomName": null, + "serverRegion": null } }, "media": { diff --git a/packages/voice_engine_v2/fixtures/event_logs/capabilities_lifecycle.json b/packages/voice_engine_v2/fixtures/event_logs/capabilities_lifecycle.json deleted file mode 100644 index cc0239606..000000000 --- a/packages/voice_engine_v2/fixtures/event_logs/capabilities_lifecycle.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "capabilities_lifecycle", - "events": [ - { - "type": "capabilities.changed", - "capabilities": { - "connect": true, - "microphone": true, - "camera": true, - "screen": true, - "screenAudio": true, - "outputDevice": true, - "participantVolume": true, - "remoteTrackSubscription": true, - "dataChannel": true, - "stats": true, - "nativeVideoFrames": true, - "hardwareEncoding": true, - "zeroCopyScreenTransport": true, - "nativeAudioTaps": true - } - }, - {"type": "capabilities.hardwareEncoderQueryRequested"}, - { - "type": "capabilities.hardwareEncoderChanged", - "operationId": 1, - "capabilities": { - "available": true, - "backend": "nvenc", - "compiled": true, - "runtime": true, - "codecs": ["h264", "hevc"], - "zeroCopy": true, - "nativeInputs": ["d3d11-texture"] - } - } - ], - "expectedCommands": [[], [{"type": "capabilities.queryHardwareEncoder", "operationId": 1}], []], - "expected": { - "nextOperationId": 2, - "hardwareEncoderOperationId": null, - "hardwareEncoderFailureCode": null, - "hardwareEncoderBackend": "nvenc", - "hasZeroCopyNativeInput": true, - "hasNativeNvencH264": true, - "hasNativeNvencH265": true - } -} diff --git a/packages/voice_engine_v2/fixtures/event_logs/diagnostics_lifecycle.json b/packages/voice_engine_v2/fixtures/event_logs/diagnostics_lifecycle.json deleted file mode 100644 index c121348ce..000000000 --- a/packages/voice_engine_v2/fixtures/event_logs/diagnostics_lifecycle.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "diagnostics_lifecycle", - "events": [ - { - "type": "diagnostics.logRequested", - "entry": { - "id": "diag-1", - "atMs": 1000, - "level": "info", - "code": "voice.session.started", - "message": "Voice session started" - } - }, - {"type": "command.succeeded", "operationId": 1, "commandType": "diagnostics.log"}, - { - "type": "diagnostics.logged", - "operationId": null, - "entry": { - "id": "diag-2", - "atMs": 1010, - "level": "warn", - "code": "voice.stats.delayed", - "message": "Stats collection was delayed", - "detail": {"delayMs": 250} - } - } - ], - "expectedCommands": [ - [ - { - "type": "diagnostics.log", - "operationId": 1, - "entry": { - "id": "diag-1", - "atMs": 1000, - "level": "info", - "code": "voice.session.started", - "message": "Voice session started" - } - } - ], - [], - [] - ], - "expected": { - "nextOperationId": 2, - "entryIds": ["diag-1", "diag-2"], - "lastCode": "voice.stats.delayed", - "operationStatuses": {"1": "succeeded"} - } -} diff --git a/packages/voice_engine_v2/fixtures/event_logs/inbound_video_frame_stats.json b/packages/voice_engine_v2/fixtures/event_logs/inbound_video_frame_stats.json deleted file mode 100644 index 69b98e191..000000000 --- a/packages/voice_engine_v2/fixtures/event_logs/inbound_video_frame_stats.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "name": "inbound video frame stats projection", - "events": [ - { - "type": "inboundVideo.trackSubscribed", - "track": { - "participantSid": "PA_alice", - "participantIdentity": "alice", - "trackSid": "TR_screen", - "source": "screen" - } - }, - { - "type": "inboundVideo.frameReceived", - "frame": { - "participantSid": "PA_alice", - "participantIdentity": "alice", - "trackSid": "TR_screen", - "width": 640, - "height": 360, - "timestampUs": 1000, - "byteLength": 345600 - } - }, - { - "type": "inboundVideo.frameStats", - "stats": { - "participantSid": "PA_alice", - "participantIdentity": "alice", - "trackSid": "TR_screen", - "width": 1280, - "height": 720, - "frameCount": 31, - "lastFrameTimestampUs": 1033000, - "lastFrameByteLength": 1382400 - } - }, - { - "type": "inboundVideo.frameStats", - "stats": { - "participantSid": "PA_alice", - "participantIdentity": "alice", - "trackSid": "TR_screen", - "width": 1280, - "height": 720, - "frameCount": 61, - "lastFrameTimestampUs": 2033000, - "lastFrameByteLength": 1382400 - } - }, - { - "type": "inboundVideo.frameStats", - "stats": { - "participantSid": "PA_bob", - "trackSid": "TR_unknown", - "width": 320, - "height": 180, - "frameCount": 5, - "lastFrameTimestampUs": 3000000, - "lastFrameByteLength": 86400 - } - } - ], - "expected": { - "inboundVideoTracks": [ - { - "participantSid": "PA_alice", - "participantIdentity": "alice", - "trackSid": "TR_screen", - "source": "screen", - "width": 1280, - "height": 720, - "frameCount": 61, - "lastFrameTimestampUs": 2033000, - "lastFrameByteLength": 1382400 - } - ], - "droppedFrameCount": 1, - "commands": [] - } -} diff --git a/packages/voice_engine_v2/fixtures/event_logs/stats_lifecycle.json b/packages/voice_engine_v2/fixtures/event_logs/stats_lifecycle.json deleted file mode 100644 index 24bd57a61..000000000 --- a/packages/voice_engine_v2/fixtures/event_logs/stats_lifecycle.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "name": "stats_lifecycle", - "events": [ - { - "type": "connection.connectRequested", - "options": { - "url": "wss://voice.example.test", - "token": "token-1" - } - }, - {"type": "connection.connectSucceeded", "operationId": 1}, - {"type": "stats.collectRequested"}, - { - "type": "stats.collected", - "operationId": 2, - "stats": { - "rttMs": 24, - "outbound": [ - { - "trackSid": "mic-track", - "source": "microphone", - "kind": "audio", - "bitrateKbps": 42, - "packetsLost": 1, - "packetsSent": 100 - }, - { - "trackSid": "screen-track", - "source": "screenshare", - "kind": "video", - "codec": "video/H265", - "bitrateKbps": 3500, - "packetsLost": 0, - "effectiveFps": 58.5, - "configuredFps": 60, - "targetFps": 60, - "width": 1920, - "height": 1080, - "zeroCopy": true - } - ], - "inbound": [ - { - "participantIdentity": "remote-user", - "participantSid": "participant-1", - "trackSid": "remote-audio", - "kind": "audio", - "bitrateKbps": 36, - "packetsLost": 3, - "packetsReceived": 97, - "jitterMs": 7 - } - ], - "droppedNativeVideoFrames": 2, - "droppedVideoFrameCallbacks": 1 - } - } - ], - "expectedCommands": [ - [ - { - "type": "connection.connect", - "operationId": 1, - "options": {"url": "wss://voice.example.test", "token": "token-1"} - } - ], - [], - [{"type": "stats.collect", "operationId": 2}], - [] - ], - "expected": { - "nextOperationId": 3, - "statsOperationId": null, - "statsFailureCode": null, - "summary": { - "network": { - "audioSendBitrateKbps": 42, - "audioRecvBitrateKbps": 36, - "videoSendBitrateKbps": 3500, - "videoRecvBitrateKbps": 0, - "audioPacketLossPercent": 3, - "videoPacketLossPercent": 0, - "jitterMs": 7, - "rttMs": 24 - }, - "localAudio": {"trackIdentifier": "mic-track"}, - "localScreenShare": { - "trackIdentifier": "screen-track", - "codec": "video/H265", - "effectiveFramesPerSecond": 58.5, - "configuredFramesPerSecond": 60, - "targetFramesPerSecond": 60, - "frameWidth": 1920, - "frameHeight": 1080 - }, - "remoteAudio": {"trackIdentifier": "remote-audio"} - } - } -} diff --git a/packages/voice_engine_v2/fixtures/policies/voice_stats_coercion.json b/packages/voice_engine_v2/fixtures/policies/voice_stats_coercion.json deleted file mode 100644 index a48634fc8..000000000 --- a/packages/voice_engine_v2/fixtures/policies/voice_stats_coercion.json +++ /dev/null @@ -1,188 +0,0 @@ -[ - { - "name": "coerces malformed sparse payload to safe defaults", - "input": { - "rttMs": "bad", - "droppedVideoFrameCallbacks": -2, - "droppedNativeVideoFrames": 3.8, - "outbound": [ - null, - { - "trackSid": "", - "source": 42, - "kind": "data", - "bitrateKbps": "bad", - "packetsLost": null, - "width": 1920.9, - "framesDropped": -4.2, - "zeroCopy": false - } - ], - "inbound": [ - null, - { - "participantSid": "PA_1", - "trackSid": "TR_audio", - "kind": "audio", - "bitrateKbps": 42.5, - "packetsLost": 2, - "jitterMs": "bad", - "audioLevel": 0.5 - } - ], - "send": null - }, - "expected": { - "rttMs": null, - "droppedVideoFrameCallbacks": 0, - "droppedNativeVideoFrames": 3, - "outbound": [ - { - "trackSid": "", - "source": "", - "kind": "audio", - "bitrateKbps": 0, - "packetsLost": 0, - "width": 1920, - "framesDropped": 0 - } - ], - "inbound": [ - { - "participantSid": "PA_1", - "trackSid": "TR_audio", - "kind": "audio", - "bitrateKbps": 42.5, - "packetsLost": 2, - "audioLevel": 0.5 - } - ], - "send": null - } - }, - { - "name": "preserves native video diagnostics and clamps send stats", - "input": { - "rttMs": 27, - "droppedVideoFrameCallbacks": 2, - "outbound": [ - { - "trackSid": "TR_screen", - "source": "screen", - "kind": "video", - "codec": "video/H265", - "bitrateKbps": 2500.25, - "packetsLost": 1, - "fps": 59.5, - "audioLevel": 0.1, - "width": 2176, - "height": 1200, - "sourceWidth": 2176, - "sourceHeight": 1200, - "targetBitrateKbps": 50000, - "configuredFps": 60, - "targetFps": 60, - "effectiveFps": 58.7, - "framesProduced": 120.8, - "framesAccepted": 119.4, - "framesDropped": 1.9, - "framesCoalesced": 0, - "framesCaptured": 118.2, - "captureFailures": 0, - "maxQueueAgeMs": 12.5, - "maxPushLatencyMs": 8.25, - "adaptiveSendTier": "full", - "adaptiveSendReason": "disabled", - "zeroCopy": true - } - ], - "inbound": [], - "send": { - "outgoingVideoQueueDepth": 4, - "outgoingVideoQueueCapacity": 8, - "outgoingVideoMaxQueueDepth": 6, - "outgoingVideoFramesProduced": 120, - "outgoingVideoFramesAccepted": 119, - "outgoingVideoFramesDropped": 1, - "outgoingVideoFramesCoalesced": 0, - "outgoingVideoFramesCaptured": 118, - "outgoingVideoCaptureFailures": -1, - "outgoingVideoEffectiveFps": 58.7, - "outgoingVideoTargetFps": 60, - "outgoingVideoPacingTargetFps": 60, - "outgoingVideoMaxQueueAgeMs": 12.5, - "outgoingVideoMaxPushLatencyMs": 8.25, - "outgoingVideoPacingMode": "source", - "outgoingVideoBusActive": true, - "outgoingAudioBufferTargetMs": 20, - "outgoingAudioBufferMaxMs": 80, - "outgoingAudioUnderruns": 0, - "outgoingAudioRebuffers": 1, - "outgoingAudioMaxFrameGapMs": 24, - "adaptiveSendTier": "full", - "adaptiveSendReason": "disabled" - } - }, - "expected": { - "rttMs": 27, - "droppedVideoFrameCallbacks": 2, - "outbound": [ - { - "trackSid": "TR_screen", - "source": "screen", - "kind": "video", - "codec": "video/H265", - "bitrateKbps": 2500.25, - "packetsLost": 1, - "fps": 59.5, - "audioLevel": 0.1, - "width": 2176, - "height": 1200, - "sourceWidth": 2176, - "sourceHeight": 1200, - "targetBitrateKbps": 50000, - "configuredFps": 60, - "targetFps": 60, - "effectiveFps": 58.7, - "framesProduced": 120, - "framesAccepted": 119, - "framesDropped": 1, - "framesCoalesced": 0, - "framesCaptured": 118, - "captureFailures": 0, - "maxQueueAgeMs": 12.5, - "maxPushLatencyMs": 8.25, - "adaptiveSendTier": "full", - "adaptiveSendReason": "disabled", - "zeroCopy": true - } - ], - "inbound": [], - "send": { - "outgoingVideoQueueDepth": 4, - "outgoingVideoQueueCapacity": 8, - "outgoingVideoMaxQueueDepth": 6, - "outgoingVideoFramesProduced": 120, - "outgoingVideoFramesAccepted": 119, - "outgoingVideoFramesDropped": 1, - "outgoingVideoFramesCoalesced": 0, - "outgoingVideoFramesCaptured": 118, - "outgoingVideoCaptureFailures": 0, - "outgoingVideoEffectiveFps": 58.7, - "outgoingVideoTargetFps": 60, - "outgoingVideoPacingTargetFps": 60, - "outgoingVideoMaxQueueAgeMs": 12.5, - "outgoingVideoMaxPushLatencyMs": 8.25, - "outgoingVideoPacingMode": "source", - "outgoingVideoBusActive": true, - "outgoingAudioBufferTargetMs": 20, - "outgoingAudioBufferMaxMs": 80, - "outgoingAudioUnderruns": 0, - "outgoingAudioRebuffers": 1, - "outgoingAudioMaxFrameGapMs": 24, - "adaptiveSendTier": "full", - "adaptiveSendReason": "disabled" - } - } - } -] diff --git a/packages/voice_engine_v2/package.json b/packages/voice_engine_v2/package.json index ef6b99d8c..45d5f56c7 100644 --- a/packages/voice_engine_v2/package.json +++ b/packages/voice_engine_v2/package.json @@ -4,18 +4,9 @@ "private": true, "type": "module", "exports": { - ".": "./src/index.ts", "./bridge": "./src/bridge/index.ts", - "./core": "./src/core/index.ts", - "./implementations": "./src/implementations/index.ts", - "./policies": "./src/policies/index.ts", - "./ports": "./src/ports/index.ts", - "./protocol": "./src/protocol/index.ts", - "./runtime": "./src/runtime/index.ts", - "./testing": "./src/testing/index.ts" + "./ports": "./src/ports/index.ts" }, - "main": "./src/index.ts", - "types": "./src/index.ts", "scripts": { "bench": "vitest bench --run", "test": "vitest run", diff --git a/packages/voice_engine_v2/src/bridge/index.ts b/packages/voice_engine_v2/src/bridge/index.ts index 0d55b7182..b17f43a97 100644 --- a/packages/voice_engine_v2/src/bridge/index.ts +++ b/packages/voice_engine_v2/src/bridge/index.ts @@ -4,14 +4,12 @@ import { assertAudioFrameInvariants, assertVideoFrameInvariants, } from '@fluxer/voice_engine_v2/src/bridge/ffi_assertions'; -import type {VoiceEngineV2HardwareEncoderCapabilities} from '@fluxer/voice_engine_v2/src/protocol'; +import type {VoiceEngineV2HardwareEncoderCapabilities} from '@fluxer/voice_engine_v2/src/protocol/types'; export const VOICE_ENGINE_V2_HARDWARE_ENCODER_IPC_CHANNEL = 'voice-engine-v2:get-hardware-encoder-capabilities'; -export type VoiceEngineV2BridgeHardwareEncoderCapabilities = VoiceEngineV2HardwareEncoderCapabilities; - export interface VoiceEngineV2BridgeHardwareEncoderApi { - getHardwareEncoderCapabilities(): Promise; + getHardwareEncoderCapabilities(): Promise; } export function assertVoiceEngineV2BridgeAudioOptionsInvariants(options: { @@ -34,46 +32,3 @@ export function assertVoiceEngineV2BridgeVideoOptionsInvariants(options: {width: timestampNs: 1, }); } - -export function unavailableVoiceEngineV2BridgeHardwareEncoderCapabilities( - reason: NonNullable, - detail?: string, -): VoiceEngineV2BridgeHardwareEncoderCapabilities { - return { - available: false, - backend: 'none', - compiled: false, - runtime: false, - codecs: [], - zeroCopy: false, - nativeInputs: [], - reason, - ...(detail ? {detail} : {}), - }; -} - -export function normalizeVoiceEngineV2BridgeHardwareEncoderCapabilities( - value: unknown, -): VoiceEngineV2BridgeHardwareEncoderCapabilities { - if (typeof value !== 'object' || value === null) { - return unavailableVoiceEngineV2BridgeHardwareEncoderCapabilities( - 'query-failed', - 'Native addon returned an invalid result', - ); - } - const candidate = value as Partial; - const backend = typeof candidate.backend === 'string' && candidate.backend.length > 0 ? candidate.backend : 'none'; - return { - available: candidate.available === true, - backend, - compiled: candidate.compiled === true, - runtime: candidate.runtime === true, - codecs: Array.isArray(candidate.codecs) ? candidate.codecs.filter((codec) => typeof codec === 'string') : [], - zeroCopy: candidate.zeroCopy === true, - nativeInputs: Array.isArray(candidate.nativeInputs) - ? candidate.nativeInputs.filter((input) => typeof input === 'string') - : [], - ...(typeof candidate.reason === 'string' ? {reason: candidate.reason} : {}), - ...(typeof candidate.detail === 'string' ? {detail: candidate.detail} : {}), - }; -} diff --git a/packages/voice_engine_v2/src/core/commandCompletion.ts b/packages/voice_engine_v2/src/core/commandCompletion.ts index 380b8a23b..0d6b1ca59 100644 --- a/packages/voice_engine_v2/src/core/commandCompletion.ts +++ b/packages/voice_engine_v2/src/core/commandCompletion.ts @@ -56,8 +56,7 @@ function staleSessionCommand(snapshot: VoiceEngineV2Snapshot, command: VoiceEngi return snapshot.outputDevice.operationId !== command.operationId; case 'stats.collect': return snapshot.statsOperationId !== command.operationId; - case 'capabilities.queryHardwareEncoder': - return snapshot.hardwareEncoder.operationId !== command.operationId; + case 'gateway.voiceState.write': case 'gateway.voiceState.clear': return snapshot.gateway.operationId !== command.operationId; diff --git a/packages/voice_engine_v2/src/core/fixtures.test.ts b/packages/voice_engine_v2/src/core/fixtures.test.ts index b5fd1dbf3..ba9c7ac31 100644 --- a/packages/voice_engine_v2/src/core/fixtures.test.ts +++ b/packages/voice_engine_v2/src/core/fixtures.test.ts @@ -14,7 +14,8 @@ import { availableVoiceEngineV2Capabilities, createVoiceEngineV2InitialSnapshot, } from '@fluxer/voice_engine_v2/src/core/state'; -import type {VoiceEngineV2Command, VoiceEngineV2Event} from '@fluxer/voice_engine_v2/src/protocol'; +import type {VoiceEngineV2Command} from '@fluxer/voice_engine_v2/src/protocol/commands'; +import type {VoiceEngineV2Event} from '@fluxer/voice_engine_v2/src/protocol/events'; import {describe, expect, it} from 'vitest'; interface VoiceEngineV2FixtureExpected { diff --git a/packages/voice_engine_v2/src/core/index.ts b/packages/voice_engine_v2/src/core/index.ts deleted file mode 100644 index 3a9ae797b..000000000 --- a/packages/voice_engine_v2/src/core/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -export {isVoiceEngineV2CommandCompletionStale, transitionVoiceEngineV2} from '@fluxer/voice_engine_v2/src/core/reducer'; -export {shouldApplyGatewayVoiceStateEcho} from '@fluxer/voice_engine_v2/src/core/reducers/gateway'; -export type { - VoiceEngineV2CapabilitiesProjection, - VoiceEngineV2DeviceProjection, - VoiceEngineV2DiagnosticsProjection, - VoiceEngineV2ParticipantProjection, - VoiceEngineV2StatsPresentationNetworkSummary, - VoiceEngineV2StatsPresentationProjection, - VoiceEngineV2StatsProjection, -} from '@fluxer/voice_engine_v2/src/core/selectors'; -export { - selectVoiceEngineV2CapabilitiesProjection, - selectVoiceEngineV2ConnectionProjection, - selectVoiceEngineV2DeviceProjection, - selectVoiceEngineV2DiagnosticsProjection, - selectVoiceEngineV2E2eeProjection, - selectVoiceEngineV2FailedSourceIds, - selectVoiceEngineV2MediaProjection, - selectVoiceEngineV2Model, - selectVoiceEngineV2ParticipantProjection, - selectVoiceEngineV2SourceLifecycle, - selectVoiceEngineV2StatsPresentationProjection, - selectVoiceEngineV2StatsProjection, - selectVoiceEngineV2StatsSummary, - selectVoiceEngineV2WatchedStreams, -} from '@fluxer/voice_engine_v2/src/core/selectors'; -export type { - SourceLifecycleState, - VoiceEngineV2ConnectionState, - VoiceEngineV2DeviceState, - VoiceEngineV2E2eeLifecycleState, - VoiceEngineV2GatewayState, - VoiceEngineV2HardwareEncoderState, - VoiceEngineV2InboundVideoState, - VoiceEngineV2LifecycleState, - VoiceEngineV2LiveKitState, - VoiceEngineV2LocalMediaState, - VoiceEngineV2MicrophoneState, - VoiceEngineV2NativeAudioTapState, - VoiceEngineV2NativeCaptureState, - VoiceEngineV2NativeFrameSinkState, - VoiceEngineV2OperationState, - VoiceEngineV2OperationStatus, - VoiceEngineV2OutputDeviceState, - VoiceEngineV2PermissionState, - VoiceEngineV2RoomState, - VoiceEngineV2Snapshot, - VoiceEngineV2Transition, -} from '@fluxer/voice_engine_v2/src/core/state'; -export { - availableVoiceEngineV2Capabilities, - createVoiceEngineV2InitialSnapshot, - createVoiceEngineV2PermissionResult, - emptyVoiceEngineV2DeviceInventory, - unavailableVoiceEngineV2Capabilities, -} from '@fluxer/voice_engine_v2/src/core/state'; diff --git a/packages/voice_engine_v2/src/core/localMediaFixtures.test.ts b/packages/voice_engine_v2/src/core/localMediaFixtures.test.ts index 1b26a000d..56935993a 100644 --- a/packages/voice_engine_v2/src/core/localMediaFixtures.test.ts +++ b/packages/voice_engine_v2/src/core/localMediaFixtures.test.ts @@ -11,7 +11,8 @@ import { createVoiceEngineV2InitialSnapshot, type VoiceEngineV2Snapshot, } from '@fluxer/voice_engine_v2/src/core/state'; -import type {VoiceEngineV2Command, VoiceEngineV2Event} from '@fluxer/voice_engine_v2/src/protocol'; +import type {VoiceEngineV2Command} from '@fluxer/voice_engine_v2/src/protocol/commands'; +import type {VoiceEngineV2Event} from '@fluxer/voice_engine_v2/src/protocol/events'; import {describe, expect, it} from 'vitest'; interface LocalMediaSnapshotExpectation { diff --git a/packages/voice_engine_v2/src/core/observability_fixtures.test.ts b/packages/voice_engine_v2/src/core/observability_fixtures.test.ts index 14721b8f1..e1da6500a 100644 --- a/packages/voice_engine_v2/src/core/observability_fixtures.test.ts +++ b/packages/voice_engine_v2/src/core/observability_fixtures.test.ts @@ -1,17 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import capabilitiesLifecycleFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/capabilities_lifecycle.json'; import deviceInventoryFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/device_inventory.json'; -import diagnosticsLifecycleFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/diagnostics_lifecycle.json'; + import e2eeLifecycleFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/e2ee_lifecycle.json'; -import statsLifecycleFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/stats_lifecycle.json'; + import {transitionVoiceEngineV2} from '@fluxer/voice_engine_v2/src/core/reducer'; import { - selectVoiceEngineV2CapabilitiesProjection, selectVoiceEngineV2DeviceProjection, - selectVoiceEngineV2DiagnosticsProjection, selectVoiceEngineV2E2eeProjection, - selectVoiceEngineV2StatsProjection, } from '@fluxer/voice_engine_v2/src/core/selectors'; import { availableVoiceEngineV2Capabilities, @@ -29,30 +25,6 @@ interface VoiceEngineV2EventLogFixture { expected: Expected; } -interface StatsLifecycleExpected { - nextOperationId: number; - statsOperationId: number | null; - statsFailureCode: string | null; - summary: Record; -} - -interface DiagnosticsLifecycleExpected { - nextOperationId: number; - entryIds: Array; - lastCode: string; - operationStatuses: Record; -} - -interface CapabilitiesLifecycleExpected { - nextOperationId: number; - hardwareEncoderOperationId: number | null; - hardwareEncoderFailureCode: string | null; - hardwareEncoderBackend: string; - hasZeroCopyNativeInput: boolean; - hasNativeNvencH264: boolean; - hasNativeNvencH265: boolean; -} - interface E2eeLifecycleExpected { nextOperationId: number; status: string; @@ -87,44 +59,6 @@ function replayFixture( } describe('voice engine v2 observability lifecycle fixtures', () => { - it('replays stats collection into the stats summary projection', () => { - const fixture = statsLifecycleFixtureJson as VoiceEngineV2EventLogFixture; - const snapshot = replayFixture(fixture); - const statsProjection = selectVoiceEngineV2StatsProjection(snapshot); - - expect(snapshot.nextOperationId).toBe(fixture.expected.nextOperationId); - expect(snapshot.statsOperationId).toBe(fixture.expected.statsOperationId); - expect(snapshot.statsFailure?.code ?? null).toBe(fixture.expected.statsFailureCode); - expect(statsProjection.summary).toMatchObject(fixture.expected.summary); - }); - - it('replays diagnostics logging into append-only diagnostics projection', () => { - const fixture = diagnosticsLifecycleFixtureJson as VoiceEngineV2EventLogFixture; - const snapshot = replayFixture(fixture); - const diagnosticsProjection = selectVoiceEngineV2DiagnosticsProjection(snapshot); - - expect(snapshot.nextOperationId).toBe(fixture.expected.nextOperationId); - expect(diagnosticsProjection.entries.map((entry) => entry.id)).toEqual(fixture.expected.entryIds); - expect(diagnosticsProjection.entries.at(-1)?.code).toBe(fixture.expected.lastCode); - expect( - Object.fromEntries(Object.entries(snapshot.operations).map(([id, operation]) => [id, operation.status])), - ).toEqual(fixture.expected.operationStatuses); - }); - - it('replays hardware capability discovery into the capabilities projection', () => { - const fixture = capabilitiesLifecycleFixtureJson as VoiceEngineV2EventLogFixture; - const snapshot = replayFixture(fixture); - const capabilitiesProjection = selectVoiceEngineV2CapabilitiesProjection(snapshot); - - expect(snapshot.nextOperationId).toBe(fixture.expected.nextOperationId); - expect(snapshot.hardwareEncoder.operationId).toBe(fixture.expected.hardwareEncoderOperationId); - expect(snapshot.hardwareEncoder.failure?.code ?? null).toBe(fixture.expected.hardwareEncoderFailureCode); - expect(capabilitiesProjection.hardwareEncoderCapabilities?.backend).toBe(fixture.expected.hardwareEncoderBackend); - expect(capabilitiesProjection.hasZeroCopyNativeInput).toBe(fixture.expected.hasZeroCopyNativeInput); - expect(capabilitiesProjection.hasNativeNvencH264).toBe(fixture.expected.hasNativeNvencH264); - expect(capabilitiesProjection.hasNativeNvencH265).toBe(fixture.expected.hasNativeNvencH265); - }); - it('rejects stale E2EE completions and projects public E2EE state without reducer bookkeeping', () => { const fixture = e2eeLifecycleFixtureJson as VoiceEngineV2EventLogFixture; const snapshot = replayFixture(fixture); diff --git a/packages/voice_engine_v2/src/core/participants_tracks.test.ts b/packages/voice_engine_v2/src/core/participants_tracks.test.ts index e081a26c8..c7d070ed1 100644 --- a/packages/voice_engine_v2/src/core/participants_tracks.test.ts +++ b/packages/voice_engine_v2/src/core/participants_tracks.test.ts @@ -1,6 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import inboundVideoFrameStatsFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/inbound_video_frame_stats.json'; import inboundVideoFramesFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/inbound_video_frames.json'; import participantsTracksFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/participants_tracks.json'; import subscriptionQualityFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/subscription_quality.json'; @@ -92,7 +91,6 @@ describe('voice engine v2 participants, subscriptions, and inbound video fixture subscriptionQualityFixtureJson, watchedStreamsFixtureJson, inboundVideoFramesFixtureJson, - inboundVideoFrameStatsFixtureJson, ] as Array) { it(`replays ${fixture.name}`, () => { assertFixture(fixture); diff --git a/packages/voice_engine_v2/src/core/reducer.bench.ts b/packages/voice_engine_v2/src/core/reducer.bench.ts index b7a22c6e9..b831f39bc 100644 --- a/packages/voice_engine_v2/src/core/reducer.bench.ts +++ b/packages/voice_engine_v2/src/core/reducer.bench.ts @@ -10,7 +10,6 @@ import { import type {VoiceEngineV2Event} from '@fluxer/voice_engine_v2/src/protocol/events'; import type { VoiceEngineV2InboundVideoFrame, - VoiceEngineV2InboundVideoFrameStats, VoiceEngineV2MicrophoneOptions, VoiceEngineV2Participant, VoiceEngineV2ScreenOptions, @@ -57,20 +56,6 @@ const frameEvent: VoiceEngineV2Event = { }) satisfies VoiceEngineV2InboundVideoFrame, }; -const frameStatsEvent: VoiceEngineV2Event = { - type: 'inboundVideo.frameStats', - stats: Object.freeze({ - participantSid: 'sid-remote', - participantIdentity: 'identity-remote', - trackSid: 'track-video-1', - width: 1280, - height: 720, - frameCount: 900, - lastFrameTimestampUs: 30_000_000, - lastFrameByteLength: 320_000, - }) satisfies VoiceEngineV2InboundVideoFrameStats, -}; - const microphoneEvent: VoiceEngineV2Event = { type: 'microphone.publishRequested', options: Object.freeze({deviceId: 'mic-alt', echoCancellation: true}) satisfies VoiceEngineV2MicrophoneOptions, @@ -105,10 +90,6 @@ test('voice engine v2 reducer hot paths', async ({bench}) => { transitionVoiceEngineV2(connectedSnapshot, frameEvent); }).run(); - await bench('inboundVideo.frameStats', () => { - transitionVoiceEngineV2(connectedSnapshot, frameStatsEvent); - }).run(); - await bench('microphone.publishRequested', () => { transitionVoiceEngineV2(connectedSnapshot, microphoneEvent); }).run(); diff --git a/packages/voice_engine_v2/src/core/reducer.test.ts b/packages/voice_engine_v2/src/core/reducer.test.ts index 2f5572e71..4fa8595e4 100644 --- a/packages/voice_engine_v2/src/core/reducer.test.ts +++ b/packages/voice_engine_v2/src/core/reducer.test.ts @@ -2,10 +2,7 @@ import {transitionVoiceEngineV2} from '@fluxer/voice_engine_v2/src/core/reducer'; import {VOICE_ENGINE_V2_TERMINAL_OPERATIONS_KEPT_MAX} from '@fluxer/voice_engine_v2/src/core/reducers/_helpers'; -import { - selectVoiceEngineV2FailedSourceIds, - selectVoiceEngineV2SourceLifecycle, -} from '@fluxer/voice_engine_v2/src/core/selectors'; + import { availableVoiceEngineV2Capabilities, createVoiceEngineV2InitialSnapshot, @@ -292,11 +289,8 @@ describe('transitionVoiceEngineV2', () => { {type: 'microphone.publishRequested', options: {deviceId: 'default'}}, {type: 'microphone.publishSucceeded', operationId: 1}, {type: 'microphone.setEnabledRequested', enabled: true}, - { - type: 'livekit.roomStateChanged', - room: {connectionState: 'reconnecting', roomSid: null, roomName: null, serverRegion: null}, - }, ]); + reconnecting.liveKit.connectionState = 'reconnecting'; expect(reconnecting.microphone.setEnabledOperationId).toBe(2); const failed = transitionVoiceEngineV2(reconnecting, { @@ -759,27 +753,6 @@ describe('transitionVoiceEngineV2', () => { }); }); - it('rejects non-zero-copy native frame events', () => { - const transition = transitionVoiceEngineV2(initialSnapshot(), { - type: 'nativeCapture.frame', - frame: { - captureId: 'capture-1', - frameId: 'frame-1', - width: 1920, - height: 1080, - timestampMs: 123, - format: 'native', - zeroCopy: false, - }, - } as unknown as VoiceEngineV2Event); - - expect(transition.commands).toEqual([]); - expect(transition.snapshot.nativeCapture.failure).toMatchObject({ - code: 'implementationError', - capability: 'zeroCopyScreenTransport', - }); - }); - it('rejects native hardware encoder usage without explicit zero-copy transport', () => { const transition = transitionVoiceEngineV2(initialSnapshot(), { type: 'screen.publishRequested', @@ -812,7 +785,7 @@ describe('transitionVoiceEngineV2', () => { }); expect(transition.commands).toEqual([]); - const state = selectVoiceEngineV2SourceLifecycle(transition.snapshot, 'source-4'); + const state = transition.snapshot.sourceLifecycles['source-4'] ?? null; expect(state).not.toBeNull(); expect(state?.kind).toBe('reconnecting'); if (state?.kind === 'reconnecting') { @@ -841,13 +814,13 @@ describe('transitionVoiceEngineV2', () => { expect(command.entry.level).toBe('error'); expect(command.entry.atMs).toBe(250); } - const state = selectVoiceEngineV2SourceLifecycle(transition.snapshot, 'source-5'); + const state = transition.snapshot.sourceLifecycles['source-5'] ?? null; expect(state?.kind).toBe('failed'); }); it('selectors return null for unknown sources', () => { const snapshot = initialSnapshot(); - expect(selectVoiceEngineV2SourceLifecycle(snapshot, 'unknown')).toBeNull(); + expect(snapshot.sourceLifecycles['unknown'] ?? null).toBeNull(); }); it('sourceLifecycle.removed drops the lifecycle slot for that source only', () => { @@ -875,9 +848,9 @@ describe('transitionVoiceEngineV2', () => { const transition = transitionVoiceEngineV2(snapshot, {type: 'sourceLifecycle.removed', sourceId: 'source-removed'}); expect(transition.commands).toEqual([]); - expect(selectVoiceEngineV2SourceLifecycle(transition.snapshot, 'source-removed')).toBeNull(); - expect(selectVoiceEngineV2SourceLifecycle(transition.snapshot, 'source-kept')?.kind).toBe('active'); - expect([...selectVoiceEngineV2FailedSourceIds(transition.snapshot)]).toEqual([]); + expect(transition.snapshot.sourceLifecycles['source-removed'] ?? null).toBeNull(); + expect((transition.snapshot.sourceLifecycles['source-kept'] ?? null)?.kind).toBe('active'); + expect(Object.values(transition.snapshot.sourceLifecycles).filter((state) => state.kind === 'failed')).toEqual([]); }); it('sourceLifecycle.removed for an unknown source leaves the snapshot untouched', () => { @@ -889,41 +862,6 @@ describe('transitionVoiceEngineV2', () => { expect(transition.snapshot).toBe(snapshot); }); - it('selectVoiceEngineV2FailedSourceIds returns only failed lifecycle ids', () => { - const snapshot = applyEvents(initialSnapshot(), [ - { - type: 'sourceLifecycle.transitioned', - sourceId: 'source-a', - kind: 'active', - since: 1n, - attempts: 0, - fault: null, - atMs: 1, - }, - { - type: 'sourceLifecycle.transitioned', - sourceId: 'source-b', - kind: 'failed', - since: 2n, - attempts: 8, - fault: 'gpuDeviceLost', - atMs: 2, - }, - { - type: 'sourceLifecycle.transitioned', - sourceId: 'source-c', - kind: 'failed', - since: 3n, - attempts: 8, - fault: 'captureDeviceLost', - atMs: 3, - }, - ]); - - const failed = selectVoiceEngineV2FailedSourceIds(snapshot); - expect([...failed]).toEqual(['source-b', 'source-c']); - }); - it('replaying the same source lifecycle event sequence yields the same snapshot hash', () => { const events: Array = [ { diff --git a/packages/voice_engine_v2/src/core/reducers/_dispatch.ts b/packages/voice_engine_v2/src/core/reducers/_dispatch.ts index 75148786f..edd0b42b0 100644 --- a/packages/voice_engine_v2/src/core/reducers/_dispatch.ts +++ b/packages/voice_engine_v2/src/core/reducers/_dispatch.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import {planDesiredState} from '@fluxer/voice_engine_v2/src/core/reducers/_plan'; import {transitionCamera} from '@fluxer/voice_engine_v2/src/core/reducers/camera'; -import {transitionCapabilities} from '@fluxer/voice_engine_v2/src/core/reducers/capabilities'; + import {transitionCommand} from '@fluxer/voice_engine_v2/src/core/reducers/command'; import {transitionConnection} from '@fluxer/voice_engine_v2/src/core/reducers/connection'; import {transitionData} from '@fluxer/voice_engine_v2/src/core/reducers/data'; @@ -140,11 +140,7 @@ export function dispatchSessionEvent( case 'stats.collected': case 'stats.collectFailed': return transitionStats(snapshot, event); - case 'capabilities.changed': - case 'capabilities.hardwareEncoderQueryRequested': - case 'capabilities.hardwareEncoderChanged': - case 'capabilities.hardwareEncoderQueryFailed': - return transitionCapabilities(snapshot, event); + default: return null; } @@ -176,7 +172,6 @@ export function dispatchPlatformEvent( case 'nativeCapture.started': case 'nativeCapture.stopped': case 'nativeCapture.failed': - case 'nativeCapture.frame': return transitionNativeCapture(snapshot, event); case 'nativeAudioTap.startRequested': case 'nativeAudioTap.stopRequested': @@ -206,7 +201,6 @@ export function dispatchRuntimeEvent( case 'timer.cancelRequested': case 'timer.fired': case 'diagnostics.logRequested': - case 'diagnostics.logged': return transitionUtilityPorts(snapshot, event); case 'lifecycle.teardownRequested': case 'lifecycle.teardownSucceeded': @@ -220,9 +214,7 @@ export function dispatchRuntimeEvent( case 'room.trackUnmuted': return transitionRoom(snapshot, event); case 'inboundVideo.trackSubscribed': - case 'inboundVideo.trackUnsubscribed': case 'inboundVideo.frameReceived': - case 'inboundVideo.frameStats': return transitionInboundVideo(snapshot, event); default: return dispatchControlEvent(snapshot, event); @@ -251,8 +243,6 @@ function dispatchControlEvent( case 'gateway.voiceStateClearSucceeded': case 'gateway.voiceStateClearFailed': case 'gateway.voiceStateUpdated': - case 'gateway.voiceServerUpdated': - case 'livekit.roomStateChanged': return transitionGateway(snapshot, event); default: return null; diff --git a/packages/voice_engine_v2/src/core/reducers/_helpers.ts b/packages/voice_engine_v2/src/core/reducers/_helpers.ts index 21d153128..c8ee5e693 100644 --- a/packages/voice_engine_v2/src/core/reducers/_helpers.ts +++ b/packages/voice_engine_v2/src/core/reducers/_helpers.ts @@ -250,9 +250,6 @@ export function clearOperationForResource( if (resourceKey === 'stats' && snapshot.statsOperationId === operationId) { return {...snapshot, statsOperationId: null}; } - if (resourceKey === 'capabilities' && snapshot.hardwareEncoder.operationId === operationId) { - return {...snapshot, hardwareEncoder: {...snapshot.hardwareEncoder, operationId: null}}; - } if (resourceKey === 'gateway' && snapshot.gateway.operationId === operationId) { return {...snapshot, gateway: {...snapshot.gateway, operationId: null}}; } diff --git a/packages/voice_engine_v2/src/core/reducers/capabilities.ts b/packages/voice_engine_v2/src/core/reducers/capabilities.ts deleted file mode 100644 index 88cdea76a..000000000 --- a/packages/voice_engine_v2/src/core/reducers/capabilities.ts +++ /dev/null @@ -1,111 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import assert from 'node:assert/strict'; -import { - allocateOperation, - markOperation, - queueCommand, - unsupportedCapability, -} from '@fluxer/voice_engine_v2/src/core/reducers/_helpers'; -import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '@fluxer/voice_engine_v2/src/core/state'; -import type {VoiceEngineV2Event} from '@fluxer/voice_engine_v2/src/protocol/events'; -import type { - VoiceEngineV2Error, - VoiceEngineV2HardwareEncoderCapabilities, - VoiceEngineV2OperationId, -} from '@fluxer/voice_engine_v2/src/protocol/types'; - -type VoiceEngineV2CapabilitiesEvent = Extract; - -export function transitionCapabilities( - snapshot: VoiceEngineV2Snapshot, - event: VoiceEngineV2CapabilitiesEvent, -): VoiceEngineV2Transition { - assert.ok(snapshot != null, 'transitionCapabilities snapshot must not be null'); - assert.ok(event != null, 'transitionCapabilities event must not be null'); - assert.equal(typeof event.type, 'string', 'capabilities event type must be a string'); - assert.ok(event.type.startsWith('capabilities.'), 'capabilities reducer received unrelated event'); - switch (event.type) { - case 'capabilities.changed': - return { - snapshot: {...snapshot, capabilities: event.capabilities}, - commands: [], - }; - case 'capabilities.hardwareEncoderQueryRequested': - return onHardwareEncoderQueryRequested(snapshot); - case 'capabilities.hardwareEncoderChanged': - return onHardwareEncoderChanged(snapshot, event.operationId, event.capabilities); - case 'capabilities.hardwareEncoderQueryFailed': - return onHardwareEncoderQueryFailed(snapshot, event.operationId, event.error); - } -} - -function onHardwareEncoderQueryRequested(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Transition { - assert.ok(snapshot != null, 'onHardwareEncoderQueryRequested snapshot must not be null'); - assert.ok(snapshot.capabilities != null, 'onHardwareEncoderQueryRequested snapshot.capabilities must not be null'); - if (!snapshot.capabilities.hardwareEncoding) { - const error = unsupportedCapability('hardwareEncoding'); - return { - snapshot: { - ...snapshot, - hardwareEncoder: {...snapshot.hardwareEncoder, failure: error}, - lastFailure: error, - }, - commands: [], - }; - } - const allocated = allocateOperation(snapshot); - return queueCommand( - { - ...allocated.snapshot, - hardwareEncoder: { - ...allocated.snapshot.hardwareEncoder, - operationId: allocated.operationId, - failure: null, - }, - }, - {type: 'capabilities.queryHardwareEncoder', operationId: allocated.operationId}, - ); -} - -function onHardwareEncoderChanged( - snapshot: VoiceEngineV2Snapshot, - operationId: VoiceEngineV2OperationId | null, - capabilities: VoiceEngineV2HardwareEncoderCapabilities, -): VoiceEngineV2Transition { - assert.ok(snapshot != null, 'onHardwareEncoderChanged snapshot must not be null'); - assert.ok(capabilities != null, 'onHardwareEncoderChanged capabilities must not be null'); - if (operationId != null && snapshot.hardwareEncoder.operationId !== operationId) { - return {snapshot, commands: []}; - } - return { - snapshot: { - ...(operationId != null ? markOperation(snapshot, operationId, 'succeeded') : snapshot), - hardwareEncoder: { - capabilities, - operationId: operationId === snapshot.hardwareEncoder.operationId ? null : snapshot.hardwareEncoder.operationId, - failure: null, - }, - }, - commands: [], - }; -} - -function onHardwareEncoderQueryFailed( - snapshot: VoiceEngineV2Snapshot, - operationId: VoiceEngineV2OperationId, - error: VoiceEngineV2Error, -): VoiceEngineV2Transition { - assert.ok(snapshot != null, 'onHardwareEncoderQueryFailed snapshot must not be null'); - assert.ok(Number.isInteger(operationId), 'onHardwareEncoderQueryFailed operationId must be an integer'); - assert.ok(error != null, 'onHardwareEncoderQueryFailed error must not be null'); - if (snapshot.hardwareEncoder.operationId !== operationId) return {snapshot, commands: []}; - return { - snapshot: { - ...markOperation(snapshot, operationId, 'failed', error), - hardwareEncoder: {...snapshot.hardwareEncoder, operationId: null, failure: error}, - lastFailure: error, - }, - commands: [], - }; -} diff --git a/packages/voice_engine_v2/src/core/reducers/gateway.ts b/packages/voice_engine_v2/src/core/reducers/gateway.ts index a4f6efb57..99c2afac7 100644 --- a/packages/voice_engine_v2/src/core/reducers/gateway.ts +++ b/packages/voice_engine_v2/src/core/reducers/gateway.ts @@ -21,7 +21,7 @@ import type { VoiceEngineV2OperationId, } from '@fluxer/voice_engine_v2/src/protocol/types'; -type VoiceEngineV2GatewayEvent = Extract; +type VoiceEngineV2GatewayEvent = Extract; export function transitionGateway( snapshot: VoiceEngineV2Snapshot, @@ -30,10 +30,7 @@ export function transitionGateway( assert.ok(snapshot != null, 'transitionGateway snapshot must not be null'); assert.ok(event != null, 'transitionGateway event must not be null'); assert.equal(typeof event.type, 'string', 'gateway event type must be a string'); - assert.ok( - event.type.startsWith('gateway.') || event.type === 'livekit.roomStateChanged', - 'gateway reducer received unrelated event', - ); + assert.ok(event.type.startsWith('gateway.'), 'gateway reducer received unrelated event'); switch (event.type) { case 'gateway.desiredVoiceStateChanged': return onDesiredVoiceStateChanged(snapshot, event.desired); @@ -53,19 +50,6 @@ export function transitionGateway( return onClearFailed(snapshot, event.operationId, event.error); case 'gateway.voiceStateUpdated': return onVoiceStateUpdated(snapshot, event.voiceState); - case 'gateway.voiceServerUpdated': - return { - snapshot: {...snapshot, gateway: {...snapshot.gateway, voiceServer: event.voiceServer}}, - commands: [], - }; - case 'livekit.roomStateChanged': - return { - snapshot: { - ...snapshot, - liveKit: {...snapshot.liveKit, ...event.room, failure: null}, - }, - commands: [], - }; } } diff --git a/packages/voice_engine_v2/src/core/reducers/inboundVideo.ts b/packages/voice_engine_v2/src/core/reducers/inboundVideo.ts index d36810c35..9b736d033 100644 --- a/packages/voice_engine_v2/src/core/reducers/inboundVideo.ts +++ b/packages/voice_engine_v2/src/core/reducers/inboundVideo.ts @@ -5,7 +5,6 @@ import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '@fluxer/voice import type {VoiceEngineV2Event} from '@fluxer/voice_engine_v2/src/protocol/events'; import type { VoiceEngineV2InboundVideoFrame, - VoiceEngineV2InboundVideoFrameStats, VoiceEngineV2InboundVideoTrackSubscription, } from '@fluxer/voice_engine_v2/src/protocol/types'; @@ -94,46 +93,6 @@ function applyInboundVideoFrame( }; } -function applyInboundVideoFrameStats( - snapshot: VoiceEngineV2Snapshot, - stats: VoiceEngineV2InboundVideoFrameStats, -): VoiceEngineV2Snapshot { - assert.ok(snapshot != null, 'applyInboundVideoFrameStats snapshot must not be null'); - assert.ok(stats != null, 'applyInboundVideoFrameStats stats must not be null'); - assert.equal(typeof stats.trackSid, 'string', 'stats.trackSid must be a string'); - assert.ok(Number.isInteger(stats.frameCount), 'stats.frameCount must be an integer'); - assert.ok(stats.frameCount >= 0, 'stats.frameCount must be non-negative'); - const track = snapshot.inboundVideo.tracks[stats.trackSid]; - if (!track) { - return { - ...snapshot, - inboundVideo: { - ...snapshot.inboundVideo, - droppedFrameCount: snapshot.inboundVideo.droppedFrameCount + 1, - }, - }; - } - return { - ...snapshot, - inboundVideo: { - ...snapshot.inboundVideo, - tracks: { - ...snapshot.inboundVideo.tracks, - [stats.trackSid]: { - ...track, - participantSid: stats.participantSid, - ...(stats.participantIdentity ? {participantIdentity: stats.participantIdentity} : {}), - width: stats.width, - height: stats.height, - frameCount: stats.frameCount, - lastFrameTimestampUs: stats.lastFrameTimestampUs, - lastFrameByteLength: stats.lastFrameByteLength, - }, - }, - }, - }; -} - export function transitionInboundVideo( snapshot: VoiceEngineV2Snapshot, event: VoiceEngineV2InboundVideoEvent, @@ -145,11 +104,8 @@ export function transitionInboundVideo( switch (event.type) { case 'inboundVideo.trackSubscribed': return {snapshot: upsertInboundVideoTrack(snapshot, event.track), commands: []}; - case 'inboundVideo.trackUnsubscribed': - return {snapshot: removeInboundVideoTrack(snapshot, event.trackSid), commands: []}; + case 'inboundVideo.frameReceived': return {snapshot: applyInboundVideoFrame(snapshot, event.frame), commands: []}; - case 'inboundVideo.frameStats': - return {snapshot: applyInboundVideoFrameStats(snapshot, event.stats), commands: []}; } } diff --git a/packages/voice_engine_v2/src/core/reducers/nativeCapture.ts b/packages/voice_engine_v2/src/core/reducers/nativeCapture.ts index 5396d3ff9..e0aecd883 100644 --- a/packages/voice_engine_v2/src/core/reducers/nativeCapture.ts +++ b/packages/voice_engine_v2/src/core/reducers/nativeCapture.ts @@ -176,8 +176,5 @@ export function transitionNativeCapture( return stopNativeCapture(snapshot, event.captureId, event.operationId); case 'nativeCapture.failed': return completeNativeCaptureOperation(snapshot, event.captureId, event.operationId, event.error); - case 'nativeCapture.frame': - if (event.frame.zeroCopy !== true) return failWithZeroCopyRequired(snapshot, 'frame'); - return {snapshot, commands: []}; } } diff --git a/packages/voice_engine_v2/src/core/reducers/utilityPorts.ts b/packages/voice_engine_v2/src/core/reducers/utilityPorts.ts index 74bcc9686..25e7865d9 100644 --- a/packages/voice_engine_v2/src/core/reducers/utilityPorts.ts +++ b/packages/voice_engine_v2/src/core/reducers/utilityPorts.ts @@ -1,12 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import assert from 'node:assert/strict'; -import { - allocateOperation, - appendDiagnostic, - markOperation, - queueCommand, -} from '@fluxer/voice_engine_v2/src/core/reducers/_helpers'; +import {allocateOperation, appendDiagnostic, queueCommand} from '@fluxer/voice_engine_v2/src/core/reducers/_helpers'; import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '@fluxer/voice_engine_v2/src/core/state'; import type {VoiceEngineV2Event} from '@fluxer/voice_engine_v2/src/protocol/events'; @@ -49,13 +44,5 @@ export function transitionUtilityPorts( {type: 'diagnostics.log', operationId: allocated.operationId, entry: event.entry}, ); } - case 'diagnostics.logged': - return { - snapshot: { - ...(event.operationId != null ? markOperation(snapshot, event.operationId, 'succeeded') : snapshot), - diagnostics: appendDiagnostic(snapshot.diagnostics, event.entry), - }, - commands: [], - }; } } diff --git a/packages/voice_engine_v2/src/core/selectors.ts b/packages/voice_engine_v2/src/core/selectors.ts index 31c10ce96..a66c04fdb 100644 --- a/packages/voice_engine_v2/src/core/selectors.ts +++ b/packages/voice_engine_v2/src/core/selectors.ts @@ -1,24 +1,19 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {VoiceEngineV2Snapshot} from '@fluxer/voice_engine_v2/src/core/state'; + import { - hasVoiceEngineV2NativeNvencEncoder, - hasVoiceEngineV2ZeroCopyNativeInput, summarizeVoiceEngineV2Stats, type VoiceEngineV2StatsNetworkSummary, type VoiceEngineV2StatsSummary, -} from '@fluxer/voice_engine_v2/src/policies'; +} from '@fluxer/voice_engine_v2/src/policies/voiceStats'; import type { VoiceEngineV2AudioInputDevice, VoiceEngineV2AudioOutputDevice, VoiceEngineV2CameraDevice, - VoiceEngineV2Capabilities, VoiceEngineV2ConnectionModel, VoiceEngineV2DeviceInventory, - VoiceEngineV2DiagnosticEntry, VoiceEngineV2E2eeState, - VoiceEngineV2Error, - VoiceEngineV2HardwareEncoderCapabilities, VoiceEngineV2InboundVideoTrack, VoiceEngineV2MediaModel, VoiceEngineV2Model, @@ -28,7 +23,6 @@ import type { VoiceEngineV2Track, VoiceEngineV2WatchedStream, } from '@fluxer/voice_engine_v2/src/protocol/types'; -import type {SourceLifecycleState} from '@fluxer/voice_engine_v2/src/source_isolation/SourceLifecycleState'; export interface VoiceEngineV2ParticipantProjection { participants: Array; @@ -44,12 +38,6 @@ export interface VoiceEngineV2DeviceProjection { selectedCamera: VoiceEngineV2CameraDevice | null; } -export interface VoiceEngineV2StatsProjection { - stats: VoiceEngineV2Stats | null; - summary: VoiceEngineV2StatsSummary | null; - failure: VoiceEngineV2Error | null; -} - export interface VoiceEngineV2StatsPresentationNetworkSummary extends VoiceEngineV2StatsNetworkSummary { droppedVideoFrameCallbacks?: number; } @@ -58,20 +46,6 @@ export interface VoiceEngineV2StatsPresentationProjection extends VoiceEngineV2S network: VoiceEngineV2StatsPresentationNetworkSummary; } -export interface VoiceEngineV2CapabilitiesProjection { - capabilities: VoiceEngineV2Capabilities; - hardwareEncoderCapabilities: VoiceEngineV2HardwareEncoderCapabilities | null; - hardwareEncoderFailure: VoiceEngineV2Error | null; - hasZeroCopyNativeInput: boolean; - hasNativeNvencH264: boolean; - hasNativeNvencH265: boolean; -} - -export interface VoiceEngineV2DiagnosticsProjection { - entries: Array; - lastFailure: VoiceEngineV2Error | null; -} - export function selectVoiceEngineV2E2eeProjection(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2E2eeState { return { status: snapshot.e2ee.status, @@ -90,7 +64,6 @@ export function selectVoiceEngineV2ConnectionProjection(snapshot: VoiceEngineV2S failed: status === 'failed', gateway: { selfVoiceState: snapshot.gateway.selfVoiceState, - voiceServer: snapshot.gateway.voiceServer, }, liveKit: { connectionState: snapshot.liveKit.connectionState, @@ -162,58 +135,6 @@ export function selectVoiceEngineV2StatsPresentationProjection( }; } -export function selectVoiceEngineV2StatsProjection(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2StatsProjection { - return { - stats: snapshot.stats, - summary: selectVoiceEngineV2StatsSummary(snapshot.stats), - failure: snapshot.statsFailure, - }; -} - -export function selectVoiceEngineV2CapabilitiesProjection( - snapshot: VoiceEngineV2Snapshot, -): VoiceEngineV2CapabilitiesProjection { - const hardwareEncoderCapabilities = snapshot.hardwareEncoder.capabilities; - return { - capabilities: snapshot.capabilities, - hardwareEncoderCapabilities, - hardwareEncoderFailure: snapshot.hardwareEncoder.failure, - hasZeroCopyNativeInput: hasVoiceEngineV2ZeroCopyNativeInput(hardwareEncoderCapabilities), - hasNativeNvencH264: hasVoiceEngineV2NativeNvencEncoder(hardwareEncoderCapabilities, 'h264'), - hasNativeNvencH265: hasVoiceEngineV2NativeNvencEncoder(hardwareEncoderCapabilities, 'h265'), - }; -} - -export function selectVoiceEngineV2DiagnosticsProjection( - snapshot: VoiceEngineV2Snapshot, -): VoiceEngineV2DiagnosticsProjection { - return { - entries: snapshot.diagnostics, - lastFailure: snapshot.lastFailure, - }; -} - -export function selectVoiceEngineV2SourceLifecycle( - snapshot: VoiceEngineV2Snapshot, - sourceId: string, -): SourceLifecycleState | null { - if (typeof sourceId !== 'string') throw new TypeError('sourceId must be a string'); - if (sourceId.length === 0) throw new RangeError('sourceId must not be empty'); - return snapshot.sourceLifecycles[sourceId] ?? null; -} - -export function selectVoiceEngineV2FailedSourceIds(snapshot: VoiceEngineV2Snapshot): ReadonlyArray { - if (snapshot === null || typeof snapshot !== 'object') throw new TypeError('snapshot must be an object'); - const lifecycles = snapshot.sourceLifecycles; - if (lifecycles === null || typeof lifecycles !== 'object') throw new TypeError('sourceLifecycles must be a record'); - const result: Array = []; - for (const sourceId of Object.keys(lifecycles).sort()) { - const state = lifecycles[sourceId]; - if (state && state.kind === 'failed') result.push(sourceId); - } - return result; -} - export function selectVoiceEngineV2Model(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Model { const status = snapshot.connection.status; const participants = selectVoiceEngineV2ParticipantProjection(snapshot); diff --git a/packages/voice_engine_v2/src/core/state.ts b/packages/voice_engine_v2/src/core/state.ts index 6a56add6e..e9822343a 100644 --- a/packages/voice_engine_v2/src/core/state.ts +++ b/packages/voice_engine_v2/src/core/state.ts @@ -13,10 +13,8 @@ import type { VoiceEngineV2E2eeState, VoiceEngineV2Error, VoiceEngineV2GatewayDesiredVoiceState, - VoiceEngineV2GatewayVoiceServer, VoiceEngineV2GatewayVoiceState, VoiceEngineV2GatewayVoiceStateWrite, - VoiceEngineV2HardwareEncoderCapabilities, VoiceEngineV2InboundVideoTrack, VoiceEngineV2LifecycleReason, VoiceEngineV2LiveKitRoomState, @@ -27,9 +25,7 @@ import type { VoiceEngineV2NativeFrameSinkOptions, VoiceEngineV2OperationId, VoiceEngineV2Participant, - VoiceEngineV2PermissionName, VoiceEngineV2PermissionResult, - VoiceEngineV2PermissionStatus, VoiceEngineV2RemoteTrackSubscriptionOptions, VoiceEngineV2ResourceKey, VoiceEngineV2ScreenAudioOptions, @@ -86,7 +82,6 @@ export interface VoiceEngineV2GatewayState { desiredVoiceState: VoiceEngineV2GatewayDesiredVoiceState | null; desiredVoiceStateWrite: VoiceEngineV2GatewayVoiceStateWrite | null; selfVoiceState: VoiceEngineV2GatewayVoiceState | null; - voiceServer: VoiceEngineV2GatewayVoiceServer | null; operationId: VoiceEngineV2OperationId | null; failure: VoiceEngineV2Error | null; } @@ -107,12 +102,6 @@ export interface VoiceEngineV2DeviceState { failure: VoiceEngineV2Error | null; } -export interface VoiceEngineV2HardwareEncoderState { - capabilities: VoiceEngineV2HardwareEncoderCapabilities | null; - operationId: VoiceEngineV2OperationId | null; - failure: VoiceEngineV2Error | null; -} - export interface VoiceEngineV2E2eeLifecycleState extends VoiceEngineV2E2eeState { operationId: VoiceEngineV2OperationId | null; } @@ -171,7 +160,6 @@ export interface VoiceEngineV2Snapshot { stats: VoiceEngineV2Stats | null; statsOperationId: VoiceEngineV2OperationId | null; statsFailure: VoiceEngineV2Error | null; - hardwareEncoder: VoiceEngineV2HardwareEncoderState; permissions: VoiceEngineV2PermissionState; devices: VoiceEngineV2DeviceState; e2ee: VoiceEngineV2E2eeLifecycleState; @@ -240,17 +228,6 @@ export function emptyVoiceEngineV2DeviceInventory(): VoiceEngineV2DeviceInventor }; } -export function createVoiceEngineV2PermissionResult( - name: VoiceEngineV2PermissionName, - status: VoiceEngineV2PermissionStatus = 'unknown', -): VoiceEngineV2PermissionResult { - return { - name, - status, - canPrompt: status === 'prompt' || status === 'unknown', - }; -} - export function createVoiceEngineV2InitialSnapshot( capabilities: VoiceEngineV2Capabilities = unavailableVoiceEngineV2Capabilities(), ): VoiceEngineV2Snapshot { @@ -270,7 +247,6 @@ export function createVoiceEngineV2InitialSnapshot( desiredVoiceState: null, desiredVoiceStateWrite: null, selfVoiceState: null, - voiceServer: null, operationId: null, failure: null, }, @@ -338,11 +314,6 @@ export function createVoiceEngineV2InitialSnapshot( stats: null, statsOperationId: null, statsFailure: null, - hardwareEncoder: { - capabilities: null, - operationId: null, - failure: null, - }, permissions: { results: {}, operationIds: {}, diff --git a/packages/voice_engine_v2/src/fuzz/ReducerFuzzer.ts b/packages/voice_engine_v2/src/fuzz/ReducerFuzzer.ts index b2c8b3eda..d773caf9a 100644 --- a/packages/voice_engine_v2/src/fuzz/ReducerFuzzer.ts +++ b/packages/voice_engine_v2/src/fuzz/ReducerFuzzer.ts @@ -74,7 +74,6 @@ const POSITIVE_EVENT_KINDS: ReadonlyArray = [ 'devices.selectCameraRequested', 'permissions.checkRequested', 'permissions.requestRequested', - 'capabilities.hardwareEncoderQueryRequested', 'localAudio.muteRequested', 'localAudio.deafenRequested', 'lifecycle.teardownRequested', @@ -499,8 +498,6 @@ function tryMaterialiseControlEvent( if (kind === 'devices.selectCameraRequested') return {type: 'devices.selectCameraRequested', deviceId: ctx.identity}; if (kind === 'permissions.checkRequested') return {type: 'permissions.checkRequested', name: 'microphone'}; if (kind === 'permissions.requestRequested') return {type: 'permissions.requestRequested', name: 'microphone'}; - if (kind === 'capabilities.hardwareEncoderQueryRequested') - return {type: 'capabilities.hardwareEncoderQueryRequested'}; if (kind === 'localAudio.muteRequested') return {type: 'localAudio.muteRequested', muted: ctx.enabled}; if (kind === 'localAudio.deafenRequested') return {type: 'localAudio.deafenRequested', deafened: ctx.enabled}; if (kind === 'lifecycle.teardownRequested') { diff --git a/packages/voice_engine_v2/src/implementations/VoiceEngineV2HostPortImplementation.ts b/packages/voice_engine_v2/src/implementations/VoiceEngineV2HostPortImplementation.ts index ef26fd7f4..cb5d9ebc3 100644 --- a/packages/voice_engine_v2/src/implementations/VoiceEngineV2HostPortImplementation.ts +++ b/packages/voice_engine_v2/src/implementations/VoiceEngineV2HostPortImplementation.ts @@ -6,12 +6,10 @@ import { VoiceEngineV2ImplementationBase, } from '@fluxer/voice_engine_v2/src/implementations/VoiceEngineV2ImplementationBase'; import type { - CapabilitiesPort, DevicePort, DiagnosticsPort, GatewayPort, LiveKitMediaPort, - LiveKitPort, NativeMediaPort, ParticipantProjectionIngestionPort, PermissionPort, @@ -31,7 +29,6 @@ import type { VoiceEngineV2DeviceInventory, VoiceEngineV2DisconnectReason, VoiceEngineV2GatewayVoiceStateWrite, - VoiceEngineV2HardwareEncoderCapabilities, VoiceEngineV2MicrophoneOptions, VoiceEngineV2NativeAudioTapOptions, VoiceEngineV2NativeCaptureOptions, @@ -45,7 +42,7 @@ import type { VoiceEngineV2ScreenEncodingOptions, VoiceEngineV2ScreenOptions, VoiceEngineV2Stats, -} from '@fluxer/voice_engine_v2/src/protocol'; +} from '@fluxer/voice_engine_v2/src/protocol/types'; export class VoiceEngineV2HostPortImplementation extends VoiceEngineV2ImplementationBase { readonly kind = 'native' as const; @@ -70,7 +67,7 @@ export class VoiceEngineV2HostPortDriver implements VoiceEngineV2Driver { } async prewarm(): Promise { - const port = this.ports.media ?? this.ports.liveKit; + const port = this.ports.media; if (port) await port.prewarm(); } @@ -154,10 +151,6 @@ export class VoiceEngineV2HostPortDriver implements VoiceEngineV2Driver { return this.statsPort().collectStats(); } - async getHardwareEncoderCapabilities(): Promise { - return this.capabilitiesPort().getHardwareEncoderCapabilities(); - } - async checkPermission(name: VoiceEngineV2PermissionName): Promise { return this.permissionPort().checkPermission(name); } @@ -240,20 +233,20 @@ export class VoiceEngineV2HostPortDriver implements VoiceEngineV2Driver { return port; } - private mediaPort(): LiveKitMediaPort | LiveKitPort { - const port = this.ports.media ?? this.ports.liveKit; + private mediaPort(): LiveKitMediaPort { + const port = this.ports.media; if (!port) throw unsupportedPortError('media'); return port; } - private subscriptionPort(): SubscriptionPort | LiveKitPort { - const port = this.ports.subscriptions ?? this.ports.liveKit; + private subscriptionPort(): SubscriptionPort { + const port = this.ports.subscriptions; if (!port) throw unsupportedPortError('subscriptions'); return port; } - private statsPort(): StatsPort | LiveKitPort { - const port = this.ports.stats ?? this.ports.liveKit; + private statsPort(): StatsPort { + const port = this.ports.stats; if (!port) throw unsupportedPortError('stats'); return port; } @@ -264,12 +257,6 @@ export class VoiceEngineV2HostPortDriver implements VoiceEngineV2Driver { return port; } - private capabilitiesPort(): CapabilitiesPort { - const port = this.ports.capabilities; - if (!port) throw unsupportedPortError('capabilities'); - return port; - } - private devicePort(): DevicePort { const port = this.ports.devices; if (!port) throw unsupportedPortError('devices'); diff --git a/packages/voice_engine_v2/src/implementations/VoiceEngineV2ImplementationBase.ts b/packages/voice_engine_v2/src/implementations/VoiceEngineV2ImplementationBase.ts index ab4c99572..3799922f3 100644 --- a/packages/voice_engine_v2/src/implementations/VoiceEngineV2ImplementationBase.ts +++ b/packages/voice_engine_v2/src/implementations/VoiceEngineV2ImplementationBase.ts @@ -11,7 +11,6 @@ import type { VoiceEngineV2DisconnectReason, VoiceEngineV2Error, VoiceEngineV2GatewayVoiceStateWrite, - VoiceEngineV2HardwareEncoderCapabilities, VoiceEngineV2ImplementationKind, VoiceEngineV2MicrophoneOptions, VoiceEngineV2NativeAudioTapOptions, @@ -31,7 +30,6 @@ import type { export interface VoiceEngineV2CommandSuccess { ok: true; stats?: VoiceEngineV2Stats; - hardwareEncoderCapabilities?: VoiceEngineV2HardwareEncoderCapabilities; permissionResult?: VoiceEngineV2PermissionResult; deviceInventory?: VoiceEngineV2DeviceInventory; } @@ -73,7 +71,6 @@ export interface VoiceEngineV2Driver { setRemoteTrackSubscription(options: VoiceEngineV2RemoteTrackSubscriptionOptions): Promise; publishData(options: VoiceEngineV2DataOptions): Promise; collectStats(): Promise; - getHardwareEncoderCapabilities?(): Promise; checkPermission?(name: VoiceEngineV2PermissionName): Promise; requestPermission?(name: VoiceEngineV2PermissionName): Promise; enumerateDevices?(): Promise; @@ -96,12 +93,12 @@ export interface VoiceEngineV2Driver { subscribe?(listener: VoiceEngineV2ExternalEventListener): () => void; } -type VoiceEngineV2CommandOfType = Extract< +export type VoiceEngineV2CommandOfType = Extract< VoiceEngineV2Command, {type: Type} >; -type VoiceEngineV2SessionCommand = VoiceEngineV2CommandOfType< +export type VoiceEngineV2SessionCommand = VoiceEngineV2CommandOfType< | 'implementation.prewarm' | 'gateway.voiceState.write' | 'gateway.voiceState.clear' @@ -110,7 +107,7 @@ type VoiceEngineV2SessionCommand = VoiceEngineV2CommandOfType< | 'lifecycle.teardown' >; -type VoiceEngineV2LocalMediaCommand = VoiceEngineV2CommandOfType< +export type VoiceEngineV2LocalMediaCommand = VoiceEngineV2CommandOfType< | 'microphone.publish' | 'microphone.unpublish' | 'microphone.setEnabled' @@ -124,19 +121,19 @@ type VoiceEngineV2LocalMediaCommand = VoiceEngineV2CommandOfType< | 'screenAudio.unpublish' >; -type VoiceEngineV2RoutingCommand = VoiceEngineV2CommandOfType< +export type VoiceEngineV2RoutingCommand = VoiceEngineV2CommandOfType< 'outputDevice.set' | 'participantVolume.set' | 'remoteTrackSubscription.set' | 'data.publish' >; -type VoiceEngineV2QueryCommand = VoiceEngineV2CommandOfType< - 'stats.collect' | 'capabilities.queryHardwareEncoder' | 'permissions.check' | 'permissions.request' +export type VoiceEngineV2QueryCommand = VoiceEngineV2CommandOfType< + 'stats.collect' | 'permissions.check' | 'permissions.request' >; -type VoiceEngineV2DevicesCommand = VoiceEngineV2CommandOfType< +export type VoiceEngineV2DevicesCommand = VoiceEngineV2CommandOfType< 'devices.enumerate' | 'devices.selectAudioInput' | 'devices.selectAudioOutput' | 'devices.selectCamera' >; -type VoiceEngineV2NativeCommand = VoiceEngineV2CommandOfType< +export type VoiceEngineV2NativeCommand = VoiceEngineV2CommandOfType< | 'nativeCapture.start' | 'nativeCapture.update' | 'nativeCapture.stop' @@ -146,7 +143,7 @@ type VoiceEngineV2NativeCommand = VoiceEngineV2CommandOfType< | 'nativeFrameSink.detach' >; -type VoiceEngineV2UtilityCommand = VoiceEngineV2CommandOfType< +export type VoiceEngineV2UtilityCommand = VoiceEngineV2CommandOfType< 'e2ee.setEnabled' | 'timer.schedule' | 'timer.cancel' | 'diagnostics.log' | 'operation.cancel' >; @@ -194,7 +191,6 @@ export abstract class VoiceEngineV2ImplementationBase implements VoiceEngineV2Im case 'data.publish': return this.executeRouting(command); case 'stats.collect': - case 'capabilities.queryHardwareEncoder': case 'permissions.check': case 'permissions.request': return this.executeQuery(command); @@ -302,14 +298,7 @@ export abstract class VoiceEngineV2ImplementationBase implements VoiceEngineV2Im switch (command.type) { case 'stats.collect': return {ok: true, stats: await this.driver.collectStats()}; - case 'capabilities.queryHardwareEncoder': { - const getHardwareEncoderCapabilities = this.driver.getHardwareEncoderCapabilities; - if (!getHardwareEncoderCapabilities) throw unsupportedDriverMethod('capabilities'); - return { - ok: true, - hardwareEncoderCapabilities: await getHardwareEncoderCapabilities.call(this.driver), - }; - } + case 'permissions.check': return { ok: true, diff --git a/packages/voice_engine_v2/src/implementations/index.ts b/packages/voice_engine_v2/src/implementations/index.ts deleted file mode 100644 index b90c6a373..000000000 --- a/packages/voice_engine_v2/src/implementations/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -export { - VoiceEngineV2HostPortDriver, - VoiceEngineV2HostPortImplementation, -} from '@fluxer/voice_engine_v2/src/implementations/VoiceEngineV2HostPortImplementation'; -export type { - VoiceEngineV2CommandFailure, - VoiceEngineV2CommandResult, - VoiceEngineV2CommandSuccess, - VoiceEngineV2Driver, - VoiceEngineV2ExternalEventListener, - VoiceEngineV2Implementation, -} from '@fluxer/voice_engine_v2/src/implementations/VoiceEngineV2ImplementationBase'; -export { - errorToVoiceEngineV2Error, - VoiceEngineV2ImplementationBase, -} from '@fluxer/voice_engine_v2/src/implementations/VoiceEngineV2ImplementationBase'; diff --git a/packages/voice_engine_v2/src/index.ts b/packages/voice_engine_v2/src/index.ts deleted file mode 100644 index 185323b5f..000000000 --- a/packages/voice_engine_v2/src/index.ts +++ /dev/null @@ -1,259 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -export type { - VoiceEngineV2CapabilitiesProjection, - VoiceEngineV2ConnectionState, - VoiceEngineV2DeviceProjection, - VoiceEngineV2DeviceState, - VoiceEngineV2DiagnosticsProjection, - VoiceEngineV2E2eeLifecycleState, - VoiceEngineV2GatewayState, - VoiceEngineV2HardwareEncoderState, - VoiceEngineV2InboundVideoState, - VoiceEngineV2LifecycleState, - VoiceEngineV2LiveKitState, - VoiceEngineV2LocalMediaState, - VoiceEngineV2MicrophoneState, - VoiceEngineV2NativeAudioTapState, - VoiceEngineV2NativeCaptureState, - VoiceEngineV2NativeFrameSinkState, - VoiceEngineV2OperationState, - VoiceEngineV2OperationStatus, - VoiceEngineV2OutputDeviceState, - VoiceEngineV2ParticipantProjection, - VoiceEngineV2PermissionState, - VoiceEngineV2RoomState, - VoiceEngineV2Snapshot, - VoiceEngineV2StatsPresentationNetworkSummary, - VoiceEngineV2StatsPresentationProjection, - VoiceEngineV2StatsProjection, - VoiceEngineV2Transition, -} from '@fluxer/voice_engine_v2/src/core'; -export { - availableVoiceEngineV2Capabilities, - createVoiceEngineV2InitialSnapshot, - createVoiceEngineV2PermissionResult, - emptyVoiceEngineV2DeviceInventory, - isVoiceEngineV2CommandCompletionStale, - selectVoiceEngineV2CapabilitiesProjection, - selectVoiceEngineV2ConnectionProjection, - selectVoiceEngineV2DeviceProjection, - selectVoiceEngineV2DiagnosticsProjection, - selectVoiceEngineV2E2eeProjection, - selectVoiceEngineV2MediaProjection, - selectVoiceEngineV2Model, - selectVoiceEngineV2ParticipantProjection, - selectVoiceEngineV2StatsPresentationProjection, - selectVoiceEngineV2StatsProjection, - selectVoiceEngineV2StatsSummary, - selectVoiceEngineV2WatchedStreams, - shouldApplyGatewayVoiceStateEcho, - transitionVoiceEngineV2, - unavailableVoiceEngineV2Capabilities, -} from '@fluxer/voice_engine_v2/src/core'; -export type { - VoiceEngineV2CommandFailure, - VoiceEngineV2CommandResult, - VoiceEngineV2CommandSuccess, - VoiceEngineV2Driver, - VoiceEngineV2ExternalEventListener, - VoiceEngineV2Implementation, -} from '@fluxer/voice_engine_v2/src/implementations'; -export { - errorToVoiceEngineV2Error, - VoiceEngineV2HostPortDriver, - VoiceEngineV2HostPortImplementation, - VoiceEngineV2ImplementationBase, -} from '@fluxer/voice_engine_v2/src/implementations'; -export type { - VoiceEngineV2CameraEncodingPlan, - VoiceEngineV2CameraEncodingPlanAction, - VoiceEngineV2CameraEncodingPlanInput, - VoiceEngineV2CameraEncodingPlanReason, - VoiceEngineV2MicrophoneFailureContext, - VoiceEngineV2MicrophoneOperationFailureAction, - VoiceEngineV2OperationFailureLike, - VoiceEngineV2OperationResultLike, - VoiceEngineV2ScreenEncodingPlan, - VoiceEngineV2ScreenEncodingPlanAction, - VoiceEngineV2ScreenEncodingPlanInput, - VoiceEngineV2ScreenEncodingPlanReason, - VoiceEngineV2StatsNetworkSummary, - VoiceEngineV2StatsSummary, - VoiceEngineV2StatsTrackClassificationInput, - VoiceEngineV2StatsTrackPublicationIds, - VoiceEngineV2StatsTrackRoleCandidate, - VoiceEngineV2StatsTrackRoleSelection, - VoiceEngineV2StatsTrackSummary, - VoiceStatsNetworkSummary, - VoiceStatsSummary, - VoiceStatsTrackClassificationInput, - VoiceStatsTrackPublicationIds, - VoiceStatsTrackRoleCandidate, - VoiceStatsTrackRoleSelection, - VoiceStatsTrackSummary, -} from '@fluxer/voice_engine_v2/src/policies'; -export { - applyVoiceEngineV2CameraEncodingOptions, - applyVoiceEngineV2ScreenEncodingOptions, - asVoiceEngineV2StatsTrackSource, - classifyVoiceEngineV2TrackStats, - coalesceVoiceEngineV2OutboundStats, - coerceVoiceEngineV2Stats, - getVoiceEngineV2MicrophoneOperationFailureAction, - hasVoiceEngineV2NativeHardwareEncoder, - hasVoiceEngineV2NativeNvencEncoder, - hasVoiceEngineV2ZeroCopyNativeInput, - normalizeVoiceEngineV2HardwareEncoderCapabilities, - planVoiceEngineV2CameraEncodingChange, - planVoiceEngineV2ScreenEncodingChange, - summarizeVoiceEngineV2Stats, - unavailableVoiceEngineV2HardwareEncoderCapabilities, - VoiceEngineV2StatsTrackSource, -} from '@fluxer/voice_engine_v2/src/policies'; -export type { - CapabilitiesPort, - DevicePort, - DiagnosticsPort, - GatewayPort, - LiveKitMediaPort, - LiveKitPort, - NativeMediaPort, - ParticipantProjectionIngestionPort, - PermissionPort, - StatsPort, - SubscriptionPort, - TimerPort, - VoiceEngineV2EventSourcePort, - VoiceEngineV2HostEventListener, - VoiceEngineV2HostPorts, - VoiceStateIngestionPort, -} from '@fluxer/voice_engine_v2/src/ports'; -export {unsupportedPortError} from '@fluxer/voice_engine_v2/src/ports'; -export type { - VoiceEngineV2AudioControls, - VoiceEngineV2AudioControlsPatch, - VoiceEngineV2AudioDeviceRole, - VoiceEngineV2AudioInputDevice, - VoiceEngineV2AudioMode, - VoiceEngineV2AudioOutputDevice, - VoiceEngineV2CameraDevice, - VoiceEngineV2CameraEncodingOptions, - VoiceEngineV2CameraOptions, - VoiceEngineV2Capabilities, - VoiceEngineV2Command, - VoiceEngineV2CommandType, - VoiceEngineV2ConnectionModel, - VoiceEngineV2ConnectionQuality, - VoiceEngineV2ConnectionStatus, - VoiceEngineV2ConnectOptions, - VoiceEngineV2DataOptions, - VoiceEngineV2DeviceChangeReason, - VoiceEngineV2DeviceInventory, - VoiceEngineV2DiagnosticEntry, - VoiceEngineV2DiagnosticLevel, - VoiceEngineV2DisconnectReason, - VoiceEngineV2E2eeState, - VoiceEngineV2E2eeStatus, - VoiceEngineV2Error, - VoiceEngineV2Event, - VoiceEngineV2GatewayDesiredVoiceState, - VoiceEngineV2GatewayVoiceServer, - VoiceEngineV2GatewayVoiceState, - VoiceEngineV2GatewayVoiceStateWrite, - VoiceEngineV2HardwareEncoderCapabilities, - VoiceEngineV2ImplementationKind, - VoiceEngineV2InboundStats, - VoiceEngineV2InboundVideoFrame, - VoiceEngineV2InboundVideoFrameStats, - VoiceEngineV2InboundVideoTrack, - VoiceEngineV2InboundVideoTrackSubscription, - VoiceEngineV2LatencyDataPoint, - VoiceEngineV2LifecycleReason, - VoiceEngineV2LiveKitConnectionState, - VoiceEngineV2LiveKitRoomState, - VoiceEngineV2LocalStreamSource, - VoiceEngineV2MediaModel, - VoiceEngineV2MediaStatus, - VoiceEngineV2MicrophoneOptions, - VoiceEngineV2Model, - VoiceEngineV2NativeAudioTapOptions, - VoiceEngineV2NativeCaptureFrame, - VoiceEngineV2NativeCaptureKind, - VoiceEngineV2NativeCaptureOptions, - VoiceEngineV2NativeCaptureSource, - VoiceEngineV2NativeFrameSinkOptions, - VoiceEngineV2OperationId, - VoiceEngineV2OutboundStats, - VoiceEngineV2OutputDeviceOptions, - VoiceEngineV2Participant, - VoiceEngineV2ParticipantVolumeOptions, - VoiceEngineV2PcmFrame, - VoiceEngineV2PermissionName, - VoiceEngineV2PermissionResult, - VoiceEngineV2PermissionStatus, - VoiceEngineV2PerTrackStats, - VoiceEngineV2RemoteTrackQuality, - VoiceEngineV2RemoteTrackSubscriptionOptions, - VoiceEngineV2ResourceKey, - VoiceEngineV2ScreenAudioOptions, - VoiceEngineV2ScreenEncodingOptions, - VoiceEngineV2ScreenOptions, - VoiceEngineV2ScreenPacing, - VoiceEngineV2SendStats, - VoiceEngineV2SourceLifecycleRemovedEvent, - VoiceEngineV2SourceLifecycleTransitionedEvent, - VoiceEngineV2SourceLifecycleTransitionKind, - VoiceEngineV2Stats, - VoiceEngineV2StatsSample, - VoiceEngineV2TimerOptions, - VoiceEngineV2Track, - VoiceEngineV2TrackKind, - VoiceEngineV2TrackSource, - VoiceEngineV2TransportInfo, - VoiceEngineV2VideoAccelerationStatus, - VoiceEngineV2VideoCodec, - VoiceEngineV2VoiceStats, - VoiceEngineV2WatchedStream, - VoiceEngineV2WatchedStreamKey, -} from '@fluxer/voice_engine_v2/src/protocol'; -export { - getVoiceEngineV2CommandResourceKey, - getVoiceEngineV2CommandTypeResourceKey, -} from '@fluxer/voice_engine_v2/src/protocol'; -export type { - VoiceEngineV2EventLogEntry, - VoiceEngineV2EventLogSpillSink, - VoiceEngineV2MemoryEventLogSpillSink, - VoiceEngineV2RuntimeClock, - VoiceEngineV2RuntimeListener, - VoiceEngineV2RuntimeListenerPayload, - VoiceEngineV2RuntimeOptions, -} from '@fluxer/voice_engine_v2/src/runtime'; -export { - commandResultToEvent, - createVoiceEngineV2MemoryEventLogSpillSink, - VoiceEngineV2Controller, - VoiceEngineV2Runtime, -} from '@fluxer/voice_engine_v2/src/runtime'; -export type { - SourceFault, - SourceLifecycleAction, - SourceLifecycleClock, - SourceLifecycleDispatchResult, - SourceLifecycleEvent, - SourceLifecycleSnapshotEntry, - SourceLifecycleState, - SourceLifecycleTransitionResult, -} from '@fluxer/voice_engine_v2/src/source_isolation'; -export { - computeReconnectBackoffMs, - createInitialActiveState, - MAX_RECONNECT_ATTEMPTS, - MAX_TRACKED_SOURCES, - RECONNECT_BACKOFF_CAP_MS, - RECONNECT_BACKOFF_STEP_MS, - SourceLifecycleError, - SourceLifecycleRegistry, - transitionSourceLifecycle, -} from '@fluxer/voice_engine_v2/src/source_isolation'; diff --git a/packages/voice_engine_v2/src/policies/cameraShare.ts b/packages/voice_engine_v2/src/policies/cameraShare.ts index 89725b41e..9ed3f84e7 100644 --- a/packages/voice_engine_v2/src/policies/cameraShare.ts +++ b/packages/voice_engine_v2/src/policies/cameraShare.ts @@ -4,7 +4,7 @@ import type { VoiceEngineV2CameraEncodingOptions, VoiceEngineV2CameraOptions, VoiceEngineV2Error, -} from '@fluxer/voice_engine_v2/src/protocol'; +} from '@fluxer/voice_engine_v2/src/protocol/types'; export type VoiceEngineV2CameraEncodingPlanAction = 'noop' | 'updateEncoding' | 'republish' | 'reject'; diff --git a/packages/voice_engine_v2/src/policies/hardwareEncoderCapabilities.ts b/packages/voice_engine_v2/src/policies/hardwareEncoderCapabilities.ts index 6da6601e6..d2797796d 100644 --- a/packages/voice_engine_v2/src/policies/hardwareEncoderCapabilities.ts +++ b/packages/voice_engine_v2/src/policies/hardwareEncoderCapabilities.ts @@ -3,7 +3,7 @@ import type { VoiceEngineV2HardwareEncoderCapabilities, VoiceEngineV2VideoCodec, -} from '@fluxer/voice_engine_v2/src/protocol'; +} from '@fluxer/voice_engine_v2/src/protocol/types'; const ZERO_COPY_NATIVE_INPUTS = new Set(['dmabuf', 'd3d11Texture', 'd3d11-texture', 'cvPixelBuffer', 'sharedTexture']); @@ -58,17 +58,6 @@ export function hasVoiceEngineV2ZeroCopyNativeInput( ); } -export function hasVoiceEngineV2NativeNvencEncoder( - capabilities: VoiceEngineV2HardwareEncoderCapabilities | null | undefined, - codec: VoiceEngineV2VideoCodec | string, -): boolean { - if (!capabilities?.available || capabilities.backend !== 'nvenc') return false; - if (!hasVoiceEngineV2ZeroCopyNativeInput(capabilities)) return false; - if (codec !== 'h264' && codec !== 'h265') return false; - const codecs = new Set(capabilities.codecs.map((entry) => entry.toLowerCase())); - return codecs.has(codec) || (codec === 'h265' && codecs.has('hevc')); -} - export function hasVoiceEngineV2NativeHardwareEncoder( capabilities: VoiceEngineV2HardwareEncoderCapabilities | null | undefined, codec: VoiceEngineV2VideoCodec | string, diff --git a/packages/voice_engine_v2/src/policies/index.ts b/packages/voice_engine_v2/src/policies/index.ts deleted file mode 100644 index 094a3119a..000000000 --- a/packages/voice_engine_v2/src/policies/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -export type { - VoiceEngineV2CameraEncodingPlan, - VoiceEngineV2CameraEncodingPlanAction, - VoiceEngineV2CameraEncodingPlanInput, - VoiceEngineV2CameraEncodingPlanReason, -} from '@fluxer/voice_engine_v2/src/policies/cameraShare'; -export { - applyVoiceEngineV2CameraEncodingOptions, - planVoiceEngineV2CameraEncodingChange, -} from '@fluxer/voice_engine_v2/src/policies/cameraShare'; -export { - hasVoiceEngineV2NativeHardwareEncoder, - hasVoiceEngineV2NativeNvencEncoder, - hasVoiceEngineV2ZeroCopyNativeInput, - normalizeVoiceEngineV2HardwareEncoderCapabilities, - unavailableVoiceEngineV2HardwareEncoderCapabilities, -} from '@fluxer/voice_engine_v2/src/policies/hardwareEncoderCapabilities'; -export type { - VoiceEngineV2MicrophoneFailureContext, - VoiceEngineV2MicrophoneOperationFailureAction, - VoiceEngineV2OperationFailureLike, - VoiceEngineV2OperationResultLike, -} from '@fluxer/voice_engine_v2/src/policies/microphoneFailureAction'; -export {getVoiceEngineV2MicrophoneOperationFailureAction} from '@fluxer/voice_engine_v2/src/policies/microphoneFailureAction'; -export type { - VoiceEngineV2ScreenEncodingPlan, - VoiceEngineV2ScreenEncodingPlanAction, - VoiceEngineV2ScreenEncodingPlanInput, - VoiceEngineV2ScreenEncodingPlanReason, -} from '@fluxer/voice_engine_v2/src/policies/screenShare'; -export { - applyVoiceEngineV2ScreenEncodingOptions, - planVoiceEngineV2ScreenEncodingChange, -} from '@fluxer/voice_engine_v2/src/policies/screenShare'; -export type { - VoiceEngineV2StatsNetworkSummary, - VoiceEngineV2StatsSummary, - VoiceEngineV2StatsTrackClassificationInput, - VoiceEngineV2StatsTrackPublicationIds, - VoiceEngineV2StatsTrackRoleCandidate, - VoiceEngineV2StatsTrackRoleSelection, - VoiceEngineV2StatsTrackSummary, - VoiceStatsNetworkSummary, - VoiceStatsSummary, - VoiceStatsTrackClassificationInput, - VoiceStatsTrackPublicationIds, - VoiceStatsTrackRoleCandidate, - VoiceStatsTrackRoleSelection, - VoiceStatsTrackSummary, -} from '@fluxer/voice_engine_v2/src/policies/voiceStats'; -export { - asVoiceEngineV2StatsTrackSource, - classifyVoiceEngineV2TrackStats, - coalesceVoiceEngineV2OutboundStats, - coerceVoiceEngineV2Stats, - summarizeVoiceEngineV2Stats, - VoiceEngineV2StatsTrackSource, -} from '@fluxer/voice_engine_v2/src/policies/voiceStats'; diff --git a/packages/voice_engine_v2/src/policies/policies.test.ts b/packages/voice_engine_v2/src/policies/policies.test.ts index aac139383..4df8f447e 100644 --- a/packages/voice_engine_v2/src/policies/policies.test.ts +++ b/packages/voice_engine_v2/src/policies/policies.test.ts @@ -4,31 +4,31 @@ import cameraShareEncodingPlanFixtures from '@fluxer/voice_engine_v2/fixtures/po import hardwareEncoderCapabilityFixtures from '@fluxer/voice_engine_v2/fixtures/policies/hardware_encoder_capabilities.json'; import microphoneFailureFixtures from '@fluxer/voice_engine_v2/fixtures/policies/microphone_failure_action.json'; import screenShareEncodingPlanFixtures from '@fluxer/voice_engine_v2/fixtures/policies/screen_share_encoding_plan.json'; -import voiceStatsCoercionFixtures from '@fluxer/voice_engine_v2/fixtures/policies/voice_stats_coercion.json'; + import voiceStatsSummaryFixtures from '@fluxer/voice_engine_v2/fixtures/policies/voice_stats_summary.json'; import voiceTrackClassificationFixtures from '@fluxer/voice_engine_v2/fixtures/policies/voice_track_classification.json'; +import {planVoiceEngineV2CameraEncodingChange} from '@fluxer/voice_engine_v2/src/policies/cameraShare'; +import {normalizeVoiceEngineV2HardwareEncoderCapabilities} from '@fluxer/voice_engine_v2/src/policies/hardwareEncoderCapabilities'; import { - classifyVoiceEngineV2TrackStats, - coerceVoiceEngineV2Stats, getVoiceEngineV2MicrophoneOperationFailureAction, - hasVoiceEngineV2NativeNvencEncoder, - normalizeVoiceEngineV2HardwareEncoderCapabilities, - planVoiceEngineV2CameraEncodingChange, - planVoiceEngineV2ScreenEncodingChange, - summarizeVoiceEngineV2Stats, type VoiceEngineV2MicrophoneFailureContext, type VoiceEngineV2OperationResultLike, +} from '@fluxer/voice_engine_v2/src/policies/microphoneFailureAction'; +import {planVoiceEngineV2ScreenEncodingChange} from '@fluxer/voice_engine_v2/src/policies/screenShare'; +import { + classifyVoiceEngineV2TrackStats, + summarizeVoiceEngineV2Stats, type VoiceEngineV2StatsSummary, type VoiceEngineV2StatsTrackClassificationInput, type VoiceEngineV2StatsTrackRoleSelection, -} from '@fluxer/voice_engine_v2/src/policies/index'; +} from '@fluxer/voice_engine_v2/src/policies/voiceStats'; import type { VoiceEngineV2CameraEncodingOptions, VoiceEngineV2CameraOptions, VoiceEngineV2ScreenEncodingOptions, VoiceEngineV2ScreenOptions, VoiceEngineV2Stats, -} from '@fluxer/voice_engine_v2/src/protocol'; +} from '@fluxer/voice_engine_v2/src/protocol/types'; import {describe, expect, it} from 'vitest'; interface MicrophoneFailureFixture { @@ -44,7 +44,6 @@ interface HardwareEncoderCapabilityFixture { input: unknown; codec: string; expectedCapabilities: Record; - expectedNativeNvenc: boolean; } interface ScreenShareEncodingPlanFixture { @@ -85,12 +84,6 @@ interface VoiceStatsSummaryFixture { expectedSummary: Partial; } -interface VoiceStatsCoercionFixture { - name: string; - input: Record; - expected: VoiceEngineV2Stats; -} - interface VoiceTrackClassificationFixture { name: string; input: VoiceEngineV2StatsTrackClassificationInput; @@ -107,10 +100,9 @@ describe('voice engine v2 policies', () => { it.each(hardwareEncoderCapabilityFixtures as Array)( 'replays hardware encoder capability fixture: $name', - ({input, codec, expectedCapabilities, expectedNativeNvenc}) => { + ({input, expectedCapabilities}) => { const capabilities = normalizeVoiceEngineV2HardwareEncoderCapabilities(input); expect(capabilities).toMatchObject(expectedCapabilities); - expect(hasVoiceEngineV2NativeNvencEncoder(capabilities, codec)).toBe(expectedNativeNvenc); }, ); @@ -162,13 +154,6 @@ describe('voice engine v2 policies', () => { }, ); - it.each(voiceStatsCoercionFixtures as Array)( - 'replays voice stats coercion fixture: $name', - ({input, expected}) => { - expect(coerceVoiceEngineV2Stats(input)).toMatchObject(expected); - }, - ); - it.each(voiceStatsSummaryFixtures as Array)( 'replays voice stats summary fixture: $name', ({input, expectedSummary}) => { diff --git a/packages/voice_engine_v2/src/policies/screenShare.ts b/packages/voice_engine_v2/src/policies/screenShare.ts index 8abcf937a..412f18049 100644 --- a/packages/voice_engine_v2/src/policies/screenShare.ts +++ b/packages/voice_engine_v2/src/policies/screenShare.ts @@ -4,7 +4,7 @@ import type { VoiceEngineV2Error, VoiceEngineV2ScreenEncodingOptions, VoiceEngineV2ScreenOptions, -} from '@fluxer/voice_engine_v2/src/protocol'; +} from '@fluxer/voice_engine_v2/src/protocol/types'; export type VoiceEngineV2ScreenEncodingPlanAction = 'noop' | 'updateEncoding' | 'republish' | 'reject'; diff --git a/packages/voice_engine_v2/src/policies/voiceStats.ts b/packages/voice_engine_v2/src/policies/voiceStats.ts index 2d690fd6a..358326b87 100644 --- a/packages/voice_engine_v2/src/policies/voiceStats.ts +++ b/packages/voice_engine_v2/src/policies/voiceStats.ts @@ -3,10 +3,8 @@ import type { VoiceEngineV2InboundStats, VoiceEngineV2OutboundStats, - VoiceEngineV2SendStats, VoiceEngineV2Stats, - VoiceEngineV2TrackKind, -} from '@fluxer/voice_engine_v2/src/protocol'; +} from '@fluxer/voice_engine_v2/src/protocol/types'; export const VoiceEngineV2StatsTrackSource = Object.freeze({ Microphone: 'microphone', @@ -111,136 +109,6 @@ interface IndexedVoiceEngineV2StatsTrack { track: VoiceEngineV2StatsTrackRoleCandidate; } -function asTrackKind(value: unknown): VoiceEngineV2TrackKind { - return value === 'video' ? 'video' : 'audio'; -} - -function asNumber(value: unknown): number { - return typeof value === 'number' && Number.isFinite(value) ? value : 0; -} - -function asNonNegativeNumber(value: unknown): number { - return Math.max(0, asNumber(value)); -} - -function asOptionalNumber(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined; -} - -function asOptionalCount(value: unknown): number | undefined { - const number = asOptionalNumber(value); - return number == null ? undefined : Math.max(0, Math.trunc(number)); -} - -function asString(value: unknown): string | undefined { - return typeof value === 'string' && value.length > 0 ? value : undefined; -} - -function coerceVoiceEngineV2SendStats(value: unknown): VoiceEngineV2SendStats | null | undefined { - if (value == null || typeof value !== 'object') return value === null ? null : undefined; - const payload = value as Record; - return { - outgoingVideoQueueDepth: asNonNegativeNumber(payload.outgoingVideoQueueDepth), - outgoingVideoQueueCapacity: asNonNegativeNumber(payload.outgoingVideoQueueCapacity), - outgoingVideoMaxQueueDepth: asNonNegativeNumber(payload.outgoingVideoMaxQueueDepth), - outgoingVideoFramesProduced: asNonNegativeNumber(payload.outgoingVideoFramesProduced), - outgoingVideoFramesAccepted: asNonNegativeNumber(payload.outgoingVideoFramesAccepted), - outgoingVideoFramesDropped: asNonNegativeNumber(payload.outgoingVideoFramesDropped), - outgoingVideoFramesCoalesced: asNonNegativeNumber(payload.outgoingVideoFramesCoalesced), - outgoingVideoFramesCaptured: asNonNegativeNumber(payload.outgoingVideoFramesCaptured), - outgoingVideoCaptureFailures: asNonNegativeNumber(payload.outgoingVideoCaptureFailures), - outgoingVideoEffectiveFps: asNonNegativeNumber(payload.outgoingVideoEffectiveFps), - outgoingVideoTargetFps: asNonNegativeNumber(payload.outgoingVideoTargetFps), - outgoingVideoPacingTargetFps: asNonNegativeNumber(payload.outgoingVideoPacingTargetFps), - outgoingVideoMaxQueueAgeMs: asNonNegativeNumber(payload.outgoingVideoMaxQueueAgeMs), - outgoingVideoMaxPushLatencyMs: asNonNegativeNumber(payload.outgoingVideoMaxPushLatencyMs), - outgoingVideoPacingMode: asString(payload.outgoingVideoPacingMode) ?? 'unknown', - outgoingVideoBusActive: payload.outgoingVideoBusActive === true, - outgoingAudioBufferTargetMs: asNonNegativeNumber(payload.outgoingAudioBufferTargetMs), - outgoingAudioBufferMaxMs: asNonNegativeNumber(payload.outgoingAudioBufferMaxMs), - outgoingAudioUnderruns: asNonNegativeNumber(payload.outgoingAudioUnderruns), - outgoingAudioRebuffers: asNonNegativeNumber(payload.outgoingAudioRebuffers), - outgoingAudioMaxFrameGapMs: asNonNegativeNumber(payload.outgoingAudioMaxFrameGapMs), - adaptiveSendTier: asString(payload.adaptiveSendTier) ?? 'unknown', - adaptiveSendReason: asString(payload.adaptiveSendReason) ?? 'unknown', - }; -} - -export function coerceVoiceEngineV2Stats(payload: Record): VoiceEngineV2Stats { - const rttRaw = payload.rttMs; - const rttMs = typeof rttRaw === 'number' && Number.isFinite(rttRaw) ? rttRaw : null; - const droppedVideoFrameCallbacks = asOptionalCount(payload.droppedVideoFrameCallbacks); - const droppedNativeVideoFrames = asOptionalCount(payload.droppedNativeVideoFrames); - const outbound = Array.isArray(payload.outbound) - ? payload.outbound.flatMap((entry) => { - if (entry == null || typeof entry !== 'object') return []; - const e = entry as Record; - const zeroCopy = e.zeroCopy === true ? true : undefined; - return [ - { - trackSid: asString(e.trackSid) ?? '', - source: asString(e.source) ?? '', - kind: asTrackKind(e.kind), - codec: asString(e.codec), - bitrateKbps: asNumber(e.bitrateKbps), - packetsLost: asNumber(e.packetsLost), - packetsSent: asOptionalCount(e.packetsSent), - fps: asOptionalNumber(e.fps), - audioLevel: asOptionalNumber(e.audioLevel), - width: asOptionalCount(e.width), - height: asOptionalCount(e.height), - sourceWidth: asOptionalCount(e.sourceWidth), - sourceHeight: asOptionalCount(e.sourceHeight), - targetBitrateKbps: asOptionalNumber(e.targetBitrateKbps), - configuredFps: asOptionalNumber(e.configuredFps), - targetFps: asOptionalNumber(e.targetFps), - effectiveFps: asOptionalNumber(e.effectiveFps), - framesProduced: asOptionalCount(e.framesProduced), - framesAccepted: asOptionalCount(e.framesAccepted), - framesDropped: asOptionalCount(e.framesDropped), - framesCoalesced: asOptionalCount(e.framesCoalesced), - framesCaptured: asOptionalCount(e.framesCaptured), - captureFailures: asOptionalCount(e.captureFailures), - maxQueueAgeMs: asOptionalNumber(e.maxQueueAgeMs), - maxPushLatencyMs: asOptionalNumber(e.maxPushLatencyMs), - adaptiveSendTier: asString(e.adaptiveSendTier), - adaptiveSendReason: asString(e.adaptiveSendReason), - ...(zeroCopy === true ? {zeroCopy} : {}), - }, - ]; - }) - : []; - const inbound = Array.isArray(payload.inbound) - ? payload.inbound.flatMap((entry) => { - if (entry == null || typeof entry !== 'object') return []; - const e = entry as Record; - const participantIdentity = asString(e.participantIdentity); - return [ - { - ...(participantIdentity ? {participantIdentity} : {}), - participantSid: asString(e.participantSid) ?? '', - trackSid: asString(e.trackSid) ?? '', - source: asString(e.source), - kind: asTrackKind(e.kind), - codec: asString(e.codec), - bitrateKbps: asNumber(e.bitrateKbps), - packetsLost: asNumber(e.packetsLost), - packetsReceived: asOptionalCount(e.packetsReceived), - jitterMs: asOptionalNumber(e.jitterMs), - audioLevel: asOptionalNumber(e.audioLevel), - width: asOptionalCount(e.width), - height: asOptionalCount(e.height), - fps: asOptionalNumber(e.fps), - sourceWidth: asOptionalCount(e.sourceWidth), - sourceHeight: asOptionalCount(e.sourceHeight), - }, - ]; - }) - : []; - const send = coerceVoiceEngineV2SendStats(payload.send); - return {rttMs, outbound, inbound, droppedVideoFrameCallbacks, droppedNativeVideoFrames, send}; -} - export function asVoiceEngineV2StatsTrackSource(source: unknown): VoiceEngineV2StatsTrackSource { switch (source) { case VoiceEngineV2StatsTrackSource.Microphone: @@ -628,11 +496,3 @@ export function classifyVoiceEngineV2TrackStats( ), }; } - -export type VoiceStatsNetworkSummary = VoiceEngineV2StatsNetworkSummary; -export type VoiceStatsTrackSummary = VoiceEngineV2StatsTrackSummary; -export type VoiceStatsSummary = VoiceEngineV2StatsSummary; -export type VoiceStatsTrackRoleCandidate = VoiceEngineV2StatsTrackRoleCandidate; -export type VoiceStatsTrackPublicationIds = VoiceEngineV2StatsTrackPublicationIds; -export type VoiceStatsTrackClassificationInput = VoiceEngineV2StatsTrackClassificationInput; -export type VoiceStatsTrackRoleSelection = VoiceEngineV2StatsTrackRoleSelection; diff --git a/packages/voice_engine_v2/src/ports/index.ts b/packages/voice_engine_v2/src/ports/index.ts index dfeeb2a4b..b52adad75 100644 --- a/packages/voice_engine_v2/src/ports/index.ts +++ b/packages/voice_engine_v2/src/ports/index.ts @@ -10,7 +10,6 @@ import type { VoiceEngineV2DisconnectReason, VoiceEngineV2Error, VoiceEngineV2GatewayVoiceStateWrite, - VoiceEngineV2HardwareEncoderCapabilities, VoiceEngineV2MicrophoneOptions, VoiceEngineV2NativeAudioTapOptions, VoiceEngineV2NativeCaptureOptions, @@ -38,28 +37,6 @@ export interface GatewayPort { clearVoiceState(guildId: string | null): Promise; } -export interface LiveKitPort { - prewarm(): Promise; - connect(options: VoiceEngineV2ConnectOptions): Promise; - disconnect(reason: VoiceEngineV2DisconnectReason): Promise; - publishMicrophone(options: VoiceEngineV2MicrophoneOptions): Promise; - unpublishMicrophone(): Promise; - setMicrophoneEnabled(enabled: boolean): Promise; - publishCamera(options: VoiceEngineV2CameraOptions): Promise; - updateCameraEncoding(options: VoiceEngineV2CameraEncodingOptions): Promise; - unpublishCamera(options?: VoiceEngineV2CameraOptions): Promise; - publishScreen(options: VoiceEngineV2ScreenOptions): Promise; - updateScreenEncoding(options: VoiceEngineV2ScreenEncodingOptions): Promise; - unpublishScreen(): Promise; - publishScreenAudio(options: VoiceEngineV2ScreenAudioOptions): Promise; - unpublishScreenAudio(): Promise; - setOutputDevice(options: VoiceEngineV2OutputDeviceOptions): Promise; - setParticipantVolume(options: VoiceEngineV2ParticipantVolumeOptions): Promise; - setRemoteTrackSubscription(options: VoiceEngineV2RemoteTrackSubscriptionOptions): Promise; - publishData(options: VoiceEngineV2DataOptions): Promise; - collectStats(): Promise; -} - export interface LiveKitMediaPort { prewarm(): Promise; connect(options: VoiceEngineV2ConnectOptions): Promise; @@ -109,10 +86,6 @@ export interface DevicePort { selectCamera(deviceId: string | null): Promise; } -export interface CapabilitiesPort { - getHardwareEncoderCapabilities(): Promise; -} - export interface PermissionPort { checkPermission(name: VoiceEngineV2PermissionName): Promise; requestPermission(name: VoiceEngineV2PermissionName): Promise; @@ -129,14 +102,12 @@ export interface DiagnosticsPort { export interface VoiceEngineV2HostPorts { gateway?: GatewayPort; - liveKit?: LiveKitPort; media?: LiveKitMediaPort; subscriptions?: SubscriptionPort; stats?: StatsPort; voiceState?: VoiceStateIngestionPort; participantProjection?: ParticipantProjectionIngestionPort; nativeMedia?: NativeMediaPort; - capabilities?: CapabilitiesPort; devices?: DevicePort; permissions?: PermissionPort; timers?: TimerPort; diff --git a/packages/voice_engine_v2/src/protocol/commands.ts b/packages/voice_engine_v2/src/protocol/commands.ts index 6bb6b30c4..d3f72f959 100644 --- a/packages/voice_engine_v2/src/protocol/commands.ts +++ b/packages/voice_engine_v2/src/protocol/commands.ts @@ -67,7 +67,6 @@ export type VoiceEngineV2Command = } | {type: 'data.publish'; operationId: VoiceEngineV2OperationId; options: VoiceEngineV2DataOptions} | {type: 'stats.collect'; operationId: VoiceEngineV2OperationId} - | {type: 'capabilities.queryHardwareEncoder'; operationId: VoiceEngineV2OperationId} | {type: 'permissions.check'; operationId: VoiceEngineV2OperationId; name: VoiceEngineV2PermissionName} | {type: 'permissions.request'; operationId: VoiceEngineV2OperationId; name: VoiceEngineV2PermissionName} | {type: 'devices.enumerate'; operationId: VoiceEngineV2OperationId} @@ -146,8 +145,7 @@ export function getVoiceEngineV2CommandTypeResourceKey( return 'dataChannel'; case 'stats.collect': return 'stats'; - case 'capabilities.queryHardwareEncoder': - return 'capabilities'; + case 'permissions.check': case 'permissions.request': return 'permissions'; diff --git a/packages/voice_engine_v2/src/protocol/events.ts b/packages/voice_engine_v2/src/protocol/events.ts index be8eb3ee4..68713eef0 100644 --- a/packages/voice_engine_v2/src/protocol/events.ts +++ b/packages/voice_engine_v2/src/protocol/events.ts @@ -5,7 +5,6 @@ import type { VoiceEngineV2AudioControlsPatch, VoiceEngineV2CameraEncodingOptions, VoiceEngineV2CameraOptions, - VoiceEngineV2Capabilities, VoiceEngineV2ConnectOptions, VoiceEngineV2DataOptions, VoiceEngineV2DeviceChangeReason, @@ -14,18 +13,13 @@ import type { VoiceEngineV2DisconnectReason, VoiceEngineV2Error, VoiceEngineV2GatewayDesiredVoiceState, - VoiceEngineV2GatewayVoiceServer, VoiceEngineV2GatewayVoiceState, VoiceEngineV2GatewayVoiceStateWrite, - VoiceEngineV2HardwareEncoderCapabilities, VoiceEngineV2InboundVideoFrame, - VoiceEngineV2InboundVideoFrameStats, VoiceEngineV2InboundVideoTrackSubscription, VoiceEngineV2LifecycleReason, - VoiceEngineV2LiveKitRoomState, VoiceEngineV2MicrophoneOptions, VoiceEngineV2NativeAudioTapOptions, - VoiceEngineV2NativeCaptureFrame, VoiceEngineV2NativeCaptureOptions, VoiceEngineV2NativeFrameSinkOptions, VoiceEngineV2OperationId, @@ -105,8 +99,6 @@ export type VoiceEngineV2Event = | {type: 'gateway.voiceStateClearSucceeded'; operationId: VoiceEngineV2OperationId} | {type: 'gateway.voiceStateClearFailed'; operationId: VoiceEngineV2OperationId; error: VoiceEngineV2Error} | {type: 'gateway.voiceStateUpdated'; voiceState: VoiceEngineV2GatewayVoiceState | null} - | {type: 'gateway.voiceServerUpdated'; voiceServer: VoiceEngineV2GatewayVoiceServer | null} - | {type: 'livekit.roomStateChanged'; room: VoiceEngineV2LiveKitRoomState} | {type: 'connection.connectRequested'; options: VoiceEngineV2ConnectOptions} | {type: 'connection.connectSucceeded'; operationId: VoiceEngineV2OperationId} | {type: 'connection.connectFailed'; operationId: VoiceEngineV2OperationId; error: VoiceEngineV2Error} @@ -166,18 +158,6 @@ export type VoiceEngineV2Event = | {type: 'stats.collectRequested'} | {type: 'stats.collected'; operationId: VoiceEngineV2OperationId; stats: VoiceEngineV2Stats} | {type: 'stats.collectFailed'; operationId: VoiceEngineV2OperationId; error: VoiceEngineV2Error} - | {type: 'capabilities.changed'; capabilities: VoiceEngineV2Capabilities} - | {type: 'capabilities.hardwareEncoderQueryRequested'} - | { - type: 'capabilities.hardwareEncoderChanged'; - operationId: VoiceEngineV2OperationId | null; - capabilities: VoiceEngineV2HardwareEncoderCapabilities; - } - | { - type: 'capabilities.hardwareEncoderQueryFailed'; - operationId: VoiceEngineV2OperationId; - error: VoiceEngineV2Error; - } | {type: 'permissions.checkRequested'; name: VoiceEngineV2PermissionName} | {type: 'permissions.requestRequested'; name: VoiceEngineV2PermissionName} | { @@ -214,7 +194,6 @@ export type VoiceEngineV2Event = captureId: string; error: VoiceEngineV2Error; } - | {type: 'nativeCapture.frame'; frame: VoiceEngineV2NativeCaptureFrame} | {type: 'nativeAudioTap.startRequested'; options: VoiceEngineV2NativeAudioTapOptions} | {type: 'nativeAudioTap.stopRequested'; tapId: string} | {type: 'nativeFrameSink.attachRequested'; options: VoiceEngineV2NativeFrameSinkOptions} @@ -227,7 +206,6 @@ export type VoiceEngineV2Event = | {type: 'timer.cancelRequested'; timerId: string} | {type: 'timer.fired'; timerId: string; operationId: VoiceEngineV2OperationId | null} | {type: 'diagnostics.logRequested'; entry: VoiceEngineV2DiagnosticEntry} - | {type: 'diagnostics.logged'; operationId: VoiceEngineV2OperationId | null; entry: VoiceEngineV2DiagnosticEntry} | {type: 'lifecycle.teardownRequested'; reason: VoiceEngineV2LifecycleReason} | {type: 'lifecycle.teardownSucceeded'; operationId: VoiceEngineV2OperationId} | {type: 'lifecycle.teardownFailed'; operationId: VoiceEngineV2OperationId; error: VoiceEngineV2Error} @@ -241,8 +219,6 @@ export type VoiceEngineV2Event = | {type: 'watchedStream.unwatchRequested'; stream: VoiceEngineV2WatchedStreamKey} | {type: 'watchedStreams.replaced'; streams: Array} | {type: 'inboundVideo.trackSubscribed'; track: VoiceEngineV2InboundVideoTrackSubscription} - | {type: 'inboundVideo.trackUnsubscribed'; trackSid: string} | {type: 'inboundVideo.frameReceived'; frame: VoiceEngineV2InboundVideoFrame} - | {type: 'inboundVideo.frameStats'; stats: VoiceEngineV2InboundVideoFrameStats} | VoiceEngineV2SourceLifecycleTransitionedEvent | VoiceEngineV2SourceLifecycleRemovedEvent; diff --git a/packages/voice_engine_v2/src/protocol/index.ts b/packages/voice_engine_v2/src/protocol/index.ts deleted file mode 100644 index d2911e3af..000000000 --- a/packages/voice_engine_v2/src/protocol/index.ts +++ /dev/null @@ -1,94 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -export type {VoiceEngineV2Command, VoiceEngineV2CommandType} from '@fluxer/voice_engine_v2/src/protocol/commands'; -export { - getVoiceEngineV2CommandResourceKey, - getVoiceEngineV2CommandTypeResourceKey, -} from '@fluxer/voice_engine_v2/src/protocol/commands'; -export type { - VoiceEngineV2Event, - VoiceEngineV2SourceLifecycleRemovedEvent, - VoiceEngineV2SourceLifecycleTransitionedEvent, - VoiceEngineV2SourceLifecycleTransitionKind, -} from '@fluxer/voice_engine_v2/src/protocol/events'; -export type { - VoiceEngineV2AudioControls, - VoiceEngineV2AudioControlsPatch, - VoiceEngineV2AudioDeviceRole, - VoiceEngineV2AudioInputDevice, - VoiceEngineV2AudioMode, - VoiceEngineV2AudioOutputDevice, - VoiceEngineV2CameraDevice, - VoiceEngineV2CameraEncodingOptions, - VoiceEngineV2CameraOptions, - VoiceEngineV2Capabilities, - VoiceEngineV2ConnectionModel, - VoiceEngineV2ConnectionQuality, - VoiceEngineV2ConnectionStatus, - VoiceEngineV2ConnectOptions, - VoiceEngineV2DataOptions, - VoiceEngineV2DeviceChangeReason, - VoiceEngineV2DeviceInventory, - VoiceEngineV2DiagnosticEntry, - VoiceEngineV2DiagnosticLevel, - VoiceEngineV2DisconnectReason, - VoiceEngineV2E2eeState, - VoiceEngineV2E2eeStatus, - VoiceEngineV2Error, - VoiceEngineV2GatewayDesiredVoiceState, - VoiceEngineV2GatewayVoiceServer, - VoiceEngineV2GatewayVoiceState, - VoiceEngineV2GatewayVoiceStateWrite, - VoiceEngineV2HardwareEncoderCapabilities, - VoiceEngineV2ImplementationKind, - VoiceEngineV2InboundStats, - VoiceEngineV2InboundVideoFrame, - VoiceEngineV2InboundVideoFrameStats, - VoiceEngineV2InboundVideoTrack, - VoiceEngineV2InboundVideoTrackSubscription, - VoiceEngineV2LatencyDataPoint, - VoiceEngineV2LifecycleReason, - VoiceEngineV2LiveKitConnectionState, - VoiceEngineV2LiveKitRoomState, - VoiceEngineV2LocalStreamSource, - VoiceEngineV2MediaModel, - VoiceEngineV2MediaStatus, - VoiceEngineV2MicrophoneOptions, - VoiceEngineV2Model, - VoiceEngineV2NativeAudioTapOptions, - VoiceEngineV2NativeCaptureFrame, - VoiceEngineV2NativeCaptureKind, - VoiceEngineV2NativeCaptureOptions, - VoiceEngineV2NativeCaptureSource, - VoiceEngineV2NativeFrameSinkOptions, - VoiceEngineV2OperationId, - VoiceEngineV2OutboundStats, - VoiceEngineV2OutputDeviceOptions, - VoiceEngineV2Participant, - VoiceEngineV2ParticipantVolumeOptions, - VoiceEngineV2PcmFrame, - VoiceEngineV2PermissionName, - VoiceEngineV2PermissionResult, - VoiceEngineV2PermissionStatus, - VoiceEngineV2PerTrackStats, - VoiceEngineV2RemoteTrackQuality, - VoiceEngineV2RemoteTrackSubscriptionOptions, - VoiceEngineV2ResourceKey, - VoiceEngineV2ScreenAudioOptions, - VoiceEngineV2ScreenEncodingOptions, - VoiceEngineV2ScreenOptions, - VoiceEngineV2ScreenPacing, - VoiceEngineV2SendStats, - VoiceEngineV2Stats, - VoiceEngineV2StatsSample, - VoiceEngineV2TimerOptions, - VoiceEngineV2Track, - VoiceEngineV2TrackKind, - VoiceEngineV2TrackSource, - VoiceEngineV2TransportInfo, - VoiceEngineV2VideoAccelerationStatus, - VoiceEngineV2VideoCodec, - VoiceEngineV2VoiceStats, - VoiceEngineV2WatchedStream, - VoiceEngineV2WatchedStreamKey, -} from '@fluxer/voice_engine_v2/src/protocol/types'; diff --git a/packages/voice_engine_v2/src/protocol/types.ts b/packages/voice_engine_v2/src/protocol/types.ts index d72079a4e..c62a8e084 100644 --- a/packages/voice_engine_v2/src/protocol/types.ts +++ b/packages/voice_engine_v2/src/protocol/types.ts @@ -28,8 +28,6 @@ export type VoiceEngineV2TrackSource = 'microphone' | 'camera' | 'screen' | 'scr export type VoiceEngineV2TrackKind = 'audio' | 'video'; -export type VoiceEngineV2ConnectionQuality = 'excellent' | 'good' | 'poor' | 'lost'; - export type VoiceEngineV2RemoteTrackQuality = 'low' | 'medium' | 'high'; export type VoiceEngineV2VideoCodec = '' | 'vp8' | 'vp9' | 'h264' | 'h265' | 'av1'; @@ -136,12 +134,6 @@ export interface VoiceEngineV2GatewayVoiceState { requestToSpeakTimestamp: string | null; } -export interface VoiceEngineV2GatewayVoiceServer { - guildId: string | null; - endpoint: string | null; - token: string; -} - export interface VoiceEngineV2GatewayVoiceStateWrite { guildId: string | null; channelId: string | null; @@ -270,18 +262,6 @@ export interface VoiceEngineV2NativeCaptureOptions { zeroCopyRequired: true; } -export interface VoiceEngineV2NativeCaptureFrame { - captureId: string; - frameId: string; - width: number; - height: number; - timestampMs: number; - format: 'i420' | 'nv12' | 'rgba' | 'bgra' | 'native'; - zeroCopy: true; - handle?: string; - byteLength?: number; -} - export interface VoiceEngineV2NativeAudioTapOptions { tapId: string; source: 'system' | 'application' | 'window'; @@ -297,12 +277,6 @@ export interface VoiceEngineV2NativeFrameSinkOptions { zeroCopyRequired: true; } -export interface VoiceEngineV2PcmFrame { - sampleRate: number; - numChannels: number; - samples: ArrayBuffer; -} - export interface VoiceEngineV2OutputDeviceOptions { deviceId: string; } @@ -558,6 +532,16 @@ export interface VoiceEngineV2PerTrackStats { concealedSamples?: number; silentConcealedSamples?: number; totalSamplesReceived?: number; + codecSdpFmtpLine?: string; + packetsReceived?: number; + bytesReceived?: number; + framesReceived?: number; + packetsSent?: number; + sourceSamplesDurationMs?: number; + packetsDiscarded?: number; + jitterBufferTargetDelayMs?: number; + insertedSamplesForDeceleration?: number; + removedSamplesForAcceleration?: number; } export interface VoiceEngineV2TransportInfo { @@ -577,6 +561,9 @@ export interface VoiceEngineV2TransportInfo { srtpCipher?: string; tlsVersion?: string; networkType?: string; + localRelayProtocol?: string; + localCandidateUrlHost?: string; + peerConnectionMode?: string; } export interface VoiceEngineV2VoiceStats { @@ -674,8 +661,6 @@ export interface VoiceEngineV2WatchedStreamKey { source: VoiceEngineV2TrackSource | string; } -export type VoiceEngineV2LocalStreamSource = 'camera' | 'screen'; - export interface VoiceEngineV2InboundVideoTrackSubscription { participantSid: string; participantIdentity?: string; @@ -695,17 +680,6 @@ export interface VoiceEngineV2InboundVideoFrame { byteLength?: number; } -export interface VoiceEngineV2InboundVideoFrameStats { - participantSid: string; - participantIdentity?: string; - trackSid: string; - width: number; - height: number; - frameCount: number; - lastFrameTimestampUs: number; - lastFrameByteLength: number | null; -} - export interface VoiceEngineV2InboundVideoTrack { participantSid: string; participantIdentity?: string; @@ -726,7 +700,6 @@ export interface VoiceEngineV2ConnectionModel { failed: boolean; gateway: { selfVoiceState: VoiceEngineV2GatewayVoiceState | null; - voiceServer: VoiceEngineV2GatewayVoiceServer | null; }; liveKit: VoiceEngineV2LiveKitRoomState; } diff --git a/packages/voice_engine_v2/src/runtime/VoiceEngineV2Controller.test.ts b/packages/voice_engine_v2/src/runtime/VoiceEngineV2Controller.test.ts index 9267a9962..7cc99dc87 100644 --- a/packages/voice_engine_v2/src/runtime/VoiceEngineV2Controller.test.ts +++ b/packages/voice_engine_v2/src/runtime/VoiceEngineV2Controller.test.ts @@ -3,16 +3,14 @@ import type { VoiceEngineV2CommandResult, VoiceEngineV2Implementation, -} from '@fluxer/voice_engine_v2/src/implementations'; -import type {VoiceEngineV2Command} from '@fluxer/voice_engine_v2/src/protocol'; +} from '@fluxer/voice_engine_v2/src/implementations/VoiceEngineV2ImplementationBase'; +import type {VoiceEngineV2Command} from '@fluxer/voice_engine_v2/src/protocol/commands'; import {createVoiceEngineV2MemoryEventLogSpillSink} from '@fluxer/voice_engine_v2/src/runtime/eventLogRing'; import {VoiceEngineV2Controller} from '@fluxer/voice_engine_v2/src/runtime/VoiceEngineV2Controller'; import {VoiceEngineV2Runtime} from '@fluxer/voice_engine_v2/src/runtime/VoiceEngineV2Runtime'; -import { - FakeVoiceEngineV2Driver, - VoiceEngineV2TestImplementation, - waitForRuntime, -} from '@fluxer/voice_engine_v2/src/testing'; +import {waitForRuntime} from '@fluxer/voice_engine_v2/src/testing/conformance'; +import {FakeVoiceEngineV2Driver} from '@fluxer/voice_engine_v2/src/testing/FakeVoiceEngineV2Driver'; +import {VoiceEngineV2TestImplementation} from '@fluxer/voice_engine_v2/src/testing/VoiceEngineV2TestImplementation'; import {describe, expect, it} from 'vitest'; interface DeferredCommand { @@ -99,33 +97,6 @@ describe('VoiceEngineV2Controller', () => { }); }); - it('queries hardware encoder capabilities through the v2 command pipeline', async () => { - const driver = new FakeVoiceEngineV2Driver({ - hardwareEncoderCapabilities: { - available: true, - backend: 'nvenc', - compiled: true, - runtime: true, - codecs: ['h264'], - zeroCopy: true, - nativeInputs: ['d3d11-texture'], - }, - }); - const controller = new VoiceEngineV2Controller( - createControllerTestRuntime(new VoiceEngineV2TestImplementation(driver)), - ); - - controller.queryHardwareEncoderCapabilities(); - await waitForRuntime(); - - expect(driver.calls).toEqual([{type: 'getHardwareEncoderCapabilities'}]); - expect(controller.snapshot.hardwareEncoder.capabilities).toMatchObject({ - available: true, - backend: 'nvenc', - zeroCopy: true, - }); - }); - it('serializes commands by resource and rejects stale completions', async () => { const implementation = new DeferredVoiceEngineV2Implementation(); const runtime = createControllerTestRuntime(implementation); diff --git a/packages/voice_engine_v2/src/runtime/VoiceEngineV2Controller.ts b/packages/voice_engine_v2/src/runtime/VoiceEngineV2Controller.ts index 4abb5604e..cba216a53 100644 --- a/packages/voice_engine_v2/src/runtime/VoiceEngineV2Controller.ts +++ b/packages/voice_engine_v2/src/runtime/VoiceEngineV2Controller.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {selectVoiceEngineV2Model, type VoiceEngineV2Snapshot} from '@fluxer/voice_engine_v2/src/core'; +import {selectVoiceEngineV2Model} from '@fluxer/voice_engine_v2/src/core/selectors'; +import type {VoiceEngineV2Snapshot} from '@fluxer/voice_engine_v2/src/core/state'; +import type {VoiceEngineV2Event} from '@fluxer/voice_engine_v2/src/protocol/events'; import type { VoiceEngineV2AudioControlsPatch, VoiceEngineV2CameraEncodingOptions, @@ -28,8 +30,7 @@ import type { VoiceEngineV2TimerOptions, VoiceEngineV2WatchedStream, VoiceEngineV2WatchedStreamKey, -} from '@fluxer/voice_engine_v2/src/protocol'; -import type {VoiceEngineV2Event} from '@fluxer/voice_engine_v2/src/protocol/events'; +} from '@fluxer/voice_engine_v2/src/protocol/types'; import type { VoiceEngineV2Runtime, VoiceEngineV2RuntimeListener, @@ -170,10 +171,6 @@ export class VoiceEngineV2Controller { this.runtime.dispatch({type: 'stats.collectRequested'}); } - queryHardwareEncoderCapabilities(): void { - this.runtime.dispatch({type: 'capabilities.hardwareEncoderQueryRequested'}); - } - checkPermission(name: VoiceEngineV2PermissionName): void { this.runtime.dispatch({type: 'permissions.checkRequested', name}); } diff --git a/packages/voice_engine_v2/src/runtime/VoiceEngineV2Runtime.bench.ts b/packages/voice_engine_v2/src/runtime/VoiceEngineV2Runtime.bench.ts index 1d5cc9f0a..ba236af1e 100644 --- a/packages/voice_engine_v2/src/runtime/VoiceEngineV2Runtime.bench.ts +++ b/packages/voice_engine_v2/src/runtime/VoiceEngineV2Runtime.bench.ts @@ -6,7 +6,7 @@ import type { VoiceEngineV2CommandResult, VoiceEngineV2ExternalEventListener, VoiceEngineV2Implementation, -} from '@fluxer/voice_engine_v2/src/implementations'; +} from '@fluxer/voice_engine_v2/src/implementations/VoiceEngineV2ImplementationBase'; import type {VoiceEngineV2Command} from '@fluxer/voice_engine_v2/src/protocol/commands'; import type {VoiceEngineV2Event} from '@fluxer/voice_engine_v2/src/protocol/events'; import type { diff --git a/packages/voice_engine_v2/src/runtime/VoiceEngineV2Runtime.test.ts b/packages/voice_engine_v2/src/runtime/VoiceEngineV2Runtime.test.ts index a74bfde36..7c7b172fb 100644 --- a/packages/voice_engine_v2/src/runtime/VoiceEngineV2Runtime.test.ts +++ b/packages/voice_engine_v2/src/runtime/VoiceEngineV2Runtime.test.ts @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import type { VoiceEngineV2CommandResult, VoiceEngineV2Implementation, -} from '@fluxer/voice_engine_v2/src/implementations'; +} from '@fluxer/voice_engine_v2/src/implementations/VoiceEngineV2ImplementationBase'; import type {VoiceEngineV2Command} from '@fluxer/voice_engine_v2/src/protocol/commands'; import {createVoiceEngineV2MemoryEventLogSpillSink} from '@fluxer/voice_engine_v2/src/runtime/eventLogRing'; import { @@ -18,11 +18,9 @@ import { VoiceEngineV2Runtime, type VoiceEngineV2RuntimeDiagnostic, } from '@fluxer/voice_engine_v2/src/runtime/VoiceEngineV2Runtime'; -import { - FakeVoiceEngineV2Driver, - VoiceEngineV2TestImplementation, - waitForRuntime, -} from '@fluxer/voice_engine_v2/src/testing'; +import {waitForRuntime} from '@fluxer/voice_engine_v2/src/testing/conformance'; +import {FakeVoiceEngineV2Driver} from '@fluxer/voice_engine_v2/src/testing/FakeVoiceEngineV2Driver'; +import {VoiceEngineV2TestImplementation} from '@fluxer/voice_engine_v2/src/testing/VoiceEngineV2TestImplementation'; import {describe, expect, it} from 'vitest'; class DeferredAllResolveImplementation implements VoiceEngineV2Implementation { diff --git a/packages/voice_engine_v2/src/runtime/VoiceEngineV2Runtime.ts b/packages/voice_engine_v2/src/runtime/VoiceEngineV2Runtime.ts index 5b8c76a5b..2bdf887ff 100644 --- a/packages/voice_engine_v2/src/runtime/VoiceEngineV2Runtime.ts +++ b/packages/voice_engine_v2/src/runtime/VoiceEngineV2Runtime.ts @@ -12,7 +12,7 @@ import { errorToVoiceEngineV2Error, type VoiceEngineV2CommandResult, type VoiceEngineV2Implementation, -} from '@fluxer/voice_engine_v2/src/implementations'; +} from '@fluxer/voice_engine_v2/src/implementations/VoiceEngineV2ImplementationBase'; import { getVoiceEngineV2CommandResourceKey, type VoiceEngineV2Command, @@ -554,5 +554,3 @@ export function assertEventLogInvariants(runtime: VoiceEngineV2Runtime): void { assert.ok(runtime instanceof VoiceEngineV2Runtime, 'assertEventLogInvariants requires a runtime instance'); runtime.verifyEventLogInvariants(); } - -export {commandResultToEvent} from '@fluxer/voice_engine_v2/src/runtime/commandEvents'; diff --git a/packages/voice_engine_v2/src/runtime/commandEvents.ts b/packages/voice_engine_v2/src/runtime/commandEvents.ts index 8b771765b..044013c18 100644 --- a/packages/voice_engine_v2/src/runtime/commandEvents.ts +++ b/packages/voice_engine_v2/src/runtime/commandEvents.ts @@ -1,64 +1,19 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type {VoiceEngineV2CommandResult} from '@fluxer/voice_engine_v2/src/implementations'; +import type { + VoiceEngineV2CommandResult, + VoiceEngineV2DevicesCommand, + VoiceEngineV2LocalMediaCommand, + VoiceEngineV2NativeCommand, + VoiceEngineV2QueryCommand, + VoiceEngineV2RoutingCommand, + VoiceEngineV2SessionCommand, + VoiceEngineV2UtilityCommand, +} from '@fluxer/voice_engine_v2/src/implementations/VoiceEngineV2ImplementationBase'; import type {VoiceEngineV2Command} from '@fluxer/voice_engine_v2/src/protocol/commands'; import type {VoiceEngineV2Event} from '@fluxer/voice_engine_v2/src/protocol/events'; import type {VoiceEngineV2Error} from '@fluxer/voice_engine_v2/src/protocol/types'; -type VoiceEngineV2CommandOfType = Extract< - VoiceEngineV2Command, - {type: Type} ->; - -type VoiceEngineV2SessionCommand = VoiceEngineV2CommandOfType< - | 'implementation.prewarm' - | 'gateway.voiceState.write' - | 'gateway.voiceState.clear' - | 'connection.connect' - | 'connection.disconnect' - | 'lifecycle.teardown' ->; - -type VoiceEngineV2LocalMediaCommand = VoiceEngineV2CommandOfType< - | 'microphone.publish' - | 'microphone.unpublish' - | 'microphone.setEnabled' - | 'camera.publish' - | 'camera.updateEncoding' - | 'camera.unpublish' - | 'screen.publish' - | 'screen.updateEncoding' - | 'screen.unpublish' - | 'screenAudio.publish' - | 'screenAudio.unpublish' ->; - -type VoiceEngineV2RoutingCommand = VoiceEngineV2CommandOfType< - 'outputDevice.set' | 'participantVolume.set' | 'remoteTrackSubscription.set' | 'data.publish' ->; - -type VoiceEngineV2QueryCommand = VoiceEngineV2CommandOfType< - 'stats.collect' | 'capabilities.queryHardwareEncoder' | 'permissions.check' | 'permissions.request' ->; - -type VoiceEngineV2DevicesCommand = VoiceEngineV2CommandOfType< - 'devices.enumerate' | 'devices.selectAudioInput' | 'devices.selectAudioOutput' | 'devices.selectCamera' ->; - -type VoiceEngineV2NativeCommand = VoiceEngineV2CommandOfType< - | 'nativeCapture.start' - | 'nativeCapture.update' - | 'nativeCapture.stop' - | 'nativeAudioTap.start' - | 'nativeAudioTap.stop' - | 'nativeFrameSink.attach' - | 'nativeFrameSink.detach' ->; - -type VoiceEngineV2UtilityCommand = VoiceEngineV2CommandOfType< - 'e2ee.setEnabled' | 'timer.schedule' | 'timer.cancel' | 'diagnostics.log' | 'operation.cancel' ->; - type VoiceEngineV2CommandSuccess = Extract; export function commandResultToEvent( @@ -92,7 +47,6 @@ export function commandResultToEvent( case 'data.publish': return routingSuccessToEvent(command); case 'stats.collect': - case 'capabilities.queryHardwareEncoder': case 'permissions.check': case 'permissions.request': return querySuccessToEvent(command, result); @@ -191,23 +145,7 @@ function querySuccessToEvent( capability: 'stats', }, }; - case 'capabilities.queryHardwareEncoder': - if (result.hardwareEncoderCapabilities) { - return { - type: 'capabilities.hardwareEncoderChanged', - operationId: command.operationId, - capabilities: result.hardwareEncoderCapabilities, - }; - } - return { - type: 'capabilities.hardwareEncoderQueryFailed', - operationId: command.operationId, - error: { - code: 'implementationError', - message: 'Voice engine v2 hardware encoder capability command succeeded without capabilities', - capability: 'hardwareEncoding', - }, - }; + case 'permissions.check': case 'permissions.request': if (result.permissionResult) { @@ -323,8 +261,7 @@ function commandFailureToEvent(command: VoiceEngineV2Command, error: VoiceEngine return {type: 'data.publishFailed', operationId: command.operationId, error}; case 'stats.collect': return {type: 'stats.collectFailed', operationId: command.operationId, error}; - case 'capabilities.queryHardwareEncoder': - return {type: 'capabilities.hardwareEncoderQueryFailed', operationId: command.operationId, error}; + case 'permissions.check': case 'permissions.request': return {type: 'permissions.failed', operationId: command.operationId, name: command.name, error}; diff --git a/packages/voice_engine_v2/src/runtime/eventLogRing.test.ts b/packages/voice_engine_v2/src/runtime/eventLogRing.test.ts index 9ce1f26ce..b9c0ed9d8 100644 --- a/packages/voice_engine_v2/src/runtime/eventLogRing.test.ts +++ b/packages/voice_engine_v2/src/runtime/eventLogRing.test.ts @@ -22,7 +22,8 @@ import { type VoiceEngineV2EventLogEntry as RuntimeEventLogEntry, VoiceEngineV2Runtime, } from '@fluxer/voice_engine_v2/src/runtime/VoiceEngineV2Runtime'; -import {FakeVoiceEngineV2Driver, VoiceEngineV2TestImplementation} from '@fluxer/voice_engine_v2/src/testing'; +import {FakeVoiceEngineV2Driver} from '@fluxer/voice_engine_v2/src/testing/FakeVoiceEngineV2Driver'; +import {VoiceEngineV2TestImplementation} from '@fluxer/voice_engine_v2/src/testing/VoiceEngineV2TestImplementation'; import {describe, expect, it} from 'vitest'; function makeInboundVideoFrameEvent(timestampUs: number): VoiceEngineV2Event { diff --git a/packages/voice_engine_v2/src/runtime/frameCoalescing.test.ts b/packages/voice_engine_v2/src/runtime/frameCoalescing.test.ts index 55132fbad..3abeb14ba 100644 --- a/packages/voice_engine_v2/src/runtime/frameCoalescing.test.ts +++ b/packages/voice_engine_v2/src/runtime/frameCoalescing.test.ts @@ -7,7 +7,8 @@ import { VOICE_ENGINE_V2_COALESCED_TRACKS_CAP, } from '@fluxer/voice_engine_v2/src/runtime/frameCoalescing'; import {VoiceEngineV2Runtime} from '@fluxer/voice_engine_v2/src/runtime/VoiceEngineV2Runtime'; -import {FakeVoiceEngineV2Driver, VoiceEngineV2TestImplementation} from '@fluxer/voice_engine_v2/src/testing'; +import {FakeVoiceEngineV2Driver} from '@fluxer/voice_engine_v2/src/testing/FakeVoiceEngineV2Driver'; +import {VoiceEngineV2TestImplementation} from '@fluxer/voice_engine_v2/src/testing/VoiceEngineV2TestImplementation'; import {describe, expect, it} from 'vitest'; function makeRuntime(options?: {eventLogCap?: number}): VoiceEngineV2Runtime { diff --git a/packages/voice_engine_v2/src/runtime/index.ts b/packages/voice_engine_v2/src/runtime/index.ts deleted file mode 100644 index 89f6197e9..000000000 --- a/packages/voice_engine_v2/src/runtime/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -export type {VoiceEngineV2MemoryEventLogSpillSink} from '@fluxer/voice_engine_v2/src/runtime/eventLogRing'; -export { - assertEventLogRingInvariants, - createVoiceEngineV2MemoryEventLogSpillSink, - VOICE_ENGINE_V2_EVENT_LOG_CAP, - VOICE_ENGINE_V2_MEMORY_EVENT_LOG_SPILL_SINK_CAP, - VoiceEngineV2EventLogRing, -} from '@fluxer/voice_engine_v2/src/runtime/eventLogRing'; -export type {VoiceEngineV2FrameReceivedEvent} from '@fluxer/voice_engine_v2/src/runtime/frameCoalescing'; -export { - canCoalesceVoiceEngineV2Events, - coalesceVoiceEngineV2EventSequence, - isVoiceEngineV2FrameReceivedEvent, - VOICE_ENGINE_V2_COALESCED_TRACKS_CAP, -} from '@fluxer/voice_engine_v2/src/runtime/frameCoalescing'; -export type { - VoiceEngineV2ClockPort, - VoiceEngineV2RandomPort, - VoiceEngineV2SystemClockPort, - VoiceEngineV2WallClockSource, -} from '@fluxer/voice_engine_v2/src/runtime/platformPort'; -export { - createVoiceEngineV2DeterministicClockPort, - createVoiceEngineV2SystemClockPort, -} from '@fluxer/voice_engine_v2/src/runtime/platformPort'; -export {VoiceEngineV2Controller} from '@fluxer/voice_engine_v2/src/runtime/VoiceEngineV2Controller'; -export type { - VoiceEngineV2EventLogEntry, - VoiceEngineV2EventLogSpillSink, - VoiceEngineV2RuntimeClock, - VoiceEngineV2RuntimeDiagnostic, - VoiceEngineV2RuntimeDiagnosticListener, - VoiceEngineV2RuntimeListener, - VoiceEngineV2RuntimeListenerPayload, - VoiceEngineV2RuntimeOptions, - VoiceEngineV2RuntimeQueueKind, -} from '@fluxer/voice_engine_v2/src/runtime/VoiceEngineV2Runtime'; -export { - assertEventLogInvariants, - commandResultToEvent, - isVoiceEngineV2ProgrammerError, - VOICE_ENGINE_V2_CANCELLED_OPERATIONS_CAP, - VOICE_ENGINE_V2_DIAGNOSTIC_LISTENERS_CAP, - VOICE_ENGINE_V2_LISTENERS_CAP, - VOICE_ENGINE_V2_QUEUED_COMMANDS_CAP, - VOICE_ENGINE_V2_RESOURCE_QUEUES_CAP, - VoiceEngineV2Runtime, -} from '@fluxer/voice_engine_v2/src/runtime/VoiceEngineV2Runtime'; diff --git a/packages/voice_engine_v2/src/simulation/SimulatorPorts.ts b/packages/voice_engine_v2/src/simulation/SimulatorPorts.ts index 172f66f75..c76dac560 100644 --- a/packages/voice_engine_v2/src/simulation/SimulatorPorts.ts +++ b/packages/voice_engine_v2/src/simulation/SimulatorPorts.ts @@ -15,7 +15,6 @@ import type { VoiceEngineV2DisconnectReason, VoiceEngineV2Error, VoiceEngineV2GatewayVoiceStateWrite, - VoiceEngineV2HardwareEncoderCapabilities, VoiceEngineV2MicrophoneOptions, VoiceEngineV2NativeAudioTapOptions, VoiceEngineV2NativeCaptureOptions, @@ -259,19 +258,6 @@ export class VoiceEngineV2SimulatorDriver implements VoiceEngineV2Driver { return {rttMs: 0, outbound: [], inbound: []}; } - async getHardwareEncoderCapabilities(): Promise { - return { - available: true, - backend: 'none', - compiled: false, - runtime: false, - codecs: [], - zeroCopy: false, - nativeInputs: [], - reason: 'simulator', - }; - } - async checkPermission(name: VoiceEngineV2PermissionName): Promise { return {name, status: 'granted', canPrompt: false}; } diff --git a/packages/voice_engine_v2/src/source_isolation/SourceLifecycleState.test.ts b/packages/voice_engine_v2/src/source_isolation/SourceLifecycleState.test.ts index 10f3c495d..43b0ff131 100644 --- a/packages/voice_engine_v2/src/source_isolation/SourceLifecycleState.test.ts +++ b/packages/voice_engine_v2/src/source_isolation/SourceLifecycleState.test.ts @@ -1,18 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { - computeReconnectBackoffMs, + MAX_TRACKED_SOURCES, + SourceLifecycleRegistry, +} from '@fluxer/voice_engine_v2/src/source_isolation/SourceLifecycleRegistry'; +import { createInitialActiveState, MAX_RECONNECT_ATTEMPTS, - MAX_TRACKED_SOURCES, - RECONNECT_BACKOFF_CAP_MS, - RECONNECT_BACKOFF_STEP_MS, type SourceLifecycleClock, type SourceLifecycleEvent, - SourceLifecycleRegistry, type SourceLifecycleState, transitionSourceLifecycle, -} from '@fluxer/voice_engine_v2/src/source_isolation/index'; +} from '@fluxer/voice_engine_v2/src/source_isolation/SourceLifecycleState'; import {describe, expect, it} from 'vitest'; function makeClock(startNs: bigint = 1_000n, stepNs: bigint = 1_000n): SourceLifecycleClock { @@ -153,22 +152,6 @@ describe('transitionSourceLifecycle', () => { expect(triggers).toEqual(['triggerReconnect', 'triggerReconnect', 'triggerReconnect']); }); - it('Backoff schedule respects cap', () => { - expect(computeReconnectBackoffMs(1)).toBe(RECONNECT_BACKOFF_STEP_MS); - expect(computeReconnectBackoffMs(2)).toBe(2 * RECONNECT_BACKOFF_STEP_MS); - expect(computeReconnectBackoffMs(MAX_RECONNECT_ATTEMPTS)).toBeLessThanOrEqual(RECONNECT_BACKOFF_CAP_MS); - }); - - it('Backoff schedule is exponential matching the Rust sibling', () => { - const sequence = [1, 2, 3, 4, 5, 6, 7, 8].map((attempt) => computeReconnectBackoffMs(attempt)); - expect(sequence).toEqual([100, 200, 400, 800, 1600, 3200, 5000, 5000]); - }); - - it('Backoff caps at RECONNECT_BACKOFF_CAP_MS once the raw doubling crosses it', () => { - expect(computeReconnectBackoffMs(7)).toBe(RECONNECT_BACKOFF_CAP_MS); - expect(computeReconnectBackoffMs(8)).toBe(RECONNECT_BACKOFF_CAP_MS); - }); - it('Active + reconnectAttempted is a no-op', () => { const clock = makeClock(); const active = createInitialActiveState(clock); diff --git a/packages/voice_engine_v2/src/source_isolation/SourceLifecycleState.ts b/packages/voice_engine_v2/src/source_isolation/SourceLifecycleState.ts index bf4e12531..60a354e8d 100644 --- a/packages/voice_engine_v2/src/source_isolation/SourceLifecycleState.ts +++ b/packages/voice_engine_v2/src/source_isolation/SourceLifecycleState.ts @@ -1,9 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later export const MAX_RECONNECT_ATTEMPTS = 8; -export const RECONNECT_BACKOFF_STEP_MS = 100; -export const RECONNECT_BACKOFF_CAP_MS = 5_000; - export type SourceFault = 'captureDeviceLost' | 'gpuDeviceLost' | 'encoderError' | 'networkError' | 'decoderError'; export type SourceLifecycleState = @@ -201,23 +198,6 @@ function transitionFromFailed( } } -export function computeReconnectBackoffMs(attempt: number): number { - assertBoundedAttempts(attempt); - if (attempt < 1) { - throw new SourceLifecycleError('invariantViolated', `backoff attempt must be >= 1 (received ${attempt})`); - } - const shift = attempt - 1; - const raw = RECONNECT_BACKOFF_STEP_MS * 2 ** shift; - const capped = raw > RECONNECT_BACKOFF_CAP_MS ? RECONNECT_BACKOFF_CAP_MS : raw; - if (capped < RECONNECT_BACKOFF_STEP_MS) { - throw new SourceLifecycleError('invariantViolated', `backoff produced sub-step value ${capped}`); - } - if (capped > RECONNECT_BACKOFF_CAP_MS) { - throw new SourceLifecycleError('invariantViolated', `backoff exceeded cap ${capped}`); - } - return capped; -} - export function assertStateShape(state: SourceLifecycleState): void { if (state === null || typeof state !== 'object') { throw new SourceLifecycleError('invalidState', 'state must be an object'); diff --git a/packages/voice_engine_v2/src/source_isolation/index.ts b/packages/voice_engine_v2/src/source_isolation/index.ts deleted file mode 100644 index 10e8443a7..000000000 --- a/packages/voice_engine_v2/src/source_isolation/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -export { - MAX_TRACKED_SOURCES, - type SourceLifecycleDispatchResult, - SourceLifecycleRegistry, - type SourceLifecycleSnapshotEntry, -} from '@fluxer/voice_engine_v2/src/source_isolation/SourceLifecycleRegistry'; -export { - computeReconnectBackoffMs, - createInitialActiveState, - MAX_RECONNECT_ATTEMPTS, - RECONNECT_BACKOFF_CAP_MS, - RECONNECT_BACKOFF_STEP_MS, - type SourceFault, - type SourceLifecycleAction, - type SourceLifecycleClock, - SourceLifecycleError, - type SourceLifecycleEvent, - type SourceLifecycleState, - type SourceLifecycleTransitionResult, - transitionSourceLifecycle, -} from '@fluxer/voice_engine_v2/src/source_isolation/SourceLifecycleState'; diff --git a/packages/voice_engine_v2/src/testing/FakeVoiceEngineV2Driver.ts b/packages/voice_engine_v2/src/testing/FakeVoiceEngineV2Driver.ts index 580904e89..5dc2b9485 100644 --- a/packages/voice_engine_v2/src/testing/FakeVoiceEngineV2Driver.ts +++ b/packages/voice_engine_v2/src/testing/FakeVoiceEngineV2Driver.ts @@ -12,7 +12,6 @@ import type { VoiceEngineV2DisconnectReason, VoiceEngineV2Error, VoiceEngineV2GatewayVoiceStateWrite, - VoiceEngineV2HardwareEncoderCapabilities, VoiceEngineV2MicrophoneOptions, VoiceEngineV2OutputDeviceOptions, VoiceEngineV2ParticipantVolumeOptions, @@ -44,10 +43,7 @@ export type FakeVoiceEngineV2DriverCall = | {type: 'setParticipantVolume'; options: VoiceEngineV2ParticipantVolumeOptions} | {type: 'setRemoteTrackSubscription'; options: VoiceEngineV2RemoteTrackSubscriptionOptions} | {type: 'publishData'; options: VoiceEngineV2DataOptions} - | {type: 'collectStats'} - | {type: 'getHardwareEncoderCapabilities'}; - -export type FakeVoiceEngineV2DriverCallType = FakeVoiceEngineV2DriverCall['type']; + | {type: 'collectStats'}; export interface FakeVoiceEngineV2FailureMap { prewarm?: VoiceEngineV2Error; @@ -71,13 +67,11 @@ export interface FakeVoiceEngineV2FailureMap { setRemoteTrackSubscription?: VoiceEngineV2Error; publishData?: VoiceEngineV2Error; collectStats?: VoiceEngineV2Error; - getHardwareEncoderCapabilities?: VoiceEngineV2Error; } export interface FakeVoiceEngineV2DriverOptions { failures?: FakeVoiceEngineV2FailureMap; stats?: VoiceEngineV2Stats; - hardwareEncoderCapabilities?: VoiceEngineV2HardwareEncoderCapabilities; } export class FakeVoiceEngineV2Driver implements VoiceEngineV2Driver { @@ -85,21 +79,10 @@ export class FakeVoiceEngineV2Driver implements VoiceEngineV2Driver { private readonly listeners = new Set(); private readonly failures: FakeVoiceEngineV2FailureMap; private readonly stats: VoiceEngineV2Stats; - private readonly hardwareEncoderCapabilities: VoiceEngineV2HardwareEncoderCapabilities; constructor(options: FakeVoiceEngineV2DriverOptions = {}) { this.failures = options.failures ?? {}; this.stats = options.stats ?? {rttMs: null, outbound: [], inbound: []}; - this.hardwareEncoderCapabilities = options.hardwareEncoderCapabilities ?? { - available: false, - backend: 'none', - compiled: false, - runtime: false, - codecs: [], - zeroCopy: false, - nativeInputs: [], - reason: 'not-queried', - }; } async prewarm(): Promise { @@ -187,11 +170,6 @@ export class FakeVoiceEngineV2Driver implements VoiceEngineV2Driver { return this.stats; } - async getHardwareEncoderCapabilities(): Promise { - this.record({type: 'getHardwareEncoderCapabilities'}); - return this.hardwareEncoderCapabilities; - } - subscribe(listener: VoiceEngineV2ExternalEventListener): () => void { this.listeners.add(listener); return () => { diff --git a/packages/voice_engine_v2/src/testing/VoiceEngineV2TestImplementation.ts b/packages/voice_engine_v2/src/testing/VoiceEngineV2TestImplementation.ts index 9a324571f..d1e74b2fd 100644 --- a/packages/voice_engine_v2/src/testing/VoiceEngineV2TestImplementation.ts +++ b/packages/voice_engine_v2/src/testing/VoiceEngineV2TestImplementation.ts @@ -1,11 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import { - type VoiceEngineV2Driver, - VoiceEngineV2ImplementationBase, -} from '@fluxer/voice_engine_v2/src/implementations/VoiceEngineV2ImplementationBase'; - -export interface VoiceEngineV2TestDriver extends VoiceEngineV2Driver {} +import {VoiceEngineV2ImplementationBase} from '@fluxer/voice_engine_v2/src/implementations/VoiceEngineV2ImplementationBase'; export class VoiceEngineV2TestImplementation extends VoiceEngineV2ImplementationBase { readonly kind = 'js' as const; diff --git a/packages/voice_engine_v2/src/testing/conformance.test.ts b/packages/voice_engine_v2/src/testing/conformance.test.ts index 39f48e905..8910e0db8 100644 --- a/packages/voice_engine_v2/src/testing/conformance.test.ts +++ b/packages/voice_engine_v2/src/testing/conformance.test.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {VoiceEngineV2HostPortImplementation} from '@fluxer/voice_engine_v2/src/implementations'; +import {VoiceEngineV2HostPortImplementation} from '@fluxer/voice_engine_v2/src/implementations/VoiceEngineV2HostPortImplementation'; import type {VoiceEngineV2HostPorts} from '@fluxer/voice_engine_v2/src/ports'; -import {createVoiceEngineV2MemoryEventLogSpillSink, VoiceEngineV2Runtime} from '@fluxer/voice_engine_v2/src/runtime'; +import {createVoiceEngineV2MemoryEventLogSpillSink} from '@fluxer/voice_engine_v2/src/runtime/eventLogRing'; +import {VoiceEngineV2Runtime} from '@fluxer/voice_engine_v2/src/runtime/VoiceEngineV2Runtime'; import type {VoiceEngineV2ConformanceSubject} from '@fluxer/voice_engine_v2/src/testing/conformance'; import {runVoiceEngineV2ConformanceSuite, waitForRuntime} from '@fluxer/voice_engine_v2/src/testing/conformance'; import {FakeVoiceEngineV2Driver} from '@fluxer/voice_engine_v2/src/testing/FakeVoiceEngineV2Driver'; diff --git a/packages/voice_engine_v2/src/testing/conformance.ts b/packages/voice_engine_v2/src/testing/conformance.ts index 6d9fc6f8e..363b761c0 100644 --- a/packages/voice_engine_v2/src/testing/conformance.ts +++ b/packages/voice_engine_v2/src/testing/conformance.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {selectVoiceEngineV2Model} from '@fluxer/voice_engine_v2/src/core'; -import type {VoiceEngineV2Implementation} from '@fluxer/voice_engine_v2/src/implementations'; -import {createVoiceEngineV2MemoryEventLogSpillSink, VoiceEngineV2Runtime} from '@fluxer/voice_engine_v2/src/runtime'; +import {selectVoiceEngineV2Model} from '@fluxer/voice_engine_v2/src/core/selectors'; +import type {VoiceEngineV2Implementation} from '@fluxer/voice_engine_v2/src/implementations/VoiceEngineV2ImplementationBase'; +import {createVoiceEngineV2MemoryEventLogSpillSink} from '@fluxer/voice_engine_v2/src/runtime/eventLogRing'; +import {VoiceEngineV2Runtime} from '@fluxer/voice_engine_v2/src/runtime/VoiceEngineV2Runtime'; import type {FakeVoiceEngineV2Driver} from '@fluxer/voice_engine_v2/src/testing/FakeVoiceEngineV2Driver'; import {describe, expect, it} from 'vitest'; diff --git a/packages/voice_engine_v2/src/testing/index.ts b/packages/voice_engine_v2/src/testing/index.ts deleted file mode 100644 index ef995651f..000000000 --- a/packages/voice_engine_v2/src/testing/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -export type { - VoiceEngineV2ConformanceSubject, - VoiceEngineV2ConformanceSubjectFactory, -} from '@fluxer/voice_engine_v2/src/testing/conformance'; -export {runVoiceEngineV2ConformanceSuite, waitForRuntime} from '@fluxer/voice_engine_v2/src/testing/conformance'; -export type { - VoiceEngineV2EventLogFixture, - VoiceEngineV2EventLogFixtureExpected, - VoiceEngineV2EventLogFixtureStep, - VoiceEngineV2EventLogReplayResult, - VoiceEngineV2EventLogReplayStepResult, -} from '@fluxer/voice_engine_v2/src/testing/eventLogReplay'; -export { - assertVoiceEngineV2EventLogFixture, - replayVoiceEngineV2EventLogFixture, - VOICE_ENGINE_V2_EVENT_LOG_FIXTURE_VERSION, -} from '@fluxer/voice_engine_v2/src/testing/eventLogReplay'; -export type { - FakeVoiceEngineV2DriverCall, - FakeVoiceEngineV2DriverCallType, - FakeVoiceEngineV2DriverOptions, - FakeVoiceEngineV2FailureMap, -} from '@fluxer/voice_engine_v2/src/testing/FakeVoiceEngineV2Driver'; -export {FakeVoiceEngineV2Driver} from '@fluxer/voice_engine_v2/src/testing/FakeVoiceEngineV2Driver'; -export type {VoiceEngineV2TestDriver} from '@fluxer/voice_engine_v2/src/testing/VoiceEngineV2TestImplementation'; -export {VoiceEngineV2TestImplementation} from '@fluxer/voice_engine_v2/src/testing/VoiceEngineV2TestImplementation'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 461b26367..c5fa62e7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,9 +171,6 @@ catalogs: '@types/validator': specifier: 13.15.10 version: 13.15.10 - '@vitest/coverage-v8': - specifier: 5.0.1 - version: 5.0.1 '@vvo/tzdb': specifier: 6.198.0 version: 6.198.0 @@ -297,9 +294,6 @@ catalogs: mobx-react-lite: specifier: 5.0.3 version: 5.0.3 - motion: - specifier: 13.4.0 - version: 13.4.0 msw: specifier: 2.15.0 version: 2.15.0 @@ -476,9 +470,6 @@ importers: '@biomejs/biome': specifier: 'catalog:' version: 2.5.14 - '@vitest/coverage-v8': - specifier: 'catalog:' - version: 5.0.1(vitest@5.0.1) eslint: specifier: 'catalog:' version: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) @@ -563,6 +554,9 @@ importers: '@fluxer/logger': specifier: workspace:* version: link:../packages/logger + '@fluxer/record_utils': + specifier: workspace:* + version: link:../packages/record_utils '@fluxer/schema': specifier: workspace:* version: link:../packages/schema @@ -608,9 +602,6 @@ importers: '@pkgs/kv_client': specifier: workspace:* version: link:pkgs/kv_client - '@pkgs/locale': - specifier: workspace:* - version: link:pkgs/locale '@pkgs/media_proxy_utils': specifier: workspace:* version: link:pkgs/media_proxy_utils @@ -668,9 +659,6 @@ importers: html-entities: specifier: 'catalog:' version: 2.6.0 - idna-uts46-hx: - specifier: 'catalog:' - version: 6.2.1 ioredis: specifier: 'catalog:' version: 6.0.0(supports-color@7.2.0) @@ -683,9 +671,6 @@ importers: livekit-server-sdk: specifier: 'catalog:' version: 2.19.0 - lodash: - specifier: 'catalog:' - version: 4.18.1 maxmind: specifier: 'catalog:' version: 5.0.7 @@ -735,9 +720,6 @@ importers: '@types/archiver': specifier: 'catalog:' version: 8.0.0 - '@types/lodash': - specifier: 'catalog:' - version: 4.17.25 esbuild: specifier: 'catalog:' version: 0.28.2 @@ -876,6 +858,12 @@ importers: '@fluxer/constants': specifier: workspace:* version: link:../../../packages/constants + '@fluxer/ip_utils': + specifier: workspace:* + version: link:../../../packages/ip_utils + '@fluxer/schema': + specifier: workspace:* + version: link:../../../packages/schema undici: specifier: 'catalog:' version: 8.10.2 @@ -912,22 +900,6 @@ importers: specifier: 'catalog:' version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@26.6.1)(@vitest/coverage-v8@5.0.1)(happy-dom@20.14.5)(msw@2.15.0(@types/node@26.6.1)(typescript@7.0.2))(vite@8.3.0(@types/node@26.6.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1)) - fluxer_api/pkgs/locale: - dependencies: - '@fluxer/constants': - specifier: workspace:* - version: link:../../../packages/constants - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 26.6.1 - typescript: - specifier: catalog:ts7 - version: 7.0.2 - vitest: - specifier: 'catalog:' - version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@26.6.1)(@vitest/coverage-v8@5.0.1)(happy-dom@20.14.5)(msw@2.15.0(@types/node@26.6.1)(typescript@7.0.2))(vite@8.3.0(@types/node@26.6.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1)) - fluxer_api/pkgs/media_proxy_utils: dependencies: '@types/node': @@ -1386,6 +1358,9 @@ importers: '@fluxer/limits': specifier: workspace:* version: link:../packages/limits + '@fluxer/record_utils': + specifier: workspace:* + version: link:../packages/record_utils '@fluxer/schema': specifier: workspace:* version: link:../packages/schema @@ -1509,9 +1484,6 @@ importers: mobx-react-lite: specifier: 'catalog:' version: 5.0.3(mobx@7.0.3)(react@19.3.0) - motion: - specifier: 'catalog:' - version: 13.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) onnxruntime-web: specifier: 'catalog:' version: 1.30.0 @@ -1718,6 +1690,9 @@ importers: '@fluxer/ip_utils': specifier: workspace:* version: link:../packages/ip_utils + '@fluxer/record_utils': + specifier: workspace:* + version: link:../packages/record_utils '@fluxer/voice_engine_v2': specifier: workspace:* version: link:../packages/voice_engine_v2 @@ -1837,6 +1812,9 @@ importers: '@fluxer/config': specifier: workspace:* version: link:../packages/config + '@fluxer/constants': + specifier: workspace:* + version: link:../packages/constants astro: specifier: 'catalog:' version: 7.3.2(@astrojs/markdown-remark@7.3.1(supports-color@7.2.0))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.6.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1) @@ -1889,9 +1867,6 @@ importers: '@types/node': specifier: 'catalog:' version: 26.6.1 - '@vitest/coverage-v8': - specifier: 'catalog:' - version: 5.0.1(vitest@5.0.1) typescript: specifier: catalog:ts7 version: 7.0.2 @@ -1928,6 +1903,10 @@ importers: version: 7.0.2 packages/desktop_ipc: + dependencies: + '@fluxer/record_utils': + specifier: workspace:* + version: link:../record_utils devDependencies: '@types/node': specifier: 'catalog:' @@ -1947,6 +1926,9 @@ importers: '@fluxer/logger': specifier: workspace:* version: link:../logger + '@fluxer/record_utils': + specifier: workspace:* + version: link:../record_utils '@fluxer/schema': specifier: workspace:* version: link:../schema @@ -2010,6 +1992,12 @@ importers: packages/i18n: dependencies: + '@fluxer/constants': + specifier: workspace:* + version: link:../constants + '@fluxer/record_utils': + specifier: workspace:* + version: link:../record_utils '@messageformat/core': specifier: 'catalog:' version: 3.4.0 @@ -2054,9 +2042,6 @@ importers: '@types/node': specifier: 'catalog:' version: 26.6.1 - '@vitest/coverage-v8': - specifier: 'catalog:' - version: 5.0.1(vitest@5.0.1) typescript: specifier: catalog:ts7 version: 7.0.2 @@ -2127,6 +2112,15 @@ importers: specifier: catalog:ts7 version: 7.0.2 + packages/record_utils: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 26.6.1 + typescript: + specifier: catalog:ts7 + version: 7.0.2 + packages/schema: dependencies: '@bufbuild/protobuf': @@ -9041,17 +9035,6 @@ packages: motion-utils@13.3.0: resolution: {integrity: sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==} - motion@13.4.0: - resolution: {integrity: sha512-aiNSA29N+vgV5p5cJSB5cFzx8KTuuxB99z8zGtxsaqlwKtBK6sq8YgPRpkVQLkbJMOcLDU0q/ShdT95qpBy/0g==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -12632,7 +12615,8 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - '@bcoe/v8-coverage@1.0.2': {} + '@bcoe/v8-coverage@1.0.2': + optional: true '@biomejs/biome@2.5.14': optionalDependencies: @@ -13753,7 +13737,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 26.6.1 + '@types/node': 24.13.5 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -15388,11 +15372,11 @@ snapshots: '@types/readdir-glob@1.1.5': dependencies: - '@types/node': 26.6.1 + '@types/node': 24.13.5 '@types/sax@1.2.7': dependencies: - '@types/node': 26.6.1 + '@types/node': 24.13.5 '@types/sdp-transform@2.15.0': {} @@ -15418,7 +15402,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 26.6.1 + '@types/node': 24.13.5 '@types/yargs-parser@21.0.3': {} @@ -15597,12 +15581,15 @@ snapshots: std-env: 4.2.0 tinyrainbow: 3.1.1 vitest: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@26.6.1)(@vitest/coverage-v8@5.0.1)(happy-dom@20.14.5)(msw@2.15.0(@types/node@26.6.1)(typescript@7.0.2))(vite@8.3.0(@types/node@26.6.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1)) + optional: true - '@vitest/istanbul-lib-coverage@1.0.1': {} + '@vitest/istanbul-lib-coverage@1.0.1': + optional: true '@vitest/istanbul-lib-report@1.0.1': dependencies: '@vitest/istanbul-lib-coverage': 1.0.1 + optional: true '@vitest/mocker@5.0.1(msw@2.15.0(@types/node@26.6.1)(typescript@6.0.3))(vite@8.3.0(@types/node@26.6.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1))': dependencies: @@ -15853,6 +15840,7 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 js-tokens: 10.0.0 + optional: true astring@1.9.0: {} @@ -16091,7 +16079,7 @@ snapshots: buffer-image-size@0.6.4: dependencies: - '@types/node': 26.6.1 + '@types/node': 24.13.5 buffer@5.6.0: dependencies: @@ -17091,7 +17079,7 @@ snapshots: happy-dom@20.14.5: dependencies: - '@types/node': 26.6.1 + '@types/node': 24.13.5 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 buffer-image-size: 0.6.4 @@ -17516,7 +17504,8 @@ snapshots: js-sha256@0.10.1: {} - js-tokens@10.0.0: {} + js-tokens@10.0.0: + optional: true js-tokens@4.0.0: {} @@ -18333,14 +18322,6 @@ snapshots: motion-utils@13.3.0: {} - motion@13.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): - dependencies: - framer-motion: 13.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - tslib: 2.8.1 - optionalDependencies: - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - mri@1.2.0: {} mrmime@2.0.1: {} @@ -19139,7 +19120,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.2 - '@types/node': 26.6.1 + '@types/node': 24.13.5 long: 5.3.2 pseudolocale@3.1.0: @@ -19930,7 +19911,8 @@ snapshots: tinypool@2.2.0: {} - tinyrainbow@3.1.1: {} + tinyrainbow@3.1.1: + optional: true tlds@1.261.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2349d53d8..1a54cce0b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,18 +17,12 @@ allowBuilds: '@parcel/watcher': true argon2: true core-js: true - cpu-features: true electron: true electron-winstaller: true esbuild: true - msgpackr-extract: true msw: true - node-mac-permissions: true protobufjs: true - puppeteer: true sharp: true - ssh2: true - uiohook-napi: true blockExoticSubdeps: true @@ -91,7 +85,6 @@ catalog: '@types/react-dom': 19.3.0 '@types/send': 1.2.1 '@types/validator': 13.15.10 - '@vitest/coverage-v8': 5.0.1 '@vvo/tzdb': 6.198.0 '@webgpu/types': 0.1.72 altcha-lib: 2.5.0 @@ -133,7 +126,6 @@ catalog: mobx: 7.0.3 mobx-persist-store: 1.1.8 mobx-react-lite: 5.0.3 - motion: 13.4.0 msw: 2.15.0 nodemailer: 10.0.10 onnxruntime-web: 1.30.0 diff --git a/tools/bench/_compare_criterion.py b/tools/bench/_compare_criterion.py deleted file mode 100755 index 65d8be8a0..000000000 --- a/tools/bench/_compare_criterion.py +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: AGPL-3.0-or-later -"""Parse criterion output and compare against a baseline.json. - -Reads: - argv[1] path to the captured `cargo bench` log - BASELINE_PATH (env) path to baseline.json - BUDGET_PERCENT (env) allowed slowdown in percent (e.g. 5 means +5%) - -Exits 0 if every bench mean is within +BUDGET of baseline median; 1 otherwise. -Unknown benches in the log are reported but do not fail the run. -Benches in the baseline that did not appear in the log are reported and fail. -""" -from __future__ import annotations - -import json -import os -import re -import sys -from pathlib import Path - -UNIT_NS = { - "ns": 1.0, - "us": 1_000.0, - "µs": 1_000.0, - "ms": 1_000_000.0, - "s": 1_000_000_000.0, -} - -LINE_WITH_NAME_RE = re.compile( - r"^(?P\S[^\t]*?)\s+time:\s*" - r"\[(?P[\d.]+)\s*(?P\S+)\s+" - r"(?P[\d.]+)\s*(?P\S+)\s+" - r"(?P[\d.]+)\s*(?P\S+)\]" -) - -TIME_ONLY_RE = re.compile( - r"^\s*time:\s*" - r"\[(?P[\d.]+)\s*(?P\S+)\s+" - r"(?P[\d.]+)\s*(?P\S+)\s+" - r"(?P[\d.]+)\s*(?P\S+)\]" -) - - -def to_ns(value: float, unit: str) -> float: - if unit not in UNIT_NS: - raise ValueError(f"unknown criterion time unit: {unit!r}") - return value * UNIT_NS[unit] - - -def parse_log(log_path: Path) -> dict[str, dict[str, float]]: - measurements: dict[str, dict[str, float]] = {} - name_buffer: str | None = None - for raw in log_path.read_text(errors="replace").splitlines(): - line = raw.rstrip() - if not line: - continue - if "change:" in line: - continue - if "time:" in line: - match = LINE_WITH_NAME_RE.match(line) - if match: - measurements[match.group("name").strip()] = build_measurement(match) - name_buffer = None - continue - match = TIME_ONLY_RE.match(line) - if match and name_buffer is not None: - measurements[name_buffer] = build_measurement(match) - name_buffer = None - continue - if line.startswith("Benchmarking "): - name = line[len("Benchmarking "):].strip() - if name.endswith(": Analyzing"): - name_buffer = name[: -len(": Analyzing")] - elif ":" not in name: - name_buffer = name - continue - return measurements - - -def build_measurement(match: re.Match[str]) -> dict[str, float]: - return { - "low_ns": to_ns(float(match.group("low")), match.group("low_u")), - "mean_ns": to_ns(float(match.group("mean")), match.group("mean_u")), - "high_ns": to_ns(float(match.group("high")), match.group("high_u")), - } - - -def compare( - baseline: dict, - measurements: dict[str, dict[str, float]], - budget_percent: float, -) -> tuple[list[str], list[str], list[str]]: - benches = baseline.get("benches", {}) - if not isinstance(benches, dict): - raise ValueError("baseline.json missing 'benches' object") - regressions: list[str] = [] - passes: list[str] = [] - missing: list[str] = [] - for name, entry in benches.items(): - if not isinstance(entry, dict): - continue - if "median_ns" not in entry: - continue - baseline_ns = float(entry["median_ns"]) - measured = measurements.get(name) - if measured is None: - missing.append(name) - continue - observed_ns = measured["mean_ns"] - delta = observed_ns - baseline_ns - delta_pct = (delta / baseline_ns) * 100.0 if baseline_ns > 0 else 0.0 - effective_budget = float(entry.get("budget_percent_override", budget_percent)) - budget_ns = baseline_ns * (effective_budget / 100.0) - status = "OK" if delta <= budget_ns else "REGRESSION" - summary = ( - f" {status:11s} {name}: baseline={baseline_ns:.3f}ns " - f"observed={observed_ns:.3f}ns delta={delta_pct:+.2f}% " - f"(budget=+{effective_budget:.1f}%)" - ) - if status == "OK": - passes.append(summary) - else: - regressions.append(summary) - return passes, regressions, missing - - -def main() -> int: - if len(sys.argv) != 2: - print("usage: _compare_criterion.py ", file=sys.stderr) - return 2 - log_path = Path(sys.argv[1]) - baseline_path = Path(os.environ["BASELINE_PATH"]) - budget = float(os.environ.get("BUDGET_PERCENT", "5")) - strict_missing = os.environ.get("STRICT_MISSING", "0") == "1" - baseline = json.loads(baseline_path.read_text()) - measurements = parse_log(log_path) - if not measurements: - print("ERROR: no criterion measurements parsed from log", file=sys.stderr) - return 2 - passes, regressions, missing = compare(baseline, measurements, budget) - for line in passes: - print(line) - for line in regressions: - print(line) - if missing and strict_missing: - print("MISSING in run (baseline expected but no measurement found):") - for name in missing: - print(f" {name}") - elif missing: - print( - f"(info: {len(missing)} baseline entries not seen in this run; " - "run other bench files in the same crate to cover them)" - ) - if regressions or (missing and strict_missing): - print( - f"FAIL: {len(regressions)} regression(s), " - f"{len(missing) if strict_missing else 0} missing bench(es)" - ) - return 1 - print( - f"OK: {len(passes)} bench(es) within budget " - f"(default +{budget:.1f}%, per-bench overrides where noted)" - ) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/bench/check-all.sh b/tools/bench/check-all.sh deleted file mode 100755 index 3d43a849b..000000000 --- a/tools/bench/check-all.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -# SPDX-License-Identifier: AGPL-3.0-or-later - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" - -DEFAULT_CRATES=$( - cat <<'ENTRIES' -fluxer_desktop/native/rt-thread:tick -fluxer_desktop/native/screen-frame-bus:staging -fluxer_desktop/native/screen-frame-bus:frame_pool -fluxer_desktop/native/encoder-ring:ring -ENTRIES -) - -ENTRIES="${BENCH_CRATES:-$DEFAULT_CRATES}" - -log() { printf '[check-all] %s\n' "$*" >&2; } - -pass_count=0 -fail_count=0 -failed_names=() - -while IFS= read -r entry; do - [ -n "$entry" ] || continue - case "$entry" in - \#*) continue ;; - esac - crate="${entry%%:*}" - bench="${entry##*:}" - log "==> $crate :: $bench" - if "$SCRIPT_DIR/check-regression.sh" "$REPO_ROOT/$crate" "$bench"; then - pass_count=$((pass_count + 1)) - else - fail_count=$((fail_count + 1)) - failed_names+=("$crate::$bench") - fi -done <<<"$ENTRIES" - -printf '\n[check-all] summary: %d passed, %d failed\n' "$pass_count" "$fail_count" - -if [ "$fail_count" -gt 0 ]; then - printf '[check-all] failed entries:\n' - for name in "${failed_names[@]}"; do - printf ' - %s\n' "$name" - done - exit 1 -fi - -exit 0 diff --git a/tools/bench/check-regression.sh b/tools/bench/check-regression.sh deleted file mode 100755 index 0d3b6c0bd..000000000 --- a/tools/bench/check-regression.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -# SPDX-License-Identifier: AGPL-3.0-or-later - -set -euo pipefail - -BUDGET="${BENCH_REGRESSION_BUDGET_PERCENT:-5}" -MEASUREMENT_TIME="${BENCH_MEASUREMENT_TIME:-5}" -WARM_UP_TIME="${BENCH_WARM_UP_TIME:-2}" - -log() { printf '[check-regression] %s\n' "$*" >&2; } -fail() { - log "ERROR: $*" - exit 1 -} - -if [ "$#" -ne 2 ]; then - fail "usage: $0 " -fi - -CRATE_PATH="$1" -BENCH_NAME="$2" - -[ -d "$CRATE_PATH" ] || fail "crate path not a directory: $CRATE_PATH" -[ -f "$CRATE_PATH/Cargo.toml" ] || fail "no Cargo.toml in $CRATE_PATH" -BASELINE="$CRATE_PATH/benches/baseline.json" -[ -f "$BASELINE" ] || fail "no baseline.json at $BASELINE" -command -v cargo >/dev/null || fail "cargo not on PATH" -command -v python3 >/dev/null || fail "python3 not on PATH" - -LOG_FILE="$(mktemp -t bench-regression.XXXXXX.log)" -trap 'rm -f "$LOG_FILE"' EXIT - -log "running cargo bench --bench $BENCH_NAME in $CRATE_PATH" -( - cd "$CRATE_PATH" - cargo bench --bench "$BENCH_NAME" -- \ - --warm-up-time "$WARM_UP_TIME" \ - --measurement-time "$MEASUREMENT_TIME" -) >"$LOG_FILE" 2>&1 || { - tail -50 "$LOG_FILE" >&2 - fail "cargo bench failed; see log above" -} - -BUDGET_PERCENT="$BUDGET" BASELINE_PATH="$BASELINE" \ - python3 "$(dirname "$0")/_compare_criterion.py" "$LOG_FILE" diff --git a/tools/ci/check-desktop-native-workspaces.sh b/tools/ci/check-desktop-native-workspaces.sh index 8613d5038..f4766b29e 100755 --- a/tools/ci/check-desktop-native-workspaces.sh +++ b/tools/ci/check-desktop-native-workspaces.sh @@ -13,10 +13,7 @@ mode="${1:-}" linux_workspaces=( "app-store/Cargo.toml" - "audio-apm/Cargo.toml" - "encoder-ring/Cargo.toml" "gateway-socket/Cargo.toml" - "gpu-rebuild/Cargo.toml" "hardware-encoder/Cargo.toml" "linux-audio-capture/Cargo.toml" "linux-evdev/Cargo.toml" @@ -25,8 +22,7 @@ linux_workspaces=( "linux-portals/Cargo.toml" "platform-info/Cargo.toml" "rt-thread/Cargo.toml" - "rust/Cargo.toml" - "screen-frame-bus/Cargo.toml" + "sample-pool/Cargo.toml" "system-hunspell/Cargo.toml" "webauthn/Cargo.toml" ) diff --git a/tools/ci/src/app_dev_server.rs b/tools/ci/src/app_dev_server.rs index 6ded125d0..f5fe2d3f4 100644 --- a/tools/ci/src/app_dev_server.rs +++ b/tools/ci/src/app_dev_server.rs @@ -1,7 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::app_wasm::resolve_app_dir; -use crate::functions::sha256_file; +use crate::common::display_command; +use crate::functions::{remove_dir_if_exists, sha256_file}; use anyhow::{Context, Result, bail, ensure}; use clap::Args; use serde::{Deserialize, Serialize}; @@ -157,7 +158,7 @@ impl AppDevServer { return Ok(()); } - self.clean_dist()?; + remove_dir_if_exists(&self.project_root.join("dist"))?; let mut css_type_watcher = self.start_css_type_watcher()?; let rspack_result = self.run_rspack(&mut shutdown_rx).await; terminate_child(&mut css_type_watcher).await; @@ -280,17 +281,6 @@ impl AppDevServer { Ok(()) } - fn clean_dist(&self) -> Result<()> { - let dist_path = self.project_root.join("dist"); - match fs::remove_dir_all(&dist_path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => { - Err(error).with_context(|| format!("Failed to remove {}", dist_path.display())) - } - } - } - fn start_css_type_watcher(&self) -> Result { let tcm = self.project_root.join("node_modules/.bin/tcm"); println!( @@ -555,21 +545,3 @@ fn timestamp_ms(timestamp: SystemTime) -> Result { .as_secs_f64() * 1000.0) } - -fn display_command(command: &str, args: &[&str]) -> String { - std::iter::once(command.to_string()) - .chain(args.iter().map(|arg| quote_arg(arg))) - .collect::>() - .join(" ") -} - -fn quote_arg(arg: &str) -> String { - if arg - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':' | '=')) - { - arg.to_string() - } else { - format!("{arg:?}") - } -} diff --git a/tools/ci/src/app_proxy.rs b/tools/ci/src/app_proxy.rs index 7017c5ddc..68c448cdd 100644 --- a/tools/ci/src/app_proxy.rs +++ b/tools/ci/src/app_proxy.rs @@ -66,12 +66,9 @@ pub async fn run(args: BuildAppProxyArgs) -> Result<()> { } fn set_metadata_step(build_version_arg: Option<&str>) -> Result<()> { - let calver_env = CalverEnv { - build_version: trim_option(build_version_arg.map(ToOwned::to_owned)) - .or_else(|| trim_option(env::var("BUILD_VERSION").ok())), - fluxer_build_version: trim_option(env::var("FLUXER_BUILD_VERSION").ok()), - fluxer_build_date: trim_option(env::var("FLUXER_BUILD_DATE").ok()), - }; + let mut calver_env = CalverEnv::from_process(); + calver_env.build_version = + trim_option(build_version_arg.map(ToOwned::to_owned)).or(calver_env.build_version); let version = resolve_calver(&calver_env, Utc::now())?; append_github_output(&[ ("build_version", version.as_str()), @@ -528,7 +525,6 @@ fn tree_differences( mod tests { use super::*; use chrono::{DateTime, TimeZone, Utc}; - use std::ffi::OsString; fn dt(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> DateTime { Utc.with_ymd_and_hms(year, month, day, hour, minute, second) @@ -536,13 +532,6 @@ mod tests { .unwrap() } - fn write_file(path: &Path, contents: &str) { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).unwrap(); - } - fs::write(path, contents).unwrap(); - } - #[test] fn resolves_calver_from_explicit_or_date_override() { let explicit = CalverEnv { @@ -565,49 +554,6 @@ mod tests { ); } - #[test] - fn ghcr_auth_config_merges_existing_auths() { - let temp = tempfile::tempdir().unwrap(); - let config_path = temp.path().join("config.json"); - fs::write( - &config_path, - r#"{"auths":{"example.com":{"auth":"old"}},"currentContext":"builder"}"#, - ) - .unwrap(); - - write_ghcr_auth_config(&config_path, "octo", "secret").unwrap(); - - let config: Value = - serde_json::from_str(&fs::read_to_string(config_path).unwrap()).unwrap(); - assert_eq!(config["auths"]["example.com"]["auth"], "old"); - assert_eq!( - config["auths"]["ghcr.io"]["auth"], - BASE64.encode("octo:secret") - ); - assert_eq!(config["currentContext"], "builder"); - } - - #[test] - fn ghcr_auth_config_rejects_non_object_roots_and_auths() { - let temp = tempfile::tempdir().unwrap(); - let config_path = temp.path().join("config.json"); - fs::write(&config_path, "[]").unwrap(); - assert_eq!( - write_ghcr_auth_config(&config_path, "octo", "secret") - .unwrap_err() - .to_string(), - "Docker config root must be a JSON object" - ); - - fs::write(&config_path, r#"{"auths":[]}"#).unwrap(); - assert_eq!( - write_ghcr_auth_config(&config_path, "octo", "secret") - .unwrap_err() - .to_string(), - "Docker config auths must be a JSON object" - ); - } - #[test] fn dockerfile_guards_the_trim_on_an_absolute_asset_base_url() { let dockerfile = include_str!("../../../fluxer_app_proxy/Dockerfile"); @@ -652,117 +598,14 @@ mod tests { ); } - #[test] - fn asset_manifest_entries_are_sorted_and_relative_to_dist() { - let temp = tempfile::tempdir().unwrap(); - let dist = temp.path().join("dist"); - write_file(&dist.join("assets/z.js"), "z"); - write_file(&dist.join("assets/z.js.map"), "{}"); - write_file(&dist.join("assets/chunks/a.js"), "a"); - write_file(&dist.join("assets/chunks/a.js.map"), "{}"); - write_file(&dist.join("index.html"), "ignored"); - - assert_eq!( - asset_manifest_entries(&dist).unwrap(), - vec![ - "assets/chunks/a.js", - "assets/chunks/a.js.map", - "assets/z.js", - "assets/z.js.map" - ] - ); - } - - #[test] - fn asset_manifest_entries_require_assets_directory() { - let temp = tempfile::tempdir().unwrap(); - let dist = temp.path().join("dist"); - fs::create_dir_all(&dist).unwrap(); - - assert!( - asset_manifest_entries(&dist) - .unwrap_err() - .to_string() - .contains("App proxy assets directory is missing") - ); - } - - #[test] - fn manifest_reader_trims_blank_lines_and_keeps_order() { - let temp = tempfile::tempdir().unwrap(); - let manifest = temp.path().join("assets-manifest.txt"); - fs::write(&manifest, "\n assets/b.js \n\nassets/a.js\n").unwrap(); - - assert_eq!( - read_asset_manifest(&manifest).unwrap(), - vec!["assets/b.js", "assets/a.js"] - ); - } - - #[test] - fn asset_upload_plan_preserves_manifest_keys() { - let temp = tempfile::tempdir().unwrap(); - let dist = temp.path().join("dist"); - write_file(&dist.join("assets/a.js"), "a"); - write_file(&dist.join("assets/chunks/b.js"), "b"); - let assets = vec!["assets/a.js".to_string(), "assets/chunks/b.js".to_string()]; - - let plan = asset_upload_plan(&dist, &assets).unwrap(); - - assert_eq!( - plan.iter() - .map(|item| item.key.as_str()) - .collect::>(), - vec!["assets/a.js", "assets/chunks/b.js"] - ); - assert_eq!(plan[0].path, dist.join("assets/a.js")); - assert_eq!(plan[1].path, dist.join("assets/chunks/b.js")); - assert_eq!( - plan[0].content_type.as_deref(), - Some("application/javascript; charset=utf-8") - ); - assert_eq!( - plan[0].cache_control.as_deref(), - Some(IMMUTABLE_ASSET_CACHE_CONTROL) - ); - assert!(plan[0].repair_existing_metadata); - } - - #[test] - fn uploaded_assets_get_the_same_policy_the_app_proxy_serves() { - assert_eq!( - IMMUTABLE_ASSET_CACHE_CONTROL, - "public, max-age=31536000, immutable" - ); - } - - #[test] - fn asset_upload_plan_rejects_manifest_entries_missing_on_disk() { - let temp = tempfile::tempdir().unwrap(); - let dist = temp.path().join("dist"); - fs::create_dir_all(&dist).unwrap(); - let assets = vec!["assets/missing.js".to_string()]; - - assert!( - asset_upload_plan(&dist, &assets) - .unwrap_err() - .to_string() - .contains("Manifest asset is missing") - ); - } - - #[test] - fn manifest_reader_rejects_paths_outside_assets() { - let temp = tempfile::tempdir().unwrap(); - let manifest = temp.path().join("assets-manifest.txt"); - fs::write(&manifest, "assets/a.js\n../secret\n").unwrap(); - - assert!(read_asset_manifest(&manifest).is_err()); - } - #[test] fn manifest_reader_rejects_absolute_parent_and_backslash_paths() { - for asset in ["/assets/a.js", "assets/../secret", r"assets\app.js"] { + for asset in [ + "../secret", + "/assets/a.js", + "assets/../secret", + r"assets\app.js", + ] { assert!(validate_manifest_asset(asset).is_err(), "{asset}"); } } @@ -811,16 +654,6 @@ mod tests { ); } - #[test] - fn tree_differences_is_empty_for_identical_trees() { - let tree = BTreeMap::from([ - ("index.html".to_string(), "aa".to_string()), - ("assets/a.js".to_string(), "bb".to_string()), - ]); - - assert!(tree_differences(&tree, &tree.clone()).is_empty()); - } - #[test] fn tree_differences_reports_missing_unexpected_and_changed() { let canonical = BTreeMap::from([ @@ -842,86 +675,6 @@ mod tests { ); } - #[test] - fn tree_differences_caps_the_reported_lines() { - let canonical = (0..50) - .map(|index| (format!("assets/{index}.js"), "aa".to_string())) - .collect::>(); - let other = BTreeMap::new(); - - let differences = tree_differences(&canonical, &other); - - assert_eq!(differences.len(), PARITY_DIFF_LIMIT + 1); - assert_eq!( - differences.last().unwrap(), - &format!("...and {} more differences", 50 - PARITY_DIFF_LIMIT) - ); - } - - #[test] - fn asset_tree_digests_hashes_every_file_relative_to_the_root() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("static"); - write_file(&root.join("index.html"), "index"); - write_file(&root.join("assets/chunks/a.js"), "a"); - - let digests = asset_tree_digests(&root).unwrap(); - - assert_eq!( - digests.keys().cloned().collect::>(), - vec!["assets/chunks/a.js", "index.html"] - ); - - write_file(&root.join("assets/chunks/a.js"), "b"); - let changed = asset_tree_digests(&root).unwrap(); - assert_ne!(digests["assets/chunks/a.js"], changed["assets/chunks/a.js"]); - assert_eq!(digests["index.html"], changed["index.html"]); - } - - #[test] - fn assets_image_ref_names_the_canonical_image() { - assert_eq!( - assets_image_ref("ghcr.io/example/fluxer-app-proxy", "2026.520.1"), - "ghcr.io/example/fluxer-app-proxy:2026.520.1-assets" - ); - } - - #[test] - fn extract_commands_pin_the_image_platform() { - let pull = pull_command("ghcr.io/example/fluxer-app-proxy:1-assets", AMD64_PLATFORM); - assert_eq!(pull.program, OsString::from("docker")); - assert_eq!( - pull.args, - vec![ - OsString::from("pull"), - OsString::from("--platform"), - OsString::from(AMD64_PLATFORM), - OsString::from("ghcr.io/example/fluxer-app-proxy:1-assets"), - ] - ); - - let create = create_command("ghcr.io/example/fluxer-app-proxy:1-arm64", ARM64_PLATFORM); - assert_eq!( - create.args, - vec![ - OsString::from("create"), - OsString::from("--platform"), - OsString::from(ARM64_PLATFORM), - OsString::from("ghcr.io/example/fluxer-app-proxy:1-arm64"), - ] - ); - - let copy = copy_command("cafe1234", RUNTIME_STATIC_DIR, Path::new("/tmp/arm64")); - assert_eq!( - copy.args, - vec![ - OsString::from("cp"), - OsString::from("cafe1234:/srv/app/static"), - OsString::from("/tmp/arm64"), - ] - ); - } - #[test] fn self_hosted_workflow_keeps_assets_same_origin() { let workflow = include_str!("../../../.github/workflows/build-app-proxy-self-hosted.yaml"); @@ -935,12 +688,4 @@ mod tests { ); } } - - #[test] - fn path_to_s3_key_uses_forward_slashes() { - assert_eq!( - path_to_s3_key(Path::new("assets").join("chunks").join("a.js").as_path()), - "assets/chunks/a.js" - ); - } } diff --git a/tools/ci/src/app_wasm.rs b/tools/ci/src/app_wasm.rs index 8dc518dc3..f03322b57 100644 --- a/tools/ci/src/app_wasm.rs +++ b/tools/ci/src/app_wasm.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::common::{CommandSpec, command_succeeds, env_bool, output_text, run_command}; +use crate::common::{CommandSpec, command_succeeds, output_text, run_command}; use anyhow::{Context, Result, anyhow, ensure}; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; @@ -194,15 +194,7 @@ fn build_libfluxcore_wasm(app_dir: &Path) -> Result<()> { .with_context(|| format!("Failed to create {}", out_dir.display()))?; let target_dir = rust_package_dir.join("target"); - let mut build = libfluxcore_build_command(&rust_package_dir, &target_dir); - - if env_bool("FLUXCORE_WASM_SIMD") { - let rustflags = match env::var("RUSTFLAGS") { - Ok(value) if !value.trim().is_empty() => format!("{value} -C target-feature=+simd128"), - _ => "-C target-feature=+simd128".to_string(), - }; - build = build.env("RUSTFLAGS", rustflags); - } + let build = libfluxcore_build_command(&rust_package_dir, &target_dir); run_command(apply_wasm_c_toolchain(build)?)?; let temp = TempDir::new().context("Failed to create libfluxcore wasm-bindgen temp dir")?; diff --git a/tools/ci/src/appimage.rs b/tools/ci/src/appimage.rs index c97e945d6..4e4f223f5 100644 --- a/tools/ci/src/appimage.rs +++ b/tools/ci/src/appimage.rs @@ -441,22 +441,6 @@ mod tests { ); } - #[test] - fn only_published_channels_and_arches_are_stamped() { - assert_eq!(published_channel("canary").unwrap(), "canary"); - assert_eq!(published_arch("arm64").unwrap(), "arm64"); - let channel = published_channel("nightly").unwrap_err().to_string(); - assert!( - channel.contains("not a published desktop channel"), - "{channel}" - ); - let arch = published_arch("x86_64").unwrap_err().to_string(); - assert!( - arch.contains("not a published Linux architecture"), - "{arch}" - ); - } - #[test] fn zsyncmake_writes_the_control_file_beside_the_stamped_artifact() { let command = zsyncmake_command( diff --git a/tools/ci/src/calver.rs b/tools/ci/src/calver.rs index bc71d0b80..ade09e780 100644 --- a/tools/ci/src/calver.rs +++ b/tools/ci/src/calver.rs @@ -2,12 +2,11 @@ use crate::common::{ CALVER_SCHEME, CalverEnv, append_github_output, micro_segment, month_day_segment, - parse_version_instant, resolve_calver, trim_option, + parse_version_instant, resolve_calver, }; use anyhow::Result; use chrono::{Datelike, Timelike, Utc}; use clap::Args; -use std::env; #[derive(Debug, Args, Clone)] pub struct ResolveCalverArgs { @@ -38,14 +37,7 @@ pub fn run(args: ResolveCalverArgs) -> Result<()> { } fn resolve_calver_from_env() -> Result { - resolve_calver( - &CalverEnv { - build_version: trim_option(env::var("BUILD_VERSION").ok()), - fluxer_build_version: trim_option(env::var("FLUXER_BUILD_VERSION").ok()), - fluxer_build_date: trim_option(env::var("FLUXER_BUILD_DATE").ok()), - }, - Utc::now(), - ) + resolve_calver(&CalverEnv::from_process(), Utc::now()) } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/tools/ci/src/ci_workflow.rs b/tools/ci/src/ci_workflow.rs index d96ce9a0f..8c74ed01d 100644 --- a/tools/ci/src/ci_workflow.rs +++ b/tools/ci/src/ci_workflow.rs @@ -229,108 +229,3 @@ fn repo_root() -> Result { .or_else(|_| env::current_dir()) .context("Failed to resolve repository root") } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn generator_commands_split_i18n_types_and_compile_by_step() { - let typecheck = generator_commands(true) - .into_iter() - .map(|command| command.args) - .collect::>(); - let test = generator_commands(false) - .into_iter() - .map(|command| command.args) - .collect::>(); - - assert!(typecheck.contains(&vec![ - OsString::from("--filter"), - OsString::from("@fluxer/i18n"), - OsString::from("generate:types"), - ])); - assert!(!test.contains(&vec![ - OsString::from("--filter"), - OsString::from("@fluxer/i18n"), - OsString::from("generate:types"), - ])); - assert!(!typecheck.contains(&vec![ - OsString::from("--filter"), - OsString::from("fluxer_app"), - OsString::from("i18n:compile"), - ])); - assert!(test.contains(&vec![ - OsString::from("--filter"), - OsString::from("fluxer_app"), - OsString::from("i18n:compile"), - ])); - } - - #[test] - fn with_test_env_sets_all_nats_urls_and_leaves_worker_counts_to_vitest() { - let spec = with_test_env(CommandSpec::new("pnpm")); - let env = spec - .env - .into_iter() - .collect::>(); - let default_nats_url = OsString::from(default_test_nats_url()); - - assert_eq!( - env.get(&OsString::from("FLUXER_NATS_URL")), - Some(&default_nats_url) - ); - assert_eq!( - env.get(&OsString::from("FLUXER_NATS_CORE_URL")), - Some(&default_nats_url) - ); - assert_eq!( - env.get(&OsString::from("FLUXER_NATS_JETSTREAM_URL")), - Some(&default_nats_url) - ); - assert!(!env.contains_key(&OsString::from("API_TEST_MAX_WORKERS"))); - assert!(!env.contains_key(&OsString::from("API_TEST_MAX_CONCURRENCY"))); - } - - #[test] - fn workspace_tests_exclude_desktop_and_leave_concurrency_to_pnpm() { - let args = workspace_test_args(None); - - assert_eq!(args[0], OsString::from("-r")); - assert!( - !args - .iter() - .any(|arg| arg.to_string_lossy().starts_with("--workspace-concurrency")) - ); - assert!(args.windows(2).any(|pair| pair - == [ - OsString::from("--filter"), - OsString::from("!fluxer_desktop") - ])); - } - - #[test] - fn workspace_tests_forward_an_explicit_concurrency_override() { - let args = workspace_test_args(Some("4")); - - assert_eq!(args[1], OsString::from("--workspace-concurrency=4")); - } - - #[test] - fn missing_app_wasm_artifact_reports_the_first_absent_output() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path(); - - assert_eq!( - missing_app_wasm_artifact(root), - Some(root.join("fluxer_app/pkgs/libfluxcore/libfluxcore.js")) - ); - - for path in app_wasm_artifacts(root) { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, "").unwrap(); - } - - assert_eq!(missing_app_wasm_artifact(root), None); - } -} diff --git a/tools/ci/src/common.rs b/tools/ci/src/common.rs index 604f11cd2..68f2c912b 100644 --- a/tools/ci/src/common.rs +++ b/tools/ci/src/common.rs @@ -37,6 +37,16 @@ pub(crate) struct CalverEnv { pub(crate) fluxer_build_date: Option, } +impl CalverEnv { + pub(crate) fn from_process() -> Self { + Self { + build_version: trim_option(env::var("BUILD_VERSION").ok()), + fluxer_build_version: trim_option(env::var("FLUXER_BUILD_VERSION").ok()), + fluxer_build_date: trim_option(env::var("FLUXER_BUILD_DATE").ok()), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CommandSpec { pub(crate) program: OsString, @@ -239,7 +249,7 @@ fn append_key_values(path_env_name: &str, pairs: &[(&str, &str)]) -> Result<()> } pub(crate) fn run_command(spec: CommandSpec) -> Result<()> { - println!("+ {}", display_command(&spec)); + println!("+ {}", display_command(&spec.program, &spec.args)); let status = spec .to_command() .status() @@ -248,7 +258,7 @@ pub(crate) fn run_command(spec: CommandSpec) -> Result<()> { status.success(), "Command failed with exit code {}: {}", status.code().unwrap_or(1), - display_command(&spec) + display_command(&spec.program, &spec.args) ); Ok(()) } @@ -274,7 +284,7 @@ fn output(spec: CommandSpec) -> Result { bail!( "Command failed with exit code {}: {}\n{}{}", captured.status, - display_command(&spec), + display_command(&spec.program, &spec.args), stdout, stderr ); @@ -282,7 +292,7 @@ fn output(spec: CommandSpec) -> Result { } pub(crate) fn capture(spec: CommandSpec) -> Result { - println!("+ {}", display_command(&spec)); + println!("+ {}", display_command(&spec.program, &spec.args)); let output = spec .to_command() .stdout(Stdio::piped()) @@ -306,9 +316,9 @@ pub(crate) fn command_succeeds(spec: CommandSpec) -> bool { .is_ok_and(|status| status.success()) } -fn display_command(spec: &CommandSpec) -> String { - std::iter::once(spec.program.to_string_lossy().to_string()) - .chain(spec.args.iter().map(|arg| quote_arg(arg))) +pub(crate) fn display_command(program: impl AsRef, args: &[impl AsRef]) -> String { + std::iter::once(program.as_ref().to_string_lossy().to_string()) + .chain(args.iter().map(|arg| quote_arg(arg.as_ref()))) .collect::>() .join(" ") } @@ -1093,42 +1103,6 @@ pub(crate) fn title_case(value: &str) -> String { mod tests { use super::*; - fn dt(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> DateTime { - Utc.with_ymd_and_hms(year, month, day, hour, minute, second) - .single() - .unwrap() - } - - #[test] - fn resolves_generated_calver_from_date_override() { - let calver_env = CalverEnv { - fluxer_build_date: Some("2026-05-20T01:02:03Z".to_string()), - ..CalverEnv::default() - }; - assert_eq!( - resolve_calver(&calver_env, dt(2026, 1, 1, 0, 0, 0)).unwrap(), - "2026.520.10203" - ); - } - - #[test] - fn rejects_invalid_explicit_time() { - assert_eq!( - parse_version_instant("2026.520.246000") - .unwrap_err() - .to_string(), - "Invalid build version date/time: 2026.520.246000" - ); - } - - #[test] - fn s3_key_helpers_are_platform_neutral() { - assert_eq!( - path_to_s3_key(Path::new("assets").join("chunks").join("a.js").as_path()), - "assets/chunks/a.js" - ); - } - #[test] fn append_only_plan_skips_existing_keys_without_reordering_pending_uploads() { let temp = tempfile::tempdir().unwrap(); @@ -1253,139 +1227,4 @@ mod tests { "Duplicate S3 upload key in local plan: same/key" ); } - - #[test] - fn s3_conditional_put_errors_are_treated_as_existing_objects() { - assert!(is_existing_object_error(Some("PreconditionFailed"), None)); - assert!(is_existing_object_error( - Some("ConditionalRequestConflict"), - None - )); - assert!(is_existing_object_error(Some("Error"), Some(412))); - assert!(is_existing_object_error(None, Some(409))); - assert!(!is_existing_object_error(Some("AccessDenied"), Some(403))); - assert!(!is_existing_object_error(None, None)); - } - - #[test] - fn s3_retryable_errors_include_unmodeled_429s() { - assert!(is_retryable_s3_error(Some("Error"), Some(429))); - assert!(is_retryable_s3_error(Some("TooManyRequests"), None)); - assert!(is_retryable_s3_error(Some("RateLimitExceeded"), None)); - assert!(!is_retryable_s3_error(None, Some(503))); - assert!(!is_retryable_s3_error(Some("AccessDenied"), Some(403))); - assert!(!is_retryable_s3_error(None, None)); - } - - #[test] - fn s3_etag_md5_accepts_only_single_part_md5_values() { - assert_eq!( - s3_etag_md5("\"D41D8CD98F00B204E9800998ECF8427E\"").as_deref(), - Some("d41d8cd98f00b204e9800998ecf8427e") - ); - assert!(s3_etag_md5("\"d41d8cd98f00b204e9800998ecf8427e-2\"").is_none()); - assert!(s3_etag_md5("\"not-md5\"").is_none()); - } - - #[test] - fn s3_list_prefixes_group_planned_keys_by_top_level_prefix() { - let plan = vec![ - S3UploadPlanItem::new(PathBuf::from("index.html"), "index.html".to_string()), - S3UploadPlanItem::new(PathBuf::from("emoji/a.svg"), "emoji/a.svg".to_string()), - S3UploadPlanItem::new( - PathBuf::from("emoji/nested/b.svg"), - "emoji/nested/b.svg".to_string(), - ), - S3UploadPlanItem::new(PathBuf::from("assets/app.js"), "assets/app.js".to_string()), - ]; - - assert_eq!( - s3_list_prefixes_for_plan(&plan), - BTreeSet::from([ - "assets/".to_string(), - "emoji/".to_string(), - "index.html".to_string() - ]) - ); - } - - #[test] - fn s3_error_summary_includes_available_metadata() { - assert_eq!( - s3_error_summary(Some("Error"), Some(404)), - " (S3 code Error, HTTP status 404)" - ); - assert_eq!(s3_error_summary(None, Some(500)), " (HTTP status 500)"); - } - - #[test] - fn s3_content_type_for_key_covers_browser_module_assets() { - assert_eq!( - s3_content_type_for_key("assets/app.js"), - Some("application/javascript; charset=utf-8") - ); - assert_eq!( - s3_content_type_for_key("assets/app.mjs"), - Some("application/javascript; charset=utf-8") - ); - assert_eq!( - s3_content_type_for_key("assets/voice_bg.wasm"), - Some("application/wasm") - ); - assert_eq!( - s3_content_type_for_key("assets/app.css"), - Some("text/css; charset=utf-8") - ); - assert_eq!(s3_content_type_for_key("assets/blob.unknown"), None); - } - - #[test] - fn collect_and_copy_dir_contents_are_sorted_and_recursive() { - let temp = tempfile::tempdir().unwrap(); - let source = temp.path().join("source"); - let dest = temp.path().join("dest"); - fs::create_dir_all(source.join("b")).unwrap(); - fs::write(source.join("b").join("two.txt"), "2").unwrap(); - fs::write(source.join("one.txt"), "1").unwrap(); - - let relative_files = collect_files(&source) - .unwrap() - .into_iter() - .map(|path| path_to_s3_key(path.strip_prefix(&source).unwrap())) - .collect::>(); - assert_eq!(relative_files, vec!["b/two.txt", "one.txt"]); - - copy_dir_contents(&source, &dest).unwrap(); - assert_eq!(fs::read_to_string(dest.join("one.txt")).unwrap(), "1"); - assert_eq!( - fs::read_to_string(dest.join("b").join("two.txt")).unwrap(), - "2" - ); - } - - #[test] - fn remove_empty_dirs_below_collapses_nested_husks_and_keeps_the_root() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("renderer"); - fs::create_dir_all(root.join("web").join("icons").join("desktop")).unwrap(); - fs::create_dir_all(root.join("assets")).unwrap(); - fs::create_dir_all(root.join("keep")).unwrap(); - fs::write(root.join("keep").join("one.txt"), "1").unwrap(); - - assert_eq!(remove_empty_dirs_below(&root).unwrap(), 4); - - assert!(root.is_dir()); - assert!(!root.join("web").exists()); - assert!(!root.join("assets").exists()); - assert!(root.join("keep").join("one.txt").is_file()); - } - - #[test] - fn remove_empty_dirs_below_is_a_no_op_on_a_missing_root() { - let temp = tempfile::tempdir().unwrap(); - assert_eq!( - remove_empty_dirs_below(&temp.path().join("absent")).unwrap(), - 0 - ); - } } diff --git a/tools/ci/src/desktop.rs b/tools/ci/src/desktop.rs index c8e9cd27c..6f28d73c9 100644 --- a/tools/ci/src/desktop.rs +++ b/tools/ci/src/desktop.rs @@ -3,9 +3,9 @@ use crate::common::{ CalverEnv, CommandSpec, append_github_env, append_github_output, append_github_path, capture, collect_files, command_succeeds, copy_dir_contents, count_files, download_file, env_bool, - env_string, output_bytes, output_text, parse_bool, path_to_s3_key, remove_dir_if_exists, + env_string, output_bytes, output_text, path_to_s3_key, remove_dir_if_exists, remove_empty_dirs_below, remove_file_if_exists, require_any_env, require_env, require_home, - resolve_calver, run_command, runner_temp, title_case, trim_option, + resolve_calver, run_command, runner_temp, title_case, }; use crate::functions::{sha256_file, write_json_pretty}; use crate::release::{ @@ -102,20 +102,6 @@ pub struct BuildDesktopArgs { channel: Option, #[arg(long)] skip_targets: Option, - #[arg(long)] - skip_windows: Option, - #[arg(long)] - skip_windows_x64: Option, - #[arg(long)] - skip_windows_arm64: Option, - #[arg(long)] - skip_macos: Option, - #[arg(long)] - skip_linux: Option, - #[arg(long)] - skip_linux_x64: Option, - #[arg(long)] - skip_linux_arm64: Option, } #[derive(Debug, Clone, Copy, ValueEnum)] @@ -301,16 +287,8 @@ pub async fn run(args: BuildDesktopArgs) -> Result<()> { } } -fn calver_env_from_process() -> CalverEnv { - CalverEnv { - build_version: trim_option(env::var("BUILD_VERSION").ok()), - fluxer_build_version: trim_option(env::var("FLUXER_BUILD_VERSION").ok()), - fluxer_build_date: trim_option(env::var("FLUXER_BUILD_DATE").ok()), - } -} - fn set_metadata_step(channel: &str) -> Result<()> { - let version = resolve_calver(&calver_env_from_process(), Utc::now())?; + let version = resolve_calver(&CalverEnv::from_process(), Utc::now())?; let pub_date = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let build_channel = if channel == "canary" { "canary" @@ -424,17 +402,12 @@ fn selected_platforms(args: &BuildDesktopArgs) -> Result> { Ok(PLATFORMS .iter() .copied() - .filter(|platform| !skip_platform(*platform, args, &skip_targets)) + .filter(|platform| !skip_platform(*platform, &skip_targets)) .collect()) } fn skip_target_set(args: &BuildDesktopArgs) -> Result> { - let raw = args - .skip_targets - .clone() - .filter(|value| !value.is_empty()) - .or_else(|| env_string("SKIP_TARGETS")) - .unwrap_or_default(); + let raw = args.skip_targets.clone().unwrap_or_default(); let valid = BTreeSet::from([ "windows", "windows-x64", @@ -461,35 +434,9 @@ fn skip_target_set(args: &BuildDesktopArgs) -> Result> { Ok(targets) } -fn skip_platform( - platform: Platform, - args: &BuildDesktopArgs, - skip_targets: &BTreeSet, -) -> bool { - let flag = |arg: &Option, env_name: &str| { - arg.as_deref() - .map(parse_bool) - .unwrap_or_else(|| env_bool(env_name)) - }; +fn skip_platform(platform: Platform, skip_targets: &BTreeSet) -> bool { let platform_arch = format!("{}-{}", platform.platform, platform.arch); - if skip_targets.contains(platform.platform) || skip_targets.contains(platform_arch.as_str()) { - return true; - } - match platform.platform { - "windows" => { - flag(&args.skip_windows, "SKIP_WINDOWS") - || (platform.arch == "x64" && flag(&args.skip_windows_x64, "SKIP_WINDOWS_X64")) - || (platform.arch == "arm64" - && flag(&args.skip_windows_arm64, "SKIP_WINDOWS_ARM64")) - } - "macos" => flag(&args.skip_macos, "SKIP_MACOS"), - "linux" => { - flag(&args.skip_linux, "SKIP_LINUX") - || (platform.arch == "x64" && flag(&args.skip_linux_x64, "SKIP_LINUX_X64")) - || (platform.arch == "arm64" && flag(&args.skip_linux_arm64, "SKIP_LINUX_ARM64")) - } - _ => false, - } + skip_targets.contains(platform.platform) || skip_targets.contains(platform_arch.as_str()) } fn platform_json(platform: Platform) -> String { @@ -6215,32 +6162,6 @@ mod tests { assert_eq!(parsed.shell.latest_version, "2026.1008.32323"); } - #[test] - fn bare_drive_workdir_resolves_to_the_drive_root() { - assert_eq!(workdir_path("W:"), PathBuf::from("W:\\")); - assert_eq!(workdir_path("w:"), PathBuf::from("w:\\")); - assert_eq!(workdir_path("W:\\src"), PathBuf::from("W:\\src")); - assert_eq!( - workdir_path("/home/runner/work"), - PathBuf::from("/home/runner/work") - ); - } - - fn matrix_args() -> BuildDesktopArgs { - BuildDesktopArgs { - step: DesktopStep::SetMatrix, - channel: None, - skip_targets: None, - skip_windows: Some("false".to_string()), - skip_windows_x64: Some("false".to_string()), - skip_windows_arm64: Some("false".to_string()), - skip_macos: Some("false".to_string()), - skip_linux: Some("false".to_string()), - skip_linux_x64: Some("false".to_string()), - skip_linux_arm64: Some("false".to_string()), - } - } - fn write_file(path: &Path, contents: &str) { if let Some(parent) = path.parent() { fs::create_dir_all(parent).unwrap(); @@ -6248,88 +6169,6 @@ mod tests { fs::write(path, contents).unwrap(); } - #[test] - fn matrix_skip_flags_filter_individual_arches() { - let mut args = matrix_args(); - args.skip_windows_x64 = Some("true".to_string()); - args.skip_macos = Some("true".to_string()); - - let selected = selected_platforms(&args) - .unwrap() - .into_iter() - .map(platform_json) - .collect::>(); - - assert_eq!( - selected, - vec![ - "{\"platform\":\"windows\",\"arch\":\"arm64\",\"os\":\"windows-2025\",\"electron_arch\":\"arm64\"}", - "{\"platform\":\"linux\",\"arch\":\"x64\",\"os\":\"ubuntu-22.04\",\"electron_arch\":\"x64\"}", - "{\"platform\":\"linux\",\"arch\":\"arm64\",\"os\":\"ubuntu-22.04-arm\",\"electron_arch\":\"arm64\"}", - ] - ); - } - - #[test] - fn matrix_selects_one_row_per_platform_arch_by_default() { - let selected = selected_platforms(&matrix_args()).unwrap(); - - assert_eq!(selected.len(), 5); - assert_eq!( - selected - .iter() - .filter(|platform| platform.platform == "windows") - .count(), - 2 - ); - } - - #[test] - fn matrix_skip_targets_filter_platforms_and_arches() { - let mut args = matrix_args(); - args.skip_targets = Some("windows-x64, macos".to_string()); - - let selected = selected_platforms(&args) - .unwrap() - .into_iter() - .map(platform_json) - .collect::>(); - - assert_eq!( - selected, - vec![ - "{\"platform\":\"windows\",\"arch\":\"arm64\",\"os\":\"windows-2025\",\"electron_arch\":\"arm64\"}", - "{\"platform\":\"linux\",\"arch\":\"x64\",\"os\":\"ubuntu-22.04\",\"electron_arch\":\"x64\"}", - "{\"platform\":\"linux\",\"arch\":\"arm64\",\"os\":\"ubuntu-22.04-arm\",\"electron_arch\":\"arm64\"}", - ] - ); - } - - #[test] - fn matrix_skip_targets_drop_every_windows_row() { - let mut args = matrix_args(); - args.skip_targets = Some("windows".to_string()); - - let selected = selected_platforms(&args).unwrap(); - - assert!( - selected - .iter() - .all(|platform| platform.platform != "windows") - ); - assert_eq!(selected.len(), 3); - } - - #[test] - fn matrix_skip_targets_reject_unknown_values() { - let mut args = matrix_args(); - args.skip_targets = Some("windows-riscv".to_string()); - - let error = selected_platforms(&args).unwrap_err(); - - assert!(error.to_string().contains("Unknown desktop skip target")); - } - const BUILD_DESKTOP_WORKFLOW: &str = include_str!("../../../.github/workflows/build-desktop.yaml"); @@ -6348,12 +6187,6 @@ mod tests { &body[..end] } - fn workflow_step_names(job: &str) -> Vec<&str> { - job.lines() - .filter_map(|line| line.strip_prefix(" - name: ")) - .collect() - } - #[test] fn the_job_handoff_travels_as_github_actions_artifacts() { let build = workflow_job("build"); @@ -6399,88 +6232,6 @@ mod tests { } } - #[test] - fn parses_handoff_artifact_dir_names() { - assert_eq!( - parse_artifact_dir_name("fluxer-desktop-canary-windows-arm64", "canary").unwrap(), - ArtifactIdentity { - platform: "windows".to_string(), - arch: "arm64".to_string(), - signed: false, - } - ); - assert!(parse_artifact_dir_name("fluxer-desktop-stable-linux-x64", "canary").is_none()); - assert_eq!( - parse_artifact_dir_name("fluxer-desktop-canary-windows-x64-signed", "canary").unwrap(), - ArtifactIdentity { - platform: "windows".to_string(), - arch: "x64".to_string(), - signed: true, - } - ); - } - - #[test] - fn handoff_artifact_name_only_marks_signed_windows_uploads() { - assert_eq!( - handoff_artifact_name("canary", "windows", "x64", true), - "fluxer-desktop-canary-windows-x64-signed" - ); - assert_eq!( - handoff_artifact_name("canary", "linux", "x64", true), - "fluxer-desktop-canary-linux-x64" - ); - assert_eq!( - handoff_artifact_name("stable", "windows", "arm64", false), - "fluxer-desktop-stable-windows-arm64" - ); - } - - #[test] - fn build_channel_content_matches_expected_typescript() { - assert_eq!( - build_channel_content("canary"), - "// SPDX-License-Identifier: AGPL-3.0-or-later\n\n\ -export type BuildChannel = 'stable' | 'canary' | 'development';\n\n\ -export const BUILD_CHANNEL = 'canary' as BuildChannel;\n\ -export const IS_CANARY = BUILD_CHANNEL === 'canary';\n\ -export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" - ); - assert!( - build_channel_content("development") - .contains("export const BUILD_CHANNEL = 'development' as BuildChannel;") - ); - } - - #[test] - fn write_build_channel_file_rejects_invalid_channels() { - let temp = tempfile::tempdir().unwrap(); - assert_eq!( - write_build_channel_file(temp.path(), "nightly") - .unwrap_err() - .to_string(), - "Invalid BUILD_CHANNEL: nightly. Must be one of: stable, canary, development." - ); - write_build_channel_file(temp.path(), "development").unwrap(); - } - - #[test] - fn write_build_channel_file_creates_and_updates_file() { - let temp = tempfile::tempdir().unwrap(); - write_build_channel_file(temp.path(), "stable").unwrap(); - let path = temp.path().join("src/common/BuildChannel.ts"); - assert_eq!( - fs::read_to_string(&path).unwrap(), - build_channel_content("stable") - ); - - write_build_channel_file(temp.path(), "canary").unwrap(); - assert_eq!( - fs::read_to_string(&path).unwrap(), - build_channel_content("canary") - ); - } - #[test] fn payload_artifact_dirs_prefer_signed_windows_artifacts() { let temp = tempfile::tempdir().unwrap(); @@ -6703,83 +6454,6 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" assert!(windows_package_config("nightly", "x64").is_err()); } - #[test] - fn create_zip_from_dir_preserves_relative_paths() { - let temp = tempfile::tempdir().unwrap(); - let source = temp.path().join("source"); - write_file(&source.join(".portable"), ""); - write_file(&source.join("resources/app.asar"), "asar"); - let zip_path = temp.path().join("portable.zip"); - - create_zip_from_dir(&source, &zip_path).unwrap(); - - let file = File::open(zip_path).unwrap(); - let mut zip = zip::ZipArchive::new(file).unwrap(); - assert!(zip.by_name(".portable").is_ok()); - assert!(zip.by_name("resources/app.asar").is_ok()); - } - - #[test] - fn velopack_portable_archives_are_removed_from_the_release_directory() { - let temp = tempfile::tempdir().unwrap(); - let output_dir = temp.path(); - write_file( - &output_dir.join("fluxer_desktop_canary-2026.810.1-Portable.zip"), - "velopack", - ); - write_file( - &output_dir.join("fluxer_desktop_canary-2026.810.1-full.nupkg"), - "payload", - ); - write_file( - &output_dir.join("Fluxer Canary-2026.810.1-win-arm64.exe"), - "setup", - ); - write_file(&output_dir.join("RELEASES"), "feed"); - - remove_velopack_portable_archives(output_dir).unwrap(); - - let remaining = collect_files(output_dir) - .unwrap() - .into_iter() - .filter_map(|path| file_name_string(&path).ok()) - .collect::>(); - assert!(!remaining.iter().any(|name| name.ends_with(".zip"))); - assert!(remaining.contains("fluxer_desktop_canary-2026.810.1-full.nupkg")); - assert!(remaining.contains("Fluxer Canary-2026.810.1-win-arm64.exe")); - assert!(remaining.contains("RELEASES")); - } - - #[test] - fn percent_encoded_archive_names_match_their_decoded_inventory_entry() { - assert_eq!( - percent_decode_archive_name("Fluxer%20Canary.exe"), - "Fluxer Canary.exe" - ); - assert_eq!(percent_decode_archive_name("Fluxer.exe"), "Fluxer.exe"); - assert_eq!( - percent_decode_archive_name("win-game-capture.win32-arm64-msvc.node"), - "win-game-capture.win32-arm64-msvc.node" - ); - assert_eq!(percent_decode_archive_name("100%.dll"), "100%.dll"); - assert_eq!(percent_decode_archive_name("a%zz.dll"), "a%zz.dll"); - } - - #[test] - fn canary_nupkg_inventory_accepts_percent_encoded_main_executable() { - let root = Path::new("lib").join("app"); - let files = expected_windows_pe_inventory("arm64", "Fluxer Canary.exe") - .into_iter() - .map(|name| { - if name == "Fluxer Canary.exe" { - return root.join("Fluxer%20Canary.exe"); - } - root.join(name) - }) - .collect::>(); - assert_expected_windows_pe_inventory(&root, &files, "arm64", "Fluxer Canary.exe").unwrap(); - } - #[test] fn shipped_account_switching_addons_are_required_windows_binaries() { for arch in ["x64", "arm64"] { @@ -6808,27 +6482,6 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" } } - #[test] - fn known_optional_windows_pe_inventory_never_repeats_a_required_binary() { - for arch in ["x64", "arm64"] { - for main_exe in ["Fluxer.exe", "Fluxer Canary.exe"] { - assert_eq!( - contradictory_optional_windows_pe_inventory(arch, main_exe), - Vec::::new(), - "{arch}/{main_exe} declares a binary as both required and known-optional" - ); - } - } - } - - #[test] - fn module_package_urls_point_at_the_content_addressed_package() { - assert_eq!( - desktop_module_package_url("canary", "fluxer_renderer", "5c1e"), - "https://pkgs.fluxer.com/desktop/canary/modules/fluxer_renderer/5c1e/package.br" - ); - } - #[test] fn metadata_version_is_the_publication_instant() { assert_eq!( @@ -7078,19 +6731,6 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" ); } - #[test] - fn the_module_payload_ships_only_through_the_github_release() { - let upload = workflow_job("upload"); - assert!(!upload.contains("fluxer-desktop-module-payload")); - assert!(!upload.contains("payload_tree/desktop/*/modules/")); - let steps = workflow_step_names(upload); - let manifest = steps - .iter() - .position(|step| *step == "Build desktop module manifest") - .unwrap(); - assert_eq!(steps[manifest + 1], "Prepare GitHub release assets"); - } - #[test] fn pruning_the_shell_renderer_keeps_the_renderer_and_drops_on_demand_modules() { let temp = tempfile::tempdir().unwrap(); @@ -7189,16 +6829,6 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" assert!(desktop_module_for_relative_path("assets/a/b/c.js").is_err()); } - #[test] - fn dictionary_directories_map_to_module_names() { - assert_eq!( - desktop_dictionary_module_name("dictionary-en-gb@3.0.0").as_deref(), - Some("fluxer_dict_en_gb") - ); - assert_eq!(desktop_dictionary_module_name("dictionary-en@"), None); - assert_eq!(desktop_dictionary_module_name("NOTICE.md"), None); - } - #[test] fn packed_modules_round_trip_through_brotli_tar() { let temp = tempfile::tempdir().unwrap(); @@ -7300,21 +6930,57 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" assert!(ensure_renderer_assets_ready(&root).is_err()); } + fn matrix_args() -> BuildDesktopArgs { + BuildDesktopArgs { + step: DesktopStep::SetMatrix, + channel: None, + skip_targets: None, + } + } + #[test] - fn pipewire_header_overlay_presence_skips_only_a_complete_install() { - let temp = TempDir::new().unwrap(); - let include_dir = temp.path(); - assert!(!linux_pipewire_header_overlay_present(include_dir).unwrap()); + fn matrix_skip_targets_filter_platforms_and_arches() { + let mut args = matrix_args(); + args.skip_targets = Some("windows-x64, macos".to_string()); - fs::create_dir_all(include_dir.join(LINUX_PIPEWIRE_HEADER_DIR)).unwrap(); - let partial = linux_pipewire_header_overlay_present(include_dir).unwrap_err(); - assert!(partial.to_string().contains("partially installed")); + let selected = selected_platforms(&args) + .unwrap() + .into_iter() + .map(platform_json) + .collect::>(); - fs::create_dir_all(include_dir.join(LINUX_SPA_HEADER_DIR)).unwrap(); - assert!(linux_pipewire_header_overlay_present(include_dir).unwrap()); + assert_eq!( + selected, + vec![ + "{\"platform\":\"windows\",\"arch\":\"arm64\",\"os\":\"windows-2025\",\"electron_arch\":\"arm64\"}", + "{\"platform\":\"linux\",\"arch\":\"x64\",\"os\":\"ubuntu-22.04\",\"electron_arch\":\"x64\"}", + "{\"platform\":\"linux\",\"arch\":\"arm64\",\"os\":\"ubuntu-22.04-arm\",\"electron_arch\":\"arm64\"}", + ] + ); + } - fs::remove_dir(include_dir.join(LINUX_PIPEWIRE_HEADER_DIR)).unwrap(); - let partial = linux_pipewire_header_overlay_present(include_dir).unwrap_err(); - assert!(partial.to_string().contains("partially installed")); + #[test] + fn matrix_skip_targets_drop_every_windows_row() { + let mut args = matrix_args(); + args.skip_targets = Some("windows".to_string()); + + let selected = selected_platforms(&args).unwrap(); + + assert!( + selected + .iter() + .all(|platform| platform.platform != "windows") + ); + assert_eq!(selected.len(), 3); + } + + #[test] + fn matrix_skip_targets_reject_unknown_values() { + let mut args = matrix_args(); + args.skip_targets = Some("windows-riscv".to_string()); + + let error = selected_platforms(&args).unwrap_err(); + + assert!(error.to_string().contains("Unknown desktop skip target")); } } diff --git a/tools/ci/src/desktop_native.rs b/tools/ci/src/desktop_native.rs index c3a29cecf..cf20a0d34 100644 --- a/tools/ci/src/desktop_native.rs +++ b/tools/ci/src/desktop_native.rs @@ -984,21 +984,6 @@ impl PeFile { mod tests { use super::*; - #[test] - fn addon_mapping_covers_expected_packages() { - let names = DESKTOP_NATIVE_ADDONS - .iter() - .map(|addon| addon.package_dir) - .collect::>(); - - assert!(names.contains(&"linux-audio-capture")); - assert!(names.contains(&"webauthn")); - assert!(names.contains(&"win-game-capture")); - assert!(names.contains(&"app-store")); - assert!(names.contains(&"gateway-socket")); - assert_eq!(DESKTOP_NATIVE_ADDONS.len(), 22); - } - #[test] fn the_desktop_build_script_builds_and_verifies_every_registered_addon() { let build_script = diff --git a/tools/ci/src/gateway.rs b/tools/ci/src/gateway.rs index 5f1cfdbf1..4ff9f99c9 100644 --- a/tools/ci/src/gateway.rs +++ b/tools/ci/src/gateway.rs @@ -32,45 +32,13 @@ pub(crate) enum GatewayStep { FmtCheck, Lint, Compile, - ProdCompile, Dialyzer, - Eqwalizer, Typecheck, Eunit, AllChecks, Clean, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum NifBuildProfile { - Debug, - Release, -} - -impl NifBuildProfile { - fn from_env_value(value: Option<&str>) -> Self { - match value { - Some("release") | None => Self::Release, - Some(_) => Self::Debug, - } - } - - fn target_dir_name(self) -> &'static str { - match self { - Self::Debug => "debug", - Self::Release => "release", - } - } - - fn cargo_args(self) -> Vec { - let mut args = vec![OsString::from("build")]; - if self == Self::Release { - args.push(OsString::from("--release")); - } - args - } -} - #[derive(Debug, Clone, PartialEq, Eq)] struct GatewayNifBuild { crate_name: String, @@ -103,12 +71,7 @@ pub(crate) fn run_gateway_step( GatewayStep::FmtCheck => run_gateway_fmt_check(gateway_dir), GatewayStep::Lint => run_rebar(gateway_dir, ["lint"]), GatewayStep::Compile => run_rebar(gateway_dir, ["compile"]), - GatewayStep::ProdCompile => { - run_rebar(gateway_dir, ["as", "prod", "clean", "-a"])?; - run_rebar(gateway_dir, ["as", "prod", "compile"]) - } GatewayStep::Dialyzer => run_rebar(gateway_dir, ["dialyzer"]), - GatewayStep::Eqwalizer => run_eqwalizer(gateway_dir, eqwalizer_profile), GatewayStep::Typecheck => run_eqwalizer(gateway_dir, eqwalizer_profile), GatewayStep::Eunit => run_rebar(gateway_dir, ["as", "test", "eunit"]), GatewayStep::AllChecks => { @@ -144,11 +107,8 @@ pub(crate) fn run_gateway_step( } fn build_gateway_nifs(gateway_dir: &Path) -> Result<()> { - let profile = - NifBuildProfile::from_env_value(env::var("FLUXER_GATEWAY_NIF_PROFILE").ok().as_deref()); let builds = gateway_nif_builds( gateway_dir, - profile, env::consts::DLL_PREFIX, env::consts::DLL_EXTENSION, ); @@ -362,21 +322,17 @@ fn asdf_shims_path() -> Option { fn gateway_nif_builds( gateway_dir: &Path, - profile: NifBuildProfile, dll_prefix: &str, dll_extension: &str, ) -> Vec { GATEWAY_NIF_CRATES .iter() - .map(|crate_name| { - gateway_nif_build(gateway_dir, profile, dll_prefix, dll_extension, crate_name) - }) + .map(|crate_name| gateway_nif_build(gateway_dir, dll_prefix, dll_extension, crate_name)) .collect() } fn gateway_nif_build( gateway_dir: &Path, - profile: NifBuildProfile, dll_prefix: &str, dll_extension: &str, crate_name: &str, @@ -384,10 +340,10 @@ fn gateway_nif_build( let native_dir = gateway_dir.join("native").join(crate_name); GatewayNifBuild { crate_name: crate_name.to_string(), - cargo_args: profile.cargo_args(), + cargo_args: vec![OsString::from("build"), OsString::from("--release")], artifact_path: native_dir .join("target") - .join(profile.target_dir_name()) + .join("release") .join(format!("{dll_prefix}{crate_name}.{dll_extension}")), output_path: gateway_dir.join("priv").join(format!("{crate_name}.so")), native_dir, @@ -409,113 +365,3 @@ fn resolve_gateway_dir() -> Result { cwd.display() )) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn nif_profile_defaults_to_release_and_uses_debug_for_other_values() { - assert_eq!( - NifBuildProfile::from_env_value(None), - NifBuildProfile::Release - ); - assert_eq!( - NifBuildProfile::from_env_value(Some("release")), - NifBuildProfile::Release - ); - assert_eq!( - NifBuildProfile::from_env_value(Some("debug")), - NifBuildProfile::Debug - ); - assert_eq!( - NifBuildProfile::from_env_value(Some("dev")), - NifBuildProfile::Debug - ); - } - - #[test] - fn debug_build_plan_omits_release_arg_and_uses_debug_target_dir() { - let build = gateway_nif_build( - Path::new("/repo/fluxer_gateway"), - NifBuildProfile::Debug, - "lib", - "dylib", - "guild_member_list_oset_nif", - ); - - assert_eq!(build.cargo_args, vec![OsString::from("build")]); - assert_eq!( - build.artifact_path, - PathBuf::from( - "/repo/fluxer_gateway/native/guild_member_list_oset_nif/target/debug/libguild_member_list_oset_nif.dylib" - ) - ); - assert_eq!( - build.output_path, - PathBuf::from("/repo/fluxer_gateway/priv/guild_member_list_oset_nif.so") - ); - } - - #[test] - fn rebar_project_plugins_are_skipped_except_for_plugin_commands() { - assert!(should_skip_rebar_project_plugins(&[OsString::from( - "compile" - )])); - assert!(should_skip_rebar_project_plugins(&[ - OsString::from("as"), - OsString::from("test"), - OsString::from("eunit"), - ])); - assert!(!should_skip_rebar_project_plugins(&[OsString::from("fmt")])); - assert!(!should_skip_rebar_project_plugins(&[OsString::from( - "lint" - )])); - assert!(!should_skip_rebar_project_plugins(&[OsString::from( - "plugins" - )])); - } - - #[test] - fn rebar_command_runs_in_gateway_dir_and_sets_skip_env_for_compile() { - let command = rebar_command(Path::new("/repo/fluxer_gateway"), ["compile"]); - - assert_eq!(command.program, OsString::from("rebar3")); - assert_eq!(command.args, vec![OsString::from("compile")]); - assert_eq!(command.cwd, Some(PathBuf::from("/repo/fluxer_gateway"))); - assert!(command.env.contains(&( - OsString::from("REBAR_SKIP_PROJECT_PLUGINS"), - OsString::from("1") - ))); - } - - #[test] - fn rebar_command_keeps_project_plugins_for_fmt() { - let command = rebar_command(Path::new("/repo/fluxer_gateway"), ["fmt", "--check"]); - - assert_eq!( - command.args, - vec![OsString::from("fmt"), OsString::from("--check")] - ); - assert!( - !command - .env - .iter() - .any(|(key, _)| key == &OsString::from("REBAR_SKIP_PROJECT_PLUGINS")) - ); - } - - #[test] - fn zstd_probe_linker_warnings_are_fatal_only_on_aarch64_linux() { - assert_eq!( - zstd_probe_ldflags("linux", "aarch64", None), - Some(OsString::from(GNU_LD_FATAL_WARNINGS)) - ); - assert_eq!( - zstd_probe_ldflags("linux", "aarch64", Some(OsStr::new("-Wl,--as-needed"))), - Some(OsString::from("-Wl,--as-needed -Wl,--fatal-warnings")) - ); - assert_eq!(zstd_probe_ldflags("linux", "x86_64", None), None); - assert_eq!(zstd_probe_ldflags("macos", "aarch64", None), None); - } -} diff --git a/tools/ci/src/image_set.rs b/tools/ci/src/image_set.rs index d9effb561..1115342ac 100644 --- a/tools/ci/src/image_set.rs +++ b/tools/ci/src/image_set.rs @@ -902,23 +902,6 @@ mod tests { assert_eq!(observed, expected); } - #[test] - fn components_are_sorted_and_unique() { - let images: Vec<&str> = COMPONENTS.iter().map(|component| component.image).collect(); - let mut sorted = images.clone(); - sorted.sort_unstable(); - sorted.dedup(); - assert_eq!(images, sorted); - - let services: Vec<&str> = COMPONENTS - .iter() - .flat_map(|component| component.services.iter().copied()) - .collect(); - let unique: BTreeSet<&str> = services.iter().copied().collect(); - assert_eq!(services.len(), unique.len()); - assert_eq!(services.len(), 18); - } - #[test] fn manifest_requires_every_component() { let mut resolved = resolved_set(); @@ -1036,32 +1019,6 @@ mod tests { ); } - #[test] - fn compose_override_is_sorted_and_stable() { - let manifest = manifest(); - let rendered = compose_override(&manifest); - assert_eq!(rendered, compose_override(&manifest)); - - let services: Vec<&str> = rendered - .lines() - .filter_map(|line| line.strip_prefix(" ")?.strip_suffix(':')) - .collect(); - let mut sorted = services.clone(); - sorted.sort_unstable(); - assert_eq!(services, sorted); - assert_eq!(services.len(), 18); - } - - #[test] - fn moving_tags_parses_and_rejects_junk() { - assert_eq!(moving_tags("").unwrap(), Vec::::new()); - assert_eq!(moving_tags(" , ").unwrap(), Vec::::new()); - assert_eq!(moving_tags(" v1, latest ").unwrap(), ["v1", "latest"]); - assert_eq!(moving_tags(DEFAULT_MOVING_TAGS).unwrap(), ["v1", "latest"]); - assert!(moving_tags("v1@sha256:abc").is_err()); - assert!(moving_tags("v1 latest").is_err()); - } - #[test] fn promote_command_targets_the_digest() { let digest = format!("sha256:{}", "0".repeat(64)); @@ -1086,35 +1043,6 @@ mod tests { ); } - #[test] - fn unpublished_moving_tags_are_reported_without_blocking_the_promote() { - let tags = ["v1".to_string(), "latest".to_string()]; - let complete = unpublished_moving_tags("ghcr.io/fluxerapp", "fluxer-docs", &tags, |_| true); - assert!(complete.is_empty()); - - let bootstrapping = - unpublished_moving_tags("ghcr.io/fluxerapp", "fluxer-docs", &tags, |reference| { - !reference.starts_with("ghcr.io/fluxerapp/fluxer-gifs:") - }); - assert_eq!( - bootstrapping, - [ - "fluxer-gifs has no published v1 image, so v1 stays an incomplete set until the build-gifs workflow runs", - "fluxer-gifs has no published latest image, so latest stays an incomplete set until the build-gifs workflow runs", - ] - ); - } - - #[test] - fn unpublished_moving_tags_never_reports_the_component_being_promoted() { - let tags = ["v1".to_string()]; - let warnings = - unpublished_moving_tags("ghcr.io/fluxerapp", "fluxer-gifs", &tags, |reference| { - !reference.starts_with("ghcr.io/fluxerapp/fluxer-gifs:") - }); - assert!(warnings.is_empty()); - } - #[test] fn calver_tag_for_digest_picks_the_single_calver_tag() { let tags = ["2026.630.20736", "latest", "v1"].map(str::to_string); @@ -1135,23 +1063,6 @@ mod tests { assert!(calver_tag_for_digest(&many).is_err()); } - #[test] - fn parse_component_versions_rejects_junk() { - let pins = parse_component_versions(&["fluxer-api=2026.830.191141".to_string()]).unwrap(); - assert_eq!(pins["fluxer-api"], "2026.830.191141"); - - assert!(parse_component_versions(&["fluxer-api".to_string()]).is_err()); - assert!(parse_component_versions(&["fluxer-nope=2026.830.191141".to_string()]).is_err()); - assert!(parse_component_versions(&["fluxer-api=v1".to_string()]).is_err()); - assert!( - parse_component_versions(&[ - "fluxer-api=2026.830.191141".to_string(), - "fluxer-api=2026.830.191142".to_string(), - ]) - .is_err() - ); - } - #[test] fn verify_resolved_detects_digest_drift() { let manifest = manifest(); @@ -1173,25 +1084,6 @@ mod tests { assert!(error.contains("records tag"), "{error}"); } - #[test] - fn digest_env_name_matches_component() { - assert_eq!(digest_env_name("fluxer-api"), "FLUXER_API_IMAGE_DIGEST"); - assert_eq!( - digest_env_name("fluxer-app-proxy-self-hosted"), - "FLUXER_APP_PROXY_SELF_HOSTED_IMAGE_DIGEST" - ); - } - - #[test] - fn validate_registry_rejects_image_references() { - validate_registry(DEFAULT_REGISTRY).unwrap(); - validate_registry("registry.example.com:5000/fluxer").unwrap(); - assert!(validate_registry("").is_err()); - assert!(validate_registry("ghcr.io/fluxerapp/").is_err()); - assert!(validate_registry("ghcr.io/fluxerapp/fluxer-api:v1").is_err()); - assert!(validate_registry("ghcr.io/ fluxerapp").is_err()); - } - fn linear_compare<'a>( history: &'a [&'a str; 3], ) -> impl FnMut(&str, &str) -> Result + 'a { @@ -1247,28 +1139,6 @@ mod tests { assert!(error.contains("divergent branches"), "{error}"); } - #[test] - fn parse_commit_compare_maps_every_github_status() { - assert_eq!( - parse_commit_compare("identical").unwrap(), - CommitCompare::Identical - ); - assert_eq!( - parse_commit_compare(" ahead ").unwrap(), - CommitCompare::Ahead - ); - assert_eq!( - parse_commit_compare("behind").unwrap(), - CommitCompare::Behind - ); - assert_eq!( - parse_commit_compare("diverged").unwrap(), - CommitCompare::Diverged - ); - assert!(parse_commit_compare("").is_err()); - assert!(parse_commit_compare("null").is_err()); - } - #[test] fn resolve_bundle_commit_refuses_a_set_with_no_source_commits() { let mut resolved = resolved_set(); diff --git a/tools/ci/src/release.rs b/tools/ci/src/release.rs index 15814fb4d..e86e6cc5b 100644 --- a/tools/ci/src/release.rs +++ b/tools/ci/src/release.rs @@ -6,13 +6,13 @@ use crate::desktop::{ DESKTOP_RENDERER_MODULE, DesktopChannelManifest, desktop_module_package_url, is_desktop_module_name, }; -use crate::functions::sha256_reader; +use crate::functions::sha256_file; use anyhow::{Context, Result, anyhow, bail, ensure}; use chrono::{DateTime, Utc}; use clap::{Args, Subcommand}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; -use std::fs::{self, File}; +use std::fs; use std::path::{Path, PathBuf}; use std::thread; use std::time::Duration; @@ -1142,12 +1142,6 @@ fn local_release_assets( Ok(assets) } -fn sha256_file(path: &Path) -> Result { - let file = File::open(path) - .with_context(|| format!("Failed to open release asset {}", path.display()))?; - sha256_reader(file).with_context(|| format!("Failed to read release asset {}", path.display())) -} - fn create_draft_release( tag: &str, title: &str, @@ -1416,7 +1410,6 @@ fn desktop_channel(component: &str) -> Option<&str> { #[cfg(test)] mod tests { use super::*; - use anyhow::anyhow; const SAMPLE_CHANNEL: &str = "canary"; const SAMPLE_VERSION: &str = "2026.913.210037"; @@ -1644,23 +1637,6 @@ mod tests { ) } - #[test] - fn the_release_inventory_is_the_one_the_publisher_stages() { - assert_eq!( - desktop_release_route_inventory(), - BTreeMap::from([ - ("darwin/arm64".to_string(), 4usize), - ("darwin/x64".to_string(), 4usize), - ("linux/arm64".to_string(), 5usize), - ("linux/x64".to_string(), 5usize), - ("win32/arm64".to_string(), 6usize), - ("win32/x64".to_string(), 6usize), - ]) - ); - assert_eq!(desktop_release_route_count(), 30); - assert_eq!(desktop_release_asset_count(), 26); - } - #[test] fn a_complete_desktop_release_validates() { let descriptor = sample_descriptor(); @@ -1712,22 +1688,6 @@ mod tests { ); } - #[test] - fn a_release_with_an_extra_route_is_refused() { - let mut descriptor = sample_descriptor(); - let extra = DesktopReleaseAsset { - storage_key: format!("desktop/{SAMPLE_CHANNEL}/linux/x64/latest-linux.yml"), - release_asset: format!("Fluxer-Canary-{SAMPLE_VERSION}-linux-x64-latest-linux.yml"), - sha256: format!("{:064x}", 99u64), - size: 4096, - }; - descriptor.assets.push(extra); - assert_eq!( - validate_sample(&descriptor).unwrap_err().to_string(), - "Desktop release descriptor must contain 30 routes, found 31" - ); - } - #[test] fn the_release_publishes_no_per_coordinate_manifest() { for (platform, _) in desktop_release_coordinates() { @@ -2054,57 +2014,4 @@ mod tests { .starts_with("Desktop release asset inventory mismatch") ); } - - #[test] - fn retry_publish_retries_until_a_publish_succeeds() { - let mut calls = 0; - let mut waits = Vec::new(); - let result = retry_publish( - 3, - |attempt| waits.push(attempt), - || { - calls += 1; - if calls < 3 { - Err(anyhow!("unexpected end of JSON input")) - } else { - Ok(()) - } - }, - ); - assert!(result.is_ok()); - assert_eq!(calls, 3); - assert_eq!(waits, vec![1, 2]); - } - - #[test] - fn retry_publish_returns_the_last_error_without_waiting_after_it() { - let mut calls = 0; - let mut waits = Vec::new(); - let result = retry_publish( - 3, - |attempt| waits.push(attempt), - || { - calls += 1; - Err(anyhow!("attempt {calls} failed")) - }, - ); - assert_eq!(result.unwrap_err().to_string(), "attempt 3 failed"); - assert_eq!(calls, 3); - assert_eq!(waits, vec![1, 2]); - } - - #[test] - fn retry_publish_does_not_retry_a_successful_publish() { - let mut calls = 0; - let result = retry_publish( - 3, - |_| panic!("a successful publish must not wait"), - || { - calls += 1; - Ok(()) - }, - ); - assert!(result.is_ok()); - assert_eq!(calls, 1); - } } diff --git a/tools/dev/Cargo.toml b/tools/dev/Cargo.toml index 3fa4ca930..0972d45c2 100644 --- a/tools/dev/Cargo.toml +++ b/tools/dev/Cargo.toml @@ -8,6 +8,8 @@ publish = false [dependencies] anyhow = "1.0.104" axum = "0.8.9" +chrono = "0.4.45" +hex = "0.4.3" clap = { version = "4.6.7", features = ["derive", "env"] } hyper = { version = "1.11.1", features = ["http1", "server"] } hyper-util = { version = "0.1.20", features = ["tokio"] } diff --git a/tools/dev/src/cassandra.rs b/tools/dev/src/cassandra.rs index 70a45c480..1baddf25c 100644 --- a/tools/dev/src/cassandra.rs +++ b/tools/dev/src/cassandra.rs @@ -345,17 +345,7 @@ pub fn render_target_schema(keyspace: &str) -> String { pub fn target_schema_sha256(keyspace: &str) -> String { let mut hasher = Sha256::new(); hasher.update(render_target_schema(keyspace).as_bytes()); - bytes_to_lower_hex(&hasher.finalize()) -} - -fn bytes_to_lower_hex(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut out = String::with_capacity(bytes.len() * 2); - for byte in bytes { - out.push(HEX[(byte >> 4) as usize] as char); - out.push(HEX[(byte & 0x0f) as usize] as char); - } - out + hex::encode(hasher.finalize()) } pub fn sorted_user_types() -> Vec { @@ -624,83 +614,3 @@ async fn verify_schema_with_session(session: &Session, keyspace: &str) -> Result ); Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalizes_frozen_user_type_wrappers() { - assert_eq!(normalize_type(" frozen "), "message_embed"); - assert_eq!( - normalize_type("LIST< frozen >"), - "list" - ); - } - - #[test] - fn renders_target_schema_with_semicolons() { - let rendered = render_target_schema("fluxer"); - assert!(rendered.starts_with("CREATE TYPE IF NOT EXISTS fluxer.")); - assert!(rendered.contains("CREATE TABLE IF NOT EXISTS fluxer.messages")); - assert!(rendered.ends_with(";\n")); - } - - #[test] - fn diff_creates_keyspace_and_missing_schema() { - let diff = create_diff(&ExistingSchema::default(), "fluxer"); - assert!(diff.statements[0].starts_with("CREATE KEYSPACE IF NOT EXISTS fluxer")); - assert!( - diff.statements - .iter() - .any(|statement| statement.starts_with("CREATE TYPE IF NOT EXISTS fluxer.")) - ); - assert!( - diff.statements - .iter() - .any(|statement| statement.starts_with("CREATE TABLE IF NOT EXISTS fluxer.")) - ); - assert!(diff.errors.is_empty()); - } - - #[test] - fn diff_reports_type_mismatches_and_extra_tables() { - let mut existing = ExistingSchema { - keyspace_exists: true, - ..ExistingSchema::default() - }; - existing.types.insert( - "message_attachment".to_owned(), - BTreeMap::from([("attachment_id".to_owned(), "text".to_owned())]), - ); - existing - .tables - .insert("unexpected".to_owned(), ExistingTable::default()); - let diff = create_diff(&existing, "fluxer"); - assert!( - diff.errors - .iter() - .any(|error| error.contains("type message_attachment.attachment_id")) - ); - assert!( - diff.warnings - .iter() - .any(|warning| warning.contains("unexpected")) - ); - } - - #[test] - fn render_diff_includes_warnings_errors_and_sha() { - let diff = SchemaDiff { - statements: vec!["SELECT 1".to_owned()], - warnings: vec!["warn".to_owned()], - errors: vec!["err".to_owned()], - target_sha256: "abc".to_owned(), - }; - let rendered = render_diff(&diff); - assert!(rendered.contains("-- Target schema sha256: abc")); - assert!(rendered.contains("-- warn")); - assert!(rendered.contains("-- err")); - assert!(rendered.ends_with("SELECT 1;\n")); - } -} diff --git a/tools/dev/src/desktop.rs b/tools/dev/src/desktop.rs index 7a9aa4046..9f577e6b0 100644 --- a/tools/dev/src/desktop.rs +++ b/tools/dev/src/desktop.rs @@ -112,34 +112,10 @@ fn ensure_desktop_dependencies() -> Result<()> { } pub fn development_version(now: SystemTime) -> String { - let seconds = now - .duration_since(UNIX_EPOCH) - .map(|elapsed| elapsed.as_secs()) - .unwrap_or(0); - let days = (seconds / 86_400) as i64; - let second_of_day = seconds % 86_400; - let (year, month, day) = civil_from_days(days); - let micro = - (second_of_day / 3600) * 10_000 + ((second_of_day % 3600) / 60) * 100 + second_of_day % 60; - format!("{year}.{month}{day:02}.{micro}") -} - -pub(crate) fn civil_from_days(days: i64) -> (i64, u32, u32) { - let shifted = days + 719_468; - let era = shifted.div_euclid(146_097); - let day_of_era = shifted.rem_euclid(146_097); - let year_of_era = - (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; - let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); - let month_index = (5 * day_of_year + 2) / 153; - let day = (day_of_year - (153 * month_index + 2) / 5 + 1) as u32; - let month = if month_index < 10 { - month_index + 3 - } else { - month_index - 9 - } as u32; - let year = year_of_era + era * 400 + i64::from(month <= 2); - (year, month, day) + let now = chrono::DateTime::::from(now); + use chrono::{Datelike, Timelike}; + let micro = now.hour() * 10_000 + now.minute() * 100 + now.second(); + format!("{}.{}{:02}.{micro}", now.year(), now.month(), now.day()) } fn target_electron_arch() -> Result { @@ -207,11 +183,7 @@ fn renderer_source_fingerprint() -> Result { } } } - Ok(hasher - .finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect()) + Ok(hex::encode(hasher.finalize())) } pub fn verify_renderer_tree(root: &Path) -> Result<()> { diff --git a/tools/dev/src/desktop_modules.rs b/tools/dev/src/desktop_modules.rs index 2ea7e6d5d..c7ef8c635 100644 --- a/tools/dev/src/desktop_modules.rs +++ b/tools/dev/src/desktop_modules.rs @@ -104,18 +104,9 @@ pub fn host_module_platform() -> Result<&'static str> { } pub fn rfc3339_utc(now: SystemTime) -> String { - let seconds = now - .duration_since(UNIX_EPOCH) - .map(|elapsed| elapsed.as_secs()) - .unwrap_or(0); - let (year, month, day) = crate::desktop::civil_from_days((seconds / 86_400) as i64); - let second_of_day = seconds % 86_400; - format!( - "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", - second_of_day / 3600, - (second_of_day % 3600) / 60, - second_of_day % 60 - ) + chrono::DateTime::::from(now) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string() } fn run_ci_step(step: &str, cwd: &Path, env: Vec<(String, Option)>) -> Result<()> { @@ -251,11 +242,7 @@ fn sha256_file(path: &Path) -> Result { } hasher.update(&buffer[..read]); } - Ok(hasher - .finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect()) + Ok(hex::encode(hasher.finalize())) } fn write_atomically(path: &Path, bytes: &[u8]) -> Result<()> { diff --git a/tools/dev/src/gateway.rs b/tools/dev/src/gateway.rs index e072716a7..a773dd185 100644 --- a/tools/dev/src/gateway.rs +++ b/tools/dev/src/gateway.rs @@ -3,19 +3,17 @@ use crate::paths::{DEV_GATEWAY_DIR, GATEWAY_CONFIG_DIR, ROOT}; use crate::proc::{ AwaitOutcome, RESTART_LIMIT, RESTART_WINDOW, RunOptions, ShutdownSignal, await_or_shutdown, - configure_process_group, force_kill_process_group, format_command, merged_env, - pending_shutdown, prefix_output, process_group_running, restart_budget_exceeded, - run_command_interruptible, stop_process_group, terminate_process_group, + can_bind_tcp_port, cleanup_orphaned_process_groups, configure_process_group, + force_kill_process_group, format_command, merged_env, pending_shutdown, prefix_output, + process_group_running, restart_budget_exceeded, run_command_interruptible, stop_process_group, + terminate_process_group, }; use anyhow::{Context, Result, bail}; use sha2::{Digest, Sha256}; -#[cfg(target_os = "linux")] -use std::collections::BTreeSet; use std::collections::VecDeque; use std::env; use std::fs; use std::io::Read; -use std::net::{SocketAddr, TcpListener}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; @@ -325,17 +323,7 @@ fn gateway_dependency_stamp() -> Result { hasher.update(metadata.len().to_le_bytes()); hasher.update(&bytes); } - Ok(bytes_to_lower_hex(&hasher.finalize())) -} - -fn bytes_to_lower_hex(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut output = String::with_capacity(bytes.len() * 2); - for byte in bytes { - output.push(HEX[(byte >> 4) as usize] as char); - output.push(HEX[(byte & 0x0f) as usize] as char); - } - output + Ok(hex::encode(hasher.finalize())) } fn gateway_dependency_stamp_matches(expected: &str) -> Result { @@ -516,200 +504,23 @@ async fn wait_for_ports_available_until(nodes: &[GatewayNode], deadline: Instant } } -fn can_bind_tcp_port(port: u16) -> bool { - TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port))).is_ok() -} - -#[cfg(target_os = "linux")] async fn cleanup_orphaned_gateway_processes( node_names: &std::collections::HashSet, ) -> Result<()> { - let leaders = orphaned_gateway_leaders(node_names)?; - if leaders.is_empty() { - return Ok(()); - } - let pids = leaders.iter().map(|leader| leader.pid).collect::>(); - - println!( - "Stopping orphaned gateway process group(s): {}", - format_pids(&pids) - ); - let term_failed = signal_process_groups(&leaders, libc::SIGTERM); - if !term_failed.is_empty() { - println!( - "SIGTERM delivery failed for orphaned gateway pid(s): {}", - format_pids(&term_failed) - ); - } - - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let remaining = surviving_gateway_group_pids(&leaders)?; - if remaining.is_empty() { - return Ok(()); - } - if Instant::now() >= deadline { - let remaining_leaders = leaders - .iter() - .filter(|leader| remaining.contains(&leader.pid)) - .cloned() - .collect::>(); - let kill_failed = signal_process_groups(&remaining_leaders, libc::SIGKILL); - sleep(Duration::from_millis(200)).await; - let survivors = surviving_gateway_group_pids(&leaders)?; - if survivors.is_empty() { - return Ok(()); - } - let delivery_failed = kill_failed - .into_iter() - .filter(|pid| survivors.contains(pid)) - .collect::>(); - if delivery_failed.is_empty() { - bail!( - "orphaned gateway node process(es) survived SIGKILL: {}", - format_pids(&survivors) - ); - } - bail!( - "orphaned gateway node process(es) survived SIGKILL: {} (signal delivery failed for: {})", - format_pids(&survivors), - format_pids(&delivery_failed) - ); - } - sleep(Duration::from_millis(200)).await; - } + cleanup_orphaned_process_groups( + "gateway node", + |process, args| { + (cmdline_has_gateway_node(args, node_names) || cmdline_has_managed_gateway_config(args)) + && fs::read_link(format!("/proc/{}/cwd", process.pid)) + .ok() + .as_deref() + == Some(gateway_dir().as_path()) + }, + cmdline_is_gateway_supervisor, + ) + .await } -#[cfg(target_os = "linux")] -fn format_pids(pids: &[i32]) -> String { - if pids.is_empty() { - return "none".to_owned(); - } - pids.iter() - .map(i32::to_string) - .collect::>() - .join(", ") -} - -#[cfg(not(target_os = "linux"))] -async fn cleanup_orphaned_gateway_processes( - _node_names: &std::collections::HashSet, -) -> Result<()> { - Ok(()) -} - -#[cfg(target_os = "linux")] -#[derive(Clone, Debug, Eq, PartialEq)] -struct GatewayLeader { - pid: i32, - members: Vec, -} - -#[cfg(target_os = "linux")] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct GatewayProcessIdentity { - pid: i32, - starttime: u64, -} - -#[cfg(target_os = "linux")] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct GatewayProcess { - pid: i32, - ppid: i32, - pgid: i32, - state: char, - starttime: u64, -} - -#[cfg(target_os = "linux")] -fn orphaned_gateway_leaders( - node_names: &std::collections::HashSet, -) -> Result> { - let current_exe = std::env::current_exe().context("failed to resolve fluxer-dev executable")?; - let processes = gateway_process_snapshot()?; - let mut owned_pids = BTreeSet::new(); - for process in &processes { - if process.ppid != 1 || proc_stat_state_is_dead(process.state) { - continue; - } - let args = proc_cmdline(process.pid); - let orphaned_supervisor = cmdline_is_gateway_supervisor(&args, ¤t_exe); - let orphaned_node = (cmdline_has_gateway_node(&args, node_names) - || cmdline_has_managed_gateway_config(&args)) - && fs::read_link(format!("/proc/{}/cwd", process.pid)) - .ok() - .as_deref() - == Some(gateway_dir().as_path()); - if orphaned_supervisor || orphaned_node { - owned_pids.insert(process.pid); - } - } - loop { - let previous_count = owned_pids.len(); - for process in &processes { - if owned_pids.contains(&process.ppid) { - owned_pids.insert(process.pid); - } - } - if owned_pids.len() == previous_count { - break; - } - } - let group_pids = processes - .iter() - .filter(|process| owned_pids.contains(&process.pid) && process.pgid > 1) - .map(|process| process.pgid) - .collect::>(); - let leaders = group_pids - .into_iter() - .map(|pid| GatewayLeader { - pid, - members: processes - .iter() - .filter(|process| owned_pids.contains(&process.pid) && process.pgid == pid) - .map(|process| GatewayProcessIdentity { - pid: process.pid, - starttime: process.starttime, - }) - .collect(), - }) - .collect::>(); - Ok(leaders) -} - -#[cfg(target_os = "linux")] -fn gateway_process_snapshot() -> Result> { - let mut processes = Vec::new(); - for entry in fs::read_dir("/proc").context("failed to read /proc")? { - let Ok(entry) = entry else { - continue; - }; - let Some(pid) = entry - .file_name() - .to_str() - .and_then(|name| name.parse::().ok()) - else { - continue; - }; - let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) else { - continue; - }; - let Some((state, ppid, pgid, starttime)) = parse_proc_stat_process(&stat) else { - continue; - }; - processes.push(GatewayProcess { - pid, - ppid, - pgid, - state, - starttime, - }); - } - Ok(processes) -} - -#[cfg(target_os = "linux")] fn cmdline_is_gateway_supervisor(args: &[String], current_exe: &Path) -> bool { args.first().map(Path::new) == Some(current_exe) && args.get(1).map(String::as_str) == Some("gateway") @@ -719,7 +530,6 @@ fn cmdline_is_gateway_supervisor(args: &[String], current_exe: &Path) -> bool { } } -#[cfg(target_os = "linux")] fn cmdline_has_managed_gateway_config(args: &[String]) -> bool { args.windows(2).any(|pair| { matches!(pair[0].as_str(), "-config" | "-args_file") @@ -727,7 +537,6 @@ fn cmdline_has_managed_gateway_config(args: &[String]) -> bool { }) } -#[cfg(target_os = "linux")] fn cmdline_has_gateway_node( args: &[String], node_names: &std::collections::HashSet, @@ -736,91 +545,6 @@ fn cmdline_has_gateway_node( .any(|pair| pair[0] == "-name" && node_names.contains(&pair[1])) } -#[cfg(target_os = "linux")] -fn proc_cmdline(pid: i32) -> Vec { - fs::read(format!("/proc/{pid}/cmdline")) - .unwrap_or_default() - .split(|byte| *byte == 0) - .filter(|arg| !arg.is_empty()) - .map(|arg| String::from_utf8_lossy(arg).into_owned()) - .collect() -} - -#[cfg(target_os = "linux")] -fn parse_proc_stat_state_and_pgid(stat: &str) -> Option<(char, i32)> { - let (_, after_comm) = stat.rsplit_once(')')?; - let mut fields = after_comm.split_ascii_whitespace(); - let state = fields.next()?.chars().next()?; - let _ppid: i32 = fields.next()?.parse().ok()?; - let pgid: i32 = fields.next()?.parse().ok()?; - Some((state, pgid)) -} - -#[cfg(target_os = "linux")] -fn parse_proc_stat_process(stat: &str) -> Option<(char, i32, i32, u64)> { - let (_, after_comm) = stat.rsplit_once(')')?; - let mut fields = after_comm.split_ascii_whitespace(); - let state = fields.next()?.chars().next()?; - let ppid = fields.next()?.parse().ok()?; - let pgid = fields.next()?.parse().ok()?; - Some((state, ppid, pgid, parse_proc_stat_starttime(stat)?)) -} - -#[cfg(target_os = "linux")] -fn parse_proc_stat_starttime(stat: &str) -> Option { - let (_, after_comm) = stat.rsplit_once(')')?; - after_comm.split_ascii_whitespace().nth(19)?.parse().ok() -} - -#[cfg(target_os = "linux")] -fn proc_stat_state_is_dead(state: char) -> bool { - state == 'Z' || state == 'X' || state == 'x' -} - -#[cfg(target_os = "linux")] -fn surviving_gateway_group_pids(leaders: &[GatewayLeader]) -> Result> { - Ok(leaders - .iter() - .filter(|leader| gateway_group_has_owned_member(leader)) - .map(|leader| leader.pid) - .collect()) -} - -#[cfg(target_os = "linux")] -fn gateway_group_has_owned_member(leader: &GatewayLeader) -> bool { - leader.members.iter().any(|member| { - let Ok(stat) = fs::read_to_string(format!("/proc/{}/stat", member.pid)) else { - return false; - }; - let Some((state, pgid)) = parse_proc_stat_state_and_pgid(&stat) else { - return false; - }; - !proc_stat_state_is_dead(state) - && pgid == leader.pid - && parse_proc_stat_starttime(&stat) == Some(member.starttime) - }) -} - -#[cfg(target_os = "linux")] -fn signal_process_groups(leaders: &[GatewayLeader], signal: i32) -> Vec { - assert!(signal == libc::SIGTERM || signal == libc::SIGKILL); - let mut failed = Vec::with_capacity(leaders.len()); - for leader in leaders { - assert!(leader.pid > 1); - if !gateway_group_has_owned_member(leader) { - continue; - } - let group_result = unsafe { libc::kill(-leader.pid, signal) }; - if group_result == 0 { - continue; - } - if gateway_group_has_owned_member(leader) { - failed.push(leader.pid); - } - } - failed -} - fn setup_gateway_cluster_config(nodes: &[GatewayNode]) -> Result<()> { let cookie = env::var("FLUXER_ERLANG_COOKIE").unwrap_or_else(|_| GATEWAY_CLUSTER_COOKIE.to_owned()); diff --git a/tools/dev/src/main.rs b/tools/dev/src/main.rs index 3c5c73801..5734d7b03 100644 --- a/tools/dev/src/main.rs +++ b/tools/dev/src/main.rs @@ -36,10 +36,6 @@ enum Command { PostStart, Gateway(GatewayArgs), Build, - Knip, - Lint, - Test, - Typecheck, Proxy(ProxyArgs), Dev(DevArgs), RustServices(RustServicesArgs), @@ -220,10 +216,7 @@ enum TunnelCommand { #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); - if !matches!( - cli.command, - Command::Build | Command::Knip | Command::Lint | Command::Test | Command::Typecheck - ) { + if !matches!(cli.command, Command::Build) { apply_default_env()?; } @@ -239,10 +232,6 @@ async fn main() -> Result<()> { std::process::exit(fluxer_dev::gateway::run_gateway_cluster().await?) } Command::Build => std::process::exit(fluxer_dev::tasks::run_build()?), - Command::Knip => std::process::exit(fluxer_dev::tasks::run_knip()?), - Command::Lint => std::process::exit(fluxer_dev::tasks::run_lint()?), - Command::Test => std::process::exit(fluxer_dev::tasks::run_test()?), - Command::Typecheck => std::process::exit(fluxer_dev::tasks::run_typecheck()?), Command::Proxy(args) => fluxer_dev::proxy::run_proxy(&args.host, args.port).await?, Command::Dev(args) => { if args.cloudflare_tunnel { diff --git a/tools/dev/src/media_proxy.rs b/tools/dev/src/media_proxy.rs index baca24e5b..1c9301548 100644 --- a/tools/dev/src/media_proxy.rs +++ b/tools/dev/src/media_proxy.rs @@ -9,7 +9,6 @@ use std::fs::{self, OpenOptions}; use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; -use tokio::time::sleep; const DEV_S3_BUCKETS: &str = "fluxer,fluxer-uploads,fluxer-downloads,fluxer-reports,fluxer-harvests,fluxer-static"; @@ -27,7 +26,7 @@ pub async fn run_dev_media_doctor( ) -> Result<()> { let media_health_url = format!("{}/media/_health", base_url.trim_end_matches('/')); ensure_dev_object_store(repair, 60).await?; - wait_for_success(&media_health_url, 30).await?; + crate::proc::wait_http_success("Media proxy", &media_health_url, 30).await?; println!("Media proxy health: ok ({media_health_url})"); if let Some(path) = media_path { check_dev_media_path(base_url, path).await?; @@ -227,23 +226,6 @@ async fn request_status(url: &str) -> Result { Ok(client.get(url).send().await?.status().as_u16()) } -async fn wait_for_success(url: &str, timeout_secs: u64) -> Result<()> { - let deadline = Instant::now() + Duration::from_secs(timeout_secs); - let mut last_error = None; - while Instant::now() < deadline { - match request_status(url).await { - Ok(status) if (200..300).contains(&status) => return Ok(()), - Ok(status) => last_error = Some(format!("HTTP {status}")), - Err(error) => last_error = Some(error.to_string()), - } - sleep(Duration::from_millis(250)).await; - } - bail!( - "Timed out waiting for {url}: {}", - last_error.unwrap_or_else(|| "unknown error".to_owned()) - ); -} - fn tcp_reachable(host: &str, port: u16) -> bool { let address = format!("{host}:{port}"); TcpStream::connect_timeout( diff --git a/tools/dev/src/proc.rs b/tools/dev/src/proc.rs index 36df89424..6c375933f 100644 --- a/tools/dev/src/proc.rs +++ b/tools/dev/src/proc.rs @@ -3,10 +3,14 @@ use crate::env::merge_default_env_with_current; use crate::paths::{DEV_ENV_FILE, DEV_LOCAL_ENV_FILE, ROOT, ROOT_LOCAL_ENV_FILE}; use anyhow::{Context, Result, bail}; +#[cfg(target_os = "linux")] +use std::collections::BTreeSet; use std::collections::{BTreeMap, VecDeque}; +#[cfg(target_os = "linux")] +use std::fs; use std::future::Future; use std::io::{BufRead, BufReader, Read}; -use std::net::{TcpStream, ToSocketAddrs}; +use std::net::{SocketAddr, TcpListener, TcpStream, ToSocketAddrs}; use std::path::Path; use std::process::{Child, Command, Output, Stdio}; use std::time::{Duration, Instant}; @@ -450,3 +454,278 @@ impl ShutdownSignal { "signal" } } + +pub fn can_bind_tcp_port(port: u16) -> bool { + TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port))).is_ok() +} + +#[cfg(target_os = "linux")] +#[derive(Clone, Debug, Eq, PartialEq)] +struct ProcessGroupLeader { + pid: i32, + members: Vec, +} + +#[cfg(target_os = "linux")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ProcessIdentity { + pid: i32, + starttime: u64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ProcProcess { + pub pid: i32, + pub ppid: i32, + pub pgid: i32, + pub state: char, + pub starttime: u64, +} + +#[cfg(target_os = "linux")] +pub async fn cleanup_orphaned_process_groups( + label: &str, + is_orphan_root: impl Fn(&ProcProcess, &[String]) -> bool, + is_supervisor: impl Fn(&[String], &Path) -> bool, +) -> Result<()> { + let leaders = orphaned_process_leaders(is_orphan_root, is_supervisor)?; + if leaders.is_empty() { + return Ok(()); + } + let pids = leaders.iter().map(|leader| leader.pid).collect::>(); + + println!( + "Stopping orphaned {label} process group(s): {}", + format_pids(&pids) + ); + let term_failed = signal_process_groups(&leaders, libc::SIGTERM); + if !term_failed.is_empty() { + println!( + "SIGTERM delivery failed for orphaned {label} pid(s): {}", + format_pids(&term_failed) + ); + } + + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let remaining = surviving_group_pids(&leaders)?; + if remaining.is_empty() { + return Ok(()); + } + if Instant::now() >= deadline { + let remaining_leaders = leaders + .iter() + .filter(|leader| remaining.contains(&leader.pid)) + .cloned() + .collect::>(); + let kill_failed = signal_process_groups(&remaining_leaders, libc::SIGKILL); + sleep(Duration::from_millis(200)).await; + let survivors = surviving_group_pids(&leaders)?; + if survivors.is_empty() { + return Ok(()); + } + let delivery_failed = kill_failed + .into_iter() + .filter(|pid| survivors.contains(pid)) + .collect::>(); + if delivery_failed.is_empty() { + bail!( + "orphaned {label} process(es) survived SIGKILL: {}", + format_pids(&survivors) + ); + } + bail!( + "orphaned {label} process(es) survived SIGKILL: {} (signal delivery failed for: {})", + format_pids(&survivors), + format_pids(&delivery_failed) + ); + } + sleep(Duration::from_millis(200)).await; + } +} + +#[cfg(not(target_os = "linux"))] +pub async fn cleanup_orphaned_process_groups( + _label: &str, + _is_orphan_root: impl Fn(&ProcProcess, &[String]) -> bool, + _is_supervisor: impl Fn(&[String], &Path) -> bool, +) -> Result<()> { + Ok(()) +} + +#[cfg(target_os = "linux")] +fn orphaned_process_leaders( + is_orphan_root: impl Fn(&ProcProcess, &[String]) -> bool, + is_supervisor: impl Fn(&[String], &Path) -> bool, +) -> Result> { + let current_exe = std::env::current_exe().context("failed to resolve fluxer-dev executable")?; + let processes = process_snapshot()?; + let mut owned_pids = BTreeSet::new(); + for process in &processes { + if process.ppid != 1 || proc_stat_state_is_dead(process.state) { + continue; + } + let args = proc_cmdline(process.pid); + if is_supervisor(&args, ¤t_exe) || is_orphan_root(process, &args) { + owned_pids.insert(process.pid); + } + } + loop { + let previous_count = owned_pids.len(); + for process in &processes { + if owned_pids.contains(&process.ppid) { + owned_pids.insert(process.pid); + } + } + if owned_pids.len() == previous_count { + break; + } + } + let group_pids = processes + .iter() + .filter(|process| owned_pids.contains(&process.pid) && process.pgid > 1) + .map(|process| process.pgid) + .collect::>(); + let leaders = group_pids + .into_iter() + .map(|pid| ProcessGroupLeader { + pid, + members: processes + .iter() + .filter(|process| owned_pids.contains(&process.pid) && process.pgid == pid) + .map(|process| ProcessIdentity { + pid: process.pid, + starttime: process.starttime, + }) + .collect(), + }) + .collect::>(); + Ok(leaders) +} + +#[cfg(target_os = "linux")] +fn format_pids(pids: &[i32]) -> String { + if pids.is_empty() { + return "none".to_owned(); + } + pids.iter() + .map(i32::to_string) + .collect::>() + .join(", ") +} + +#[cfg(target_os = "linux")] +fn process_snapshot() -> Result> { + let mut processes = Vec::new(); + for entry in fs::read_dir("/proc").context("failed to read /proc")? { + let Ok(entry) = entry else { + continue; + }; + let Some(pid) = entry + .file_name() + .to_str() + .and_then(|name| name.parse::().ok()) + else { + continue; + }; + let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) else { + continue; + }; + let Some((state, ppid, pgid, starttime)) = parse_proc_stat_process(&stat) else { + continue; + }; + processes.push(ProcProcess { + pid, + ppid, + pgid, + state, + starttime, + }); + } + Ok(processes) +} + +#[cfg(target_os = "linux")] +fn proc_cmdline(pid: i32) -> Vec { + fs::read(format!("/proc/{pid}/cmdline")) + .unwrap_or_default() + .split(|byte| *byte == 0) + .filter(|arg| !arg.is_empty()) + .map(|arg| String::from_utf8_lossy(arg).into_owned()) + .collect() +} + +#[cfg(target_os = "linux")] +fn parse_proc_stat_state_and_pgid(stat: &str) -> Option<(char, i32)> { + let (_, after_comm) = stat.rsplit_once(')')?; + let mut fields = after_comm.split_ascii_whitespace(); + let state = fields.next()?.chars().next()?; + let _ppid: i32 = fields.next()?.parse().ok()?; + let pgid: i32 = fields.next()?.parse().ok()?; + Some((state, pgid)) +} + +#[cfg(target_os = "linux")] +fn parse_proc_stat_process(stat: &str) -> Option<(char, i32, i32, u64)> { + let (_, after_comm) = stat.rsplit_once(')')?; + let mut fields = after_comm.split_ascii_whitespace(); + let state = fields.next()?.chars().next()?; + let ppid = fields.next()?.parse().ok()?; + let pgid = fields.next()?.parse().ok()?; + Some((state, ppid, pgid, parse_proc_stat_starttime(stat)?)) +} + +#[cfg(target_os = "linux")] +fn parse_proc_stat_starttime(stat: &str) -> Option { + let (_, after_comm) = stat.rsplit_once(')')?; + after_comm.split_ascii_whitespace().nth(19)?.parse().ok() +} + +#[cfg(target_os = "linux")] +fn proc_stat_state_is_dead(state: char) -> bool { + state == 'Z' || state == 'X' || state == 'x' +} + +#[cfg(target_os = "linux")] +fn surviving_group_pids(leaders: &[ProcessGroupLeader]) -> Result> { + Ok(leaders + .iter() + .filter(|leader| group_has_owned_member(leader)) + .map(|leader| leader.pid) + .collect()) +} + +#[cfg(target_os = "linux")] +fn group_has_owned_member(leader: &ProcessGroupLeader) -> bool { + leader.members.iter().any(|member| { + let Ok(stat) = fs::read_to_string(format!("/proc/{}/stat", member.pid)) else { + return false; + }; + let Some((state, pgid)) = parse_proc_stat_state_and_pgid(&stat) else { + return false; + }; + !proc_stat_state_is_dead(state) + && pgid == leader.pid + && parse_proc_stat_starttime(&stat) == Some(member.starttime) + }) +} + +#[cfg(target_os = "linux")] +fn signal_process_groups(leaders: &[ProcessGroupLeader], signal: i32) -> Vec { + assert!(signal == libc::SIGTERM || signal == libc::SIGKILL); + let mut failed = Vec::with_capacity(leaders.len()); + for leader in leaders { + assert!(leader.pid > 1); + if !group_has_owned_member(leader) { + continue; + } + let group_result = unsafe { libc::kill(-leader.pid, signal) }; + if group_result == 0 { + continue; + } + if group_has_owned_member(leader) { + failed.push(leader.pid); + } + } + failed +} diff --git a/tools/dev/src/rust_services.rs b/tools/dev/src/rust_services.rs index c58883464..b394e692c 100644 --- a/tools/dev/src/rust_services.rs +++ b/tools/dev/src/rust_services.rs @@ -4,16 +4,14 @@ use crate::manifest::{DEV_PROXY_PORT, MEDIA_PROXY_PORT, RustServiceSpec, rust_se use crate::paths::{ROOT, TARGET_DIR}; use crate::proc::{ AwaitOutcome, RESTART_LIMIT, RESTART_WINDOW, RunOptions, ShutdownSignal, await_or_shutdown, - configure_process_group, format_command, merged_env, pending_shutdown, prefix_output, - restart_budget_exceeded, run_command_interruptible, + can_bind_tcp_port, cleanup_orphaned_process_groups, configure_process_group, format_command, + merged_env, pending_shutdown, prefix_output, restart_budget_exceeded, + run_command_interruptible, }; use anyhow::{Result, bail}; use std::collections::{BTreeSet, VecDeque}; use std::env; -#[cfg(target_os = "linux")] use std::fs; -use std::net::{SocketAddr, TcpListener}; -#[cfg(target_os = "linux")] use std::path::Path; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; @@ -348,188 +346,26 @@ fn wait_for_service_ports_available(services: &[RustServiceSpec]) -> Result<()> Ok(()) } -fn can_bind_tcp_port(port: u16) -> bool { - TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port))).is_ok() -} - -#[cfg(target_os = "linux")] async fn cleanup_orphaned_service_processes(services: &[RustServiceSpec]) -> Result<()> { - let leaders = orphaned_service_leaders(services)?; - if leaders.is_empty() { - return Ok(()); - } - let pids = leaders.iter().map(|leader| leader.pid).collect::>(); - - println!( - "Stopping orphaned Rust service process group(s): {}", - format_pids(&pids) - ); - let term_failed = signal_process_groups(&leaders, libc::SIGTERM); - if !term_failed.is_empty() { - println!( - "SIGTERM delivery failed for orphaned Rust service pid(s): {}", - format_pids(&term_failed) - ); - } - - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let remaining = surviving_service_group_pids(&leaders)?; - if remaining.is_empty() { - return Ok(()); - } - if Instant::now() >= deadline { - let remaining_leaders = leaders - .iter() - .filter(|leader| remaining.contains(&leader.pid)) - .cloned() - .collect::>(); - let kill_failed = signal_process_groups(&remaining_leaders, libc::SIGKILL); - sleep(Duration::from_millis(200)).await; - let survivors = surviving_service_group_pids(&leaders)?; - if survivors.is_empty() { - return Ok(()); - } - let delivery_failed = kill_failed - .into_iter() - .filter(|pid| survivors.contains(pid)) - .collect::>(); - if delivery_failed.is_empty() { - bail!( - "orphaned Rust service process(es) survived SIGKILL: {}", - format_pids(&survivors) - ); - } - bail!( - "orphaned Rust service process(es) survived SIGKILL: {} (signal delivery failed for: {})", - format_pids(&survivors), - format_pids(&delivery_failed) - ); - } - sleep(Duration::from_millis(200)).await; - } + cleanup_orphaned_process_groups( + "Rust service", + |process, _args| { + proc_has_managed_service_environment(process.pid, services) + && fs::read_link(format!("/proc/{}/cwd", process.pid)) + .ok() + .as_deref() + == Some(ROOT.as_path()) + }, + cmdline_is_service_supervisor, + ) + .await } -#[cfg(not(target_os = "linux"))] -async fn cleanup_orphaned_service_processes(_services: &[RustServiceSpec]) -> Result<()> { - Ok(()) -} - -#[cfg(target_os = "linux")] -#[derive(Clone, Debug, Eq, PartialEq)] -struct RustServiceLeader { - pid: i32, - members: Vec, -} - -#[cfg(target_os = "linux")] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct RustServiceProcessIdentity { - pid: i32, - starttime: u64, -} - -#[cfg(target_os = "linux")] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct RustServiceProcess { - pid: i32, - ppid: i32, - pgid: i32, - state: char, - starttime: u64, -} - -#[cfg(target_os = "linux")] -fn orphaned_service_leaders(services: &[RustServiceSpec]) -> Result> { - let current_exe = std::env::current_exe()?; - let processes = service_process_snapshot()?; - let mut owned_pids = BTreeSet::new(); - for process in &processes { - if process.ppid != 1 || proc_stat_state_is_dead(process.state) { - continue; - } - let args = proc_cmdline(process.pid); - let orphaned_supervisor = cmdline_is_service_supervisor(&args, ¤t_exe); - let orphaned_service = proc_has_managed_service_environment(process.pid, services) - && fs::read_link(format!("/proc/{}/cwd", process.pid)) - .ok() - .as_deref() - == Some(ROOT.as_path()); - if orphaned_supervisor || orphaned_service { - owned_pids.insert(process.pid); - } - } - loop { - let previous_count = owned_pids.len(); - for process in &processes { - if owned_pids.contains(&process.ppid) { - owned_pids.insert(process.pid); - } - } - if owned_pids.len() == previous_count { - break; - } - } - let group_pids = processes - .iter() - .filter(|process| owned_pids.contains(&process.pid) && process.pgid > 1) - .map(|process| process.pgid) - .collect::>(); - let leaders = group_pids - .into_iter() - .map(|pid| RustServiceLeader { - pid, - members: processes - .iter() - .filter(|process| owned_pids.contains(&process.pid) && process.pgid == pid) - .map(|process| RustServiceProcessIdentity { - pid: process.pid, - starttime: process.starttime, - }) - .collect(), - }) - .collect::>(); - Ok(leaders) -} - -#[cfg(target_os = "linux")] -fn service_process_snapshot() -> Result> { - let mut processes = Vec::new(); - for entry in fs::read_dir("/proc")? { - let Ok(entry) = entry else { - continue; - }; - let Some(pid) = entry - .file_name() - .to_str() - .and_then(|name| name.parse::().ok()) - else { - continue; - }; - let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) else { - continue; - }; - let Some((state, ppid, pgid, starttime)) = parse_proc_stat_process(&stat) else { - continue; - }; - processes.push(RustServiceProcess { - pid, - ppid, - pgid, - state, - starttime, - }); - } - Ok(processes) -} - -#[cfg(target_os = "linux")] fn cmdline_is_service_supervisor(args: &[String], current_exe: &Path) -> bool { args.first().map(Path::new) == Some(current_exe) && args.get(1).map(String::as_str) == Some("rust-services") } -#[cfg(target_os = "linux")] fn proc_has_managed_service_environment(pid: i32, services: &[RustServiceSpec]) -> bool { let environment_bytes = fs::read(format!("/proc/{pid}/environ")).unwrap_or_default(); let environment = environment_bytes @@ -570,99 +406,3 @@ fn proc_has_managed_service_environment(pid: i32, services: &[RustServiceSpec]) } }) } - -#[cfg(target_os = "linux")] -fn format_pids(pids: &[i32]) -> String { - if pids.is_empty() { - return "none".to_owned(); - } - pids.iter() - .map(i32::to_string) - .collect::>() - .join(", ") -} - -#[cfg(target_os = "linux")] -fn proc_cmdline(pid: i32) -> Vec { - fs::read(format!("/proc/{pid}/cmdline")) - .unwrap_or_default() - .split(|byte| *byte == 0) - .filter(|arg| !arg.is_empty()) - .map(|arg| String::from_utf8_lossy(arg).into_owned()) - .collect() -} - -#[cfg(target_os = "linux")] -fn parse_proc_stat_state_and_pgid(stat: &str) -> Option<(char, i32)> { - let (_, after_comm) = stat.rsplit_once(')')?; - let mut fields = after_comm.split_ascii_whitespace(); - let state = fields.next()?.chars().next()?; - let _ppid: i32 = fields.next()?.parse().ok()?; - let pgid: i32 = fields.next()?.parse().ok()?; - Some((state, pgid)) -} - -#[cfg(target_os = "linux")] -fn parse_proc_stat_process(stat: &str) -> Option<(char, i32, i32, u64)> { - let (_, after_comm) = stat.rsplit_once(')')?; - let mut fields = after_comm.split_ascii_whitespace(); - let state = fields.next()?.chars().next()?; - let ppid = fields.next()?.parse().ok()?; - let pgid = fields.next()?.parse().ok()?; - Some((state, ppid, pgid, parse_proc_stat_starttime(stat)?)) -} - -#[cfg(target_os = "linux")] -fn parse_proc_stat_starttime(stat: &str) -> Option { - let (_, after_comm) = stat.rsplit_once(')')?; - after_comm.split_ascii_whitespace().nth(19)?.parse().ok() -} - -#[cfg(target_os = "linux")] -fn proc_stat_state_is_dead(state: char) -> bool { - state == 'Z' || state == 'X' || state == 'x' -} - -#[cfg(target_os = "linux")] -fn surviving_service_group_pids(leaders: &[RustServiceLeader]) -> Result> { - Ok(leaders - .iter() - .filter(|leader| service_group_has_owned_member(leader)) - .map(|leader| leader.pid) - .collect()) -} - -#[cfg(target_os = "linux")] -fn service_group_has_owned_member(leader: &RustServiceLeader) -> bool { - leader.members.iter().any(|member| { - let Ok(stat) = fs::read_to_string(format!("/proc/{}/stat", member.pid)) else { - return false; - }; - let Some((state, pgid)) = parse_proc_stat_state_and_pgid(&stat) else { - return false; - }; - !proc_stat_state_is_dead(state) - && pgid == leader.pid - && parse_proc_stat_starttime(&stat) == Some(member.starttime) - }) -} - -#[cfg(target_os = "linux")] -fn signal_process_groups(leaders: &[RustServiceLeader], signal: i32) -> Vec { - assert!(signal == libc::SIGTERM || signal == libc::SIGKILL); - let mut failed = Vec::with_capacity(leaders.len()); - for leader in leaders { - assert!(leader.pid > 1); - if !service_group_has_owned_member(leader) { - continue; - } - let group_result = unsafe { libc::kill(-leader.pid, signal) }; - if group_result == 0 { - continue; - } - if service_group_has_owned_member(leader) { - failed.push(leader.pid); - } - } - failed -} diff --git a/tools/dev/src/tasks.rs b/tools/dev/src/tasks.rs index 5caf4e8ea..fefdf5914 100644 --- a/tools/dev/src/tasks.rs +++ b/tools/dev/src/tasks.rs @@ -3,8 +3,6 @@ use crate::desktop::build_desktop_shell; use crate::proc::{RunOptions, run_command}; use anyhow::Result; -use std::env; -use std::path::Path; fn task_run(args: &[&str]) -> Result<()> { run_command( @@ -17,104 +15,10 @@ fn task_run(args: &[&str]) -> Result<()> { .map(drop) } -fn test_env() -> Vec<(String, Option)> { - let nats_url = env::var("FLUXER_NATS_URL").unwrap_or_else(|_| default_test_nats_url()); - vec![ - ("FLUXER_NATS_URL".to_owned(), Some(nats_url.clone())), - ( - "FLUXER_NATS_CORE_URL".to_owned(), - Some(env::var("FLUXER_NATS_CORE_URL").unwrap_or_else(|_| nats_url.clone())), - ), - ( - "FLUXER_NATS_JETSTREAM_URL".to_owned(), - Some(env::var("FLUXER_NATS_JETSTREAM_URL").unwrap_or(nats_url)), - ), - ] -} - -fn default_test_nats_url() -> String { - let host = if Path::new("/.dockerenv").exists() { - "nats" - } else { - "127.0.0.1" - }; - format!("nats://{host}:4222") -} - -fn run_generators(for_typecheck: bool) -> Result<()> { - task_run(&["pnpm", "--filter", "@fluxer/schema", "generate"])?; - if for_typecheck { - return task_run(&["pnpm", "--filter", "@fluxer/i18n", "generate:types"]); - } - task_run(&["pnpm", "--filter", "fluxer_app", "i18n:compile"]) -} - -pub fn run_typecheck() -> Result { - run_generators(true)?; - task_run(&["pnpm", "-r", "--if-present", "typecheck"])?; - Ok(0) -} - -pub fn run_test() -> Result { - run_generators(false)?; - let mut args = vec!["pnpm".to_owned(), "-r".to_owned()]; - if let Ok(concurrency) = env::var("PNPM_TEST_WORKSPACE_CONCURRENCY") { - args.push(format!("--workspace-concurrency={concurrency}")); - } - args.extend( - [ - "--filter", - "!fluxer_api", - "--filter", - "!fluxer", - "--filter", - "!fluxer_desktop", - "--if-present", - "test", - ] - .into_iter() - .map(str::to_owned), - ); - let env = test_env(); - run_command( - &args.iter().map(String::as_str).collect::>(), - RunOptions { - env: env.clone(), - load_default_env: false, - ..RunOptions::default() - }, - )?; - task_run(&["pnpm", "--filter", "fluxer_desktop", "test"])?; - run_command( - &["pnpm", "--filter", "fluxer_api", "test"], - RunOptions { - env, - load_default_env: false, - ..RunOptions::default() - }, - )?; - Ok(0) -} - pub fn run_build() -> Result { - run_generators(false)?; + task_run(&["pnpm", "--filter", "@fluxer/schema", "generate"])?; + task_run(&["pnpm", "--filter", "fluxer_app", "i18n:compile"])?; build_desktop_shell()?; task_run(&["pnpm", "--filter", "fluxer_app", "build"])?; Ok(0) } - -pub fn run_lint() -> Result { - task_run(&["pnpm", "exec", "biome", "ci"])?; - task_run(&["pnpm", "exec", "eslint", ".", "--max-warnings", "0"])?; - task_run(&["pnpm", "--filter", "fluxer_app", "theming:data-flx:check"])?; - Ok(0) -} - -pub fn run_knip() -> Result { - task_run(&["pnpm", "--filter", "fluxer_app", "wasm:codegen"])?; - task_run(&["pnpm", "--filter", "fluxer_app", "generate:masks"])?; - task_run(&["pnpm", "--filter", "fluxer_desktop", "set-channel"])?; - task_run(&["pnpm", "--filter", "fluxer_app", "i18n:compile"])?; - task_run(&["pnpm", "exec", "knip"])?; - Ok(0) -} diff --git a/tools/fonts/build_fonts.py b/tools/fonts/build_fonts.py index 921f2218e..bcd803bdb 100644 --- a/tools/fonts/build_fonts.py +++ b/tools/fonts/build_fonts.py @@ -215,7 +215,6 @@ SOURCES: dict[str, Source] = { "plex-sans-arabic": NpmSource("@ibm/plex-sans-arabic", "1.1.0"), "plex-sans-devanagari": NpmSource("@ibm/plex-sans-devanagari", "1.1.0"), "plex-sans-hebrew": NpmSource("@ibm/plex-sans-hebrew", "1.1.0"), - "plex-sans-thai": NpmSource("@ibm/plex-sans-thai", "1.1.0"), "plex-sans-thai-looped": NpmSource("@ibm/plex-sans-thai-looped", "1.1.0"), "plex-sans-jp": NpmSource("@ibm/plex-sans-jp", "3.0.0"), "plex-sans-sc": NpmSource("@ibm/plex-sans-sc", "1.1.0"), @@ -309,17 +308,6 @@ FAMILIES: tuple[Family, ...] = ( "Fluxer Sans Hebrew", "script-hebrew.css", ), - Family( - "FluxerSansThai", - "IBMPlexSansThai", - UPRIGHT_WEIGHTS, - "plex-sans-thai", - PLEX_WOFF2, - REBUILD, - PLEX_GASP, - "Fluxer Sans Thai", - "script-thai.css", - ), Family( "FluxerSansThaiLooped", "IBMPlexSansThaiLooped", @@ -418,7 +406,6 @@ SCRIPT_FAMILY_ORDER: tuple[str, ...] = ( GENERIC_UI_FACES: tuple[str, ...] = ("system-ui", "-apple-system", "BlinkMacSystemFont") -UNCHAINED_SCRIPT_FAMILIES: tuple[str, ...] = ("Fluxer Sans Thai",) LOCALE_FALLBACKS: tuple[tuple[tuple[str, ...], str, tuple[str, ...]], ...] = ( ( @@ -1522,8 +1509,6 @@ def verify() -> int: for family in FAMILIES: if family.stylesheet in LATIN_CORE_STYLESHEETS: continue - if family.css_family in UNCHAINED_SCRIPT_FAMILIES: - continue if f"'{family.css_family}'" not in value: failures.append( f"packages/fonts/css/variables.css: base --font-sans does not name " diff --git a/tools/fonts/fonts.lock.json b/tools/fonts/fonts.lock.json index e42583512..94368d1f7 100644 --- a/tools/fonts/fonts.lock.json +++ b/tools/fonts/fonts.lock.json @@ -70,12 +70,6 @@ "version": "1.1.0", "sha256": "24e908ea7d251be77a09c0773e0579dcc7327ff7f758b69df9f08cb0ae9f1054" }, - "plex-sans-thai": { - "type": "npm", - "package": "@ibm/plex-sans-thai", - "version": "1.1.0", - "sha256": "9ddc70a54f7992646989fbc981f0bfeca32b3adceaa9bdf425ece7f0fbfd6565" - }, "plex-sans-thai-looped": { "type": "npm", "package": "@ibm/plex-sans-thai-looped", @@ -1808,253 +1802,6 @@ } } }, - "FluxerSansThai": { - "mode": "rebuild", - "source": "plex-sans-thai", - "srcSubdir": "fonts/complete/woff2", - "cssFamily": "Fluxer Sans Thai", - "stylesheet": "css/script-thai.css", - "latinCore": false, - "gasp": { - "8": 10, - "65535": 15 - }, - "codepoints": 451, - "faces": { - "Thin": { - "sourceFile": "IBMPlexSansThai-Thin.woff2", - "sourceSha256": "9bbe3c3f11812e6544f435403ae0c21a9c092454f37cba36b94b56f2b1502c27", - "hinting": { - "fpgm": 371, - "prep": 610, - "cvt ": 74 - }, - "metrics": { - "head.unitsPerEm": 1000, - "head.macStyle": 0, - "hhea.ascender": 1116, - "hhea.descender": -534, - "hhea.lineGap": 0, - "hhea.caretSlopeRise": 1, - "hhea.caretSlopeRun": 0, - "OS/2.sTypoAscender": 780, - "OS/2.sTypoDescender": -220, - "OS/2.sTypoLineGap": 650, - "OS/2.usWinAscent": 1116, - "OS/2.usWinDescent": 534, - "OS/2.sxHeight": 506, - "OS/2.sCapHeight": 698, - "OS/2.usWeightClass": 100, - "OS/2.fsSelection": 256 - }, - "files": 1, - "codepoints": 451 - }, - "ExtraLight": { - "sourceFile": "IBMPlexSansThai-ExtraLight.woff2", - "sourceSha256": "d553a18e78e826ac9d79580ef67750269d44da355760f1c73d27af430b97cd79", - "hinting": { - "fpgm": 371, - "prep": 610, - "cvt ": 78 - }, - "metrics": { - "head.unitsPerEm": 1000, - "head.macStyle": 0, - "hhea.ascender": 1116, - "hhea.descender": -534, - "hhea.lineGap": 0, - "hhea.caretSlopeRise": 1, - "hhea.caretSlopeRun": 0, - "OS/2.sTypoAscender": 780, - "OS/2.sTypoDescender": -220, - "OS/2.sTypoLineGap": 650, - "OS/2.usWinAscent": 1116, - "OS/2.usWinDescent": 534, - "OS/2.sxHeight": 508, - "OS/2.sCapHeight": 698, - "OS/2.usWeightClass": 200, - "OS/2.fsSelection": 256 - }, - "files": 1, - "codepoints": 451 - }, - "Light": { - "sourceFile": "IBMPlexSansThai-Light.woff2", - "sourceSha256": "0353228819543acef0debab28a41b67bead7a3d0b9e6dca038e3e8fa53c0daa7", - "hinting": { - "fpgm": 371, - "prep": 594, - "cvt ": 78 - }, - "metrics": { - "head.unitsPerEm": 1000, - "head.macStyle": 0, - "hhea.ascender": 1116, - "hhea.descender": -534, - "hhea.lineGap": 0, - "hhea.caretSlopeRise": 1, - "hhea.caretSlopeRun": 0, - "OS/2.sTypoAscender": 780, - "OS/2.sTypoDescender": -220, - "OS/2.sTypoLineGap": 650, - "OS/2.usWinAscent": 1116, - "OS/2.usWinDescent": 534, - "OS/2.sxHeight": 512, - "OS/2.sCapHeight": 698, - "OS/2.usWeightClass": 300, - "OS/2.fsSelection": 256 - }, - "files": 1, - "codepoints": 451 - }, - "Regular": { - "sourceFile": "IBMPlexSansThai-Regular.woff2", - "sourceSha256": "0350508969dad82ffd2b7608d8299454bf19ec54e464a16a66e5a9733e83654d", - "hinting": { - "fpgm": 371, - "prep": 492, - "cvt ": 80 - }, - "metrics": { - "head.unitsPerEm": 1000, - "head.macStyle": 0, - "hhea.ascender": 1116, - "hhea.descender": -534, - "hhea.lineGap": 0, - "hhea.caretSlopeRise": 1, - "hhea.caretSlopeRun": 0, - "OS/2.sTypoAscender": 780, - "OS/2.sTypoDescender": -220, - "OS/2.sTypoLineGap": 650, - "OS/2.usWinAscent": 1116, - "OS/2.usWinDescent": 534, - "OS/2.sxHeight": 516, - "OS/2.sCapHeight": 698, - "OS/2.usWeightClass": 400, - "OS/2.fsSelection": 320 - }, - "files": 1, - "codepoints": 451 - }, - "Text": { - "sourceFile": "IBMPlexSansThai-Text.woff2", - "sourceSha256": "deb6005a9eeddc81faea5849a5d1be85d545c3984621e15aaaf5dfb033f3f55c", - "hinting": { - "fpgm": 371, - "prep": 458, - "cvt ": 74 - }, - "metrics": { - "head.unitsPerEm": 1000, - "head.macStyle": 0, - "hhea.ascender": 1116, - "hhea.descender": -534, - "hhea.lineGap": 0, - "hhea.caretSlopeRise": 1, - "hhea.caretSlopeRun": 0, - "OS/2.sTypoAscender": 780, - "OS/2.sTypoDescender": -220, - "OS/2.sTypoLineGap": 650, - "OS/2.usWinAscent": 1116, - "OS/2.usWinDescent": 534, - "OS/2.sxHeight": 518, - "OS/2.sCapHeight": 698, - "OS/2.usWeightClass": 450, - "OS/2.fsSelection": 256 - }, - "files": 1, - "codepoints": 451 - }, - "Medium": { - "sourceFile": "IBMPlexSansThai-Medium.woff2", - "sourceSha256": "8249537de3768f46ce7b04e9e832c47f3ffa84900bdac2b18968d431d8814c61", - "hinting": { - "fpgm": 371, - "prep": 482, - "cvt ": 74 - }, - "metrics": { - "head.unitsPerEm": 1000, - "head.macStyle": 0, - "hhea.ascender": 1116, - "hhea.descender": -534, - "hhea.lineGap": 0, - "hhea.caretSlopeRise": 1, - "hhea.caretSlopeRun": 0, - "OS/2.sTypoAscender": 780, - "OS/2.sTypoDescender": -220, - "OS/2.sTypoLineGap": 650, - "OS/2.usWinAscent": 1116, - "OS/2.usWinDescent": 534, - "OS/2.sxHeight": 520, - "OS/2.sCapHeight": 698, - "OS/2.usWeightClass": 500, - "OS/2.fsSelection": 256 - }, - "files": 1, - "codepoints": 451 - }, - "SemiBold": { - "sourceFile": "IBMPlexSansThai-SemiBold.woff2", - "sourceSha256": "83f9d86c099e0006077854cac1cf6f9d3177fb0c4f356254a7d56d047c097e52", - "hinting": { - "fpgm": 371, - "prep": 484, - "cvt ": 72 - }, - "metrics": { - "head.unitsPerEm": 1000, - "head.macStyle": 0, - "hhea.ascender": 1116, - "hhea.descender": -534, - "hhea.lineGap": 0, - "hhea.caretSlopeRise": 1, - "hhea.caretSlopeRun": 0, - "OS/2.sTypoAscender": 780, - "OS/2.sTypoDescender": -220, - "OS/2.sTypoLineGap": 650, - "OS/2.usWinAscent": 1116, - "OS/2.usWinDescent": 534, - "OS/2.sxHeight": 522, - "OS/2.sCapHeight": 698, - "OS/2.usWeightClass": 600, - "OS/2.fsSelection": 256 - }, - "files": 1, - "codepoints": 451 - }, - "Bold": { - "sourceFile": "IBMPlexSansThai-Bold.woff2", - "sourceSha256": "c65640f017e19c668aa927ec7e539d4306a8c77fec717ea979b12a1bb1c40121", - "hinting": { - "fpgm": 371, - "prep": 574, - "cvt ": 74 - }, - "metrics": { - "head.unitsPerEm": 1000, - "head.macStyle": 1, - "hhea.ascender": 1116, - "hhea.descender": -534, - "hhea.lineGap": 0, - "hhea.caretSlopeRise": 1, - "hhea.caretSlopeRun": 0, - "OS/2.sTypoAscender": 780, - "OS/2.sTypoDescender": -220, - "OS/2.sTypoLineGap": 650, - "OS/2.usWinAscent": 1116, - "OS/2.usWinDescent": 534, - "OS/2.sxHeight": 525, - "OS/2.sCapHeight": 698, - "OS/2.usWeightClass": 700, - "OS/2.fsSelection": 288 - }, - "files": 1, - "codepoints": 451 - } - } - }, "FluxerSansThaiLooped": { "mode": "rebuild", "source": "plex-sans-thai-looped", @@ -3532,17 +3279,13 @@ "sha256": "bfb106e6cd9474e2f482c3bb0ec3e2a78cacebc38f766f8c1a8f0a2ef42a0038", "bytes": 8662 }, - "css/script-thai.css": { - "sha256": "0b3a1a7176e318aa8d13f28e6154bcdd6930509278c8fa1be4841613c2dee7b9", - "bytes": 8510 - }, "css/variables.css": { "sha256": "8fe0dd78ec093d7a8dd4c55bb320e887f14bf33279b4d30e5106a9d26830f1a3", "bytes": 486 }, "manifest.json": { - "sha256": "da980d123e88665fa0e87c0d5693b527293ee4d6738711fbf4035f9888d09ab5", - "bytes": 1210587 + "sha256": "02c5a7e6ceaad648ae5ccc51b8c28ecb7f892187116c35713a07924808223aa7", + "bytes": 1202350 } }, "files": { @@ -3770,38 +3513,6 @@ "sha256": "afb3c756aaa1fb27aa892b8be4e499768d6c3924304629a1a57e755dcf850c2b", "bytes": 33804 }, - "files/FluxerSansThai/FluxerSansThai-Thin.woff2": { - "sha256": "897e4e26a160691756c7fc3451118b1d193ce7536d08f077c5ece74af0ecc07f", - "bytes": 40916 - }, - "files/FluxerSansThai/FluxerSansThai-ExtraLight.woff2": { - "sha256": "5406635cf4836882b2924a163d643461cf7db3dec9e00e6b9de8855efece66f4", - "bytes": 42184 - }, - "files/FluxerSansThai/FluxerSansThai-Light.woff2": { - "sha256": "5038d59185a52ab56f37fda94cc2583d00b25b3a37844c13f894162edf48d58f", - "bytes": 41652 - }, - "files/FluxerSansThai/FluxerSansThai-Regular.woff2": { - "sha256": "6f31db3a1dd40bd884711603409d6c3292a0e646a5d2c0548fbae434412764c5", - "bytes": 39840 - }, - "files/FluxerSansThai/FluxerSansThai-Text.woff2": { - "sha256": "9c3b734c0a6c713fa3d8d643da38fc9908e88ccf6eeb9ee552420ddd19b99be5", - "bytes": 41008 - }, - "files/FluxerSansThai/FluxerSansThai-Medium.woff2": { - "sha256": "6bf065f633743675b1563097243f926ee84bfa91173c6971d59b85960aaede6d", - "bytes": 41068 - }, - "files/FluxerSansThai/FluxerSansThai-SemiBold.woff2": { - "sha256": "a7daaf5d6fb58b12e081d0cdb30115d3db0cb4e2a7a68998a7fac83f3644f7d8", - "bytes": 41416 - }, - "files/FluxerSansThai/FluxerSansThai-Bold.woff2": { - "sha256": "a4f18fa77a2dea9e52c0f63ee801ea5a6545596bc15e84908f938e6190f9c6b9", - "bytes": 40300 - }, "files/FluxerSansThaiLooped/FluxerSansThaiLooped-Thin.woff2": { "sha256": "5b3ed57fd06bb438dd9a5a2b42a898a3e4f966455737d25e136058f052402d54", "bytes": 43328 diff --git a/tools/i18n_auto/src/auto.rs b/tools/i18n_auto/src/auto.rs index 344d57fef..ceb6f0d85 100644 --- a/tools/i18n_auto/src/auto.rs +++ b/tools/i18n_auto/src/auto.rs @@ -6,9 +6,8 @@ use std::path::PathBuf; use anyhow::Result; use crate::config::{ - DEFAULT_OPENROUTER_APP_TITLE, DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OPENROUTER_FALLBACK_MODELS, - DEFAULT_OPENROUTER_HTTP_REFERER, DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_PROVIDER_SORT, - EnvOverlay, env_value, trim_trailing_slash, + DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OPENROUTER_MODEL, EmptyFallback, EnvOverlay, env_value, + trim_trailing_slash, }; use crate::openrouter::is_openrouter_available; use crate::runner::translate_main; @@ -72,55 +71,7 @@ pub fn auto_main(args: &[String]) -> Result { ); } - let mut runner_env = env_overrides; - runner_env.insert( - "FLUXER_AUTO_I18N".to_string(), - if should_run { - "1".to_string() - } else { - fluxer_auto_i18n - }, - ); - runner_env.insert("I18N_LLM_MODEL".to_string(), i18n_llm_model.clone()); - runner_env.insert( - "OPENROUTER_MODEL".to_string(), - env_value("OPENROUTER_MODEL", &runner_env, DEFAULT_OPENROUTER_MODEL), - ); - runner_env.insert("OPENROUTER_BASE_URL".to_string(), openrouter_base_url); - runner_env.insert("OPENROUTER_API_KEY".to_string(), openrouter_api_key); - runner_env.insert( - "OPENROUTER_FALLBACK_MODELS".to_string(), - env_value( - "OPENROUTER_FALLBACK_MODELS", - &runner_env, - DEFAULT_OPENROUTER_FALLBACK_MODELS, - ), - ); - runner_env.insert( - "OPENROUTER_PROVIDER_SORT".to_string(), - env_value( - "OPENROUTER_PROVIDER_SORT", - &runner_env, - DEFAULT_OPENROUTER_PROVIDER_SORT, - ), - ); - runner_env.insert( - "OPENROUTER_HTTP_REFERER".to_string(), - env_value( - "OPENROUTER_HTTP_REFERER", - &runner_env, - DEFAULT_OPENROUTER_HTTP_REFERER, - ), - ); - runner_env.insert( - "OPENROUTER_APP_TITLE".to_string(), - env_value( - "OPENROUTER_APP_TITLE", - &runner_env, - DEFAULT_OPENROUTER_APP_TITLE, - ), - ); - translate_main(args, &runner_env) + translate_main(args, &env_overrides) } pub fn load_env_from_files(keys: &[&str]) -> EnvOverlay { @@ -180,13 +131,3 @@ pub fn strip_quotes(value: &str) -> String { trimmed.to_string() } } - -trait EmptyFallback { - fn if_empty(self, fallback: impl FnOnce() -> String) -> String; -} - -impl EmptyFallback for String { - fn if_empty(self, fallback: impl FnOnce() -> String) -> String { - if self.is_empty() { fallback() } else { self } - } -} diff --git a/tools/i18n_auto/src/config.rs b/tools/i18n_auto/src/config.rs index dd3d0a503..5651d787e 100644 --- a/tools/i18n_auto/src/config.rs +++ b/tools/i18n_auto/src/config.rs @@ -65,3 +65,13 @@ pub fn positive_int_env(key: &str, fallback: usize, env_overrides: &EnvOverlay) pub fn trim_trailing_slash(value: &str) -> String { value.trim_end_matches('/').to_string() } + +pub(crate) trait EmptyFallback { + fn if_empty(self, fallback: impl FnOnce() -> String) -> String; +} + +impl EmptyFallback for String { + fn if_empty(self, fallback: impl FnOnce() -> String) -> String { + if self.is_empty() { fallback() } else { self } + } +} diff --git a/tools/i18n_auto/src/po.rs b/tools/i18n_auto/src/po.rs index 1b5015dd8..239ec804f 100644 --- a/tools/i18n_auto/src/po.rs +++ b/tools/i18n_auto/src/po.rs @@ -374,11 +374,4 @@ mod tests { format!("{}\n", content.trim_end()) ); } - - #[test] - fn reset_clears_msgstr() { - let reset = reset_po_translations(sample_po()).unwrap(); - let entries = parse_po(&reset).unwrap(); - assert!(entries.iter().all(|entry| entry.msgstr.is_empty())); - } } diff --git a/tools/i18n_auto/src/runner.rs b/tools/i18n_auto/src/runner.rs index 09fd2a12f..2c6bf3980 100644 --- a/tools/i18n_auto/src/runner.rs +++ b/tools/i18n_auto/src/runner.rs @@ -15,8 +15,8 @@ use crate::config::{ DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OPENROUTER_FALLBACK_MODELS, DEFAULT_OPENROUTER_HTTP_REFERER, DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_PROVIDER_SORT, DEFAULT_PROGRESS_INTERVAL_SECONDS, DEFAULT_REQUEST_TIMEOUT_SECONDS, DEFAULT_STRING_CONCURRENCY, - EnvOverlay, SOURCE_LOCALE, default_app_dir, env_value, locales_dir, positive_float_env, - positive_int_env, trim_trailing_slash, + EmptyFallback, EnvOverlay, SOURCE_LOCALE, default_app_dir, env_value, locales_dir, + positive_float_env, positive_int_env, trim_trailing_slash, }; use crate::json_catalog::{ StaticJsonCatalogConfig, read_static_json_entries, rebuild_static_json_allow_replacing, @@ -227,16 +227,6 @@ impl RuntimeConfig { } } -trait EmptyFallback { - fn if_empty(self, fallback: impl FnOnce() -> String) -> String; -} - -impl EmptyFallback for String { - fn if_empty(self, fallback: impl FnOnce() -> String) -> String { - if self.is_empty() { fallback() } else { self } - } -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct LocaleResult { pub locale: String, diff --git a/tools/i18n_auto/src/tokens.rs b/tools/i18n_auto/src/tokens.rs index 5a5f5c72e..8b73237a4 100644 --- a/tools/i18n_auto/src/tokens.rs +++ b/tools/i18n_auto/src/tokens.rs @@ -341,23 +341,6 @@ fn push_unique(tokens: &mut Vec, token: String) { mod tests { use super::*; - #[test] - fn detects_sources_that_should_stay_unchanged() { - assert!(should_keep_unchanged("", "de")); - assert!(should_keep_unchanged("https://fluxer.app/docs", "de")); - assert!(should_keep_unchanged("{productName}", "de")); - assert!(should_keep_unchanged("{authorName} {description}", "de")); - assert!(should_keep_unchanged( - "{emojiName}: {reactionCountText}, {actionText}", - "de" - )); - assert!(should_keep_unchanged("{start} – {end}", "de")); - assert!(should_keep_unchanged("C++", "de")); - assert!(!should_keep_unchanged("DSA {reportId}", "de")); - assert!(!should_keep_unchanged("Hello", "de")); - assert!(!should_keep_unchanged("{productName}", "en-US")); - } - #[test] fn extracts_masks_and_restores_preserved_tokens() { let entry = Entry::with_msgid("{productName} needs <0>{permission} %@ $1 :wave:"); @@ -400,46 +383,4 @@ mod tests { validate_localization(&Entry::with_msgid("Open {productName}"), "Open").unwrap_err(); assert!(missing_token.to_string().contains("{productName}")); } - - #[test] - fn validates_en_gb_protected_region_terms() { - let entry = Entry::with_msgid("US color settings for the United States"); - validate_locale_specific_localization( - &entry, - "en-GB", - "US colour settings for the United States", - ) - .unwrap(); - let error = validate_locale_specific_localization( - &entry, - "en-GB", - "UK colour settings for the United Kingdom", - ) - .unwrap_err(); - assert!( - error - .to_string() - .contains("protected source term United States") - ); - } - - #[test] - fn normalizes_non_english_parenthesized_capitalization() { - assert_eq!( - normalize_localized_capitalization( - &Entry::with_msgid("(No content)"), - "es-ES", - "(Sin contenido)", - ), - "(sin contenido)" - ); - assert_eq!( - normalize_localized_capitalization( - &Entry::with_msgid("(No content)"), - "en-GB", - "(No content)", - ), - "(No content)" - ); - } } diff --git a/tsconfigs/vitest.base.mts b/tsconfigs/vitest.base.mts index 257efd785..fd3b461c9 100644 --- a/tsconfigs/vitest.base.mts +++ b/tsconfigs/vitest.base.mts @@ -7,10 +7,5 @@ export default { environment: 'node', include: ['**/*.{test,spec}.{ts,tsx}'], exclude: ['node_modules', 'dist'], - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'], - }, }, };