diff --git a/.github/workflows/build-desktop.yaml b/.github/workflows/build-desktop.yaml index 506242635..84c4d11c6 100644 --- a/.github/workflows/build-desktop.yaml +++ b/.github/workflows/build-desktop.yaml @@ -83,12 +83,6 @@ jobs: timeout-minutes: 25 outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} - windows_x64: ${{ steps.set-matrix.outputs.windows_x64 }} - windows_arm64: ${{ steps.set-matrix.outputs.windows_arm64 }} - windows_x64_default: ${{ steps.set-matrix.outputs.windows_x64_default }} - windows_arm64_default: ${{ steps.set-matrix.outputs.windows_arm64_default }} - windows_game_capture_x64: ${{ steps.set-matrix.outputs.windows_game_capture_x64 }} - windows_game_capture_arm64: ${{ steps.set-matrix.outputs.windows_game_capture_arm64 }} steps: - name: Checkout source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 @@ -135,11 +129,9 @@ jobs: DESKTOP_PLATFORM: ${{ matrix.platform }} DESKTOP_ARCH: ${{ matrix.arch }} DESKTOP_VARIANT: ${{ matrix.desktop_variant }} - FLUXER_DESKTOP_BUILD_VARIANT: ${{ matrix.desktop_variant }} PLATFORM: ${{ matrix.platform }} ARCH: ${{ matrix.arch }} ELECTRON_ARCH: ${{ matrix.electron_arch }} - FLUXER_WINDOWS_GAME_CAPTURE_MODULE_ENABLED: ${{ matrix.desktop_variant == 'windows-game-capture' && 'true' || 'false' }} steps: - name: Checkout CI helpers uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 @@ -339,6 +331,83 @@ jobs: cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop --step build_app_windows + - name: Validate Windows signing inputs + if: matrix.platform == 'windows' + env: + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_ARTIFACT_SIGNING_ENDPOINT: ${{ secrets.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} + AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + run: >- + cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop + --step validate_windows_signing_inputs + + - name: Azure login for Artifact Signing + if: matrix.platform == 'windows' + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Write Velopack Trusted Signing metadata + if: matrix.platform == 'windows' + env: + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_ARTIFACT_SIGNING_ENDPOINT: ${{ secrets.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} + AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + run: >- + cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop + --step write_windows_signing_metadata + + - name: Resolve unpacked Windows app directory + id: resolve_unpacked + if: matrix.platform == 'windows' + working-directory: ${{ env.WORKDIR }}/fluxer_desktop + env: + BUILD_CHANNEL: ${{ env.BUILD_CHANNEL }} + run: >- + cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop + --step resolve_windows_unpacked_dir + + - name: Sign unpacked Windows binaries with Artifact Signing + if: matrix.platform == 'windows' + uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 + with: + endpoint: ${{ secrets.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + signing-account-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + files-folder: ${{ steps.resolve_unpacked.outputs.unpacked_dir }} + files-folder-filter: exe,dll,node + files-folder-recurse: true + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + exclude-environment-credential: true + + - name: Verify unpacked Windows signatures + if: matrix.platform == 'windows' + working-directory: ${{ env.WORKDIR }}/fluxer_desktop + env: + BUILD_CHANNEL: ${{ env.BUILD_CHANNEL }} + run: >- + cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop + --step verify_windows_unpacked_signatures + + - name: Create portable ZIP (Windows) + if: matrix.platform == 'windows' + working-directory: ${{ env.WORKDIR }}/fluxer_desktop + env: + BUILD_CHANNEL: ${{ env.BUILD_CHANNEL }} + run: >- + cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop + --step create_portable_zip_windows + - name: Package Windows app with Velopack if: matrix.platform == 'windows' working-directory: ${{ env.WORKDIR }}/fluxer_desktop @@ -370,14 +439,14 @@ jobs: cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop --step build_app_linux - - name: Create portable ZIP (Windows) + - name: Verify signed Windows artifacts if: matrix.platform == 'windows' working-directory: ${{ env.WORKDIR }}/fluxer_desktop env: BUILD_CHANNEL: ${{ env.BUILD_CHANNEL }} run: >- cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop - --step create_portable_zip_windows + --step verify_windows_signed_artifacts - name: Prepare artifacts (Windows) if: runner.os == 'Windows' @@ -409,162 +478,17 @@ jobs: cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop --step generate_checksums_windows - - name: Build desktop source tarball - if: matrix.platform == 'linux' && matrix.arch == 'x64' && needs.meta.outputs.build_channel == 'canary' - run: >- - cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop - --step build_source_tarball - - name: Upload artifacts to S3 handoff run: >- cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop --step upload_handoff - check_signing: - name: Check signing secrets - runs-on: ubuntu-24.04-arm - environment: desktop-releases - timeout-minutes: 5 - outputs: - enabled: ${{ steps.check.outputs.enabled }} - steps: - - name: Checkout source - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - - - name: Set up Rust toolchain (CI helpers) - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 - with: - toolchain: "1.93.0" - - - name: Check for Azure signing secrets - id: check - env: - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - run: >- - cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop - --step check_signing_secrets - - sign_windows: - name: Sign Windows artifacts (${{ matrix.arch }}, ${{ matrix.desktop_variant }}) - if: ${{ needs.check_signing.outputs.enabled == 'true' }} - needs: - - meta - - matrix - - build - - check_signing - runs-on: blacksmith-32vcpu-windows-2025 - environment: desktop-releases - timeout-minutes: 25 - env: - BUILD_CHANNEL: ${{ needs.meta.outputs.build_channel }} - DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }} - S3_ENDPOINT: https://ewr1.vultrobjects.com - S3_BUCKET: fluxer-downloads - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - EXPECT_WINDOWS_X64: ${{ needs.matrix.outputs.windows_x64 }} - EXPECT_WINDOWS_ARM64: ${{ needs.matrix.outputs.windows_arm64 }} - EXPECT_WINDOWS_X64_DEFAULT: ${{ needs.matrix.outputs.windows_x64_default }} - EXPECT_WINDOWS_ARM64_DEFAULT: ${{ needs.matrix.outputs.windows_arm64_default }} - EXPECT_WINDOWS_GAME_CAPTURE_X64: ${{ needs.matrix.outputs.windows_game_capture_x64 }} - EXPECT_WINDOWS_GAME_CAPTURE_ARM64: ${{ needs.matrix.outputs.windows_game_capture_arm64 }} - EXPECT_WINDOWS_ARTIFACTS: ${{ (matrix.desktop_variant == 'default' && matrix.arch == 'x64' && needs.matrix.outputs.windows_x64_default == 'true') || (matrix.desktop_variant == 'default' && matrix.arch == 'arm64' && needs.matrix.outputs.windows_arm64_default == 'true') || (matrix.desktop_variant == 'windows-game-capture' && matrix.arch == 'x64' && needs.matrix.outputs.windows_game_capture_x64 == 'true') || (matrix.desktop_variant == 'windows-game-capture' && matrix.arch == 'arm64' && needs.matrix.outputs.windows_game_capture_arm64 == 'true') }} - DESKTOP_VARIANT: ${{ matrix.desktop_variant }} - strategy: - fail-fast: false - matrix: - include: - - arch: x64 - desktop_variant: default - - arch: arm64 - desktop_variant: default - - arch: x64 - desktop_variant: windows-game-capture - - arch: arm64 - desktop_variant: windows-game-capture - steps: - - name: Checkout CI helpers - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - ref: ${{ needs.meta.outputs.source_sha }} - - - name: Set up Rust toolchain (CI helpers) - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 - with: - toolchain: "1.93.0" - - - name: Download Windows artifacts from S3 handoff - id: download_artifact - if: env.EXPECT_WINDOWS_ARTIFACTS == 'true' - env: - ARCH: ${{ matrix.arch }} - run: >- - cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop - --step download_windows_handoff - - - name: Check whether artifacts exist for this arch - id: check_artifacts - env: - ARCH: ${{ matrix.arch }} - run: >- - cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop - --step check_windows_artifacts - - - name: Azure login for Artifact Signing - if: steps.check_artifacts.outputs.found == 'true' - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 - with: - client-id: ${{ secrets.AZURE_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - - name: Sign Windows executables with Artifact Signing - if: steps.check_artifacts.outputs.found == 'true' - uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 - with: - endpoint: ${{ secrets.AZURE_ARTIFACT_SIGNING_ENDPOINT }} - signing-account-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} - certificate-profile-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} - files-folder: ${{ github.workspace }}\artifacts\windows-${{ matrix.arch }}${{ matrix.desktop_variant == 'windows-game-capture' && '-windows-game-capture' || '' }} - files-folder-filter: exe - files-folder-recurse: true - file-digest: SHA256 - timestamp-rfc3161: http://timestamp.acs.microsoft.com - timestamp-digest: SHA256 - - - name: Verify Authenticode signatures - if: steps.check_artifacts.outputs.found == 'true' - env: - ARCH: ${{ matrix.arch }} - run: >- - cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop - --step verify_authenticode - - - name: Regenerate SHA256 checksums for signed executables - if: steps.check_artifacts.outputs.found == 'true' - env: - ARCH: ${{ matrix.arch }} - run: >- - cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop - --step regenerate_signed_checksums - - - name: Re-upload signed Windows artifacts to S3 handoff - if: steps.check_artifacts.outputs.found == 'true' - env: - DESKTOP_PLATFORM: windows - DESKTOP_ARCH: ${{ matrix.arch }} - DESKTOP_VARIANT: ${{ matrix.desktop_variant }} - run: >- - cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop - --step stage_signed_windows_artifacts - upload: name: Upload to S3 - if: ${{ !failure() && !cancelled() }} + if: ${{ !cancelled() && needs.build.result == 'success' }} needs: - meta - build - - sign_windows runs-on: ubuntu-24.04-arm environment: desktop-releases timeout-minutes: 60 @@ -615,11 +539,6 @@ jobs: cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop --step upload_payload - - name: Verify uploaded source tarball - run: >- - cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop - --step verify_source_tarball - - name: Build summary run: >- cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop @@ -645,14 +564,6 @@ jobs: if-no-files-found: error retention-days: 14 - - name: Notify canary desktop webhook - if: ${{ success() && needs.meta.outputs.channel == 'canary' }} - env: - FLUXER_WEBHOOK_URL: ${{ secrets.FLUXER_WEBHOOK_URL }} - run: >- - cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop - --step notify_webhook - - name: Cleanup S3 handoff if: ${{ success() }} run: >- @@ -661,7 +572,7 @@ jobs: finalise_release: name: Finalise GitHub desktop release - if: ${{ !failure() && !cancelled() && needs.meta.outputs.test_build != 'true' }} + if: ${{ !cancelled() && needs.upload.result == 'success' && needs.meta.outputs.test_build != 'true' }} needs: - meta - upload diff --git a/.github/workflows/deploy-service.yaml b/.github/workflows/deploy-service.yaml index c9339c135..d48bdc89d 100644 --- a/.github/workflows/deploy-service.yaml +++ b/.github/workflows/deploy-service.yaml @@ -75,8 +75,6 @@ on: required: false GHCR_TOKEN: required: false - FLUXER_WEBHOOK_URL: - required: false env: GHCR_OWNER: ${{ github.repository_owner }} @@ -458,37 +456,6 @@ jobs: --dry-run=client -o yaml \ | kubectl apply -f - - - name: notify web app canary deploy - if: ${{ success() && inputs.service == 'app-proxy' && inputs.channel == 'canary' }} - shell: bash - env: - FLUXER_WEBHOOK_URL: ${{ secrets.FLUXER_WEBHOOK_URL }} - IMAGE_TAG: ${{ inputs['image-tag'] }} - BUILD_VERSION: ${{ inputs['build-version'] }} - run: | - set -euo pipefail - - if [[ -z "${FLUXER_WEBHOOK_URL:-}" ]]; then - echo "FLUXER_WEBHOOK_URL is not set; skipping web app canary deploy notification." - exit 0 - fi - - web_app_version="${BUILD_VERSION:-$IMAGE_TAG}" - - markdown_tick=$(printf '\140') - content=$(printf '## Canary Web App Deployed\n\nWeb app version: %s%s%s' \ - "$markdown_tick" "$web_app_version" "$markdown_tick") - if [[ "$IMAGE_TAG" != "$web_app_version" ]]; then - content=$(printf '%s\nContainer image tag: %s%s%s' "$content" "$markdown_tick" "$IMAGE_TAG" "$markdown_tick") - fi - - jq -n --arg content "$content" \ - '{content: $content, allowed_mentions: {parse: []}}' \ - | curl -fsS --retry 3 \ - -H 'Content-Type: application/json' \ - --data-binary @- \ - "$FLUXER_WEBHOOK_URL" - - name: recover stuck release on failure if: failure() || cancelled() shell: bash diff --git a/fluxer_api/src/api/download/DownloadController.ts b/fluxer_api/src/api/download/DownloadController.ts index db7040984..44c8ad6ce 100644 --- a/fluxer_api/src/api/download/DownloadController.ts +++ b/fluxer_api/src/api/download/DownloadController.ts @@ -4,13 +4,7 @@ import {Readable} from 'node:stream'; import { DesktopChecksumRedirectParam, DesktopRedirectParam, - DesktopSourceChecksumResponse, DesktopTestBuildQuery, - DesktopVariantChecksumRedirectParam, - DesktopVariantRedirectParam, - DesktopVariantVersionedChecksumRedirectParam, - DesktopVariantVersionedRedirectParam, - DesktopVariantVersionsParam, DesktopVersionedChecksumRedirectParam, DesktopVersionedRedirectParam, DesktopVersionsParam, @@ -136,239 +130,6 @@ function checksumFileResponse(ctx: Context, checksum: DesktopChecksumFi } export function DownloadController(routes: Hono): void { - routes.on( - ['GET', 'HEAD'], - `${DESKTOP_REDIRECT_PREFIX}/source/latest`, - OpenAPI({ - operationId: 'download_latest_desktop_source', - summary: 'Download latest desktop source tarball', - responseSchema: null, - statusCode: 200, - security: [], - tags: ['Downloads'], - description: 'Streams the latest published Fluxer desktop source tarball from the downloads bucket.', - }), - async (ctx) => { - const downloadService = ctx.get('downloadService'); - const info = await downloadService.getLatestDesktopSourceInfo({baseUrl: Config.endpoints.apiClient}); - if (!info) { - return ctx.text('Not Found', 404); - } - return streamArtifactResponse(ctx, downloadService, info.key, 'public, max-age=300', info.filename); - }, - ); - routes.get( - `${DESKTOP_REDIRECT_PREFIX}/source/latest/sha256`, - OpenAPI({ - operationId: 'get_latest_desktop_source_sha256', - summary: 'Get latest desktop source tarball checksum', - responseSchema: DesktopSourceChecksumResponse, - statusCode: 200, - security: [], - tags: ['Downloads'], - description: 'Returns the SHA-256 checksum and metadata for the latest published Fluxer desktop source tarball.', - }), - async (ctx) => { - const info = await ctx.get('downloadService').getLatestDesktopSourceInfo({baseUrl: Config.endpoints.apiClient}); - if (!info) { - return ctx.text('Not Found', 404); - } - return ctx.json( - { - sha256: info.sha256, - filename: info.filename, - url: info.url, - ...(info.commit ? {commit: info.commit} : {}), - ...(info.desktop_version ? {desktop_version: info.desktop_version} : {}), - ...(info.desktop_version_source ? {desktop_version_source: info.desktop_version_source} : {}), - published_at: info.published_at, - ...(info.size === undefined ? {} : {size: info.size}), - }, - 200, - {'Cache-Control': 'public, max-age=300'}, - ); - }, - ); - routes.get( - `${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/:variant/latest`, - Validator('param', DesktopVariantVersionsParam), - Validator('query', DesktopTestBuildQuery), - OpenAPI({ - operationId: 'get_latest_desktop_variant_version', - summary: 'Get latest desktop variant version', - responseSchema: VersionInfoResponse, - statusCode: 200, - security: [], - tags: ['Downloads'], - description: - 'Returns metadata for the latest desktop version in a build variant, including download URLs and SHA-256 checksums for all available formats.', - }), - async (ctx) => { - const {channel, plat, arch, variant} = ctx.req.valid('param'); - const {test} = ctx.req.valid('query'); - const result = await ctx.get('downloadService').getLatestDesktopVersion({ - channel, - plat, - arch, - variant, - baseUrl: Config.endpoints.apiClient, - test, - }); - if (!result) { - return ctx.text('Not Found', 404); - } - return ctx.json(result, 200, { - 'Cache-Control': 'public, max-age=300', - }); - }, - ); - routes.on( - ['GET', 'HEAD'], - `${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/:variant/latest/:format{[a-z_]+\\.sha256}`, - Validator('param', DesktopVariantChecksumRedirectParam), - Validator('query', DesktopTestBuildQuery), - OpenAPI({ - operationId: 'download_latest_desktop_variant_checksum', - summary: 'Download latest desktop variant checksum', - responseSchema: null, - statusCode: 200, - security: [], - tags: ['Downloads'], - description: - 'Returns a plain text SHA-256 checksum file for the latest available desktop application version in a build variant.', - }), - async (ctx) => { - const {channel, plat, arch, variant, format} = ctx.req.valid('param'); - const {test} = ctx.req.valid('query'); - const checksum = await ctx - .get('downloadService') - .resolveLatestDesktopChecksumFile({channel, plat, arch, variant, format, test}); - if (!checksum) { - return ctx.text('Not Found', 404); - } - return checksumFileResponse(ctx, checksum, 'no-store'); - }, - ); - routes.on( - ['GET', 'HEAD'], - `${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/:variant/latest/:format`, - Validator('param', DesktopVariantRedirectParam), - Validator('query', DesktopTestBuildQuery), - OpenAPI({ - operationId: 'download_latest_desktop_variant', - summary: 'Download latest desktop variant', - responseSchema: null, - statusCode: 200, - security: [], - tags: ['Downloads'], - description: - 'Streams the latest available desktop application version for the specified platform, architecture, and build variant.', - }), - async (ctx) => { - const {channel, plat, arch, variant, format} = ctx.req.valid('param'); - const {test} = ctx.req.valid('query'); - const downloadService = ctx.get('downloadService'); - const key = await downloadService.resolveLatestDesktopKey({channel, plat, arch, variant, format, test}); - if (!key) { - return ctx.text('Not Found', 404); - } - return streamArtifactResponse(ctx, downloadService, key, 'no-store'); - }, - ); - routes.get( - `${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/:variant/versions`, - Validator('param', DesktopVariantVersionsParam), - Validator('query', DesktopVersionsQuery), - OpenAPI({ - operationId: 'list_desktop_variant_versions', - summary: 'List desktop variant versions', - responseSchema: DesktopVersionsResponse, - statusCode: 200, - security: [], - tags: ['Downloads'], - description: - 'Lists available desktop versions with pagination for the specified platform, architecture, and variant.', - }), - async (ctx) => { - const {channel, plat, arch, variant} = ctx.req.valid('param'); - const {limit, before, after, test} = ctx.req.valid('query'); - const {versions, hasMore} = await ctx.get('downloadService').listDesktopVersions({ - channel, - plat, - arch, - variant, - limit, - before, - after, - baseUrl: Config.endpoints.apiClient, - test, - }); - return ctx.json({versions, has_more: hasMore}, 200, { - 'Cache-Control': 'public, max-age=300', - }); - }, - ); - routes.on( - ['GET', 'HEAD'], - `${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/:variant/:version/:format{[a-z_]+\\.sha256}`, - Validator('param', DesktopVariantVersionedChecksumRedirectParam), - Validator('query', DesktopTestBuildQuery), - OpenAPI({ - operationId: 'download_desktop_variant_version_checksum', - summary: 'Download desktop variant version checksum', - responseSchema: null, - statusCode: 200, - security: [], - tags: ['Downloads'], - description: - 'Returns a plain text SHA-256 checksum file for a specific desktop application version in a build variant.', - }), - async (ctx) => { - const {channel, plat, arch, variant, version, format} = ctx.req.valid('param'); - const {test} = ctx.req.valid('query'); - const checksum = await ctx - .get('downloadService') - .resolveVersionedDesktopChecksumFile({channel, plat, arch, variant, version, format, test}); - if (!checksum) { - return ctx.text('Not Found', 404); - } - return checksumFileResponse(ctx, checksum, 'public, max-age=86400'); - }, - ); - routes.on( - ['GET', 'HEAD'], - `${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/:variant/:version/:format`, - Validator('param', DesktopVariantVersionedRedirectParam), - Validator('query', DesktopTestBuildQuery), - OpenAPI({ - operationId: 'download_desktop_variant_version', - summary: 'Download desktop variant version', - responseSchema: null, - statusCode: 200, - security: [], - tags: ['Downloads'], - description: - 'Streams a specific desktop application version for the given platform, architecture, and build variant.', - }), - async (ctx) => { - const {channel, plat, arch, variant, version, format} = ctx.req.valid('param'); - const {test} = ctx.req.valid('query'); - const downloadService = ctx.get('downloadService'); - const key = await downloadService.resolveVersionedDesktopKey({ - channel, - plat, - arch, - variant, - version, - format, - test, - }); - if (!key) { - return ctx.text('Not Found', 404); - } - return streamArtifactResponse(ctx, downloadService, key, 'public, max-age=86400'); - }, - ); routes.get( `${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/latest`, Validator('param', DesktopVersionsParam), diff --git a/fluxer_api/src/api/download/DownloadService.ts b/fluxer_api/src/api/download/DownloadService.ts index 3550a2924..5fe875d6c 100644 --- a/fluxer_api/src/api/download/DownloadService.ts +++ b/fluxer_api/src/api/download/DownloadService.ts @@ -8,7 +8,6 @@ import type { DesktopChannel, DesktopFormat, DesktopPlatform, - DesktopVariant, } from '@fluxer/schema/src/domains/download/DownloadSchemas'; import {Config} from '../Config'; import type {IStorageService} from '../infrastructure/IStorageService'; @@ -46,7 +45,6 @@ function isUnsatisfiableRangeError(error: unknown): boolean { } const DESKTOP_BUCKET_PREFIX = 'desktop'; const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test'; -const DESKTOP_SOURCE_MANIFEST_KEY = `${DESKTOP_BUCKET_PREFIX}/source/latest.json`; const DEFAULT_API_CLIENT_BASE_URL = 'https://api.fluxer.app'; function desktopBucketPrefix(test?: boolean): string { @@ -57,14 +55,9 @@ function desktopArtifactPrefix(params: { channel: DesktopChannel; plat: DesktopPlatform; arch: DesktopArch; - variant?: DesktopVariant; test?: boolean; }): string | null { - if (params.variant && params.plat !== 'win32') { - return null; - } - const base = `${desktopBucketPrefix(params.test)}/${params.channel}/${params.plat}/${params.arch}`; - return params.variant ? `${base}/${params.variant}` : base; + return `${desktopBucketPrefix(params.test)}/${params.channel}/${params.plat}/${params.arch}`; } type DesktopManifestFileEntry = @@ -77,7 +70,6 @@ type DesktopManifest = { channel: DesktopChannel; platform: DesktopPlatform; arch: DesktopArch; - variant?: DesktopVariant | null; version: string; pub_date: string; minimum_system_version?: string | null; @@ -111,7 +103,6 @@ type VersionFile = { }; type VersionInfo = { version: string; - variant?: DesktopVariant | null; pub_date: string; minimum_system_version?: string | null; files: Record; @@ -121,26 +112,6 @@ export type DesktopChecksumFile = { sha256: string; body: string; }; -type DesktopSourceManifest = { - filename: string; - key: string; - sha256: string; - commit?: string; - desktop_version?: string; - desktop_version_source?: { - channel: DesktopChannel; - platform: DesktopPlatform; - arch: DesktopArch; - key: string; - pub_date: string; - }; - published_at: string; - size?: number; -}; -type DesktopSourceInfo = DesktopSourceManifest & { - url: string; -}; - function isDesktopManifestFileEntry(value: unknown): value is DesktopManifestFileEntry { if (typeof value === 'string') { return true; @@ -154,7 +125,6 @@ function isDesktopManifest(value: unknown): value is DesktopManifest { (value.channel === 'stable' || value.channel === 'canary') && (value.platform === 'win32' || value.platform === 'darwin' || value.platform === 'linux') && (value.arch === 'x64' || value.arch === 'arm64') && - (value.variant === undefined || value.variant === null || value.variant === 'windows-game-capture') && typeof value.version === 'string' && typeof value.pub_date === 'string' && (value.minimum_system_version === undefined || @@ -164,37 +134,11 @@ function isDesktopManifest(value: unknown): value is DesktopManifest { ); } -function isDesktopSourceManifest(value: unknown): value is DesktopSourceManifest { - if (!isJsonRecord(value)) return false; - return ( - typeof value.filename === 'string' && - typeof value.key === 'string' && - typeof value.sha256 === 'string' && - (value.commit === undefined || typeof value.commit === 'string') && - (value.desktop_version === undefined || typeof value.desktop_version === 'string') && - (value.desktop_version_source === undefined || isDesktopVersionSource(value.desktop_version_source)) && - typeof value.published_at === 'string' && - (value.size === undefined || typeof value.size === 'number') - ); -} - -function isDesktopVersionSource(value: unknown): value is DesktopSourceManifest['desktop_version_source'] { - if (!isJsonRecord(value)) return false; - return ( - (value.channel === 'stable' || value.channel === 'canary') && - (value.platform === 'win32' || value.platform === 'darwin' || value.platform === 'linux') && - (value.arch === 'x64' || value.arch === 'arm64') && - typeof value.key === 'string' && - typeof value.pub_date === 'string' - ); -} - interface LatestFilenameLookupParams { channel: DesktopChannel; plat: DesktopPlatform; arch: DesktopArch; format: DesktopFormat; - variant?: DesktopVariant; test?: boolean; } @@ -205,42 +149,11 @@ interface ManifestFilenameResolutionParams extends LatestFilenameLookupParams { export class DownloadService { constructor(private readonly storageService: IStorageService) {} - async getLatestDesktopSourceInfo(params: {baseUrl?: string} = {}): Promise { - const manifest = await this.readDesktopSourceManifest(); - if (!manifest) { - return null; - } - let size = manifest.size; - try { - const metadata = await this.storageService.getObjectMetadata(Config.s3.buckets.downloads, manifest.key); - if (!metadata) { - return null; - } - size = size ?? metadata.contentLength; - } catch (error) { - if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) { - return null; - } - throw error; - } - return { - ...manifest, - ...(size === undefined ? {} : {size}), - url: `${this.buildBaseUrl(params.baseUrl)}${DESKTOP_REDIRECT_PREFIX}/source/latest`, - }; - } - - async resolveLatestDesktopSourceKey(): Promise { - const info = await this.getLatestDesktopSourceInfo(); - return info?.key ?? null; - } - async resolveLatestDesktopKey(params: { channel: DesktopChannel; plat: DesktopPlatform; arch: DesktopArch; format: DesktopFormat; - variant?: DesktopVariant; test?: boolean; }): Promise { const prefix = desktopArtifactPrefix(params); @@ -267,7 +180,6 @@ export class DownloadService { arch: params.arch, format: params.format, filename, - variant: params.variant, test: params.test, }); if (!resolvedFilename) { @@ -278,7 +190,6 @@ export class DownloadService { plat: params.plat, arch: params.arch, filename: resolvedFilename, - variant: params.variant, test: params.test, }); } catch (error) { @@ -293,7 +204,6 @@ export class DownloadService { channel: DesktopChannel; plat: DesktopPlatform; arch: DesktopArch; - variant?: DesktopVariant; baseUrl?: string; test?: boolean; }): Promise { @@ -324,7 +234,6 @@ export class DownloadService { channel: DesktopChannel; plat: DesktopPlatform; arch: DesktopArch; - variant?: DesktopVariant; limit: number; before?: string | null; after?: string | null; @@ -457,7 +366,6 @@ export class DownloadService { channel: params.channel, plat: params.plat, arch: params.arch, - variant: params.variant, version, format, baseUrl: params.baseUrl, @@ -469,7 +377,6 @@ export class DownloadService { channel: params.channel, plat: params.plat, arch: params.arch, - variant: params.variant, version, format, baseUrl: params.baseUrl, @@ -480,7 +387,6 @@ export class DownloadService { } versions.push({ version, - ...(params.variant ? {variant: params.variant} : {}), pub_date: entry.pub_date.toISOString(), files, }); @@ -498,7 +404,6 @@ export class DownloadService { channel: DesktopChannel; plat: DesktopPlatform; arch: DesktopArch; - variant?: DesktopVariant; version: string; format: DesktopFormat; test?: boolean; @@ -544,7 +449,6 @@ export class DownloadService { plat: DesktopPlatform; arch: DesktopArch; format: DesktopFormat; - variant?: DesktopVariant; test?: boolean; }): Promise { const version = await this.getLatestDesktopVersion(params); @@ -564,7 +468,6 @@ export class DownloadService { channel: DesktopChannel; plat: DesktopPlatform; arch: DesktopArch; - variant?: DesktopVariant; version: string; format: DesktopFormat; test?: boolean; @@ -592,11 +495,9 @@ export class DownloadService { return null; } const rewrittenKey = params.test ? this.rewriteToTestBucketKey(key) : key; - const keysToTry = [rewrittenKey]; const normalizedKey = this.normalizePlatformArchKey(rewrittenKey); - if (normalizedKey) { - keysToTry.push(normalizedKey); - } + const candidateKeys = [rewrittenKey, normalizedKey]; + const keysToTry = Array.from(new Set(candidateKeys.filter((candidate): candidate is string => candidate !== null))); for (const candidateKey of keysToTry) { try { const metadata = await this.storageService.getObjectMetadata(Config.s3.buckets.downloads, candidateKey); @@ -669,14 +570,12 @@ export class DownloadService { channel: DesktopChannel; plat: DesktopPlatform; arch: DesktopArch; - variant?: DesktopVariant; version: string; format: DesktopFormat; baseUrl?: string; test?: boolean; }): string { - const variantSegment = params.variant ? `/${params.variant}` : ''; - const url = `${this.buildBaseUrl(params.baseUrl)}${DOWNLOAD_PREFIX}/desktop/${params.channel}/${params.plat}/${params.arch}${variantSegment}/${params.version}/${params.format}`; + const url = `${this.buildBaseUrl(params.baseUrl)}${DOWNLOAD_PREFIX}/desktop/${params.channel}/${params.plat}/${params.arch}/${params.version}/${params.format}`; return params.test ? `${url}?test=1` : url; } @@ -684,14 +583,12 @@ export class DownloadService { channel: DesktopChannel; plat: DesktopPlatform; arch: DesktopArch; - variant?: DesktopVariant; version: string; format: DesktopFormat; baseUrl?: string; test?: boolean; }): string { - const variantSegment = params.variant ? `/${params.variant}` : ''; - const url = `${this.buildBaseUrl(params.baseUrl)}${DOWNLOAD_PREFIX}/desktop/${params.channel}/${params.plat}/${params.arch}${variantSegment}/${params.version}/${params.format}.sha256`; + const url = `${this.buildBaseUrl(params.baseUrl)}${DOWNLOAD_PREFIX}/desktop/${params.channel}/${params.plat}/${params.arch}/${params.version}/${params.format}.sha256`; return params.test ? `${url}?test=1` : url; } @@ -722,65 +619,10 @@ export class DownloadService { return parseJsonUnknown(text); } - private async readDesktopSourceManifest(): Promise { - let manifest: unknown | null; - try { - manifest = await this.readJsonObjectFromStorage(DESKTOP_SOURCE_MANIFEST_KEY); - } catch (error) { - if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) { - return null; - } - throw error; - } - if (!isDesktopSourceManifest(manifest)) { - return null; - } - if ( - !this.isSafeDesktopSourceKey(manifest.key) || - !this.isValidSha256(manifest.sha256) || - (manifest.desktop_version !== undefined && !this.isValidVersion(manifest.desktop_version)) || - (manifest.desktop_version_source !== undefined && - !this.isValidDesktopVersionSource(manifest.desktop_version_source)) || - manifest.filename.trim().length === 0 || - manifest.published_at.trim().length === 0 - ) { - return null; - } - return manifest; - } - - private isSafeDesktopSourceKey(key: string): boolean { - if (!key.startsWith(`${DESKTOP_BUCKET_PREFIX}/source/by-commit/`)) { - return false; - } - const normalized = posix.normalize(key); - if (normalized !== key || normalized.startsWith('..') || normalized.includes('\0')) { - return false; - } - return key.endsWith('.tar.gz'); - } - private isValidSha256(value: string): boolean { return /^[a-f0-9]{64}$/u.test(value); } - private isValidVersion(value: string): boolean { - return /^\d+\.\d+\.\d+$/u.test(value); - } - - private isValidDesktopVersionSource(source: DesktopSourceManifest['desktop_version_source']): boolean { - if (!source) { - return false; - } - return ( - source.channel === 'canary' && - source.platform === 'linux' && - source.arch === 'x64' && - source.key === 'desktop/canary/linux/x64/manifest.json' && - source.pub_date.trim().length > 0 - ); - } - private async resolveManifestFilename(params: ManifestFilenameResolutionParams): Promise { const manifestFilename = params.filename.trim(); if (manifestFilename.length === 0) { @@ -792,7 +634,6 @@ export class DownloadService { channel: params.channel, plat: params.plat, arch: params.arch, - variant: params.variant, filename: manifestFilename, test: params.test, })) @@ -1038,7 +879,6 @@ export class DownloadService { plat: params.plat, arch: params.arch, filename, - variant: params.variant, test: params.test, }); } @@ -1047,7 +887,6 @@ export class DownloadService { channel: DesktopChannel; plat: DesktopPlatform; arch: DesktopArch; - variant?: DesktopVariant; version: string; format: DesktopFormat; test?: boolean; @@ -1079,7 +918,6 @@ export class DownloadService { arch: params.arch, format: params.format, filename, - variant: params.variant, test: params.test, }); if (!resolvedFilename) { @@ -1090,7 +928,6 @@ export class DownloadService { plat: params.plat, arch: params.arch, filename: resolvedFilename, - variant: params.variant, test: params.test, }); } catch (error) { @@ -1106,7 +943,6 @@ export class DownloadService { channel: DesktopChannel; plat: DesktopPlatform; arch: DesktopArch; - variant?: DesktopVariant; baseUrl?: string; test?: boolean; }, @@ -1125,7 +961,6 @@ export class DownloadService { arch: params.arch, format, filename: manifestFilename, - variant: params.variant, test: params.test, }); if (!resolvedFilename) { @@ -1143,7 +978,6 @@ export class DownloadService { arch: params.arch, format, filename: resolvedFilename, - variant: params.variant, test: params.test, }) ) { @@ -1156,7 +990,6 @@ export class DownloadService { entry, manifestFilename, resolvedFilename, - variant: params.variant, test: params.test, }); files[format] = { @@ -1164,7 +997,6 @@ export class DownloadService { channel: params.channel, plat: params.plat, arch: params.arch, - variant: params.variant, version: manifest.version, format, baseUrl: params.baseUrl, @@ -1176,7 +1008,6 @@ export class DownloadService { channel: params.channel, plat: params.plat, arch: params.arch, - variant: params.variant, version: manifest.version, format, baseUrl: params.baseUrl, @@ -1191,7 +1022,6 @@ export class DownloadService { const minimumSystemVersion = manifest.minimum_system_version ?? null; return { version: manifest.version, - ...(params.variant ? {variant: params.variant} : {}), pub_date: manifest.pub_date, ...(minimumSystemVersion ? {minimum_system_version: minimumSystemVersion} : {}), files, @@ -1202,7 +1032,6 @@ export class DownloadService { channel: DesktopChannel; plat: DesktopPlatform; arch: DesktopArch; - variant?: DesktopVariant; baseUrl?: string; test?: boolean; }): Promise { @@ -1210,7 +1039,6 @@ export class DownloadService { channel: params.channel, plat: params.plat, arch: params.arch, - variant: params.variant, limit: 1, baseUrl: params.baseUrl, test: params.test, @@ -1222,7 +1050,6 @@ export class DownloadService { channel: DesktopChannel; plat: DesktopPlatform; arch: DesktopArch; - variant?: DesktopVariant; entry: DesktopManifestFileEntry; manifestFilename: string; resolvedFilename: string; @@ -1238,7 +1065,6 @@ export class DownloadService { channel: params.channel, plat: params.plat, arch: params.arch, - variant: params.variant, filename: params.resolvedFilename, test: params.test, }); @@ -1270,7 +1096,6 @@ export class DownloadService { channel: DesktopChannel; plat: DesktopPlatform; arch: DesktopArch; - variant?: DesktopVariant; filename: string; test?: boolean; }): Promise { @@ -1293,7 +1118,6 @@ export class DownloadService { channel: DesktopChannel; plat: DesktopPlatform; arch: DesktopArch; - variant?: DesktopVariant; filename: string; test?: boolean; }): string | null { diff --git a/fluxer_app/src/features/app/components/dialogs/components/ClientInfo.tsx b/fluxer_app/src/features/app/components/dialogs/components/ClientInfo.tsx index a2e774eb2..00357d3d5 100644 --- a/fluxer_app/src/features/app/components/dialogs/components/ClientInfo.tsx +++ b/fluxer_app/src/features/app/components/dialogs/components/ClientInfo.tsx @@ -6,7 +6,6 @@ import DeveloperMode from '@app/features/devtools/state/DeveloperMode'; import {UNKNOWN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import { formatClientBuildInfo, - formatDesktopBuildVariantLabel, formatReleaseChannelLabel, getClientInfo, getClientInfoSync, @@ -84,12 +83,7 @@ export const ClientInfo = observer(() => { const buildVersion = Config.PUBLIC_BUILD_VERSION || 'dev'; const desktopReleaseChannel = desktopChannel ? formatReleaseChannelLabel(desktopChannel) : null; const primaryDesktopReleaseChannel = desktopReleaseChannel ?? releaseChannel; - const desktopBuildVariant = - clientInfo.desktopBuildVariant && clientInfo.desktopBuildVariant !== 'default' - ? formatDesktopBuildVariantLabel(clientInfo.desktopBuildVariant) - : null; - const desktopVersionLabel = - desktopVersion && desktopBuildVariant ? `${desktopVersion} (${desktopBuildVariant})` : desktopVersion; + const desktopVersionLabel = desktopVersion; const desktopBuildLabel = desktopVersion ? i18n._(DESKTOP_BUILD_DESCRIPTOR, { desktopChannel: primaryDesktopReleaseChannel, diff --git a/fluxer_app/src/features/app/state/Updater.ts b/fluxer_app/src/features/app/state/Updater.ts index 75e800685..146978ba5 100644 --- a/fluxer_app/src/features/app/state/Updater.ts +++ b/fluxer_app/src/features/app/state/Updater.ts @@ -126,7 +126,6 @@ class Updater { currentVersion: string | null = null; channel: string | null = null; private desktopArch: string | null = null; - private desktopBuildVariant: string | null = null; private isNative: boolean; private backgroundCheckStarted = false; private backgroundCheckInterval: number | null = null; @@ -234,10 +233,6 @@ class Updater { return getUpdaterDisplayVersion(this.snapshot); } - get buildVariant(): string | null { - return this.desktopBuildVariant; - } - private transition(event: UpdaterMachineEvent): void { runInAction(() => { this.snapshot = transitionUpdaterMachineSnapshot(this.snapshot, event); @@ -258,7 +253,6 @@ class Updater { runInAction(() => { this.currentVersion = info.desktopVersion ?? null; this.channel = info.desktopChannel ?? null; - this.desktopBuildVariant = info.desktopBuildVariant ?? null; this.desktopArch = info.desktopArch ?? info.arch ?? null; }); } catch (error) { diff --git a/fluxer_app/src/features/platform/types/Electron.ts b/fluxer_app/src/features/platform/types/Electron.ts index 0130f15ba..e06c3c1fb 100644 --- a/fluxer_app/src/features/platform/types/Electron.ts +++ b/fluxer_app/src/features/platform/types/Electron.ts @@ -8,12 +8,9 @@ import type { RegistrationResponseJSON, } from '@simplewebauthn/browser'; -export type DesktopBuildVariant = 'default' | 'windows-game-capture'; - export interface DesktopInfo { version: string; channel: 'stable' | 'canary'; - buildVariant: DesktopBuildVariant; arch: string; hardwareArch: string; runningUnderRosetta: boolean; @@ -308,7 +305,6 @@ export interface AppMetricsSnapshot { export interface ElectronAPI { platform: NodeJS.Platform; - buildVariant: DesktopBuildVariant; getDesktopInfo: () => Promise; getGpuInfo?: () => Promise; getAppMetrics?: () => Promise; diff --git a/fluxer_app/src/features/platform/utils/ClientInfo.ts b/fluxer_app/src/features/platform/utils/ClientInfo.ts index 150887647..2e9488f13 100644 --- a/fluxer_app/src/features/platform/utils/ClientInfo.ts +++ b/fluxer_app/src/features/platform/utils/ClientInfo.ts @@ -19,7 +19,6 @@ export interface ClientInfo { arch?: string; desktopVersion?: string; desktopChannel?: string; - desktopBuildVariant?: string; desktopArch?: string; desktopOS?: string; desktopRunningUnderRosetta?: boolean; @@ -47,13 +46,6 @@ export function formatReleaseChannelLabel(value: string): string { return normalized.charAt(0).toUpperCase() + normalized.slice(1); } -export function formatDesktopBuildVariantLabel(value: string): string { - if (value === 'windows-game-capture') { - return 'Windows Game Capture'; - } - return value; -} - const ARCHITECTURE_PATTERNS: ReadonlyArray<{ pattern: RegExp; label: string; @@ -191,7 +183,6 @@ function getDesktopContextFromInfo(desktopInfo: DesktopInfo): Partial { + if (!isNativeVoiceEngineSelected()) { + await voiceEngineV2AppScreenShareExecutionAdapter.startDeviceScreenShare(this.room, options, publishOptions); + return; + } await voiceEngineV2AppScreenShareExecutionAdapter.startNativeDeviceScreenShare( await this.getNativeDeviceScreenShareCaptureOptions(options), { @@ -4512,6 +4516,13 @@ class MediaEngineFacade extends Store { options?: ScreenShareCaptureOptions, publishOptions?: TrackPublishOptions, ): Promise { + if (!isNativeVoiceEngineSelected()) { + return voiceEngineV2AppScreenShareExecutionAdapter.replaceActiveDisplayScreenShare( + this.room, + options, + publishOptions, + ); + } return voiceEngineV2AppScreenShareExecutionAdapter.replaceActiveNativeDisplayScreenShareFromActiveSource( options, publishOptions, @@ -4522,6 +4533,13 @@ class MediaEngineFacade extends Store { options?: DeviceScreenShareCaptureOptions, publishOptions?: TrackPublishOptions, ): Promise { + if (!isNativeVoiceEngineSelected()) { + return voiceEngineV2AppScreenShareExecutionAdapter.replaceActiveDeviceScreenShare( + this.room, + options, + publishOptions, + ); + } return voiceEngineV2AppScreenShareExecutionAdapter.replaceActiveNativeDeviceScreenShare( await this.getNativeDeviceScreenShareCaptureOptions(options), publishOptions, diff --git a/fluxer_app/src/features/voice/engine/native_voice_engine/NativeVoiceEngineSelection.ts b/fluxer_app/src/features/voice/engine/native_voice_engine/NativeVoiceEngineSelection.ts index 248f58536..407959ce3 100644 --- a/fluxer_app/src/features/voice/engine/native_voice_engine/NativeVoiceEngineSelection.ts +++ b/fluxer_app/src/features/voice/engine/native_voice_engine/NativeVoiceEngineSelection.ts @@ -55,7 +55,10 @@ let nativeSupportPromise: Promise | null = null; let nativePrewarmPromise: Promise | null = null; let nativeCapabilitiesSnapshot: VoiceEngineV2BridgeCapabilities | null = null; +const NATIVE_VOICE_ENGINE_FORCE_DISABLED: boolean = true; + function isNativeVoiceEngineRequired(): boolean { + if (NATIVE_VOICE_ENGINE_FORCE_DISABLED) return false; return isElectronPlatform(); } diff --git a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppNativeBridge.test.ts b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppNativeBridge.test.ts new file mode 100644 index 000000000..85f8a8a09 --- /dev/null +++ b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppNativeBridge.test.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +const nativeVoiceEngineSelected = vi.fn<() => boolean>(); +const electronApi = vi.fn<() => unknown>(); + +vi.mock('@app/features/voice/engine/native_voice_engine/NativeVoiceEngineSelection', () => ({ + isNativeVoiceEngineSelected: () => nativeVoiceEngineSelected(), +})); + +vi.mock('@app/features/ui/utils/NativeUtils', () => ({ + getElectronAPI: () => electronApi(), +})); + +const { + isVoiceEngineV2AppNativeAudioDeviceBridgeAvailable, + isVoiceEngineV2AppNativeScreenShareAudioBridgeAvailable, + isVoiceEngineV2AppNativeScreenShareBridgeAvailable, + isVoiceEngineV2AppNativeScreenShareEncodingUpdateAvailable, +} = await import('./VoiceEngineV2AppNativeBridge'); + +const FULLY_CAPABLE_BRIDGE = { + getCapabilities: async () => ({ + screenShare: true, + screenShareAudio: true, + screenShareEncodingUpdate: true, + }), + listAudioInputDevices: async () => [], + listAudioOutputDevices: async () => [], +}; + +describe('VoiceEngineV2AppNativeBridge availability', () => { + beforeEach(() => { + electronApi.mockReturnValue({voiceEngine: FULLY_CAPABLE_BRIDGE}); + }); + + it('reports native capabilities when the native voice engine is selected', async () => { + nativeVoiceEngineSelected.mockReturnValue(true); + expect(await isVoiceEngineV2AppNativeScreenShareBridgeAvailable()).toBe(true); + expect(await isVoiceEngineV2AppNativeScreenShareEncodingUpdateAvailable()).toBe(true); + expect(await isVoiceEngineV2AppNativeScreenShareAudioBridgeAvailable()).toBe(true); + expect(isVoiceEngineV2AppNativeAudioDeviceBridgeAvailable()).toBe(true); + }); + + it('reports nothing available when the native voice engine is not selected, even on a fully capable bridge', async () => { + nativeVoiceEngineSelected.mockReturnValue(false); + expect(await isVoiceEngineV2AppNativeScreenShareBridgeAvailable()).toBe(false); + expect(await isVoiceEngineV2AppNativeScreenShareEncodingUpdateAvailable()).toBe(false); + expect(await isVoiceEngineV2AppNativeScreenShareAudioBridgeAvailable()).toBe(false); + expect(isVoiceEngineV2AppNativeAudioDeviceBridgeAvailable()).toBe(false); + }); + + it('does not query the bridge at all when the native voice engine is not selected', async () => { + nativeVoiceEngineSelected.mockReturnValue(false); + const getCapabilities = vi.fn(async () => ({screenShare: true})); + electronApi.mockReturnValue({voiceEngine: {...FULLY_CAPABLE_BRIDGE, getCapabilities}}); + await isVoiceEngineV2AppNativeScreenShareBridgeAvailable(); + expect(getCapabilities).not.toHaveBeenCalled(); + }); +}); diff --git a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppNativeBridge.ts b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppNativeBridge.ts index e31e3339b..63a7bb401 100644 --- a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppNativeBridge.ts +++ b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppNativeBridge.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {getElectronAPI} from '@app/features/ui/utils/NativeUtils'; +import {isNativeVoiceEngineSelected} from '@app/features/voice/engine/native_voice_engine/NativeVoiceEngineSelection'; import type {VoiceEngineV2BridgeApi, VoiceEngineV2BridgeCapabilities} from '@fluxer/voice_engine_v2/bridge'; function buildMissingBridgeError(context: string): Error { @@ -26,21 +27,25 @@ export async function getVoiceEngineV2AppNativeBridgeCapabilities(): Promise { + if (!isNativeVoiceEngineSelected()) return false; const capabilities = await getVoiceEngineV2AppNativeBridgeCapabilities(); return capabilities?.screenShare === true; } export async function isVoiceEngineV2AppNativeScreenShareEncodingUpdateAvailable(): Promise { + if (!isNativeVoiceEngineSelected()) return false; const capabilities = await getVoiceEngineV2AppNativeBridgeCapabilities(); return capabilities?.screenShareEncodingUpdate === true; } export async function isVoiceEngineV2AppNativeScreenShareAudioBridgeAvailable(): Promise { + if (!isNativeVoiceEngineSelected()) return false; const capabilities = await getVoiceEngineV2AppNativeBridgeCapabilities(); return capabilities?.screenShareAudio === true; } export function isVoiceEngineV2AppNativeAudioDeviceBridgeAvailable(): boolean { + if (!isNativeVoiceEngineSelected()) return false; const bridge = getVoiceEngineV2AppNativeBridge(); if (!bridge) return false; return typeof bridge.listAudioInputDevices === 'function' && typeof bridge.listAudioOutputDevices === 'function'; diff --git a/fluxer_app/src/features/voice/engine/voice_screen_share_manager/DisplayMediaCapture.ts b/fluxer_app/src/features/voice/engine/voice_screen_share_manager/DisplayMediaCapture.ts index 696a09126..eba312a3b 100644 --- a/fluxer_app/src/features/voice/engine/voice_screen_share_manager/DisplayMediaCapture.ts +++ b/fluxer_app/src/features/voice/engine/voice_screen_share_manager/DisplayMediaCapture.ts @@ -2,6 +2,7 @@ import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions'; import {getElectronAPI} from '@app/features/ui/utils/NativeUtils'; +import {isNativeVoiceEngineSelected} from '@app/features/voice/engine/native_voice_engine/NativeVoiceEngineSelection'; import { markScreenShareCaptureActive, markScreenShareCaptureEnded, @@ -199,6 +200,7 @@ export interface NativeEngineScreenCapture { } export async function isNativeScreenCaptureAvailable(): Promise { + if (!isNativeVoiceEngineSelected()) return false; const electronApi = getElectronAPI(); const api = electronApi?.nativeScreenCapture ?? getNativeScreenCaptureApi(); if (!api) return false; diff --git a/fluxer_app/src/features/voice/state/VoiceSettings.ts b/fluxer_app/src/features/voice/state/VoiceSettings.ts index a695210c5..570f902fd 100644 --- a/fluxer_app/src/features/voice/state/VoiceSettings.ts +++ b/fluxer_app/src/features/voice/state/VoiceSettings.ts @@ -765,6 +765,7 @@ class VoiceSettings { } getBackgroundImageId(): string { + if (!areVoiceBackgroundsAvailable()) return NONE_BACKGROUND_ID; return this.backgroundImageId; } @@ -1119,10 +1120,6 @@ class VoiceSettings { backgroundImages = backgroundImages.slice(0, 3); } } - if (!areVoiceBackgroundsAvailable()) { - backgroundImages = []; - backgroundImageId = NONE_BACKGROUND_ID; - } if (backgroundImageId !== NONE_BACKGROUND_ID && backgroundImageId !== BLUR_BACKGROUND_ID) { const imageExists = backgroundImages.some((img: BackgroundImage) => img.id === backgroundImageId); if (!imageExists) { diff --git a/fluxer_app/src/features/voice/state/VoiceSettingsBackgroundPersistence.test.ts b/fluxer_app/src/features/voice/state/VoiceSettingsBackgroundPersistence.test.ts new file mode 100644 index 000000000..5a342fbd3 --- /dev/null +++ b/fluxer_app/src/features/voice/state/VoiceSettingsBackgroundPersistence.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +const voiceBackgroundsAvailable = vi.fn<() => boolean>(); + +vi.mock('@app/features/voice/utils/VoiceBackgroundAvailability', () => ({ + areVoiceBackgroundsAvailable: () => voiceBackgroundsAvailable(), +})); + +vi.mock('@app/features/app/utils/LimitResolverAdapter', () => ({ + LimitResolver: {resolve: () => 0}, +})); + +vi.mock('@app/features/app/utils/LimitUtils', () => ({ + isLimitToggleEnabled: () => false, +})); + +const {default: VoiceSettings, NONE_BACKGROUND_ID} = await import('./VoiceSettings'); + +const SAVED_BACKGROUND = {id: 'saved-background', createdAt: 1, mediaKind: 'static' as const}; + +describe('VoiceSettings background persistence when voice backgrounds are unavailable', () => { + beforeEach(() => { + voiceBackgroundsAvailable.mockReturnValue(true); + VoiceSettings.updateSettings({backgroundImages: [SAVED_BACKGROUND], backgroundImageId: SAVED_BACKGROUND.id}); + voiceBackgroundsAvailable.mockReturnValue(false); + }); + + it('keeps the uploaded background list across a no-op revalidation', () => { + VoiceSettings.updateSettings({}); + expect(VoiceSettings.backgroundImages).toEqual([SAVED_BACKGROUND]); + }); + + it('keeps the stored selection so it comes back once backgrounds are available again', () => { + VoiceSettings.updateSettings({}); + expect(VoiceSettings.backgroundImageId).toBe(SAVED_BACKGROUND.id); + voiceBackgroundsAvailable.mockReturnValue(true); + expect(VoiceSettings.getBackgroundImageId()).toBe(SAVED_BACKGROUND.id); + }); + + it('never hands out an active background while the feature is unavailable', () => { + VoiceSettings.updateSettings({}); + expect(VoiceSettings.getBackgroundImageId()).toBe(NONE_BACKGROUND_ID); + expect(VoiceSettings.getBackgroundImages()).toEqual([SAVED_BACKGROUND]); + }); + + it('still drops a selection that no longer matches a stored background', () => { + VoiceSettings.updateSettings({backgroundImages: []}); + expect(VoiceSettings.backgroundImageId).toBe(NONE_BACKGROUND_ID); + }); +}); diff --git a/fluxer_app/src/types/electron.d.ts b/fluxer_app/src/types/electron.d.ts index bf61b48fa..b41fb31ed 100644 --- a/fluxer_app/src/types/electron.d.ts +++ b/fluxer_app/src/types/electron.d.ts @@ -4,8 +4,6 @@ import type {VoiceEngineV2BridgeApi} from '@fluxer/voice_engine_v2/bridge'; import type {AuthenticationResponseJSON, RegistrationResponseJSON} from '@simplewebauthn/browser'; export type InputMonitoringPermissionStatus = 'granted' | 'denied' | 'not-determined' | 'unsupported'; -export type DesktopBuildVariant = 'default' | 'windows-game-capture'; - export interface DesktopSource { id: string; name: string; @@ -20,7 +18,6 @@ export interface DesktopSource { export interface DesktopInfo { version: string; channel: 'stable' | 'canary'; - buildVariant: DesktopBuildVariant; arch: string; hardwareArch: string; runningUnderRosetta: boolean; @@ -352,7 +349,6 @@ export interface AppMetricsSnapshot { export interface ElectronAPI { platform: 'darwin' | 'win32' | 'linux' | string; buildChannel: 'stable' | 'canary'; - buildVariant: DesktopBuildVariant; openExternal(url: string): Promise; downloadFile(url: string, suggestedName: string): Promise; onUpdaterEvent(callback: (event: UpdaterEvent) => void): () => void; diff --git a/fluxer_desktop/electron-builder.config.cjs b/fluxer_desktop/electron-builder.config.cjs index 4ec31a3f7..75d5c6efd 100644 --- a/fluxer_desktop/electron-builder.config.cjs +++ b/fluxer_desktop/electron-builder.config.cjs @@ -12,9 +12,6 @@ const appId = isCanary ? 'app.fluxer.canary' : 'app.fluxer'; const iconDir = isCanary ? 'icons-canary' : 'icons-stable'; const packageName = isCanary ? 'fluxer_desktop_canary' : 'fluxer_desktop'; const linuxPackageName = isCanary ? 'fluxer-canary' : 'fluxer'; -const desktopBuildVariant = process.env.FLUXER_DESKTOP_BUILD_VARIANT || process.env.DESKTOP_VARIANT || 'default'; -const windowsGameCaptureModuleEnabled = - desktopBuildVariant === 'windows-game-capture' || process.env.FLUXER_WINDOWS_GAME_CAPTURE_MODULE_ENABLED === 'true'; const linuxDesktopActionIds = ['open-settings', 'new-dm']; const linuxDesktopActionList = `${linuxDesktopActionIds.join(';')};`; const rpmBuildIdFilePrefix = '/usr/lib/.build-id'; @@ -57,7 +54,7 @@ const fluxerNativePackages = [ '@fluxer/mac-tcc', '@fluxer/macos-input-hook', '@fluxer/win-process-loopback', - ...(windowsGameCaptureModuleEnabled ? ['@fluxer/win-game-capture'] : []), + '@fluxer/win-game-capture', '@fluxer/win-clipboard', '@fluxer/win-shell', '@fluxer/win-toast', @@ -87,7 +84,7 @@ const fluxerNativePackagesByPlatform = { ], win32: [ '@fluxer/win-process-loopback', - ...(windowsGameCaptureModuleEnabled ? ['@fluxer/win-game-capture'] : []), + '@fluxer/win-game-capture', '@fluxer/win-clipboard', '@fluxer/win-shell', '@fluxer/win-toast', @@ -143,18 +140,14 @@ const nativeRuntimeFilePatterns = [ 'node_modules/@fluxer/win-process-loopback/binding.js', 'node_modules/@fluxer/win-process-loopback/loader-diagnostics.cjs', 'node_modules/@fluxer/win-process-loopback/*.node', - ...(windowsGameCaptureModuleEnabled - ? [ - 'node_modules/@fluxer/win-game-capture/package.json', - 'node_modules/@fluxer/win-game-capture/index.js', - 'node_modules/@fluxer/win-game-capture/loader-diagnostics.cjs', - 'node_modules/@fluxer/win-game-capture/*.node', - 'node_modules/@fluxer/win-game-capture/*.dll', - 'node_modules/@fluxer/win-game-capture/*.exe', - 'node_modules/@fluxer/win-game-capture/compatibility.json', - 'node_modules/@fluxer/win-game-capture/fluxer-vulkan-layer.*.json', - ] - : []), + 'node_modules/@fluxer/win-game-capture/package.json', + 'node_modules/@fluxer/win-game-capture/index.js', + 'node_modules/@fluxer/win-game-capture/loader-diagnostics.cjs', + 'node_modules/@fluxer/win-game-capture/*.node', + 'node_modules/@fluxer/win-game-capture/*.dll', + 'node_modules/@fluxer/win-game-capture/*.exe', + 'node_modules/@fluxer/win-game-capture/compatibility.json', + 'node_modules/@fluxer/win-game-capture/fluxer-vulkan-layer.*.json', 'node_modules/@fluxer/win-clipboard/package.json', 'node_modules/@fluxer/win-clipboard/index.js', 'node_modules/@fluxer/win-clipboard/loader-diagnostics.cjs', @@ -221,15 +214,11 @@ const nativeRuntimeFilePatterns = [ 'node_modules/@fluxer/webrtc-sender/*.node', 'node_modules/.pnpm/@fluxer+*/node_modules/@fluxer/*/loader-diagnostics.cjs', 'node_modules/.pnpm/@fluxer+win-process-loopback@*/node_modules/@fluxer/win-process-loopback/*.node', - ...(windowsGameCaptureModuleEnabled - ? [ - 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.node', - 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.dll', - 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.exe', - 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/compatibility.json', - 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/fluxer-vulkan-layer.*.json', - ] - : []), + 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.node', + 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.dll', + 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.exe', + 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/compatibility.json', + 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/fluxer-vulkan-layer.*.json', 'node_modules/.pnpm/@fluxer+win-clipboard@*/node_modules/@fluxer/win-clipboard/*.node', 'node_modules/.pnpm/@fluxer+win-shell@*/node_modules/@fluxer/win-shell/*.node', 'node_modules/.pnpm/@fluxer+win-toast@*/node_modules/@fluxer/win-toast/*.node', @@ -308,9 +297,6 @@ const bundledDependencyExcludes = [ '!node_modules/xml2js/**/*', '!node_modules/xmlbuilder/**/*', ]; -const windowsGameCapturePackageExcludes = !windowsGameCaptureModuleEnabled - ? ['!node_modules/@fluxer/win-game-capture/**/*', '!node_modules/.pnpm/@fluxer+win-game-capture@*/**/*'] - : []; const platformNativeRuntimeExcludes = platformNativeExcludes(targetPlatform, targetNativeArch); const platformRuntimeDependencyExcludes = targetPlatform === 'darwin' @@ -352,19 +338,32 @@ function velopackNativeFile(platform, arch) { return null; } +function pnpmStoreDirName(packageName) { + return packageName.replace('/', '+'); +} + function platformNativeExcludes(platform, arch) { - if (!arch) return []; const keepFluxerPackages = new Set(fluxerNativePackagesByPlatform[platform] ?? []); + const fluxerPackageExcludes = fluxerNativePackages + .filter((packageName) => !keepFluxerPackages.has(packageName)) + .flatMap((packageName) => [ + `!node_modules/${packageName}/**/*`, + `!node_modules/.pnpm/${pnpmStoreDirName(packageName)}@*/**/*`, + ]); + if (platform !== 'win32') { + return [...fluxerPackageExcludes, '!node_modules/velopack/**/*']; + } const keepVelopackNativeFile = velopackNativeFile(platform, arch); + if (!keepVelopackNativeFile) { + throw new Error( + `Cannot determine the Velopack native module for win32 without a target architecture; set ELECTRON_ARCH or pass --x64/--arm64 (received ${JSON.stringify(arch)})`, + ); + } return [ - ...fluxerNativePackages - .filter((packageName) => !keepFluxerPackages.has(packageName)) - .map((packageName) => `!node_modules/${packageName}/**/*`), - ...(platform === 'win32' - ? velopackNativeFiles - .filter((fileName) => fileName !== keepVelopackNativeFile) - .map((fileName) => `!node_modules/velopack/lib/native/${fileName}`) - : ['!node_modules/velopack/**/*']), + ...fluxerPackageExcludes, + ...velopackNativeFiles + .filter((fileName) => fileName !== keepVelopackNativeFile) + .map((fileName) => `!node_modules/velopack/lib/native/${fileName}`), ]; } @@ -393,7 +392,6 @@ function platformTag(platform, arch) { } function addWindowsGameCaptureArtifacts(artifacts, tag, arch) { - if (!windowsGameCaptureModuleEnabled) return; const add = (relativePath) => { artifacts.push({ packageName: '@fluxer/win-game-capture', @@ -1066,14 +1064,13 @@ module.exports = { ...nativeBuildArtifactExcludes, ...packagedRuntimeArtifactExcludes, ...bundledDependencyExcludes, - ...windowsGameCapturePackageExcludes, ...platformNativeRuntimeExcludes, ...platformRuntimeDependencyExcludes, ], extraMetadata: { main: 'dist/main/index.js', name: metadataName, - ...(Boolean(process.env.VERSION) ? {version: process.env.VERSION} : {}), + ...(process.env.VERSION ? {version: process.env.VERSION} : {}), ...(targetPlatform === 'linux' ? {desktopName: `${linuxPackageName}.desktop`} : {}), }, extraResources: [ @@ -1107,14 +1104,10 @@ module.exports = { asarUnpack: [ '**/*.node', 'node_modules/@fluxer/win-process-loopback/*.node', - ...(windowsGameCaptureModuleEnabled - ? [ - 'node_modules/@fluxer/win-game-capture/*.node', - 'node_modules/@fluxer/win-game-capture/*.dll', - 'node_modules/@fluxer/win-game-capture/*.exe', - 'node_modules/@fluxer/win-game-capture/*.json', - ] - : []), + 'node_modules/@fluxer/win-game-capture/*.node', + 'node_modules/@fluxer/win-game-capture/*.dll', + 'node_modules/@fluxer/win-game-capture/*.exe', + 'node_modules/@fluxer/win-game-capture/*.json', 'node_modules/@fluxer/win-clipboard/*.node', 'node_modules/@fluxer/win-shell/*.node', 'node_modules/@fluxer/win-toast/*.node', @@ -1137,14 +1130,10 @@ module.exports = { 'node_modules/@fluxer/webauthn/*.node', 'node_modules/@fluxer/webauthn/*.so*', 'node_modules/.pnpm/@fluxer+win-process-loopback@*/node_modules/@fluxer/win-process-loopback/*.node', - ...(windowsGameCaptureModuleEnabled - ? [ - 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.node', - 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.dll', - 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.exe', - 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.json', - ] - : []), + 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.node', + 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.dll', + 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.exe', + 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.json', 'node_modules/.pnpm/@fluxer+win-clipboard@*/node_modules/@fluxer/win-clipboard/*.node', 'node_modules/.pnpm/@fluxer+win-shell@*/node_modules/@fluxer/win-shell/*.node', 'node_modules/.pnpm/@fluxer+win-toast@*/node_modules/@fluxer/win-toast/*.node', diff --git a/fluxer_desktop/native/win-game-capture/index.d.ts b/fluxer_desktop/native/win-game-capture/index.d.ts index 262531f97..1f74e8876 100644 --- a/fluxer_desktop/native/win-game-capture/index.d.ts +++ b/fluxer_desktop/native/win-game-capture/index.d.ts @@ -158,6 +158,7 @@ export declare function getAvailability(): AvailabilityInfo; export declare function listSources(): Promise>; export declare function resolveGameHookPath(): string | null; export declare function resolveGameHookPathX86(): string | null; +export declare function isGameCaptureHookAvailable(): boolean; export declare function resolveVulkanLayerManifestPath(): string | null; export declare function registerVulkanLayerManifest(): boolean; export declare function unregisterVulkanLayerManifest(): boolean; diff --git a/fluxer_desktop/native/win-game-capture/index.js b/fluxer_desktop/native/win-game-capture/index.js index afb2de729..6df6c6b91 100644 --- a/fluxer_desktop/native/win-game-capture/index.js +++ b/fluxer_desktop/native/win-game-capture/index.js @@ -6,7 +6,6 @@ const {join, sep} = require('node:path'); const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs'); const MODULE_NAME = '@fluxer/win-game-capture'; -const WINDOWS_GAME_CAPTURE_MODULE_ENV = 'FLUXER_WINDOWS_GAME_CAPTURE_MODULE_ENABLED'; function resolveNativeRoot() { const asarSegment = `${sep}app.asar${sep}`; @@ -30,14 +29,7 @@ let binding = null; let loadError = null; const nativeRoot = resolveNativeRoot(); -if (process.platform === 'win32' && process.env[WINDOWS_GAME_CAPTURE_MODULE_ENV] !== 'true') { - loadError = createNativeLoadError({ - moduleName: MODULE_NAME, - nativeRoot, - packageDir: __dirname, - reason: 'Windows game capture is disabled in this build', - }); -} else if (process.platform === 'win32') { +if (process.platform === 'win32') { const fileName = nativeFileName(process.arch); if (!fileName) { loadError = createNativeLoadError({ @@ -117,8 +109,15 @@ function resolveVulkanLayerManifestPath(root = nativeRoot) { return existsSync(manifestPath) ? manifestPath : null; } +function isGameCaptureHookAvailable(root = nativeRoot) { + if (typeof binding?.isGameCaptureHookAvailable !== 'function') return false; + if (binding.isGameCaptureHookAvailable() !== true) return false; + return resolveGameHookPath(root) !== null; +} + function registerVulkanLayerManifest(root = nativeRoot) { if (!binding?.registerVulkanLayerManifest) return false; + if (!isGameCaptureHookAvailable(root)) return false; const manifestPath = resolveVulkanLayerManifestPath(root); if (!manifestPath) return false; binding.registerVulkanLayerManifest(manifestPath); @@ -151,14 +150,6 @@ function getVulkanLayerRegistrationState(root = nativeRoot) { } } -if (process.platform === 'win32' && binding) { - try { - registerVulkanLayerManifest(); - } catch (error) { - console.warn('[win-game-capture] registerVulkanLayerManifest failed:', error?.message || error); - } -} - class ScreenCapture extends EventEmitter { constructor(options = {}) { super(); @@ -219,9 +210,6 @@ class ScreenCapture extends EventEmitter { async start() { if (this.started || this.stopped) return undefined; - if (this.sourceKind === 'game' && !this.hookDllPath) { - throw new Error(`Game capture hook unavailable for ${process.platform}-${process.arch}`); - } this.started = true; try { if (this.frameSinkHandle != null) { @@ -453,6 +441,7 @@ module.exports = { getAvailability, resolveGameHookPath, resolveGameHookPathX86, + isGameCaptureHookAvailable, resolveVulkanLayerManifestPath, registerVulkanLayerManifest, unregisterVulkanLayerManifest, diff --git a/fluxer_desktop/native/win-game-capture/index.test.mjs b/fluxer_desktop/native/win-game-capture/index.test.mjs index 1c401f92f..6fa4b833f 100644 --- a/fluxer_desktop/native/win-game-capture/index.test.mjs +++ b/fluxer_desktop/native/win-game-capture/index.test.mjs @@ -10,6 +10,7 @@ const winGameCapture = require('./index.js'); const startupSupported = winGameCapture.isSupported(); const startupAvailability = winGameCapture.getAvailability(); +const startupHookAvailable = winGameCapture.isGameCaptureHookAvailable(); const realBindingSkip = startupSupported ? false : 'no native binding loaded at startup (binding-less platform / unbuilt addon)'; @@ -29,6 +30,8 @@ function makeFakeBinding() { const calls = []; const frameSinkHandleCalls = []; const priorityCalls = []; + const vulkanCalls = []; + const hookAvailable = {value: false}; const natives = []; const diagnostics = { state: 1, @@ -195,10 +198,19 @@ function makeFakeBinding() { restoreGpuSchedulingPriority: (processId) => { priorityCalls.push({type: 'restore', processId}); }, + isGameCaptureHookAvailable: () => hookAvailable.value, + registerVulkanLayerManifest: (manifestPath) => { + vulkanCalls.push({type: 'register', manifestPath}); + }, + unregisterVulkanLayerManifest: (manifestPath) => { + vulkanCalls.push({type: 'unregister', manifestPath}); + }, }, calls, frameSinkHandleCalls, priorityCalls, + vulkanCalls, + hookAvailable, natives, diagnostics, encoderDiagnostics, @@ -297,9 +309,63 @@ describe('win-game-capture loader wrapper -- real native binding (built Windows assert.equal(typeof r, 'string', 'expected a Vulkan layer manifest path on a built Windows box'); assert.ok(existsSync(r), `Vulkan layer manifest should exist on disk: ${r}`); }); + + test('the native binding reports hook-based game capture as unavailable', {skip: realBindingSkip}, () => { + assert.equal(startupHookAvailable, false, 'hook capture is force-disabled in the native crate'); + }); + + test('requiring the module leaves the Vulkan implicit layer unregistered', {skip: realBindingSkip}, () => { + assert.equal( + winGameCapture.registerVulkanLayerManifest(), + false, + 'the Vulkan implicit layer must not be registered while hook capture is disabled', + ); + assert.equal(winGameCapture.getVulkanLayerRegistrationState().registered, false); + }); }); describe('win-game-capture loader wrapper -- injected fake binding', () => { + test('isGameCaptureHookAvailable() follows the native hook flag', {skip: injectionSkip}, () => { + const {binding} = makeFakeBinding(); + winGameCapture.__setBindingForTests(binding); + assert.equal(winGameCapture.isGameCaptureHookAvailable(), false); + }); + + test( + 'isGameCaptureHookAvailable() is false when the native binding predates the hook flag', + {skip: injectionSkip}, + () => { + const {binding} = makeFakeBinding(); + binding.isGameCaptureHookAvailable = undefined; + winGameCapture.__setBindingForTests(binding); + assert.equal(winGameCapture.isGameCaptureHookAvailable(), false); + }, + ); + + test( + 'registerVulkanLayerManifest() never touches the registry while hook capture is unavailable', + {skip: injectionSkip}, + () => { + const {binding, vulkanCalls} = makeFakeBinding(); + winGameCapture.__setBindingForTests(binding); + assert.equal(winGameCapture.registerVulkanLayerManifest(), false); + assert.deepEqual(vulkanCalls, [], 'the native registration entry point must not be called'); + }, + ); + + test( + 'registerVulkanLayerManifest() still refuses when the hook DLL is missing for this host', + {skip: injectionSkip || (winGameCapture.resolveGameHookPath() !== null && 'host ships a game capture hook DLL')}, + () => { + const {binding, hookAvailable, vulkanCalls} = makeFakeBinding(); + hookAvailable.value = true; + winGameCapture.__setBindingForTests(binding); + assert.equal(winGameCapture.isGameCaptureHookAvailable(), false); + assert.equal(winGameCapture.registerVulkanLayerManifest(), false); + assert.deepEqual(vulkanCalls, []); + }, + ); + test( 'listSources() forwards sanitized screen/window sources from the native binding', {skip: injectionSkip}, @@ -546,7 +612,7 @@ describe('win-game-capture loader wrapper -- injected fake binding', () => { ); test( - 'game sourceKind without a hook path fails closed without WGC/browser fallback', + 'game sourceKind starts without a hook path because capture no longer injects', {skip: injectionSkip}, async () => { const {binding, calls} = makeFakeBinding(); @@ -558,8 +624,9 @@ describe('win-game-capture loader wrapper -- injected fake binding', () => { hookDllPathX86: '', }); capture.on('error', () => {}); - await assert.rejects(() => capture.start(), /Game capture hook unavailable/); - assert.equal(calls.length, 0, 'native start must not be called when the game hook is unavailable'); + await capture.start(); + assert.equal(calls.length, 1, 'native start must be called for game capture without a hook'); + assert.equal(calls[0].sourceKind, 'game'); }, ); diff --git a/fluxer_desktop/native/win-game-capture/scripts/fixture-smoke.mjs b/fluxer_desktop/native/win-game-capture/scripts/fixture-smoke.mjs index edf2ea5f3..300442957 100644 --- a/fluxer_desktop/native/win-game-capture/scripts/fixture-smoke.mjs +++ b/fluxer_desktop/native/win-game-capture/scripts/fixture-smoke.mjs @@ -27,62 +27,19 @@ const FRAME_RATE = Number.parseInt(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_F const START_TIMEOUT_MS = Number.parseInt(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_START_TIMEOUT_MS ?? '15000', 10); const FRAME_TIMEOUT_MS = Number.parseInt(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_FRAME_TIMEOUT_MS ?? '15000', 10); +const MIN_OBSERVED_FRAMES = 3; + +const API_NONE = 0; const TRANSPORT_MEMORY = 0; -const TRANSPORT_SHARED_TEXTURE = 1; - -const API_OPENGL = 1; -const API_D3D9 = 3; -const API_D3D10 = 4; -const API_D3D11 = 5; -const API_D3D12 = 6; -const API_VULKAN = 7; - const FALLBACK_NONE = 0; -const FALLBACK_SHARED_TEXTURE_UNSUPPORTED = 1; +const DXGI_FORMAT_UNKNOWN = 0; +const EXPECTED_ACTIVE_STRATEGY = 'wgc'; const EXPECTED_DIAGNOSTICS = { - 'd3d9-present-fixture': { - apiType: API_D3D9, - transport: TRANSPORT_SHARED_TEXTURE, - fallbackReason: FALLBACK_NONE, - requiresDxgiFormat: true, - }, - 'd3d10-present-fixture': { - apiType: API_D3D10, - transport: TRANSPORT_SHARED_TEXTURE, - fallbackReason: FALLBACK_NONE, - requiresDxgiFormat: true, - }, - 'd3d11-present-fixture': { - apiType: API_D3D11, - transport: TRANSPORT_SHARED_TEXTURE, - fallbackReason: FALLBACK_NONE, - requiresDxgiFormat: true, - }, - 'd3d12-present-fixture': { - apiType: API_D3D12, - transport: TRANSPORT_SHARED_TEXTURE, - fallbackReason: FALLBACK_NONE, - requiresDxgiFormat: true, - }, - 'opengl-swapbuffers-fixture': { - apiType: API_OPENGL, - transportOneOf: [TRANSPORT_SHARED_TEXTURE, TRANSPORT_MEMORY], - fallbackReasonOneOf: [FALLBACK_NONE, FALLBACK_SHARED_TEXTURE_UNSUPPORTED], - requiresDxgiFormatWhenShared: true, - }, - 'vulkan-present-fixture': { - apiType: API_VULKAN, - transport: TRANSPORT_SHARED_TEXTURE, - fallbackReason: FALLBACK_NONE, - requiresDxgiFormat: true, - }, - 'i686-present-fixture': { - apiType: API_D3D11, - transport: TRANSPORT_SHARED_TEXTURE, - fallbackReason: FALLBACK_NONE, - requiresDxgiFormat: true, - }, + apiType: API_NONE, + transport: TRANSPORT_MEMORY, + fallbackReason: FALLBACK_NONE, + dxgiFormat: DXGI_FORMAT_UNKNOWN, }; function envFlag(name) { @@ -216,50 +173,29 @@ function waitForHwnd(child, fixture) { }); } -function frameSignature(frame) { - const bytes = frame.data; - const width = Math.max(1, frame.width); - const height = Math.max(1, frame.height); - const stride = Math.max(1, frame.strideY || (frame.format === 'bgra' ? width * 4 : width)); - const rows = frame.format === 'bgra' ? Math.min(height, 12) : Math.min(height, 32); - let hash = 2166136261; - for (let y = 0; y < rows; y += 1) { - const row = y * stride; - const rowBytes = frame.format === 'bgra' ? Math.min(stride, width * 4, 256) : Math.min(stride, width, 256); - for (let x = 0; x < rowBytes; x += 4) { - hash ^= bytes[row + x] ?? 0; - hash = Math.imul(hash, 16777619) >>> 0; - } - } - return hash >>> 0; +function observedFrameCount(diagnostics) { + if (!diagnostics) return 0; + const accepted = Number(diagnostics.frameSinkAccepted ?? 0); + const coalesced = Number(diagnostics.frameSinkCoalesced ?? 0); + const droppedWithoutSink = Number(diagnostics.mediaFramesDroppedWithoutSink ?? 0); + const total = accepted + coalesced + droppedWithoutSink; + return Number.isFinite(total) ? total : 0; } -async function waitForAdvancingFrames(screenCapture, fixture) { - const signatures = new Set(); - let frameCount = 0; +async function waitForAdvancingFrames(screenCapture, fixture, stalls) { let lastDiagnostics = null; - const onFrame = (frame) => { - frameCount += 1; - signatures.add(frameSignature(frame)); - lastDiagnostics = screenCapture.getDiagnostics?.() ?? lastDiagnostics; - }; - screenCapture.on('frame', onFrame); const start = Date.now(); - try { - while (Date.now() - start < FRAME_TIMEOUT_MS) { - lastDiagnostics = screenCapture.getDiagnostics?.() ?? lastDiagnostics; - const nativeFrames = Number(lastDiagnostics?.frameCounter ?? 0); - if (frameCount >= 3 && (signatures.size >= 2 || nativeFrames >= 3)) { - return {frameCount, signatures: signatures.size, diagnostics: lastDiagnostics}; - } - await delay(100); + while (Date.now() - start < FRAME_TIMEOUT_MS) { + lastDiagnostics = screenCapture.getDiagnostics?.() ?? lastDiagnostics; + const frameCount = observedFrameCount(lastDiagnostics); + if (frameCount >= MIN_OBSERVED_FRAMES) { + return {frameCount, diagnostics: lastDiagnostics}; } - throw new Error( - `${fixture} capture did not deliver advancing frames within ${FRAME_TIMEOUT_MS}ms (frames=${frameCount}, signatures=${signatures.size}, diagnostics=${JSON.stringify(lastDiagnostics)})`, - ); - } finally { - screenCapture.off('frame', onFrame); + await delay(100); } + throw new Error( + `${fixture} capture did not deliver advancing frames within ${FRAME_TIMEOUT_MS}ms (frames=${observedFrameCount(lastDiagnostics)}, stalls=${JSON.stringify(stalls)}, diagnostics=${JSON.stringify(lastDiagnostics)})`, + ); } function assertEqualDiagnostic(fixture, diagnostics, key, expected) { @@ -268,53 +204,25 @@ function assertEqualDiagnostic(fixture, diagnostics, key, expected) { } } -function assertOneOfDiagnostic(fixture, diagnostics, key, expected) { - if (!expected.includes(diagnostics?.[key])) { - throw new Error( - `${fixture} expected diagnostics.${key} in [${expected.join(', ')}], got ${JSON.stringify(diagnostics)}`, - ); - } -} - function assertFixtureDiagnostics(fixture, diagnostics) { - const expectedBase = EXPECTED_DIAGNOSTICS[fixture]; - if (!expectedBase) return; - const expected = {...expectedBase}; + const expected = {...EXPECTED_DIAGNOSTICS}; const apiOverride = diagnosticOverride('FLUXER_WIN_GAME_CAPTURE_EXPECT_API_TYPE', fixture); const transportOverride = diagnosticOverride('FLUXER_WIN_GAME_CAPTURE_EXPECT_TRANSPORT', fixture); const fallbackOverride = diagnosticOverride('FLUXER_WIN_GAME_CAPTURE_EXPECT_FALLBACK_REASON', fixture); if (apiOverride !== undefined) expected.apiType = apiOverride; - if (transportOverride !== undefined) { - expected.transport = transportOverride; - delete expected.transportOneOf; - expected.requiresDxgiFormat = expected.transport === TRANSPORT_SHARED_TEXTURE; - } - if (fallbackOverride !== undefined) { - expected.fallbackReason = fallbackOverride; - delete expected.fallbackReasonOneOf; - } + if (transportOverride !== undefined) expected.transport = transportOverride; + if (fallbackOverride !== undefined) expected.fallbackReason = fallbackOverride; + if (diagnostics?.activeStrategy !== EXPECTED_ACTIVE_STRATEGY) { + throw new Error( + `${fixture} expected activeStrategy=${EXPECTED_ACTIVE_STRATEGY}, got ${JSON.stringify(diagnostics)}`, + ); + } assertEqualDiagnostic(fixture, diagnostics, 'apiType', expected.apiType); - if (expected.transportOneOf) { - assertOneOfDiagnostic(fixture, diagnostics, 'transport', expected.transportOneOf); - } else { - assertEqualDiagnostic(fixture, diagnostics, 'transport', expected.transport); - } - if (expected.fallbackReason !== undefined) { - assertEqualDiagnostic(fixture, diagnostics, 'fallbackReason', expected.fallbackReason); - } else if (expected.fallbackReasonOneOf) { - assertOneOfDiagnostic(fixture, diagnostics, 'fallbackReason', expected.fallbackReasonOneOf); - } - if (diagnostics?.activeStrategy !== 'game-hook') { - throw new Error(`${fixture} expected activeStrategy=game-hook, got ${JSON.stringify(diagnostics)}`); - } - if ( - (expected.requiresDxgiFormat || - (expected.requiresDxgiFormatWhenShared && diagnostics?.transport === TRANSPORT_SHARED_TEXTURE)) && - Number(diagnostics?.dxgiFormat ?? 0) === 0 - ) { - throw new Error(`${fixture} expected a non-zero shared texture DXGI format, got ${JSON.stringify(diagnostics)}`); - } + assertEqualDiagnostic(fixture, diagnostics, 'transport', expected.transport); + assertEqualDiagnostic(fixture, diagnostics, 'fallbackReason', expected.fallbackReason); + assertEqualDiagnostic(fixture, diagnostics, 'dxgiFormat', expected.dxgiFormat); + assertEqualDiagnostic(fixture, diagnostics, 'injectionMethod', ''); } async function runFixture(fixture) { @@ -338,14 +246,16 @@ async function runFixture(fixture) { frameRate: FRAME_RATE, injectionMethod: process.env.FLUXER_WIN_GAME_CAPTURE_INJECTION_METHOD || 'auto', }); + const stalls = []; + screenCapture.on('stalled', (message) => stalls.push(message ?? '')); let result; try { result = await screenCapture.start(); started = true; - const observed = await waitForAdvancingFrames(screenCapture, fixture); + const observed = await waitForAdvancingFrames(screenCapture, fixture, stalls); assertFixtureDiagnostics(fixture, observed.diagnostics); console.log( - `[fixture-smoke] PASS ${fixture}: start=${JSON.stringify(result)} frames=${observed.frameCount} signatures=${observed.signatures} diagnostics=${JSON.stringify(observed.diagnostics)}`, + `[fixture-smoke] PASS ${fixture}: start=${JSON.stringify(result)} frames=${observed.frameCount} stalls=${JSON.stringify(stalls)} diagnostics=${JSON.stringify(observed.diagnostics)}`, ); } finally { if (started) await screenCapture.stop().catch(() => {}); diff --git a/fluxer_desktop/native/win-game-capture/src/game_capture.rs b/fluxer_desktop/native/win-game-capture/src/game_capture.rs index 316c636ba..192aeaddf 100644 --- a/fluxer_desktop/native/win-game-capture/src/game_capture.rs +++ b/fluxer_desktop/native/win-game-capture/src/game_capture.rs @@ -54,7 +54,11 @@ use windows_sys::Win32::{ MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, PAGE_READWRITE, UnmapViewOfFile, VirtualAllocEx, VirtualFreeEx, }, - SystemInformation::{IMAGE_FILE_MACHINE, IMAGE_FILE_MACHINE_UNKNOWN}, + SystemInformation::{ + IMAGE_FILE_MACHINE, IMAGE_FILE_MACHINE_AMD64, IMAGE_FILE_MACHINE_ARM, + IMAGE_FILE_MACHINE_ARM64, IMAGE_FILE_MACHINE_ARMNT, IMAGE_FILE_MACHINE_I386, + IMAGE_FILE_MACHINE_IA64, IMAGE_FILE_MACHINE_UNKNOWN, + }, Threading::{ CreateEventW, CreateMutexW, CreateRemoteThread, GetCurrentProcessId, IsWow64Process, IsWow64Process2, OpenProcess, PROCESS_CREATE_THREAD, PROCESS_QUERY_INFORMATION, @@ -294,13 +298,19 @@ impl GameCaptureSession { InjectionPolicy::Allow => false, }; - let target_is_32_bit = target_process_is_32_bit(target_pid)?; + let target_machine = target_process_machine(target_pid)?; + let target_is_32_bit = machine_is_32_bit(target_machine)?; let selected_hook_path = if target_is_32_bit { hook_path_x86.ok_or( "target game process is 32-bit but no 32-bit game capture hook DLL was provided", )? - } else { + } else if target_machine == HOST_MACHINE { hook_path + } else { + return Err(format!( + "target game process architecture (IMAGE_FILE_MACHINE 0x{target_machine:04x}) \ + differs from the host architecture and no matching game capture hook DLL ships for it" + )); }; let (capture_width, capture_height) = window_capture_size(target_hwnd)?; @@ -485,6 +495,15 @@ struct Injected { } const HOST_IS_32_BIT: bool = cfg!(target_pointer_width = "32"); +const HOST_MACHINE: IMAGE_FILE_MACHINE = if cfg!(target_arch = "x86_64") { + IMAGE_FILE_MACHINE_AMD64 +} else if cfg!(target_arch = "aarch64") { + IMAGE_FILE_MACHINE_ARM64 +} else if cfg!(target_arch = "x86") { + IMAGE_FILE_MACHINE_I386 +} else { + IMAGE_FILE_MACHINE_UNKNOWN +}; fn inject( method: InjectionMethod, @@ -816,7 +835,10 @@ fn parse_screen_ordinal(source_id: &str) -> Option { token.parse::().ok() } -fn resolve_game_capture_target(source_id: &str, source_kind: &str) -> Result { +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); } @@ -883,7 +905,7 @@ fn target_process_id(hwnd: HWND) -> Result { } } -fn target_process_is_32_bit(target_pid: u32) -> Result { +fn target_process_machine(target_pid: u32) -> Result { let process = unsafe { OpenProcess( PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_QUERY_INFORMATION, @@ -897,7 +919,12 @@ fn target_process_is_32_bit(target_pid: u32) -> Result { let mut native_machine: IMAGE_FILE_MACHINE = IMAGE_FILE_MACHINE_UNKNOWN; let ok = unsafe { IsWow64Process2(process.raw(), &mut process_machine, &mut native_machine) }; if ok != 0 { - return Ok(process_machine != IMAGE_FILE_MACHINE_UNKNOWN); + let effective_machine = if process_machine == IMAGE_FILE_MACHINE_UNKNOWN { + native_machine + } else { + process_machine + }; + return Ok(effective_machine); } let mut is_wow64: windows_sys::core::BOOL = 0; @@ -905,7 +932,21 @@ fn target_process_is_32_bit(target_pid: u32) -> Result { if ok == 0 { return Err("failed to query target process bitness".into()); } - Ok(is_wow64 != 0) + if is_wow64 != 0 { + return Ok(IMAGE_FILE_MACHINE_I386); + } + Ok(HOST_MACHINE) +} + +fn machine_is_32_bit(machine: IMAGE_FILE_MACHINE) -> Result { + match machine { + IMAGE_FILE_MACHINE_I386 | IMAGE_FILE_MACHINE_ARM | IMAGE_FILE_MACHINE_ARMNT => Ok(true), + IMAGE_FILE_MACHINE_AMD64 | IMAGE_FILE_MACHINE_ARM64 | IMAGE_FILE_MACHINE_IA64 => Ok(false), + _ => Err(format!( + "unsupported target process architecture (IMAGE_FILE_MACHINE 0x{machine:04x}); \ + cannot pick a game capture hook DLL for it" + )), + } } fn window_capture_size(hwnd: HWND) -> Result<(u32, u32), String> { @@ -1697,4 +1738,22 @@ mod tests { None ); } + + #[test] + fn emulated_x64_target_on_arm64_host_is_not_classified_as_32_bit() { + assert_eq!(machine_is_32_bit(IMAGE_FILE_MACHINE_AMD64), Ok(false)); + assert_eq!(machine_is_32_bit(IMAGE_FILE_MACHINE_ARM64), Ok(false)); + } + + #[test] + fn wow64_guest_machines_are_classified_as_32_bit() { + assert_eq!(machine_is_32_bit(IMAGE_FILE_MACHINE_I386), Ok(true)); + assert_eq!(machine_is_32_bit(IMAGE_FILE_MACHINE_ARMNT), Ok(true)); + assert_eq!(machine_is_32_bit(IMAGE_FILE_MACHINE_ARM), Ok(true)); + } + + #[test] + fn unknown_target_machine_fails_loudly_instead_of_guessing_bitness() { + assert!(machine_is_32_bit(IMAGE_FILE_MACHINE_UNKNOWN).is_err()); + } } diff --git a/fluxer_desktop/native/win-game-capture/src/lib.rs b/fluxer_desktop/native/win-game-capture/src/lib.rs index ab40ee932..b9cbabbf8 100644 --- a/fluxer_desktop/native/win-game-capture/src/lib.rs +++ b/fluxer_desktop/native/win-game-capture/src/lib.rs @@ -17,6 +17,8 @@ mod hdr; #[cfg(target_os = "windows")] mod nv12_gpu; mod sources; +#[cfg(any(target_os = "windows", test))] +mod stall; #[cfg(target_os = "windows")] mod vulkan_layer_registry; #[cfg(target_os = "windows")] @@ -46,6 +48,7 @@ use wgc_capture::WgcCaptureSession; const LIFECYCLE_QUEUE_LIMIT: usize = 8; const START_OPTION_UNSUPPORTED_LIMIT: usize = 4; +const GAME_CAPTURE_HOOK_FORCE_DISABLED: bool = true; type LifecycleTsfn = Arc< ThreadsafeFunction< @@ -952,7 +955,7 @@ impl ScreenCapture { return Err(napi::Error::from_reason("Capture already running")); } - if source_kind == "game" { + if source_kind == "game" && !GAME_CAPTURE_HOOK_FORCE_DISABLED { return self.start_windows_game( source_id, source_kind, @@ -967,6 +970,7 @@ impl ScreenCapture { } let _ = (hook_path, hook_path_x86, injection_method); 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); @@ -986,10 +990,17 @@ impl ScreenCapture { return self.start_windows_wgc_session(session, target_frame_rate); } - let hwnd = + let hwnd = if source_kind == "game" { + let target = game_capture::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); @@ -1265,6 +1276,11 @@ pub fn is_supported() -> bool { cfg!(target_os = "windows") } +#[napi(js_name = "isGameCaptureHookAvailable")] +pub fn is_game_capture_hook_available() -> bool { + cfg!(target_os = "windows") && !GAME_CAPTURE_HOOK_FORCE_DISABLED +} + #[napi(js_name = "getAvailability")] pub fn get_availability() -> AvailabilityInfo { AvailabilityInfo { diff --git a/fluxer_desktop/native/win-game-capture/src/stall.rs b/fluxer_desktop/native/win-game-capture/src/stall.rs new file mode 100644 index 000000000..d5803ed11 --- /dev/null +++ b/fluxer_desktop/native/win-game-capture/src/stall.rs @@ -0,0 +1,183 @@ +// 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 index fc27ef8be..8a041287e 100644 --- a/fluxer_desktop/native/win-game-capture/src/wgc_capture.rs +++ b/fluxer_desktop/native/win-game-capture/src/wgc_capture.rs @@ -23,7 +23,7 @@ use windows::Win32::System::WinRT::Direct3D11::{ }; use windows::Win32::System::WinRT::Graphics::Capture::IGraphicsCaptureItemInterop; use windows::Win32::System::WinRT::{RO_INIT_MULTITHREADED, RoInitialize}; -use windows::Win32::UI::WindowsAndMessaging::IsWindow; +use windows::Win32::UI::WindowsAndMessaging::{IsWindow, IsWindowVisible}; use windows::core::{BOOL, Interface}; use crate::dxgi_capture::{ @@ -31,6 +31,7 @@ use crate::dxgi_capture::{ 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, @@ -39,6 +40,7 @@ use crate::{ 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) }; @@ -81,6 +83,20 @@ impl WgcCaptureTarget { } } + 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", @@ -339,6 +355,7 @@ pub fn capture_loop(inner: &Arc, frame_interval: std::time::Durati 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() { @@ -377,6 +394,12 @@ pub fn capture_loop(inner: &Arc, frame_interval: std::time::Durati 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, @@ -396,6 +419,46 @@ pub fn capture_loop(inner: &Arc, frame_interval: std::time::Durati 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, diff --git a/fluxer_desktop/scripts/build.mjs b/fluxer_desktop/scripts/build.mjs index db9f2b17b..ed068b13b 100644 --- a/fluxer_desktop/scripts/build.mjs +++ b/fluxer_desktop/scripts/build.mjs @@ -18,20 +18,11 @@ const isProduction = const skipNative = process.env.FLUXER_SKIP_NATIVE === 'true'; const embeddedBuildVersion = process.env.PUBLIC_BUILD_VERSION || process.env.BUILD_VERSION || ''; const embeddedReleaseChannel = process.env.PUBLIC_RELEASE_CHANNEL || process.env.RELEASE_CHANNEL || ''; -const requestedDesktopBuildVariant = process.env.FLUXER_DESKTOP_BUILD_VARIANT || process.env.DESKTOP_VARIANT || ''; -const windowsGameCaptureModuleEnabled = - requestedDesktopBuildVariant === 'windows-game-capture' || - process.env.FLUXER_WINDOWS_GAME_CAPTURE_MODULE_ENABLED === 'true'; -const embeddedDesktopBuildVariant = windowsGameCaptureModuleEnabled ? 'windows-game-capture' : 'default'; const publicBuildDefines = { 'process.env.PUBLIC_BUILD_VERSION': JSON.stringify(embeddedBuildVersion), 'process.env.BUILD_VERSION': JSON.stringify(embeddedBuildVersion), 'process.env.PUBLIC_RELEASE_CHANNEL': JSON.stringify(embeddedReleaseChannel), 'process.env.RELEASE_CHANNEL': JSON.stringify(embeddedReleaseChannel), - 'process.env.FLUXER_DESKTOP_BUILD_VARIANT': JSON.stringify(embeddedDesktopBuildVariant), - 'process.env.FLUXER_WINDOWS_GAME_CAPTURE_MODULE_ENABLED': JSON.stringify( - windowsGameCaptureModuleEnabled ? 'true' : 'false', - ), }; const electronExternals = [ 'electron', @@ -175,7 +166,6 @@ function isWindowsNativeRuntimeManifest(fileName) { } function addWinGameCaptureRuntimeArtifacts(artifacts, tag, arch) { - if (!windowsGameCaptureModuleEnabled) return; const add = (relativePath) => { artifacts.push({ label: '@fluxer/win-game-capture', @@ -498,14 +488,12 @@ function buildNativeAddons() { commands: [['pnpm', 'build']], jsEntry: 'index.js', }); - if (windowsGameCaptureModuleEnabled) { - buildNativeAddon({ - label: '@fluxer/win-game-capture', - dirName: 'win-game-capture', - commands: [['pnpm', 'build']], - jsEntry: 'index.js', - }); - } + buildNativeAddon({ + label: '@fluxer/win-game-capture', + dirName: 'win-game-capture', + commands: [['pnpm', 'build']], + jsEntry: 'index.js', + }); buildNativeAddon({ label: '@fluxer/platform-info', dirName: 'platform-info', diff --git a/fluxer_desktop/src/common/BuildVariant.ts b/fluxer_desktop/src/common/BuildVariant.ts deleted file mode 100644 index 316bae2da..000000000 --- a/fluxer_desktop/src/common/BuildVariant.ts +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import type {DesktopBuildVariant} from './Types'; - -const EMBEDDED_DESKTOP_BUILD_VARIANT = process.env.FLUXER_DESKTOP_BUILD_VARIANT; - -export const DESKTOP_BUILD_VARIANT: DesktopBuildVariant = - EMBEDDED_DESKTOP_BUILD_VARIANT === 'windows-game-capture' ? 'windows-game-capture' : 'default'; - -export const IS_WINDOWS_GAME_CAPTURE_BUILD = DESKTOP_BUILD_VARIANT === 'windows-game-capture'; diff --git a/fluxer_desktop/src/common/Types.ts b/fluxer_desktop/src/common/Types.ts index 961ae17e3..8bd960869 100644 --- a/fluxer_desktop/src/common/Types.ts +++ b/fluxer_desktop/src/common/Types.ts @@ -14,12 +14,9 @@ export interface LinuxAppearanceSnapshot { accent: {r: number; g: number; b: number} | null; } -export type DesktopBuildVariant = 'default' | 'windows-game-capture'; - export interface DesktopInfo { version: string; channel: 'stable' | 'canary'; - buildVariant: DesktopBuildVariant; arch: string; hardwareArch: string; runningUnderRosetta: boolean; @@ -532,7 +529,7 @@ export interface NativeScreenCaptureLifecycleMessage { source?: NativeScreenCaptureLifecycleSource; } -export type NativeScreenCaptureStrategy = 'game-hook' | 'dxgi-duplication' | 'window-gdi' | string; +export type NativeScreenCaptureStrategy = 'game-hook' | 'wgc' | 'dxgi-duplication' | 'window-gdi' | string; export interface NativeScreenCaptureDiagnostics { state?: number; @@ -650,7 +647,6 @@ export type TrayActionPayload = export interface ElectronAPI { platform: NodeJS.Platform; buildChannel: 'stable' | 'canary'; - buildVariant: DesktopBuildVariant; getDesktopInfo: () => Promise; getGpuInfo: () => Promise; getOpenH264Status: () => Promise; diff --git a/fluxer_desktop/src/main/DesktopDebugInfo.ts b/fluxer_desktop/src/main/DesktopDebugInfo.ts index 9cc26087b..5963ecba3 100644 --- a/fluxer_desktop/src/main/DesktopDebugInfo.ts +++ b/fluxer_desktop/src/main/DesktopDebugInfo.ts @@ -249,20 +249,12 @@ function formatReleaseChannelLabel(value: string): string { return normalized.charAt(0).toUpperCase() + normalized.slice(1); } -function formatBuildVariantLabel(value: DesktopInfo['buildVariant']): string { - if (value === 'windows-game-capture') { - return 'Windows Game Capture'; - } - return ''; -} - function getBuildString(desktopInfo: DesktopInfo): string { const buildVersion = process.env.PUBLIC_BUILD_VERSION || process.env.BUILD_VERSION || desktopInfo.version || 'dev'; const releaseChannel = formatReleaseChannelLabel( process.env.PUBLIC_RELEASE_CHANNEL || process.env.RELEASE_CHANNEL || desktopInfo.channel, ); - const buildVariant = formatBuildVariantLabel(desktopInfo.buildVariant); - return `${releaseChannel} Desktop${buildVariant ? ` ${buildVariant}` : ''} ${buildVersion}`; + return `${releaseChannel} Desktop ${buildVersion}`; } function safeGetLocale(): string { diff --git a/fluxer_desktop/src/main/NativeScreenCapture.test.mjs b/fluxer_desktop/src/main/NativeScreenCapture.test.mjs index 2a5e97a25..31e73f9c9 100644 --- a/fluxer_desktop/src/main/NativeScreenCapture.test.mjs +++ b/fluxer_desktop/src/main/NativeScreenCapture.test.mjs @@ -91,13 +91,11 @@ function loadNativeScreenCapture({ addon, tccStatus = 'not-determined', frameSinkHandle = null, - windowsGameCaptureModuleEnabled = false, } = {}) { const handlers = new Map(); const calls = { logs: {debug: [], warn: []}, nativeModuleImports: [], - windowsGameCapturePolicyEnableCalls: 0, }; let uuidCounter = 0; @@ -163,16 +161,6 @@ function loadNativeScreenCapture({ normalizeScreenCaptureDimension: (value) => value, }; } - if (specifier === './WindowsGameCapturePolicy') { - return { - WINDOWS_GAME_CAPTURE_DISABLED_DETAIL: 'windows-game-capture-disabled-until-code-signed', - WINDOWS_GAME_CAPTURE_DISABLED_REASON: 'disabled-by-launch', - WINDOWS_GAME_CAPTURE_MODULE_ENABLED: windowsGameCaptureModuleEnabled, - enableWindowsGameCaptureModuleForCurrentProcess: () => { - calls.windowsGameCapturePolicyEnableCalls += 1; - }, - }; - } throw new Error(`Unexpected import: ${specifier}`); } @@ -180,6 +168,7 @@ function loadNativeScreenCapture({ const context = vm.createContext({ Buffer, ArrayBuffer, + Error, console, clearTimeout, exports: module.exports, @@ -457,7 +446,7 @@ describe('NativeScreenCapture source identity and capability reporting', () => { }); }); - test('reports Windows native game capture disabled before loading the addon', async () => { + test('reports a Windows native game capture load failure when requiring the addon throws', async () => { const harness = loadNativeScreenCapture({platform: 'win32'}); harness.module.registerNativeScreenCaptureHandlers(); @@ -465,24 +454,50 @@ describe('NativeScreenCapture source identity and capability reporting', () => { assert.deepEqual(plain(availability), { available: false, backend: 'windows-game-capture', - reason: 'disabled-by-launch', - detail: 'windows-game-capture-disabled-until-code-signed', - capabilities: {hidesCursor: true, screens: false, windows: false}, + reason: 'load-failed', + detail: 'No fake addon configured for @fluxer/win-game-capture', windowsHagsState: 'enabled', windowsHagsDetail: 'HwSchMode=2', }); - assert.deepEqual(harness.calls.nativeModuleImports, []); - assert.equal(harness.calls.windowsGameCapturePolicyEnableCalls, 0); + assert.deepEqual(harness.calls.nativeModuleImports, ['@fluxer/win-game-capture']); + assert.equal(harness.calls.logs.warn[0][0], 'Failed to load Windows native screen capture addon'); }); - test('loads Windows native game capture only for the game capture build variant', async () => { + test('reports a Windows native game capture load failure surfaced by the addon loadError', async () => { + const {addon} = makeNativeAddon(); + const loadError = new Error( + '@fluxer/win-game-capture native module failed to load.\nmodule=@fluxer/win-game-capture\nreason=native binary not found', + ); + loadError.name = 'NativeModuleLoadError'; + loadError.nativeDiagnostics = { + schemaVersion: 1, + moduleName: '@fluxer/win-game-capture', + reason: 'native binary not found', + }; + addon.loadError = loadError; + const harness = loadNativeScreenCapture({platform: 'win32', addon}); + harness.module.registerNativeScreenCaptureHandlers(); + + const availability = await harness.handlers.get('native-screen-capture:get-availability')(); + + assert.deepEqual(plain(availability), { + available: false, + backend: 'windows-game-capture', + reason: 'load-failed', + detail: + '@fluxer/win-game-capture native module failed to load.\nmodule=@fluxer/win-game-capture\nreason=native binary not found', + windowsHagsState: 'enabled', + windowsHagsDetail: 'HwSchMode=2', + }); + assert.deepEqual(harness.calls.nativeModuleImports, ['@fluxer/win-game-capture']); + assert.equal(harness.calls.logs.warn[0][0], 'Windows native screen capture addon reported load error'); + assert.equal(harness.calls.logs.warn[0][1], loadError); + }); + + test('loads Windows native game capture and reports its backend capabilities', async () => { const {addon} = makeNativeAddon(); addon.getAvailability = () => ({available: true, backend: 'windows-game-capture'}); - const harness = loadNativeScreenCapture({ - platform: 'win32', - addon, - windowsGameCaptureModuleEnabled: true, - }); + const harness = loadNativeScreenCapture({platform: 'win32', addon}); harness.module.registerNativeScreenCaptureHandlers(); const availability = await harness.handlers.get('native-screen-capture:get-availability')(); @@ -495,7 +510,6 @@ describe('NativeScreenCapture source identity and capability reporting', () => { capabilities: {hidesCursor: false, screens: true, windows: true}, }); assert.deepEqual(harness.calls.nativeModuleImports, ['@fluxer/win-game-capture']); - assert.equal(harness.calls.windowsGameCapturePolicyEnableCalls, 1); }); test('routes Windows display sources through the native screen path without remapping to game', async () => { @@ -504,7 +518,6 @@ describe('NativeScreenCapture source identity and capability reporting', () => { const harness = loadNativeScreenCapture({ platform: 'win32', addon, - windowsGameCaptureModuleEnabled: true, frameSinkHandle: (captureId) => ({native: true, captureId}), }); harness.module.registerNativeScreenCaptureHandlers(); diff --git a/fluxer_desktop/src/main/NativeScreenCapture.ts b/fluxer_desktop/src/main/NativeScreenCapture.ts index a9c2f5b1b..db2ef088b 100644 --- a/fluxer_desktop/src/main/NativeScreenCapture.ts +++ b/fluxer_desktop/src/main/NativeScreenCapture.ts @@ -20,12 +20,6 @@ import {ipcMain} from 'electron'; import {getTccStatus} from './MacTcc'; import {isValidStartOptions, normalizeScreenCaptureDimension} from './NativeScreenCaptureValidation'; import {createNativeVoiceEngineScreenFrameSinkHandle} from './NativeVoiceEngine'; -import { - enableWindowsGameCaptureModuleForCurrentProcess, - WINDOWS_GAME_CAPTURE_DISABLED_DETAIL, - WINDOWS_GAME_CAPTURE_DISABLED_REASON, - WINDOWS_GAME_CAPTURE_MODULE_ENABLED, -} from './WindowsGameCapturePolicy'; const logger = createChildLogger('NativeScreenCapture'); const requireModule = createRequire(import.meta.url); @@ -150,11 +144,9 @@ interface ActiveNativeScreenSession { const MAX_NATIVE_SCREEN_SESSIONS_PER_SENDER = 2; const GAME_CAPTURE_DETAIL = { - hookDisabled: 'game-capture-hook-disabled', addonError: 'game-capture-addon-error', } as const; -const FLUXER_GAME_CAPTURE_DISABLE_HOOK = 'FLUXER_GAME_CAPTURE_DISABLE_HOOK'; const WINDOWS_HAGS_REGISTRY_PATH = 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\GraphicsDrivers'; const WINDOWS_HAGS_REGISTRY_VALUE = 'HwSchMode'; const WINDOWS_HAGS_REGISTRY_READ_TIMEOUT_MS = 1500; @@ -164,11 +156,6 @@ interface WindowsHagsDiagnostic { windowsHagsDetail?: string; } -function isGameCaptureHookDisabledByEnv(): boolean { - const value = process.env[FLUXER_GAME_CAPTURE_DISABLE_HOOK]; - return typeof value === 'string' && value.length > 0; -} - let cachedLoadResult: NativeAddonLoadResult | undefined; let cachedWindowsHagsDiagnostic: {expiresAtMs: number; value: WindowsHagsDiagnostic} | undefined; let handlersRegistered = false; @@ -362,22 +349,7 @@ function loadLinuxNativeScreenCaptureAddon(): NativeAddonLoadResult { } function loadWindowsNativeScreenCaptureAddon(): NativeAddonLoadResult { - if (!WINDOWS_GAME_CAPTURE_MODULE_ENABLED) { - const result: NativeAddonLoadResult = { - platform: process.platform, - availability: { - available: false, - backend: 'windows-game-capture', - reason: WINDOWS_GAME_CAPTURE_DISABLED_REASON, - detail: WINDOWS_GAME_CAPTURE_DISABLED_DETAIL, - capabilities: {hidesCursor: true, screens: false, windows: false}, - }, - }; - cachedLoadResult = result; - return result; - } try { - enableWindowsGameCaptureModuleForCurrentProcess(); const addon = requireModule('@fluxer/win-game-capture') as WindowsNativeScreenCaptureModule; if (addon.loadError) { const detail = describeAddonLoadError(addon.loadError); @@ -846,9 +818,6 @@ async function startNativeScreenCapture( } const requestedWidth = normalizeScreenCaptureDimension(options.width); const requestedHeight = normalizeScreenCaptureDimension(options.height); - if (loadResult.platform === 'win32' && options.sourceKind === 'game' && isGameCaptureHookDisabledByEnv()) { - throw new Error(`Game capture hook disabled by environment (${GAME_CAPTURE_DETAIL.hookDisabled})`); - } const captureId = options.captureId?.trim() || randomUUID(); if (activeSessions.has(captureId)) { throw new Error('Native screen capture id is already active'); @@ -900,16 +869,22 @@ async function startNativeScreenCapture( ); }, }; - if (options.sourceKind === 'game') { - session.onStalled = (stallMessage) => { - if (session.finalized) return; - logger.info('Game capture reported a stall (non-fatal)', {captureId, detail: stallMessage}); - }; - session.onDiagnostic = (diagnosticMessage) => { - if (session.finalized) return; - logger.debug('Game capture diagnostic', {captureId, detail: diagnosticMessage}); - }; - } + session.onStalled = (stallMessage) => { + if (session.finalized) return; + logger.info('Native screen capture reported a stall (non-fatal)', { + captureId, + sourceKind: options.sourceKind, + detail: stallMessage, + }); + }; + session.onDiagnostic = (diagnosticMessage) => { + if (session.finalized) return; + logger.debug('Native screen capture diagnostic', { + captureId, + sourceKind: options.sourceKind, + detail: diagnosticMessage, + }); + }; capture.on('error', session.onError); capture.on('closed', session.onClosed); if (session.onStalled) capture.on('stalled', session.onStalled); diff --git a/fluxer_desktop/src/main/PlatformInfo.ts b/fluxer_desktop/src/main/PlatformInfo.ts index 8cb9b81ce..031b4e581 100644 --- a/fluxer_desktop/src/main/PlatformInfo.ts +++ b/fluxer_desktop/src/main/PlatformInfo.ts @@ -3,7 +3,6 @@ import {createRequire} from 'node:module'; import os from 'node:os'; import {BUILD_CHANNEL} from '@electron/common/BuildChannel'; -import {DESKTOP_BUILD_VARIANT} from '@electron/common/BuildVariant'; import type { AppMetricsSnapshot, CpuInfo, @@ -139,7 +138,6 @@ export async function getDesktopInfo(options: DesktopInfoOptions = {}): Promise< return { version: app.getVersion(), channel: BUILD_CHANNEL, - buildVariant: DESKTOP_BUILD_VARIANT, arch: process.arch, hardwareArch, runningUnderRosetta, diff --git a/fluxer_desktop/src/main/RpcServer.ts b/fluxer_desktop/src/main/RpcServer.ts index cbfe0e6f1..287ebb25d 100644 --- a/fluxer_desktop/src/main/RpcServer.ts +++ b/fluxer_desktop/src/main/RpcServer.ts @@ -2,7 +2,6 @@ import http from 'node:http'; import {BUILD_CHANNEL} from '@electron/common/BuildChannel'; -import {DESKTOP_BUILD_VARIANT} from '@electron/common/BuildVariant'; import {CANARY_APP_URL, STABLE_APP_URL} from '@electron/common/Constants'; import {getCustomAppUrl} from '@electron/common/DesktopConfig'; import {getMainWindow, showWindow} from '@electron/main/Window'; @@ -115,7 +114,6 @@ const handleHealth = (_req: http.IncomingMessage, res: http.ServerResponse) => { data: { status: 'ok', channel: BUILD_CHANNEL, - build_variant: DESKTOP_BUILD_VARIANT, version: app.getVersion(), platform: process.platform, }, diff --git a/fluxer_desktop/src/main/StreamingPriority.test.mjs b/fluxer_desktop/src/main/StreamingPriority.test.mjs index 4b6782ff6..64ee151ee 100644 --- a/fluxer_desktop/src/main/StreamingPriority.test.mjs +++ b/fluxer_desktop/src/main/StreamingPriority.test.mjs @@ -45,7 +45,6 @@ function loadStreamingPriority({ currentPriority = 0, elevateResult = true, restoreResult = true, - windowsGameCaptureModuleEnabled = false, } = {}) { const calls = { elevate: [], @@ -58,7 +57,6 @@ function loadStreamingPriority({ guardStop: [], setPriority: [], nativeModuleImports: [], - windowsGameCapturePolicyEnableCalls: 0, logs: {debug: [], info: [], warn: []}, }; @@ -119,15 +117,6 @@ function loadStreamingPriority({ if (specifier === 'electron') return {app, powerSaveBlocker}; if (specifier === 'electron-log') return log; if (specifier === './WindowsScreenCaptureGuard') return guard; - if (specifier === './WindowsGameCapturePolicy') { - return { - WINDOWS_GAME_CAPTURE_DISABLED_DETAIL: 'windows-game-capture-disabled-until-code-signed', - WINDOWS_GAME_CAPTURE_MODULE_ENABLED: windowsGameCaptureModuleEnabled, - enableWindowsGameCaptureModuleForCurrentProcess: () => { - calls.windowsGameCapturePolicyEnableCalls += 1; - }, - }; - } throw new Error(`Unexpected import: ${specifier}`); } @@ -156,7 +145,7 @@ function loadStreamingPriority({ } describe('StreamingPriority GPU scheduling priority', () => { - test('keeps streaming priority active without loading unsigned Windows game capture code', () => { + test('elevates GPU scheduling priority on Windows', () => { const webContents = makeWebContents(2001); const {calls, module} = loadStreamingPriority({ metrics: [ @@ -170,8 +159,14 @@ describe('StreamingPriority GPU scheduling priority', () => { module.acquireStreamingPriority(webContents); - assert.deepEqual(calls.nativeModuleImports, []); - assert.deepEqual(calls.elevate, []); + assert.deepEqual(calls.nativeModuleImports, ['@fluxer/win-game-capture']); + assert.deepEqual(calls.elevate, [ + {processId: 1000, priorityClass: 'high'}, + {processId: 2001, priorityClass: 'high'}, + {processId: 3001, priorityClass: 'high'}, + {processId: 3002, priorityClass: 'high'}, + {processId: 3003, priorityClass: 'high'}, + ]); assert.deepEqual(calls.setPriority, [-7]); assert.deepEqual(webContents.backgroundThrottlingAllowed, [false]); assert.equal(calls.intervals.length, 1); @@ -190,14 +185,20 @@ describe('StreamingPriority GPU scheduling priority', () => { supported: true, priorityClass: 'high', env: GPU_SCHEDULING_PRIORITY_ENV, - nativeModuleStatus: 'unavailable', - nativeModuleLoadErrorDetail: 'windows-game-capture-disabled-until-code-signed', + nativeModuleStatus: 'loaded', + nativeModuleLoadErrorDetail: null, refreshActive: true, refreshIntervalMs: 20000, trackedWebContents: 1, - elevatedProcesses: [], + elevatedProcesses: [ + {processId: 1000, priorityClass: 'high'}, + {processId: 2001, priorityClass: 'high'}, + {processId: 3001, priorityClass: 'high'}, + {processId: 3002, priorityClass: 'high'}, + {processId: 3003, priorityClass: 'high'}, + ], lastAcquire: { - status: 'native-module-unavailable', + status: 'succeeded', priorityClass: 'high', targets: [ {processId: 1000, reasons: ['native-main-encoder-capture']}, @@ -206,26 +207,19 @@ describe('StreamingPriority GPU scheduling priority', () => { {processId: 3002, reasons: ['chromium-video-encode']}, {processId: 3003, reasons: ['chromium-video-capture']}, ], - elevatedProcessIds: [], + elevatedProcessIds: [1000, 2001, 3001, 3002, 3003], skippedProcessIds: [], - failedProcessIds: [ - {processId: 1000, reason: 'windows-game-capture-disabled-until-code-signed'}, - {processId: 2001, reason: 'windows-game-capture-disabled-until-code-signed'}, - {processId: 3001, reason: 'windows-game-capture-disabled-until-code-signed'}, - {processId: 3002, reason: 'windows-game-capture-disabled-until-code-signed'}, - {processId: 3003, reason: 'windows-game-capture-disabled-until-code-signed'}, - ], - detail: 'windows-game-capture-disabled-until-code-signed', + failedProcessIds: [], }, lastRestore: null, }, }); module.acquireStreamingPriority(webContents); - assert.equal(calls.elevate.length, 0); + assert.equal(calls.elevate.length, 5); assert.deepEqual( normalize(module.getStreamingPriorityDiagnostics().gpuScheduling.lastAcquire.skippedProcessIds), - [], + [1000, 2001, 3001, 3002, 3003], ); module.releaseStreamingPriority(); @@ -233,13 +227,19 @@ describe('StreamingPriority GPU scheduling priority', () => { module.releaseStreamingPriority(); assert.deepEqual(webContents.backgroundThrottlingAllowed, [false, false, true]); - assert.deepEqual(calls.restore, []); + assert.deepEqual(calls.restore, [ + {processId: 1000}, + {processId: 2001}, + {processId: 3001}, + {processId: 3002}, + {processId: 3003}, + ]); assert.deepEqual(calls.setPriority, [-7, 0]); assert.deepEqual(normalize(module.getStreamingPriorityDiagnostics().gpuScheduling.elevatedProcesses), []); assert.deepEqual(normalize(module.getStreamingPriorityDiagnostics().gpuScheduling.lastRestore), { - status: 'no-active-priority', - processIds: [], - restoredProcessIds: [], + status: 'succeeded', + processIds: [1000, 2001, 3001, 3002, 3003], + restoredProcessIds: [1000, 2001, 3001, 3002, 3003], failedProcessIds: [], }); assert.deepEqual(calls.guardStop, ['streaming-priority-release']); @@ -251,22 +251,20 @@ describe('StreamingPriority GPU scheduling priority', () => { module.acquireStreamingPriority(); - assert.deepEqual(calls.elevate, []); + assert.deepEqual(calls.elevate, [{processId: 1000, priorityClass: 'realtime'}]); assert.equal(module.getStreamingPriorityDiagnostics().gpuScheduling.priorityClass, 'realtime'); } }); - test('loads Windows game capture GPU priority support only for the game capture build variant', () => { + test('loads the Windows game capture native module for GPU priority elevation', () => { const webContents = makeWebContents(2001); const {calls, module} = loadStreamingPriority({ - windowsGameCaptureModuleEnabled: true, metrics: [{pid: 3001, type: 'GPU'}], }); module.acquireStreamingPriority(webContents); assert.deepEqual(calls.nativeModuleImports, ['@fluxer/win-game-capture']); - assert.equal(calls.windowsGameCapturePolicyEnableCalls, 1); assert.deepEqual( calls.elevate.map((entry) => entry.processId), [1000, 2001, 3001], @@ -317,7 +315,7 @@ describe('StreamingPriority GPU scheduling priority', () => { module.acquireStreamingPriority(); - assert.deepEqual(calls.elevate, []); + assert.deepEqual(calls.elevate, [{processId: 1000, priorityClass: 'high'}]); assert.equal(calls.logs.warn.length, 1); assert.equal(calls.logs.warn[0][0], '[StreamingPriority] Ignoring invalid GPU scheduling priority override'); }); @@ -329,12 +327,12 @@ describe('StreamingPriority GPU scheduling priority', () => { module.releaseStreamingPriority(); assert.deepEqual(calls.setPriority, []); - assert.deepEqual(calls.restore, []); + assert.deepEqual(calls.restore, [{processId: 1000}]); assert.equal(module.getStreamingPriorityDiagnostics().processPriority.elevated, false); assert.equal(module.getStreamingPriorityDiagnostics().processPriority.savedPriority, null); }); - test('records native module diagnostics when unsigned game capture is disabled', () => { + test('records the real native module load failure detail', () => { const webContents = makeWebContents(2001); const {calls, module} = loadStreamingPriority({ addonLoadError: new Error('native binary missing'), @@ -344,11 +342,11 @@ describe('StreamingPriority GPU scheduling priority', () => { module.acquireStreamingPriority(webContents); assert.deepEqual(calls.elevate, []); - assert.deepEqual(calls.nativeModuleImports, []); + assert.deepEqual(calls.nativeModuleImports, ['@fluxer/win-game-capture']); assert.equal(module.getStreamingPriorityDiagnostics().gpuScheduling.nativeModuleStatus, 'unavailable'); assert.equal( module.getStreamingPriorityDiagnostics().gpuScheduling.nativeModuleLoadErrorDetail, - 'windows-game-capture-disabled-until-code-signed', + 'native binary missing', ); assert.deepEqual(normalize(module.getStreamingPriorityDiagnostics().gpuScheduling.lastAcquire), { status: 'native-module-unavailable', @@ -361,15 +359,41 @@ describe('StreamingPriority GPU scheduling priority', () => { elevatedProcessIds: [], skippedProcessIds: [], failedProcessIds: [ - {processId: 1000, reason: 'windows-game-capture-disabled-until-code-signed'}, - {processId: 2001, reason: 'windows-game-capture-disabled-until-code-signed'}, - {processId: 3001, reason: 'windows-game-capture-disabled-until-code-signed'}, + {processId: 1000, reason: 'native binary missing'}, + {processId: 2001, reason: 'native binary missing'}, + {processId: 3001, reason: 'native binary missing'}, ], - detail: 'windows-game-capture-disabled-until-code-signed', + detail: 'native binary missing', }); + assert.equal(calls.logs.debug[0][0], '[StreamingPriority] Windows GPU priority module reported load error'); assert.equal( - calls.logs.debug[0][0], + calls.logs.debug[1][0], '[StreamingPriority] Cannot elevate GPU scheduling priority; native module unavailable', ); }); + + test('reduces a multi-line native load error to its first line for per-process reasons', () => { + const {calls, module} = loadStreamingPriority({ + addonLoadError: new Error( + '@fluxer/win-game-capture native module failed to load.\nreason=native binary not found\nnativeRoot=C:/fluxer', + ), + }); + + module.acquireStreamingPriority(); + module.releaseStreamingPriority(); + + assert.deepEqual(calls.elevate, []); + const diagnostics = module.getStreamingPriorityDiagnostics(); + assert.equal( + diagnostics.gpuScheduling.nativeModuleLoadErrorDetail, + '@fluxer/win-game-capture native module failed to load.\nreason=native binary not found\nnativeRoot=C:/fluxer', + ); + assert.deepEqual(normalize(diagnostics.gpuScheduling.lastAcquire.failedProcessIds), [ + {processId: 1000, reason: '@fluxer/win-game-capture native module failed to load.'}, + ]); + assert.equal( + diagnostics.gpuScheduling.lastAcquire.detail, + '@fluxer/win-game-capture native module failed to load.\nreason=native binary not found\nnativeRoot=C:/fluxer', + ); + }); }); diff --git a/fluxer_desktop/src/main/StreamingPriority.ts b/fluxer_desktop/src/main/StreamingPriority.ts index ee05257fa..9c1f9309b 100644 --- a/fluxer_desktop/src/main/StreamingPriority.ts +++ b/fluxer_desktop/src/main/StreamingPriority.ts @@ -4,11 +4,6 @@ import {createRequire} from 'node:module'; import os from 'node:os'; import {app, powerSaveBlocker} from 'electron'; import log from 'electron-log'; -import { - enableWindowsGameCaptureModuleForCurrentProcess, - WINDOWS_GAME_CAPTURE_DISABLED_DETAIL, - WINDOWS_GAME_CAPTURE_MODULE_ENABLED, -} from './WindowsGameCapturePolicy'; import {retainWindowsScreenCaptureGuard, stopWindowsScreenCaptureGuard} from './WindowsScreenCaptureGuard'; const STREAMING_PRIORITY = os.constants?.priority?.PRIORITY_ABOVE_NORMAL ?? -7; @@ -127,6 +122,15 @@ function formatErrorDetail(error: unknown): string { return String(error); } +const GPU_PRIORITY_MODULE_UNAVAILABLE_DETAIL = 'Windows GPU priority native API unavailable'; + +function gpuPriorityModuleUnavailableReason(): string { + if (gpuPriorityModuleLoadErrorDetail === null) return GPU_PRIORITY_MODULE_UNAVAILABLE_DETAIL; + const lineBreakIndex = gpuPriorityModuleLoadErrorDetail.indexOf('\n'); + if (lineBreakIndex === -1) return gpuPriorityModuleLoadErrorDetail; + return gpuPriorityModuleLoadErrorDetail.slice(0, lineBreakIndex).trimEnd(); +} + function resolveGpuSchedulingPriority(): WindowsGpuSchedulingPriority | null { const raw = process.env[GPU_SCHEDULING_PRIORITY_ENV]; if (raw == null || raw.trim() === '') return 'high'; @@ -189,13 +193,7 @@ function restoreProcessPriority(): void { function loadWindowsGpuPriorityModule(): WindowsGpuPriorityModule | null { if (process.platform !== 'win32') return null; if (gpuPriorityModule !== undefined) return gpuPriorityModule; - if (!WINDOWS_GAME_CAPTURE_MODULE_ENABLED) { - gpuPriorityModuleLoadErrorDetail = WINDOWS_GAME_CAPTURE_DISABLED_DETAIL; - gpuPriorityModule = null; - return null; - } try { - enableWindowsGameCaptureModuleForCurrentProcess(); const addon = requireModule('@fluxer/win-game-capture') as WindowsGpuPriorityModule; if (addon.loadError) { gpuPriorityModuleLoadErrorDetail = formatErrorDetail(addon.loadError); @@ -405,9 +403,9 @@ function elevateGpuSchedulingPriority(webContents?: Electron.WebContents): void skippedProcessIds: [], failedProcessIds: targets.slice(0, MAX_GPU_PRIORITY_DIAGNOSTIC_TARGETS).map((target) => ({ processId: target.processId, - reason: gpuPriorityModuleLoadErrorDetail ?? 'Windows GPU priority native API unavailable', + reason: gpuPriorityModuleUnavailableReason(), })), - detail: gpuPriorityModuleLoadErrorDetail ?? 'Windows GPU priority native API unavailable', + detail: gpuPriorityModuleLoadErrorDetail ?? GPU_PRIORITY_MODULE_UNAVAILABLE_DETAIL, }; log.debug('[StreamingPriority] Cannot elevate GPU scheduling priority; native module unavailable', { priorityClass: GPU_SCHEDULING_PRIORITY, @@ -504,9 +502,9 @@ function restoreGpuSchedulingPriority(): void { restoredProcessIds: [], failedProcessIds: processIds.map((processId) => ({ processId, - reason: gpuPriorityModuleLoadErrorDetail ?? 'Windows GPU priority native API unavailable', + reason: gpuPriorityModuleUnavailableReason(), })), - detail: gpuPriorityModuleLoadErrorDetail ?? 'Windows GPU priority native API unavailable', + detail: gpuPriorityModuleLoadErrorDetail ?? GPU_PRIORITY_MODULE_UNAVAILABLE_DETAIL, }; log.debug('[StreamingPriority] Cannot restore GPU scheduling priority; native module unavailable', { processIds, diff --git a/fluxer_desktop/src/main/Updater.ts b/fluxer_desktop/src/main/Updater.ts index 3d25b352b..670c0bd88 100644 --- a/fluxer_desktop/src/main/Updater.ts +++ b/fluxer_desktop/src/main/Updater.ts @@ -2,7 +2,6 @@ import {createRequire} from 'node:module'; import {BUILD_CHANNEL} from '@electron/common/BuildChannel'; -import {DESKTOP_BUILD_VARIANT} from '@electron/common/BuildVariant'; import {isPortableMode} from '@electron/common/UserDataPath'; import {destroyDesktopTray} from '@electron/main/DesktopTray'; import {isFlatpakRuntime} from '@electron/main/LinuxSandbox'; @@ -72,9 +71,7 @@ function getDesktopDownloadArch(arch: NodeJS.Architecture): DesktopDownloadArch const DESKTOP_DOWNLOAD_ARCH = getDesktopDownloadArch(process.arch); const UPDATE_API_ENDPOINT = BUILD_CHANNEL === 'canary' ? 'https://api.canary.fluxer.app' : 'https://api.fluxer.app'; -const UPDATE_VARIANT_SEGMENT = - process.platform === 'win32' && DESKTOP_BUILD_VARIANT !== 'default' ? `/${DESKTOP_BUILD_VARIANT}` : ''; -const UPDATE_BASE_URL = `${UPDATE_API_ENDPOINT}/dl/desktop/${BUILD_CHANNEL}/${process.platform}/${DESKTOP_DOWNLOAD_ARCH}${UPDATE_VARIANT_SEGMENT}`; +const UPDATE_BASE_URL = `${UPDATE_API_ENDPOINT}/dl/desktop/${BUILD_CHANNEL}/${process.platform}/${DESKTOP_DOWNLOAD_ARCH}`; const DOWNLOAD_PAGE_URL = BUILD_CHANNEL === 'canary' ? 'https://canary.fluxer.app/download' : 'https://fluxer.app/download'; diff --git a/fluxer_desktop/src/main/WindowsGameCapturePolicy.ts b/fluxer_desktop/src/main/WindowsGameCapturePolicy.ts deleted file mode 100644 index cf6562b72..000000000 --- a/fluxer_desktop/src/main/WindowsGameCapturePolicy.ts +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {IS_WINDOWS_GAME_CAPTURE_BUILD} from '@electron/common/BuildVariant'; - -const WINDOWS_GAME_CAPTURE_MODULE_ENV = 'FLUXER_WINDOWS_GAME_CAPTURE_MODULE_ENABLED'; -export const WINDOWS_GAME_CAPTURE_DISABLED_DETAIL = 'windows-game-capture-disabled-until-code-signed'; -export const WINDOWS_GAME_CAPTURE_DISABLED_REASON = 'disabled-by-launch'; - -export const WINDOWS_GAME_CAPTURE_MODULE_ENABLED = - IS_WINDOWS_GAME_CAPTURE_BUILD || process.env.FLUXER_WINDOWS_GAME_CAPTURE_MODULE_ENABLED === 'true'; - -export function enableWindowsGameCaptureModuleForCurrentProcess(): void { - if (!WINDOWS_GAME_CAPTURE_MODULE_ENABLED) return; - process.env[WINDOWS_GAME_CAPTURE_MODULE_ENV] = 'true'; -} diff --git a/fluxer_desktop/src/main/WindowsVulkanGameCaptureLayer.ts b/fluxer_desktop/src/main/WindowsVulkanGameCaptureLayer.ts index 7dcd48178..9b83d4eb8 100644 --- a/fluxer_desktop/src/main/WindowsVulkanGameCaptureLayer.ts +++ b/fluxer_desktop/src/main/WindowsVulkanGameCaptureLayer.ts @@ -3,10 +3,6 @@ import {execFileSync} from 'node:child_process'; import {createRequire} from 'node:module'; import log from 'electron-log'; -import { - enableWindowsGameCaptureModuleForCurrentProcess, - WINDOWS_GAME_CAPTURE_MODULE_ENABLED, -} from './WindowsGameCapturePolicy'; const requireModule = createRequire(import.meta.url); const VULKAN_IMPLICIT_LAYERS_REGISTRY_KEY = 'Software\\Khronos\\Vulkan\\ImplicitLayers'; @@ -21,6 +17,7 @@ interface VulkanLayerRegistrationState { type WindowsGameCaptureModule = { loadError?: Error | null; + isGameCaptureHookAvailable?: () => boolean; registerVulkanLayerManifest?: () => boolean; unregisterVulkanLayerManifest?: () => boolean; resolveVulkanLayerManifestPath?: () => string | null; @@ -39,8 +36,12 @@ function parseRegistryValueNames(stdout: string): Array { return valueNames; } +function normalizeVulkanLayerValueName(valueName: string): string { + return valueName.replace(/\//g, '\\').toLowerCase(); +} + function isFluxerGameCaptureVulkanLayerValue(valueName: string): boolean { - const normalized = valueName.replace(/\//g, '\\').toLowerCase(); + const normalized = normalizeVulkanLayerValueName(valueName); if (!normalized.includes('\\@fluxer\\win-game-capture\\')) return false; return /\\fluxer-vulkan-layer\.win32-(?:x64|ia32|arm64)-msvc\.json$/.test(normalized); } @@ -66,13 +67,33 @@ function deleteVulkanLayerRegistryValue(root: string, valueName: string): void { }); } -function removeStaleFluxerGameCaptureVulkanLayers(): void { +function isSameVulkanLayerManifestPath(left: string, right: string): boolean { + return normalizeVulkanLayerValueName(left) === normalizeVulkanLayerValueName(right); +} + +function removeStaleFluxerGameCaptureVulkanLayers(keepManifestPath: string | null): void { if (process.platform !== 'win32') return; for (const root of VULKAN_REGISTRY_ROOTS) { - const valueNames = queryVulkanLayerRegistryValues(root); + let valueNames: Array; + try { + valueNames = queryVulkanLayerRegistryValues(root); + } catch (error) { + log.warn('[VulkanGameCaptureLayer] Failed to enumerate Vulkan implicit layer registry values', {root, error}); + continue; + } for (const valueName of valueNames) { if (!isFluxerGameCaptureVulkanLayerValue(valueName)) continue; - deleteVulkanLayerRegistryValue(root, valueName); + if (keepManifestPath !== null && isSameVulkanLayerManifestPath(valueName, keepManifestPath)) continue; + try { + deleteVulkanLayerRegistryValue(root, valueName); + } catch (error) { + log.warn('[VulkanGameCaptureLayer] Failed to remove stale Fluxer Vulkan layer registry value', { + root, + valueName, + error, + }); + continue; + } log.info('[VulkanGameCaptureLayer] Removed stale Fluxer Vulkan layer registry value', {root, valueName}); } } @@ -80,31 +101,35 @@ function removeStaleFluxerGameCaptureVulkanLayers(): void { function loadWindowsGameCaptureModule(): WindowsGameCaptureModule | null { if (process.platform !== 'win32') return null; - if (!WINDOWS_GAME_CAPTURE_MODULE_ENABLED) return null; - enableWindowsGameCaptureModuleForCurrentProcess(); - const addon = requireModule('@fluxer/win-game-capture') as WindowsGameCaptureModule; - if (addon.loadError) { - log.warn('[VulkanGameCaptureLayer] Native game capture addon unavailable', addon.loadError); + try { + const addon = requireModule('@fluxer/win-game-capture') as WindowsGameCaptureModule; + if (addon.loadError) { + log.warn('[VulkanGameCaptureLayer] Native game capture addon unavailable', addon.loadError); + return null; + } + return addon; + } catch (error) { + log.warn('[VulkanGameCaptureLayer] Failed to load the native game capture addon', error); return null; } - return addon; } export function initializeWindowsVulkanGameCaptureLayer(): void { if (process.platform !== 'win32') return; + const addon = loadWindowsGameCaptureModule(); + if (!addon || addon.isGameCaptureHookAvailable?.() !== true) { + removeStaleFluxerGameCaptureVulkanLayers(null); + log.info('[VulkanGameCaptureLayer] Vulkan implicit layer left unregistered; hook-based game capture is disabled'); + return; + } try { - if (!WINDOWS_GAME_CAPTURE_MODULE_ENABLED) { - removeStaleFluxerGameCaptureVulkanLayers(); - log.info('[VulkanGameCaptureLayer] Native game capture disabled until Windows binaries are code signed'); - return; - } - const addon = loadWindowsGameCaptureModule(); - if (!addon) return; + const manifestPath = addon.resolveVulkanLayerManifestPath?.() ?? null; + removeStaleFluxerGameCaptureVulkanLayers(manifestPath); const registered = addon.registerVulkanLayerManifest?.() ?? false; const state = addon.getVulkanLayerRegistrationState?.() ?? null; log.info('[VulkanGameCaptureLayer] Vulkan implicit layer registration checked', { registered, - manifestPath: addon.resolveVulkanLayerManifestPath?.() ?? null, + manifestPath, state, }); } catch (error) { @@ -114,19 +139,15 @@ export function initializeWindowsVulkanGameCaptureLayer(): void { export function unregisterWindowsVulkanGameCaptureLayer(): void { if (process.platform !== 'win32') return; + const addon = loadWindowsGameCaptureModule(); try { - if (!WINDOWS_GAME_CAPTURE_MODULE_ENABLED) { - removeStaleFluxerGameCaptureVulkanLayers(); - return; - } - const addon = loadWindowsGameCaptureModule(); - if (!addon) return; - const unregistered = addon.unregisterVulkanLayerManifest?.() ?? false; + const unregistered = addon?.unregisterVulkanLayerManifest?.() ?? false; log.info('[VulkanGameCaptureLayer] Vulkan implicit layer unregistration attempted', { unregistered, - manifestPath: addon.resolveVulkanLayerManifestPath?.() ?? null, + manifestPath: addon?.resolveVulkanLayerManifestPath?.() ?? null, }); } catch (error) { log.warn('[VulkanGameCaptureLayer] Failed to unregister Vulkan implicit layer', error); } + removeStaleFluxerGameCaptureVulkanLayers(null); } diff --git a/fluxer_desktop/src/preload/index.ts b/fluxer_desktop/src/preload/index.ts index 5c289c592..970eb4912 100644 --- a/fluxer_desktop/src/preload/index.ts +++ b/fluxer_desktop/src/preload/index.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {BUILD_CHANNEL} from '@electron/common/BuildChannel'; -import {DESKTOP_BUILD_VARIANT} from '@electron/common/BuildVariant'; import type { AppMetricsSnapshot, ClipboardWriteFileOptions, @@ -356,7 +355,6 @@ applyStartupAccessibilitySettings(); const api: ElectronAPI = { platform: process.platform, buildChannel: BUILD_CHANNEL, - buildVariant: DESKTOP_BUILD_VARIANT, getDesktopInfo: (): Promise => ipcRenderer.invoke('get-desktop-info'), getGpuInfo: (): Promise => ipcRenderer.invoke('get-gpu-info'), getAppMetrics: (): Promise => ipcRenderer.invoke('get-app-metrics'), diff --git a/fluxer_marketing/locales/ar.po b/fluxer_marketing/locales/ar.po index c76f41161..0331c6f83 100644 --- a/fluxer_marketing/locales/ar.po +++ b/fluxer_marketing/locales/ar.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: ar\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2156,13 +2156,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "استخدم تطبيق سطح المكتب (تطبيق الجوال قيد التطوير)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"قد يتم اكتشاف هذا الإصدار التجريبي بواسطة {microsoft_defender} أو برامج مكافحة الفيروسات الأخرى على {windows} في الوقت الحالي. راجع مشكلة GitHub {issue_link} لمزيد من التفاصيل." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "حزمة {flatpak} متأخرة حاليًا عن تنزيلات {linux} الأخرى." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2405,7 +2403,8 @@ msgstr "تطبيق {ios}" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "تطبيق {ios} على {testflight} متاح حاليًا لأعضاء {premium_tier_full_name} فقط. سيتاح الوصول العام قريبًا. يعمل تطبيق الويب الكامل {product_name} أيضًا في سفاري ويمكن إضافته إلى شاشتك الرئيسية." +msgstr "" +"تطبيق {ios} على {testflight} متاح حاليًا لأعضاء {premium_tier_full_name} فقط. سيتاح الوصول العام قريبًا. يعمل تطبيق الويب الكامل {product_name} أيضًا في سفاري ويمكن إضافته إلى شاشتك الرئيسية." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/bg.po b/fluxer_marketing/locales/bg.po index 69702cb6b..d5cbf0cdb 100644 --- a/fluxer_marketing/locales/bg.po +++ b/fluxer_marketing/locales/bg.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: bg\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2177,13 +2177,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Използвай десктоп клиента (мобилни приложения очаквайте скоро)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Тази Canary версия може временно да бъде маркирана от {microsoft_defender} или друг антивирусен софтуер на {windows}. Вижте проблема в GitHub {issue_link} за повече подробности." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "Пакетът {flatpak} в момента изостава от другите {linux} изтегляния." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2426,7 +2424,8 @@ msgstr "Приложение за {ios}" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "{ios} приложението в {testflight} в момента е достъпно само за членове на {premium_tier_full_name}. Скоро ще има публичен достъп. Пълната уеб версия на {product_name} също работи в Safari и може да бъде добавена към началния ви екран." +msgstr "" +"{ios} приложението в {testflight} в момента е достъпно само за членове на {premium_tier_full_name}. Скоро ще има публичен достъп. Пълната уеб версия на {product_name} също работи в Safari и може да бъде добавена към началния ви екран." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/cs.po b/fluxer_marketing/locales/cs.po index aa952c8c4..e3460dab9 100644 --- a/fluxer_marketing/locales/cs.po +++ b/fluxer_marketing/locales/cs.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: cs\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2171,13 +2171,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Použít desktopovou aplikaci (mobilní aplikace připravujeme)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Tato sestava Canary může být dočasně označena programem {microsoft_defender} nebo jiným antivirovým softwarem v systému {windows}. Další podrobnosti naleznete v problému na GitHubu {issue_link}." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "Balíček {flatpak} aktuálně zaostává za ostatními {linux} stahováními." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2420,7 +2418,8 @@ msgstr "Aplikace pro {ios}" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "Aplikace {ios} na {testflight} je momentálně omezena na členy {premium_tier_full_name}. Veřejný přístup bude brzy. Celá webová aplikace {product_name} funguje také v Safari a můžete si ji přidat na plochu." +msgstr "" +"Aplikace {ios} na {testflight} je momentálně omezena na členy {premium_tier_full_name}. Veřejný přístup bude brzy. Celá webová aplikace {product_name} funguje také v Safari a můžete si ji přidat na plochu." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/da.po b/fluxer_marketing/locales/da.po index 8f6e8d792..35b58e8ef 100644 --- a/fluxer_marketing/locales/da.po +++ b/fluxer_marketing/locales/da.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: da\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2176,13 +2176,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Brug desktop-klienten (mobilversionen på vej)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Denne Canary-version kan blive flagget af {microsoft_defender} eller anden antivirussoftware på {windows} for nu. Se GitHub-issue {issue_link} for flere detaljer." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "{flatpak}-pakken er i øjeblikket bag de andre {linux}-downloads." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2425,7 +2423,8 @@ msgstr "{ios}-app" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "Appen til {ios} på {testflight} er i øjeblikket begrænset til medlemmer af {premium_tier_full_name}. Offentlig adgang kommer snart. Den fulde {product_name} webapp virker også i Safari og kan føjes til din startskærm." +msgstr "" +"Appen til {ios} på {testflight} er i øjeblikket begrænset til medlemmer af {premium_tier_full_name}. Offentlig adgang kommer snart. Den fulde {product_name} webapp virker også i Safari og kan føjes til din startskærm." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/de.po b/fluxer_marketing/locales/de.po index 578c53841..256ae4ca3 100644 --- a/fluxer_marketing/locales/de.po +++ b/fluxer_marketing/locales/de.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2188,13 +2188,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Nutze den Desktop-Client (Mobile Version bald verfügbar)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Diese Canary-Version wird möglicherweise vorübergehend von {microsoft_defender} oder anderer Antivirensoftware unter {windows} erkannt. Weitere Details findest du in GitHub-Issue {issue_link}." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "Das {flatpak}-Paket hinkt derzeit den anderen {linux}-Downloads hinterher." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2437,7 +2435,8 @@ msgstr "{ios}-App" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "Die {ios}-App über {testflight} ist derzeit auf {premium_tier_full_name}-Mitglieder beschränkt. Öffentlicher Zugang folgt in Kürze. Die vollständige {product_name}-Web-App funktioniert auch in Safari und kann zu deinem Homescreen hinzugefügt werden." +msgstr "" +"Die {ios}-App über {testflight} ist derzeit auf {premium_tier_full_name}-Mitglieder beschränkt. Öffentlicher Zugang folgt in Kürze. Die vollständige {product_name}-Web-App funktioniert auch in Safari und kann zu deinem Homescreen hinzugefügt werden." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/el.po b/fluxer_marketing/locales/el.po index 72fd00cd8..b8396268f 100644 --- a/fluxer_marketing/locales/el.po +++ b/fluxer_marketing/locales/el.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: el\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2189,13 +2189,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Χρησιμοποίησε την εφαρμογή για επιφάνεια εργασίας (η έκδοση για κινητά έρχεται σύντομα)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Αυτή η έκδοση Canary ενδέχεται να επισημανθεί προσωρινά από το {microsoft_defender} ή άλλο λογισμικό προστασίας από ιούς στα {windows}. Δείτε το ζήτημα στο GitHub {issue_link} για περισσότερες λεπτομέρειες." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "Το πακέτο {flatpak} είναι προς το παρόν πίσω από τις άλλες λήψεις {linux}." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2438,7 +2436,8 @@ msgstr "Εφαρμογή {ios}" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "Η εφαρμογή {ios} στο {testflight} είναι προς το παρόν διαθέσιμη μόνο για μέλη του {premium_tier_full_name}. Η δημόσια πρόσβαση έρχεται σύντομα. Η πλήρης web εφαρμογή {product_name} λειτουργεί επίσης στο Safari και μπορεί να προστεθεί στην Αρχική σας οθόνη." +msgstr "" +"Η εφαρμογή {ios} στο {testflight} είναι προς το παρόν διαθέσιμη μόνο για μέλη του {premium_tier_full_name}. Η δημόσια πρόσβαση έρχεται σύντομα. Η πλήρης web εφαρμογή {product_name} λειτουργεί επίσης στο Safari και μπορεί να προστεθεί στην Αρχική σας οθόνη." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/en-GB.po b/fluxer_marketing/locales/en-GB.po index 2c7b18a8c..c9f7c12bd 100644 --- a/fluxer_marketing/locales/en-GB.po +++ b/fluxer_marketing/locales/en-GB.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: en-GB\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2166,13 +2166,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Use the desktop client (mobile coming soon)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "The {flatpak} package is currently behind the other {linux} downloads." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2416,7 +2414,8 @@ msgstr "{ios} app" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." +msgstr "" +"The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/en-US.po b/fluxer_marketing/locales/en-US.po index 27e2d0a7b..0444dc973 100644 --- a/fluxer_marketing/locales/en-US.po +++ b/fluxer_marketing/locales/en-US.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: en-US\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2166,13 +2166,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Use the desktop client (mobile coming soon)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "The {flatpak} package is currently behind the other {linux} downloads." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop diff --git a/fluxer_marketing/locales/es-419.po b/fluxer_marketing/locales/es-419.po index 7ea955446..90453a8a2 100644 --- a/fluxer_marketing/locales/es-419.po +++ b/fluxer_marketing/locales/es-419.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: es-419\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2184,13 +2184,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Utiliza el cliente de escritorio (móvil próximamente)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Es posible que esta versión Canary sea marcada por {microsoft_defender} u otro software antivirus en {windows} por ahora. Consulta el problema de GitHub {issue_link} para más detalles." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "El paquete {flatpak} está actualmente por detrás de las otras descargas de {linux}." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2434,7 +2432,8 @@ msgstr "Aplicación de {ios}" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "La app de {ios} en {testflight} está actualmente limitada a miembros de {premium_tier_full_name}. El acceso público llegará pronto. La app web completa de {product_name} también funciona en Safari y se puede añadir a tu pantalla de inicio." +msgstr "" +"La app de {ios} en {testflight} está actualmente limitada a miembros de {premium_tier_full_name}. El acceso público llegará pronto. La app web completa de {product_name} también funciona en Safari y se puede añadir a tu pantalla de inicio." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/es-ES.po b/fluxer_marketing/locales/es-ES.po index e51ccfaee..2c019ca00 100644 --- a/fluxer_marketing/locales/es-ES.po +++ b/fluxer_marketing/locales/es-ES.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: es-ES\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2185,13 +2185,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Usa el cliente de escritorio (la versión móvil, próximamente)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Es posible que esta compilación Canary sea marcada por {microsoft_defender} u otro software antivirus en {windows} por ahora. Consulta el problema de GitHub {issue_link} para más detalles." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "El paquete {flatpak} está actualmente por detrás de las otras descargas de {linux}." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2434,7 +2432,8 @@ msgstr "Aplicación para {ios}" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "La app de {ios} en {testflight} está actualmente limitada a miembros de {premium_tier_full_name}. El acceso público llegará pronto. La app web completa de {product_name} también funciona en Safari y se puede añadir a tu pantalla de inicio." +msgstr "" +"La app de {ios} en {testflight} está actualmente limitada a miembros de {premium_tier_full_name}. El acceso público llegará pronto. La app web completa de {product_name} también funciona en Safari y se puede añadir a tu pantalla de inicio." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/fi.po b/fluxer_marketing/locales/fi.po index 0452cdef0..bc09a2437 100644 --- a/fluxer_marketing/locales/fi.po +++ b/fluxer_marketing/locales/fi.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: fi\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2175,13 +2175,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Käytä työpöytäsovellusta (mobiilisovellus tulossa pian)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Tämä Canary-koontiversio saattaa toistaiseksi aiheuttaa hälytyksen {microsoft_defender}- tai muissa virustorjuntaohjelmissa järjestelmässä {windows}. Katso lisätietoja GitHub-ongelmasta {issue_link}." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "{flatpak}-paketti on tällä hetkellä jäljessä muista {linux}-latauksista." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2425,7 +2423,8 @@ msgstr "{ios}-sovellus" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "{ios}-sovellus {testflight}-ohjelmassa on tällä hetkellä rajoitettu {premium_tier_full_name}-jäsenille. Julkinen käyttö on tulossa pian. Koko {product_name}-verkkosovellus toimii myös Safarin kautta, ja sen voi lisätä Koti-valikkoon." +msgstr "" +"{ios}-sovellus {testflight}-ohjelmassa on tällä hetkellä rajoitettu {premium_tier_full_name}-jäsenille. Julkinen käyttö on tulossa pian. Koko {product_name}-verkkosovellus toimii myös Safarin kautta, ja sen voi lisätä Koti-valikkoon." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/fr.po b/fluxer_marketing/locales/fr.po index 6369fbfcf..e42e20d26 100644 --- a/fluxer_marketing/locales/fr.po +++ b/fluxer_marketing/locales/fr.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2189,13 +2189,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Utilise l'application de bureau (mobile bientôt disponible)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Cette version Canary peut être signalée par {microsoft_defender} ou d'autres logiciels antivirus sur {windows} pour le moment. Voir le problème GitHub {issue_link} pour plus de détails." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "Le paquet {flatpak} est actuellement en retard par rapport aux autres téléchargements {linux}." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2439,7 +2437,8 @@ msgstr "Application {ios}" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "L'application {ios} sur {testflight} est actuellement réservée aux membres {premium_tier_full_name}. L'accès public sera bientôt disponible. L'application web complète {product_name} fonctionne également dans Safari et peut être ajoutée à votre écran d'accueil." +msgstr "" +"L'application {ios} sur {testflight} est actuellement réservée aux membres {premium_tier_full_name}. L'accès public sera bientôt disponible. L'application web complète {product_name} fonctionne également dans Safari et peut être ajoutée à votre écran d'accueil." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/he.po b/fluxer_marketing/locales/he.po index 5f167efe2..137319541 100644 --- a/fluxer_marketing/locales/he.po +++ b/fluxer_marketing/locales/he.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: he\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2154,13 +2154,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "השתמשו בלקוח הדסקטופ (מובייל בקרוב)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"ייתכן שגרסת Canary זו תסומן על ידי {microsoft_defender} או תוכנות אנטי-וירוס אחרות ב-{windows} לעת עתה. עיין בבעיה ב-GitHub {issue_link} לפרטים נוספים." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "חבילת {flatpak} נמצאת כרגע מאחור לעומת ההורדות האחרות של {linux}." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2403,7 +2401,8 @@ msgstr "אפליקציית {ios}" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "האפליקציה ב-{ios} ב-{testflight} מוגבלת כעת לחברי {premium_tier_full_name}. גישה ציבורית תגיע בקרוב. אפליקציית האינטרנט המלאה של {product_name} עובדת גם בספארי וניתן להוסיף אותה למסך הבית שלך." +msgstr "" +"האפליקציה ב-{ios} ב-{testflight} מוגבלת כעת לחברי {premium_tier_full_name}. גישה ציבורית תגיע בקרוב. אפליקציית האינטרנט המלאה של {product_name} עובדת גם בספארי וניתן להוסיף אותה למסך הבית שלך." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/hi.po b/fluxer_marketing/locales/hi.po index bdd4eb27f..8f4c8dfc2 100644 --- a/fluxer_marketing/locales/hi.po +++ b/fluxer_marketing/locales/hi.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: hi\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2174,13 +2174,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "डेस्कटॉप क्लाइंट का उपयोग करें (मोबाइल जल्द आ रहा है)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"यह कैनरी बिल्ड फिलहाल {microsoft_defender} या अन्य एंटीवायरस सॉफ़्टवेयर द्वारा {windows} पर फ़्लैग किया जा सकता है। अधिक जानकारी के लिए GitHub इश्यू {issue_link} देखें।" +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "{flatpak} पैकेज वर्तमान में अन्य {linux} डाउनलोड से पीछे है।" #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2423,7 +2421,8 @@ msgstr "{ios} ऐप" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "{ios} ऐप {testflight} पर अभी केवल {premium_tier_full_name} सदस्यों के लिए उपलब्ध है। जल्द ही यह सभी के लिए उपलब्ध होगा। पूरा {product_name} वेब ऐप सफारी में भी काम करता है और इसे आपकी होम स्क्रीन पर जोड़ा जा सकता है।" +msgstr "" +"{ios} ऐप {testflight} पर अभी केवल {premium_tier_full_name} सदस्यों के लिए उपलब्ध है। जल्द ही यह सभी के लिए उपलब्ध होगा। पूरा {product_name} वेब ऐप सफारी में भी काम करता है और इसे आपकी होम स्क्रीन पर जोड़ा जा सकता है।" #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/hr.po b/fluxer_marketing/locales/hr.po index 8d910fa58..053ede027 100644 --- a/fluxer_marketing/locales/hr.po +++ b/fluxer_marketing/locales/hr.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: hr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2173,13 +2173,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Koristi desktop klijent (mobilna verzija uskoro)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Ovo Canary izdanje može trenutačno biti označeno od strane {microsoft_defender} ili drugog antivirusnog softvera na {windows}. Više detalja potražite u GitHub izdanju {issue_link}." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "{flatpak} paket trenutačno zaostaje za ostalim {linux} preuzimanjima." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2422,7 +2420,8 @@ msgstr "{ios} aplikacija" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "Aplikacija za {ios} na {testflight} trenutno je dostupna samo članovima {premium_tier_full_name}. Javni pristup stiže uskoro. Puna web-aplikacija {product_name} također radi u Safariju i može se dodati na početni zaslon." +msgstr "" +"Aplikacija za {ios} na {testflight} trenutno je dostupna samo članovima {premium_tier_full_name}. Javni pristup stiže uskoro. Puna web-aplikacija {product_name} također radi u Safariju i može se dodati na početni zaslon." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/hu.po b/fluxer_marketing/locales/hu.po index 9a5b9d250..3363e6539 100644 --- a/fluxer_marketing/locales/hu.po +++ b/fluxer_marketing/locales/hu.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: hu\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2183,13 +2183,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Használd az asztali alkalmazást (mobil verzió hamarosan)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Ez a Canary build átmenetileg felkeltheti a figyelmet a(z) {microsoft_defender} vagy más víruskereső szoftvereknél a(z) {windows} rendszeren. További részletekért tekintsd meg a(z) {issue_link} GitHub-ügyet." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "A {flatpak} csomag jelenleg le van maradva a többi {linux} letöltéstől." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2433,7 +2431,8 @@ msgstr "{ios} alkalmazás" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "Az {ios} alkalmazás a {testflight} béta verzióban jelenleg csak a {premium_tier_full_name} tagok számára érhető el. Hamarosan megnyílik a nyilvános hozzáférés. A teljes {product_name} webalkalmazás Safari böngészőben is működik, és hozzáadható a kezdőképernyőhöz." +msgstr "" +"Az {ios} alkalmazás a {testflight} béta verzióban jelenleg csak a {premium_tier_full_name} tagok számára érhető el. Hamarosan megnyílik a nyilvános hozzáférés. A teljes {product_name} webalkalmazás Safari böngészőben is működik, és hozzáadható a kezdőképernyőhöz." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/id.po b/fluxer_marketing/locales/id.po index 481cdf7fa..19e6b963b 100644 --- a/fluxer_marketing/locales/id.po +++ b/fluxer_marketing/locales/id.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: id\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2176,13 +2176,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Pakai aplikasi desktop (aplikasi seluler akan segera hadir)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Build Canary ini mungkin ditandai oleh {microsoft_defender} atau perangkat lunak antivirus lainnya di {windows} untuk sementara. Lihat isu GitHub {issue_link} untuk detail selengkapnya." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "Paket {flatpak} saat ini tertinggal dari unduhan {linux} lainnya." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2425,7 +2423,8 @@ msgstr "Aplikasi {ios}" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "Aplikasi {ios} di {testflight} saat ini terbatas untuk anggota {premium_tier_full_name}. Akses publik akan segera hadir. Aplikasi web {product_name} lengkap juga berfungsi di Safari dan dapat ditambahkan ke Layar Utama Anda." +msgstr "" +"Aplikasi {ios} di {testflight} saat ini terbatas untuk anggota {premium_tier_full_name}. Akses publik akan segera hadir. Aplikasi web {product_name} lengkap juga berfungsi di Safari dan dapat ditambahkan ke Layar Utama Anda." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/it.po b/fluxer_marketing/locales/it.po index 7f2db8394..67866bcd8 100644 --- a/fluxer_marketing/locales/it.po +++ b/fluxer_marketing/locales/it.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: it\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2182,13 +2182,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Usa il client desktop (mobile in arrivo)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Questa build Canary potrebbe essere segnalata da {microsoft_defender} o da altri software antivirus su {windows} per ora. Vedi l'issue di GitHub {issue_link} per maggiori dettagli." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "Il pacchetto {flatpak} è attualmente indietro rispetto agli altri download per {linux}." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2432,7 +2430,8 @@ msgstr "App per {ios}" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "L'app {ios} su {testflight} è attualmente limitata ai membri {premium_tier_full_name}. L'accesso pubblico arriverà presto. La web app completa di {product_name} funziona anche su Safari e può essere aggiunta alla schermata Home." +msgstr "" +"L'app {ios} su {testflight} è attualmente limitata ai membri {premium_tier_full_name}. L'accesso pubblico arriverà presto. La web app completa di {product_name} funziona anche su Safari e può essere aggiunta alla schermata Home." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/ja.po b/fluxer_marketing/locales/ja.po index 00c62210b..1550be37e 100644 --- a/fluxer_marketing/locales/ja.po +++ b/fluxer_marketing/locales/ja.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: ja\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2138,13 +2138,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "デスクトップクライアントを利用してください(モバイル版は近日公開予定です)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"このCanaryビルドは、現在{microsoft_defender}やその他の{windows}上のウイルス対策ソフトウェアによってフラグが立てられる場合があります。詳細については、GitHubのIssue {issue_link}をご覧ください。" +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "{flatpak} パッケージは現在、他の {linux} ダウンロードよりも遅れています。" #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2387,7 +2385,8 @@ msgstr "{ios} アプリ" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "{ios}アプリの{testflight}版は、現在{premium_tier_full_name}メンバー限定です。一般公開は近日公開予定です。Safariで利用できる{product_name}のWebアプリ版も、ホーム画面に追加できます。" +msgstr "" +"{ios}アプリの{testflight}版は、現在{premium_tier_full_name}メンバー限定です。一般公開は近日公開予定です。Safariで利用できる{product_name}のWebアプリ版も、ホーム画面に追加できます。" #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/ko.po b/fluxer_marketing/locales/ko.po index 1070a9e61..b84ee1620 100644 --- a/fluxer_marketing/locales/ko.po +++ b/fluxer_marketing/locales/ko.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: ko\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2139,13 +2139,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "데스크톱 클라이언트를 사용해 보세요 (모바일 앱은 곧 출시 예정이에요)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"이 카나리 빌드는 현재 {microsoft_defender} 또는 다른 바이러스 백신 소프트웨어에서 {windows}에 대해 플래그를 지정할 수 있습니다. 자세한 내용은 GitHub 이슈 {issue_link}를 참조하세요." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "{flatpak} 패키지는 현재 다른 {linux} 다운로드보다 뒤처져 있습니다." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2388,7 +2386,8 @@ msgstr "{ios} 앱" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "{ios} 앱의 {testflight}은 현재 {premium_tier_full_name} 회원에게만 제공됩니다. 곧 일반에 공개될 예정입니다. Safari에서 전체 {product_name} 웹 앱을 사용하고 홈 화면에 추가할 수도 있습니다." +msgstr "" +"{ios} 앱의 {testflight}은 현재 {premium_tier_full_name} 회원에게만 제공됩니다. 곧 일반에 공개될 예정입니다. Safari에서 전체 {product_name} 웹 앱을 사용하고 홈 화면에 추가할 수도 있습니다." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/lt.po b/fluxer_marketing/locales/lt.po index 0ee33db19..1b437ecbf 100644 --- a/fluxer_marketing/locales/lt.po +++ b/fluxer_marketing/locales/lt.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: lt\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2176,13 +2176,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Naudok darbalaukio klientą (mobilioji programėlė pasirodys netrukus)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Šią „Canary“ versiją gali laikinai pažymėti {microsoft_defender} arba kita antivirusinė programinė įranga sistemoje {windows}. Daugiau informacijos rasite „GitHub“ įraše {issue_link}." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "{flatpak} paketas šiuo metu atsilieka nuo kitų {linux} atsisiuntimų." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2425,7 +2423,8 @@ msgstr "{ios} programa" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "„{ios}“ programėlė per „{testflight}“ šiuo metu pasiekiama tik „{premium_tier_full_name}“ nariams. Netrukus bus atidarytas viešas priėjimas. Visa „{product_name}“ žiniatinklio programėlė taip pat veikia „Safari“ ir ją galima pridėti prie pagrindinio ekrano." +msgstr "" +"„{ios}“ programėlė per „{testflight}“ šiuo metu pasiekiama tik „{premium_tier_full_name}“ nariams. Netrukus bus atidarytas viešas priėjimas. Visa „{product_name}“ žiniatinklio programėlė taip pat veikia „Safari“ ir ją galima pridėti prie pagrindinio ekrano." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/nl.po b/fluxer_marketing/locales/nl.po index de962eccb..82391a4d7 100644 --- a/fluxer_marketing/locales/nl.po +++ b/fluxer_marketing/locales/nl.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: nl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2176,13 +2176,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Gebruik de desktopclient (mobiel binnenkort beschikbaar)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Deze Canary-build kan voorlopig worden gemarkeerd door {microsoft_defender} of andere antivirussoftware op {windows}. Zie GitHub-issue {issue_link} voor meer details." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "Het {flatpak}-pakket loopt momenteel achter op de andere {linux}-downloads." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2425,7 +2423,8 @@ msgstr "{ios}-app" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "De {ios}-app op {testflight} is momenteel beperkt tot leden van {premium_tier_full_name}. Openbare toegang volgt binnenkort. De volledige {product_name}-webapp werkt ook in Safari en kan aan je beginscherm worden toegevoegd." +msgstr "" +"De {ios}-app op {testflight} is momenteel beperkt tot leden van {premium_tier_full_name}. Openbare toegang volgt binnenkort. De volledige {product_name}-webapp werkt ook in Safari en kan aan je beginscherm worden toegevoegd." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/no.po b/fluxer_marketing/locales/no.po index ee1972395..e9138deb4 100644 --- a/fluxer_marketing/locales/no.po +++ b/fluxer_marketing/locales/no.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: no\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2174,13 +2174,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Bruk skrivebordsklienten (mobil kommer snart)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Denne Canary-versjonen kan bli flagget av {microsoft_defender} eller annen antivirusprogramvare på {windows} foreløpig. Se GitHub-sak {issue_link} for mer informasjon." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "{flatpak}-pakken ligger for øyeblikket bak de andre {linux}-nedlastingene." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2423,7 +2421,8 @@ msgstr "{ios}-app" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "Appen for {ios} på {testflight} er for øyeblikket begrenset til medlemmer av {premium_tier_full_name}. Offentlig tilgang kommer snart. Den fullverdige {product_name}-nettappen fungerer også i Safari og kan legges til på Hjem-skjermen din." +msgstr "" +"Appen for {ios} på {testflight} er for øyeblikket begrenset til medlemmer av {premium_tier_full_name}. Offentlig tilgang kommer snart. Den fullverdige {product_name}-nettappen fungerer også i Safari og kan legges til på Hjem-skjermen din." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/pl.po b/fluxer_marketing/locales/pl.po index 2438381c9..93c9236ce 100644 --- a/fluxer_marketing/locales/pl.po +++ b/fluxer_marketing/locales/pl.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: pl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2181,13 +2181,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Korzystaj z klienta komputerowego (aplikacja mobilna już wkrótce)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Ta wersja Canary może być tymczasowo oznaczana przez {microsoft_defender} lub inne oprogramowanie antywirusowe w systemie {windows}. Więcej szczegółów znajdziesz w zgłoszeniu na GitHubie {issue_link}." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "Pakiet {flatpak} jest obecnie w tyle za innymi pobraniami dla systemu {linux}." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2430,7 +2428,8 @@ msgstr "Aplikacja na {ios}" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "Aplikacja {ios} w {testflight} jest obecnie dostępna tylko dla członków {premium_tier_full_name}. Publiczny dostęp już wkrótce. Pełna aplikacja internetowa {product_name} działa również w Safari i można ją dodać do ekranu głównego." +msgstr "" +"Aplikacja {ios} w {testflight} jest obecnie dostępna tylko dla członków {premium_tier_full_name}. Publiczny dostęp już wkrótce. Pełna aplikacja internetowa {product_name} działa również w Safari i można ją dodać do ekranu głównego." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/pt-BR.po b/fluxer_marketing/locales/pt-BR.po index 7e8774634..7a3530c2f 100644 --- a/fluxer_marketing/locales/pt-BR.po +++ b/fluxer_marketing/locales/pt-BR.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: pt-BR\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2183,13 +2183,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Use o cliente desktop (mobile em breve)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Esta build Canary pode ser sinalizada pelo {microsoft_defender} ou outro antivírus no {windows} por enquanto. Veja o issue do GitHub {issue_link} para mais detalhes." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "O pacote {flatpak} está atualmente atrasado em relação aos outros downloads para {linux}." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2433,7 +2431,8 @@ msgstr "App para {ios}" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "O app {ios} no {testflight} está atualmente limitado a membros {premium_tier_full_name}. O acesso público chegará em breve. O app web completo {product_name} também funciona no Safari e pode ser adicionado à sua Tela de Início." +msgstr "" +"O app {ios} no {testflight} está atualmente limitado a membros {premium_tier_full_name}. O acesso público chegará em breve. O app web completo {product_name} também funciona no Safari e pode ser adicionado à sua Tela de Início." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/ro.po b/fluxer_marketing/locales/ro.po index 58d470b3d..afb4d9899 100644 --- a/fluxer_marketing/locales/ro.po +++ b/fluxer_marketing/locales/ro.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: ro\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2186,13 +2186,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Folosește clientul desktop (mobil în cur'nd)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Acest build Canary poate fi semnalat temporar de {microsoft_defender} sau de alte programe antivirus pe {windows}. Vezi problema de pe GitHub {issue_link} pentru mai multe detalii." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "Pachetul {flatpak} este în prezent în urma celorlalte descărcări {linux}." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2435,7 +2433,8 @@ msgstr "Aplicație {ios}" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "Aplicația {ios} pe {testflight} este momentan limitată la membrii {premium_tier_full_name}. Accesul public va fi disponibil în curând. Aplicația web completă {product_name} funcționează și în Safari și poate fi adăugată pe ecranul principal." +msgstr "" +"Aplicația {ios} pe {testflight} este momentan limitată la membrii {premium_tier_full_name}. Accesul public va fi disponibil în curând. Aplicația web completă {product_name} funcționează și în Safari și poate fi adăugată pe ecranul principal." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/ru.po b/fluxer_marketing/locales/ru.po index 5ab64d513..f92762d7e 100644 --- a/fluxer_marketing/locales/ru.po +++ b/fluxer_marketing/locales/ru.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2176,13 +2176,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Используй десктоп-приложение (мобильное приложение скоро появится)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Эта версия Canary может временно блокироваться {microsoft_defender} или другим антивирусным ПО на {windows}. Подробнее см. в issue GitHub {issue_link}." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "Пакет {flatpak} в настоящее время отстает от других загрузок для {linux}." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2425,7 +2423,8 @@ msgstr "Приложение для {ios}" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "Приложение {ios} в {testflight} сейчас доступно только участникам {premium_tier_full_name}. Скоро откроется доступ для всех. Полнофункциональное веб-приложение {product_name} также работает в Safari, и его можно добавить на главный экран." +msgstr "" +"Приложение {ios} в {testflight} сейчас доступно только участникам {premium_tier_full_name}. Скоро откроется доступ для всех. Полнофункциональное веб-приложение {product_name} также работает в Safari, и его можно добавить на главный экран." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/sv-SE.po b/fluxer_marketing/locales/sv-SE.po index b35147b22..c01f01e1d 100644 --- a/fluxer_marketing/locales/sv-SE.po +++ b/fluxer_marketing/locales/sv-SE.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: sv-SE\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2171,13 +2171,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Använd desktop-klienten (mobilappen kommer snart)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Den här Canary-versionen kan flaggas av {microsoft_defender} eller annan antivirusprogramvara på {windows} tills vidare. Se GitHub-problem {issue_link} för mer information." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "{flatpak}-paketet ligger för närvarande efter de andra {linux}-nedladdningarna." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2420,7 +2418,8 @@ msgstr "{ios}-app" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "Appen för {ios} på {testflight} är för närvarande begränsad till medlemmar i {premium_tier_full_name}. Offentlig åtkomst kommer snart. Hela webbappen {product_name} fungerar även i Safari och kan läggas till på hemskärmen." +msgstr "" +"Appen för {ios} på {testflight} är för närvarande begränsad till medlemmar i {premium_tier_full_name}. Offentlig åtkomst kommer snart. Hela webbappen {product_name} fungerar även i Safari och kan läggas till på hemskärmen." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/th.po b/fluxer_marketing/locales/th.po index 2cfc3d22b..cdee2edae 100644 --- a/fluxer_marketing/locales/th.po +++ b/fluxer_marketing/locales/th.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: th\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2166,13 +2166,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "ใช้แอปเดสก์ท็อป (แอปมือถือกำลังจะมา)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"บิลด์ Canary นี้อาจถูก {microsoft_defender} หรือซอฟต์แวร์ป้องกันไวรัสอื่นๆ บน {windows} แจ้งเตือนชั่วคราว ดูรายละเอียดเพิ่มเติมได้ที่ GitHub issue {issue_link}" +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "แพ็กเกจ {flatpak} ล้าหลังการดาวน์โหลด {linux} อื่นๆ อยู่ในขณะนี้" #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2415,7 +2413,8 @@ msgstr "แอป {ios}" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "แอป {ios} บน {testflight} ขณะนี้จำกัดเฉพาะสมาชิก {premium_tier_full_name} เท่านั้น การเข้าถึงแบบสาธารณะกำลังจะมาเร็วๆ นี้ เว็บแอป {product_name} ฉบับเต็มยังใช้งานได้ใน Safari และสามารถเพิ่มไปยังหน้าจอโฮมของคุณได้" +msgstr "" +"แอป {ios} บน {testflight} ขณะนี้จำกัดเฉพาะสมาชิก {premium_tier_full_name} เท่านั้น การเข้าถึงแบบสาธารณะกำลังจะมาเร็วๆ นี้ เว็บแอป {product_name} ฉบับเต็มยังใช้งานได้ใน Safari และสามารถเพิ่มไปยังหน้าจอโฮมของคุณได้" #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/tr.po b/fluxer_marketing/locales/tr.po index 0178e4ee5..4e2b34988 100644 --- a/fluxer_marketing/locales/tr.po +++ b/fluxer_marketing/locales/tr.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: tr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2181,13 +2181,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Masaüstü uygulamasını kullan (mobil yakında)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Bu Canary sürümü şimdilik {microsoft_defender} veya diğer antivirüs yazılımları tarafından {windows}'de işaretlenebilir. Daha fazla ayrıntı için GitHub sorunu {issue_link}'ye bakın." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "{flatpak} paketi şu anda diğer {linux} indirmelerinin gerisinde." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2430,7 +2428,8 @@ msgstr "{ios} uygulaması" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "{ios} uygulaması {testflight} üzerinden şu anda yalnızca {premium_tier_full_name} üyeleriyle sınırlıdır. Yakında herkese açık erişim sunulacak. Tam {product_name} web uygulaması Safari'de de çalışır ve Ana Ekran'ınıza eklenebilir." +msgstr "" +"{ios} uygulaması {testflight} üzerinden şu anda yalnızca {premium_tier_full_name} üyeleriyle sınırlıdır. Yakında herkese açık erişim sunulacak. Tam {product_name} web uygulaması Safari'de de çalışır ve Ana Ekran'ınıza eklenebilir." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/uk.po b/fluxer_marketing/locales/uk.po index 959d43bd6..aff020e68 100644 --- a/fluxer_marketing/locales/uk.po +++ b/fluxer_marketing/locales/uk.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: uk\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2178,13 +2178,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Використовуй клієнт для комп'ютера (мобільний застосунок незабаром)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Цю збірку Canary тимчасово можуть позначати {microsoft_defender} або інші антивірусні програми на {windows}. Детальніше дивіться у GitHub issue {issue_link}." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "Пакет {flatpak} наразі відстає від інших завантажень для {linux}." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2427,7 +2425,8 @@ msgstr "Додаток для {ios}" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "Наразі програма {ios} у {testflight} доступна лише для учасників {premium_tier_full_name}. Незабаром буде відкрито загальний доступ. Повнофункціональна веб-версія {product_name} також працює в Safari, і її можна додати на головний екран." +msgstr "" +"Наразі програма {ios} у {testflight} доступна лише для учасників {premium_tier_full_name}. Незабаром буде відкрито загальний доступ. Повнофункціональна веб-версія {product_name} також працює в Safari, і її можна додати на головний екран." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/vi.po b/fluxer_marketing/locales/vi.po index 044d33807..2e46732f6 100644 --- a/fluxer_marketing/locales/vi.po +++ b/fluxer_marketing/locales/vi.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: vi\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2177,13 +2177,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "Sử dụng ứng dụng desktop (di động sẽ sớm ra mắt)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"Bản dựng Canary này có thể bị {microsoft_defender} hoặc phần mềm diệt virus khác trên {windows} gắn cờ tạm thời. Xem vấn đề trên GitHub {issue_link} để biết thêm chi tiết." +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "Gói {flatpak} hiện đang chậm hơn so với các bản tải xuống {linux} khác." #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2426,7 +2424,8 @@ msgstr "Ứng dụng {ios}" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "Ứng dụng {ios} trên {testflight} hiện chỉ dành cho thành viên {premium_tier_full_name}. Quyền truy cập công khai sẽ sớm ra mắt. Phiên bản web đầy đủ của {product_name} cũng hoạt động trên Safari và có thể được thêm vào Màn hình chính của bạn." +msgstr "" +"Ứng dụng {ios} trên {testflight} hiện chỉ dành cho thành viên {premium_tier_full_name}. Quyền truy cập công khai sẽ sớm ra mắt. Phiên bản web đầy đủ của {product_name} cũng hoạt động trên Safari và có thể được thêm vào Màn hình chính của bạn." #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/zh-CN.po b/fluxer_marketing/locales/zh-CN.po index 1871407ba..977e3669b 100644 --- a/fluxer_marketing/locales/zh-CN.po +++ b/fluxer_marketing/locales/zh-CN.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: zh-CN\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2135,13 +2135,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "使用桌面客户端(移动版即将上线)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"此 Canary 版本暂时可能会被 {microsoft_defender} 或其他 {windows} 上的杀毒软件标记。请参阅 GitHub issue {issue_link} 了解更多详情。" +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "{flatpak} 包目前落后于其他 {linux} 下载。" #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2383,7 +2381,8 @@ msgstr "{ios} 应用" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "{ios} 应用的 {testflight} 测试目前仅限 {premium_tier_full_name} 会员。公开测试即将推出。您也可以在 Safari 中使用完整的 {product_name} 网页版应用,并将其添加到主屏幕。" +msgstr "" +"{ios} 应用的 {testflight} 测试目前仅限 {premium_tier_full_name} 会员。公开测试即将推出。您也可以在 Safari 中使用完整的 {product_name} 网页版应用,并将其添加到主屏幕。" #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/locales/zh-TW.po b/fluxer_marketing/locales/zh-TW.po index f6ca0d93f..26f9c38ba 100644 --- a/fluxer_marketing/locales/zh-TW.po +++ b/fluxer_marketing/locales/zh-TW.po @@ -4,8 +4,8 @@ msgid "" msgstr "" "Project-Id-Version: fluxer-marketing\n" -"POT-Creation-Date: 2026-08-02 00:00+0000\n" -"PO-Revision-Date: 2026-08-02 00:00+0000\n" +"POT-Creation-Date: 2026-08-10 00:00+0000\n" +"PO-Revision-Date: 2026-08-10 00:00+0000\n" "Language: zh-TW\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2135,13 +2135,11 @@ msgctxt "platform_support.desktop.use_desktop_client_mobile_soon" msgid "Use the desktop client (mobile coming soon)" msgstr "使用桌面版(行動應用程式即將推出)" -#. Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly. -#: fluxer_marketing/generated:platform_support.desktop.canary_windows_warning -msgctxt "platform_support.desktop.canary_windows_warning" -msgid "" -"This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details." -msgstr "" -"此 Canary 版本目前可能會被 {microsoft_defender} 或其他 {windows} 防毒軟體標記。請參閱 GitHub issue {issue_link} 了解更多詳情。" +#. Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly. +#: fluxer_marketing/generated:platform_support.desktop.flatpak_outdated +msgctxt "platform_support.desktop.flatpak_outdated" +msgid "The {flatpak} package is currently behind the other {linux} downloads." +msgstr "{flatpak} 套件目前落後於其他 {linux} 下載。" #. Body copy for mobile/PWA install guidance on the download page. Keep instructions clear, device-appropriate, and concise; preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.install_as_app.done_desktop @@ -2384,7 +2382,8 @@ msgstr "{ios} App" msgctxt "platform_support.mobile.ios.body" msgid "" "The {ios} app on {testflight} is currently limited to {premium_tier_full_name} members. Public access is coming soon. The full {product_name} web app also works in Safari and can be added to your Home Screen." -msgstr "{ios} 應用程式的 {testflight} 測試目前僅限 {premium_tier_full_name} 會員。公開測試即將開放。完整的 {product_name} 網頁應用程式也能在 Safari 中使用,並可加入主畫面。" +msgstr "" +"{ios} 應用程式的 {testflight} 測試目前僅限 {premium_tier_full_name} 會員。公開測試即將開放。完整的 {product_name} 網頁應用程式也能在 Safari 中使用,並可加入主畫面。" #. Card title for the Android app option on the download page. Keep the platform name conventional and short. Preserve placeholders exactly. #: fluxer_marketing/generated:platform_support.mobile.android.title diff --git a/fluxer_marketing/src/config.rs b/fluxer_marketing/src/config.rs index 0519d8568..eee530b44 100644 --- a/fluxer_marketing/src/config.rs +++ b/fluxer_marketing/src/config.rs @@ -13,7 +13,6 @@ pub struct MarketingConfig { pub secret_key_base: String, pub base_path: String, pub api_endpoint: String, - pub app_endpoint: String, pub static_cdn_endpoint: String, pub marketing_endpoint: String, pub geoip_db_path: String, @@ -38,6 +37,8 @@ pub enum ReleaseChannel { Canary, } +pub const DOWNLOAD_RELEASE_CHANNEL: ReleaseChannel = ReleaseChannel::Canary; + impl MarketingConfig { pub fn from_env() -> Self { let geoip_source = cfg::parse_geoip_source_config( @@ -66,10 +67,6 @@ impl MarketingConfig { "FLUXER_API_ENDPOINT", "https://api.fluxer.app", )), - app_endpoint: cfg::trim_trailing_slash(&cfg::read_env( - "FLUXER_APP_ENDPOINT", - "https://app.fluxer.app", - )), static_cdn_endpoint: cfg::trim_trailing_slash(&cfg::read_env( "FLUXER_STATIC_CDN_ENDPOINT", "", diff --git a/fluxer_marketing/src/i18n/descriptors/platform.rs b/fluxer_marketing/src/i18n/descriptors/platform.rs index 0743f3eb9..d461ee21a 100644 --- a/fluxer_marketing/src/i18n/descriptors/platform.rs +++ b/fluxer_marketing/src/i18n/descriptors/platform.rs @@ -41,10 +41,10 @@ crate::marketing_message!( ); crate::marketing_message!( - pub const PLATFORM_SUPPORT_DESKTOP_CANARY_WINDOWS_WARNING_DESCRIPTOR = { - key: "platform_support.desktop.canary_windows_warning", - message: "This Canary build may be flagged by {microsoft_defender} or other antivirus software on {windows} for now. See GitHub issue {issue_link} for more details.", - comment: "Canary-only notice below the Windows download row. Preserve Microsoft Defender and Windows as names; keep {issue_link} exactly where the linked issue number should appear and make clear the antivirus warning is temporary. Preserve placeholders exactly.", + pub const PLATFORM_SUPPORT_DESKTOP_FLATPAK_OUTDATED_DESCRIPTOR = { + key: "platform_support.desktop.flatpak_outdated", + message: "The {flatpak} package is currently behind the other {linux} downloads.", + comment: "Notice below the Linux download row warning that the Flatpak build lags the other Linux downloads at the moment. Preserve Flatpak and Linux as names and preserve placeholders exactly.", }; ); diff --git a/fluxer_marketing/src/invariant_text.rs b/fluxer_marketing/src/invariant_text.rs index 6c6e4bbed..9e15afba7 100644 --- a/fluxer_marketing/src/invariant_text.rs +++ b/fluxer_marketing/src/invariant_text.rs @@ -17,11 +17,11 @@ pub const BRAND_PLACEHOLDERS: &[(&str, &str)] = &[ ("windows", "Windows"), ("macos", "macOS"), ("linux", "Linux"), + ("flatpak", "Flatpak"), ("android", "Android"), ("ios", "iOS"), ("ipados", "iPadOS"), ("microsoft", "Microsoft"), - ("microsoft_defender", "Microsoft Defender"), ("testflight", "TestFlight"), ("apple_silicon", "Apple Silicon"), ("chrome", "Chrome"), diff --git a/fluxer_marketing/src/request_context.rs b/fluxer_marketing/src/request_context.rs index 41bc9a57d..e0f8bd8fa 100644 --- a/fluxer_marketing/src/request_context.rs +++ b/fluxer_marketing/src/request_context.rs @@ -22,6 +22,7 @@ use std::{ type HmacSha256 = Hmac; const CANARY_API_ENDPOINT: &str = "https://api.canary.fluxer.app"; +pub const CANARY_WEB_APP_ENDPOINT: &str = "https://web.canary.fluxer.app"; const LOCALE_COOKIE_MAX_AGE_SECONDS: u64 = 60 * 60 * 24 * 365; const STABLE_API_ENDPOINT: &str = "https://api.fluxer.app"; @@ -42,7 +43,6 @@ pub struct RequestContext { pub current_path: String, pub base_path: String, pub base_url: String, - pub app_endpoint: String, pub api_endpoint: String, pub static_cdn_endpoint: String, pub asset_version: String, @@ -92,7 +92,6 @@ impl RequestContext { current_path, base_path: state.config.base_path.clone(), base_url: state.config.base_url(), - app_endpoint: state.config.app_endpoint.clone(), api_endpoint: state.config.api_endpoint.clone(), static_cdn_endpoint: state.config.static_cdn_endpoint.clone(), asset_version: state.config.build_version.clone(), @@ -141,7 +140,7 @@ impl RequestContext { } pub fn app_url(&self, path: &str) -> String { - format!("{}{}", self.app_endpoint, path) + format!("{CANARY_WEB_APP_ENDPOINT}{path}") } pub fn api_url(&self, path: &str) -> String { diff --git a/fluxer_marketing/src/routes.rs b/fluxer_marketing/src/routes.rs index 395d2c194..d1df1a2c7 100644 --- a/fluxer_marketing/src/routes.rs +++ b/fluxer_marketing/src/routes.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::{ - config::MarketingConfig, + config::{DOWNLOAD_RELEASE_CHANNEL, MarketingConfig}, content::{ BLOG_POSTS, BlogBookmarkAsset, BlogPost, HELP_ARTICLES, HELP_CATEGORIES, JOBS, POLICIES, blog_tag_label, blog_tag_slug, get_blog_post, get_help_article, get_job, get_policy, @@ -10,7 +10,7 @@ use crate::{ downloads::fetch_latest_desktop_versions_cached, geoip::resolver_from_marketing_config, i18n::{Locale, MarketingI18n, descriptors::*}, - request_context::{AppState, RequestContext, create_locale_cookie}, + request_context::{AppState, CANARY_WEB_APP_ENDPOINT, RequestContext, create_locale_cookie}, swish::SwishQrCache, templates, }; @@ -345,20 +345,14 @@ async fn canonical_host_redirect_middleware( return Redirect::permanent(&target).into_response(); } Some("fluxer.gg") => { - let target = append_uri( - &format!("{}/invite", state.config.app_endpoint), - request.uri(), - ); + let target = append_uri(&format!("{CANARY_WEB_APP_ENDPOINT}/invite"), request.uri()); return Redirect::temporary(&target).into_response(); } Some("fluxer.gift") => { let target = if request.uri().path() == "/" { state.config.base_url() } else { - append_uri( - &format!("{}/gift", state.config.app_endpoint), - request.uri(), - ) + append_uri(&format!("{CANARY_WEB_APP_ENDPOINT}/gift"), request.uri()) }; return Redirect::temporary(&target).into_response(); } @@ -406,7 +400,7 @@ fn request_host(headers: &HeaderMap) -> Option { fn legacy_marketing_redirect(config: &MarketingConfig, uri: &Uri) -> Option { match uri.path().trim_end_matches('/') { "/channels" => { - let target = append_uri(&config.app_endpoint, uri); + let target = append_uri(CANARY_WEB_APP_ENDPOINT, uri); Some(Redirect::temporary(&target).into_response()) } "/delete-my-account" => Some(Redirect::temporary("/help/delete-account").into_response()), @@ -418,7 +412,7 @@ fn legacy_marketing_redirect(config: &MarketingConfig, uri: &Uri) -> Option { - let target = append_uri(&config.app_endpoint, uri); + let target = append_uri(CANARY_WEB_APP_ENDPOINT, uri); Some(Redirect::temporary(&target).into_response()) } _ => None, @@ -605,7 +599,7 @@ async fn download(State(state): State, headers: HeaderMap, uri: Uri) - &state.latest_versions_cache, &state.http_client, &state.config.api_endpoint, - state.config.release_channel.segment(), + DOWNLOAD_RELEASE_CHANNEL.segment(), ) .await; let mut response = diff --git a/fluxer_marketing/src/templates/mod.rs b/fluxer_marketing/src/templates/mod.rs index 9426ad6d5..19d69bc2f 100644 --- a/fluxer_marketing/src/templates/mod.rs +++ b/fluxer_marketing/src/templates/mod.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::{ + config::DOWNLOAD_RELEASE_CHANNEL, content::{ HELP_ARTICLES, HELP_CATEGORIES, HeadingEntry, HelpArticle, HelpCategory, JOBS, JobListing, POLICIES, Policy, get_help_category, render_markdown_with_copy_label, @@ -1656,7 +1657,7 @@ fn alternate_builds( desktop_url(ctx, "win32", other_arch, "setup"), false, )]; - if ctx.release_channel.is_canary() { + if DOWNLOAD_RELEASE_CHANNEL.is_canary() { builds.push(alt( tr(i18n, ctx, PLATFORM_SUPPORT_PLATFORMS_PORTABLE_DESCRIPTOR), desktop_url(ctx, "win32", arch, "portable"), @@ -1682,31 +1683,29 @@ fn alternate_builds( )] } Platform::Linux => { - let mut builds = Vec::new(); - if !ctx.release_channel.is_canary() { - builds.push(alt("Flatpak".to_owned(), FLATPAK_URL.to_owned(), true)); - } - builds.push(alt( - "DEB".to_owned(), - desktop_url(ctx, "linux", arch, "deb"), - false, - )); - builds.push(alt( - "RPM".to_owned(), - desktop_url(ctx, "linux", arch, "rpm"), - false, - )); - builds.push(alt( - "tar.gz".to_owned(), - desktop_url(ctx, "linux", arch, "tar_gz"), - false, - )); - builds.push(alt( - other_arch.to_owned(), - desktop_url(ctx, "linux", other_arch, "appimage"), - false, - )); - builds + vec![ + alt("Flatpak".to_owned(), FLATPAK_URL.to_owned(), true), + alt( + "DEB".to_owned(), + desktop_url(ctx, "linux", arch, "deb"), + false, + ), + alt( + "RPM".to_owned(), + desktop_url(ctx, "linux", arch, "rpm"), + false, + ), + alt( + "tar.gz".to_owned(), + desktop_url(ctx, "linux", arch, "tar_gz"), + false, + ), + alt( + other_arch.to_owned(), + desktop_url(ctx, "linux", other_arch, "appimage"), + false, + ), + ] } _ => Vec::new(), } @@ -1735,19 +1734,10 @@ fn download_strip( DOWNLOAD_DOWNLOAD_FOR_PLATFORM_DESCRIPTOR, &[("platform", &name)], ); - let description = if platform == Platform::Windows && ctx.release_channel.is_canary() { - let warning = i18n.template( - ctx.locale, - PLATFORM_SUPPORT_DESKTOP_CANARY_WINDOWS_WARNING_DESCRIPTOR, - ); + let description = if platform == Platform::Linux { html! { - p class="body-sm mt-2 max-w-xl text-amber-800" { - (message_with_links(&warning, &[LinkReplacement { - variable: "issue_link", - text: "#1393", - href: "https://github.com/fluxerapp/fluxer/issues/1393", - class: "font-medium underline decoration-amber-400 underline-offset-2 hover:text-amber-950", - }])) + p class="body-sm mt-2 max-w-xl text-gray-500" { + (tr(i18n, ctx, PLATFORM_SUPPORT_DESKTOP_FLATPAK_OUTDATED_DESCRIPTOR)) } } } else { @@ -1922,7 +1912,7 @@ fn platform_icon(platform: Platform) -> Icon { } fn desktop_url(ctx: &RequestContext, platform: &str, arch: &str, format: &str) -> String { - let channel = ctx.release_channel.segment(); + let channel = DOWNLOAD_RELEASE_CHANNEL.segment(); let path = format!("/dl/desktop/{channel}/{platform}/{arch}/latest/{format}"); let final_path = desktop_path_with_query(path, ctx.test_build); ctx.api_url(&final_path) diff --git a/fluxer_marketing/src/templates/pwa.rs b/fluxer_marketing/src/templates/pwa.rs index d375bf134..d82d76c87 100644 --- a/fluxer_marketing/src/templates/pwa.rs +++ b/fluxer_marketing/src/templates/pwa.rs @@ -82,7 +82,7 @@ fn android_steps(i18n: &MarketingI18n, ctx: &RequestContext) -> Markup { ol class="space-y-4" { (step("1", html! { span { - a href="https://web.fluxer.app" target="_blank" rel="noopener noreferrer" class="text-gray-900 underline hover:text-gray-700" { + a href=(ctx.app_url("")) target="_blank" rel="noopener noreferrer" class="text-gray-900 underline hover:text-gray-700" { (tr(i18n, ctx, APP_OPEN_OPEN_WEB_APP_DESCRIPTOR)) } (tr(i18n, ctx, PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_GUIDES_IN_CHROME_DESCRIPTOR)) @@ -105,7 +105,7 @@ fn ios_steps(i18n: &MarketingI18n, ctx: &RequestContext) -> Markup { ol class="space-y-4" { (step("1", html! { span { - a href="https://web.fluxer.app" target="_blank" rel="noopener noreferrer" class="text-gray-900 underline hover:text-gray-700" { + a href=(ctx.app_url("")) target="_blank" rel="noopener noreferrer" class="text-gray-900 underline hover:text-gray-700" { (tr(i18n, ctx, APP_OPEN_OPEN_WEB_APP_DESCRIPTOR)) } (tr(i18n, ctx, PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_GUIDES_IN_SAFARI_DESCRIPTOR)) @@ -129,7 +129,7 @@ fn desktop_steps(i18n: &MarketingI18n, ctx: &RequestContext) -> Markup { ol class="space-y-4" { (step("1", html! { span { - a href="https://web.fluxer.app" target="_blank" rel="noopener noreferrer" class="text-gray-900 underline hover:text-gray-700" { + a href=(ctx.app_url("")) target="_blank" rel="noopener noreferrer" class="text-gray-900 underline hover:text-gray-700" { (tr(i18n, ctx, APP_OPEN_OPEN_WEB_APP_DESCRIPTOR)) } (tr(i18n, ctx, PLATFORM_SUPPORT_MOBILE_INSTALL_AS_APP_GUIDES_IN_CHROME_OR_ANOTHER_BROWSER_DESCRIPTOR)) diff --git a/fluxer_marketing/tests/routes.rs b/fluxer_marketing/tests/routes.rs index 2eac6e580..c939cfabb 100644 --- a/fluxer_marketing/tests/routes.rs +++ b/fluxer_marketing/tests/routes.rs @@ -7,7 +7,7 @@ use axum::{ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use fluxer_marketing::{ build_router, - config::{MarketingConfig, ReleaseChannel}, + config::{DOWNLOAD_RELEASE_CHANNEL, MarketingConfig, ReleaseChannel}, }; use http_body_util::BodyExt; use std::collections::{BTreeMap, BTreeSet}; @@ -22,7 +22,6 @@ fn test_config() -> MarketingConfig { config.marketing_endpoint = "https://fluxer.test".to_owned(); config.base_path.clear(); config.api_endpoint = "https://api.fluxer.test".to_owned(); - config.app_endpoint = "https://app.fluxer.test".to_owned(); config.build_version = "test".to_owned(); config.geoip_db_path.clear(); config.trust_client_ip_header = false; @@ -537,7 +536,7 @@ async fn old_origin_marketing_redirects_are_preserved() { assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); assert_eq!( response.headers().get(header::LOCATION).unwrap(), - "https://app.fluxer.test/invite/" + "https://web.canary.fluxer.app/invite/" ); let response = app @@ -554,7 +553,7 @@ async fn old_origin_marketing_redirects_are_preserved() { assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); assert_eq!( response.headers().get(header::LOCATION).unwrap(), - "https://app.fluxer.test/invite/guild" + "https://web.canary.fluxer.app/invite/guild" ); let response = app @@ -571,7 +570,7 @@ async fn old_origin_marketing_redirects_are_preserved() { assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); assert_eq!( response.headers().get(header::LOCATION).unwrap(), - "https://app.fluxer.test/gift/spring" + "https://web.canary.fluxer.app/gift/spring" ); let response = app @@ -621,7 +620,7 @@ async fn old_origin_marketing_redirects_are_preserved() { assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); assert_eq!( response.headers().get(header::LOCATION).unwrap(), - "https://app.fluxer.test/channels/@me?source=old" + "https://web.canary.fluxer.app/channels/@me?source=old" ); let response = app @@ -656,6 +655,62 @@ async fn old_origin_marketing_redirects_are_preserved() { ); } +#[tokio::test] +async fn app_redirects_and_cta_links_share_the_canary_web_app_origin_on_every_channel() { + const CANARY_WEB_APP_ORIGIN: &str = "https://web.canary.fluxer.app"; + + for channel in [ReleaseChannel::Stable, ReleaseChannel::Canary] { + let mut config = test_config(); + config.release_channel = channel; + let app = build_router(config); + + for (host, uri, expected) in [ + ("fluxer.gg", "/", format!("{CANARY_WEB_APP_ORIGIN}/invite/")), + ( + "fluxer.gg", + "/guild", + format!("{CANARY_WEB_APP_ORIGIN}/invite/guild"), + ), + ( + "fluxer.gift", + "/spring", + format!("{CANARY_WEB_APP_ORIGIN}/gift/spring"), + ), + ( + "fluxer.app", + "/channels/@me?source=old", + format!("{CANARY_WEB_APP_ORIGIN}/channels/@me?source=old"), + ), + ( + "fluxer.app", + "/channels", + format!("{CANARY_WEB_APP_ORIGIN}/channels"), + ), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri(uri) + .header(header::HOST, host) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + response.headers().get(header::LOCATION).unwrap(), + expected.as_str() + ); + } + + let html = render_path(app, "/").await; + assert!(html.contains(&format!("{CANARY_WEB_APP_ORIGIN}/channels/@me"))); + assert!(!html.contains("https://app.fluxer.")); + } +} + #[tokio::test] async fn blog_serves_imported_posts_feeds_assets_and_legacy_redirects() { let app = build_router(test_config()); @@ -1109,14 +1164,14 @@ async fn blog_host_only_redirects_to_canonical_marketing_blog_routes() { async fn download_page_renders_strips_and_cache_header() { let mut config = test_config(); config.api_endpoint = "http://127.0.0.1:9".to_owned(); - let release_channel = config.release_channel; + config.release_channel = ReleaseChannel::Stable; let expected_download_url = format!( "/dl/desktop/{}/win32/x64/latest/setup?test=1", - release_channel.segment() + DOWNLOAD_RELEASE_CHANNEL.segment() ); let expected_other_arch_url = format!( "/dl/desktop/{}/win32/arm64/latest/setup?test=1", - release_channel.segment() + DOWNLOAD_RELEASE_CHANNEL.segment() ); let app = build_router(config); let response = app @@ -1142,11 +1197,11 @@ async fn download_page_renders_strips_and_cache_header() { assert!(!html.contains("/dl/desktop/source/latest")); assert!(html.contains(&expected_download_url)); assert!(html.contains(&expected_other_arch_url)); - if release_channel.is_canary() { - assert!(html.contains("/dl/desktop/canary/linux/x64/latest/appimage?test=1")); - } else { - assert!(html.contains("https://flathub.org/en/apps/app.fluxer.Fluxer")); - } + assert!(html.contains("/dl/desktop/canary/linux/x64/latest/appimage?test=1")); + assert!(!html.contains("/dl/desktop/stable/")); + assert!(html.contains("https://flathub.org/en/apps/app.fluxer.Fluxer")); + assert!(html.contains("Flatpak package is currently behind the other Linux downloads")); + assert!(html.contains("https://web.canary.fluxer.app/channels/@me")); } #[tokio::test] diff --git a/fluxer_media_proxy/build.rs b/fluxer_media_proxy/build.rs index 09dddd082..ef2dde822 100644 --- a/fluxer_media_proxy/build.rs +++ b/fluxer_media_proxy/build.rs @@ -11,7 +11,7 @@ fn main() { build .file("src/vips_shim.c") .include("src") - .flag_if_supported("-std=c11"); + .flag_if_supported("-std=gnu11"); let mut link_paths: Vec = Vec::new(); let mut link_files: Vec = Vec::new(); diff --git a/fluxer_media_proxy/src/media_process.rs b/fluxer_media_proxy/src/media_process.rs index cde984134..bec4f39f0 100644 --- a/fluxer_media_proxy/src/media_process.rs +++ b/fluxer_media_proxy/src/media_process.rs @@ -2862,6 +2862,33 @@ mod tests { std::fs::read(&out).ok() } + fn ffmpeg_gen_rotated_mp4(display_rotation: &str, source_args: &[&str]) -> Option> { + let dir = tempfile::tempdir().ok()?; + let source = dir.path().join("source.mp4"); + let out = dir.path().join("rotated.mp4"); + let source_status = std::process::Command::new("ffmpeg") + .args(["-nostdin", "-loglevel", "error", "-y"]) + .args(source_args) + .arg(source.to_str()?) + .status() + .ok()?; + if !source_status.success() { + return None; + } + let rotate_status = std::process::Command::new("ffmpeg") + .args(["-nostdin", "-loglevel", "error", "-y", "-noautorotate"]) + .args(["-display_rotation", display_rotation]) + .args(["-i", source.to_str()?]) + .args(["-c", "copy", "-f", "mp4"]) + .arg(out.to_str()?) + .status() + .ok()?; + if !rotate_status.success() { + return None; + } + std::fs::read(&out).ok() + } + fn png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" || &bytes[12..16] != b"IHDR" { return None; @@ -2936,19 +2963,19 @@ mod tests { "sub-square pixel video should grow height to its display size" ); - let rotated = ffmpeg_gen_mp4(&[ - "-noautorotate", - "-display_rotation", + let rotated = ffmpeg_gen_rotated_mp4( "90", - "-f", - "lavfi", - "-i", - "testsrc=size=640x480:rate=10:duration=1", - "-pix_fmt", - "yuv420p", - "-f", - "mp4", - ]) + &[ + "-f", + "lavfi", + "-i", + "testsrc=size=640x480:rate=10:duration=1", + "-pix_fmt", + "yuv420p", + "-f", + "mp4", + ], + ) .expect("rotated fixture"); let thumb = extract_video_thumbnail(&rotated, AssetExtension::Png).expect("rotated thumbnail"); @@ -2958,19 +2985,19 @@ mod tests { "rotation-metadata video should present in its display (portrait) orientation" ); - let rotated_counterclockwise = ffmpeg_gen_mp4(&[ - "-noautorotate", - "-display_rotation", + let rotated_counterclockwise = ffmpeg_gen_rotated_mp4( "-90", - "-f", - "lavfi", - "-i", - "testsrc=size=640x480:rate=10:duration=1", - "-pix_fmt", - "yuv420p", - "-f", - "mp4", - ]) + &[ + "-f", + "lavfi", + "-i", + "testsrc=size=640x480:rate=10:duration=1", + "-pix_fmt", + "yuv420p", + "-f", + "mp4", + ], + ) .expect("counterclockwise rotated fixture"); let thumb = extract_video_thumbnail(&rotated_counterclockwise, AssetExtension::Png) .expect("counterclockwise rotated thumbnail"); @@ -2980,21 +3007,21 @@ mod tests { "either quarter-turn direction should swap dimensions" ); - let rotated_anamorphic = ffmpeg_gen_mp4(&[ - "-noautorotate", - "-display_rotation", + let rotated_anamorphic = ffmpeg_gen_rotated_mp4( "90", - "-f", - "lavfi", - "-i", - "testsrc=size=320x180:rate=10:duration=1", - "-vf", - "setsar=2/1", - "-pix_fmt", - "yuv420p", - "-f", - "mp4", - ]) + &[ + "-f", + "lavfi", + "-i", + "testsrc=size=320x180:rate=10:duration=1", + "-vf", + "setsar=2/1", + "-pix_fmt", + "yuv420p", + "-f", + "mp4", + ], + ) .expect("rotated anamorphic fixture"); let thumb = extract_video_thumbnail(&rotated_anamorphic, AssetExtension::Png) .expect("rotated anamorphic thumbnail"); @@ -3028,19 +3055,19 @@ mod tests { #[test] fn video_metadata_placeholder_and_dimensions_are_display_corrected() { - let Some(rotated) = ffmpeg_gen_mp4(&[ - "-noautorotate", - "-display_rotation", + let Some(rotated) = ffmpeg_gen_rotated_mp4( "90", - "-f", - "lavfi", - "-i", - "testsrc=size=640x480:rate=10:duration=1", - "-pix_fmt", - "yuv420p", - "-f", - "mp4", - ]) else { + &[ + "-f", + "lavfi", + "-i", + "testsrc=size=640x480:rate=10:duration=1", + "-pix_fmt", + "yuv420p", + "-f", + "mp4", + ], + ) else { eprintln!("skipping: ffmpeg CLI not available"); return; }; diff --git a/fluxer_media_proxy/src/vips_shim.c b/fluxer_media_proxy/src/vips_shim.c index 0edf1100f..b2e43e7a9 100644 --- a/fluxer_media_proxy/src/vips_shim.c +++ b/fluxer_media_proxy/src/vips_shim.c @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later #define _GNU_SOURCE +#define _DARWIN_C_SOURCE #define _POSIX_C_SOURCE 200809L #include "vips_shim.h" @@ -529,13 +530,13 @@ static int fluxer_gif_setup_filter_graph( if (avfilter_graph_create_filter(&src_ctx, avfilter_get_by_name("buffer"), "in", src_args, NULL, graph) < 0) goto fail; - if (avfilter_graph_create_filter(&sink_ctx, avfilter_get_by_name("buffersink"), - "out", NULL, NULL, graph) < 0) - goto fail; + sink_ctx = avfilter_graph_alloc_filter(graph, avfilter_get_by_name("buffersink"), "out"); + if (sink_ctx == NULL) goto fail; enum AVPixelFormat sink_fmts[] = { AV_PIX_FMT_PAL8, AV_PIX_FMT_NONE }; if (av_opt_set_int_list(sink_ctx, "pix_fmts", sink_fmts, AV_PIX_FMT_NONE, AV_OPT_SEARCH_CHILDREN) < 0) goto fail; + if (avfilter_init_dict(sink_ctx, NULL) < 0) goto fail; char descr[256]; snprintf(descr, sizeof(descr), diff --git a/packages/schema/src/domains/download/DownloadSchemas.ts b/packages/schema/src/domains/download/DownloadSchemas.ts index dd91cbc89..f62dd9080 100644 --- a/packages/schema/src/domains/download/DownloadSchemas.ts +++ b/packages/schema/src/domains/download/DownloadSchemas.ts @@ -62,17 +62,6 @@ export const DesktopFormatEnum = withOpenApiType( export type DesktopFormat = z.infer; -export const DesktopVariantEnum = withOpenApiType( - z - .literal('windows-game-capture') - .describe( - 'fluxer:EnumValues:[{"n":"Windows Game Capture","v":"windows-game-capture","d":"Windows desktop build that includes the Windows Game Capture module"}] The desktop build variant', - ), - 'DesktopVariant', -); - -export type DesktopVariant = z.infer; - const VersionString = z .string() .regex(/^\d+\.\d+\.\d+$/u) @@ -107,27 +96,6 @@ export const DesktopVersionedRedirectParam = z.object({ export type DesktopVersionedRedirectParam = z.infer; -export const DesktopVariantRedirectParam = z.object({ - channel: DesktopChannelEnum, - plat: DesktopPlatformEnum, - arch: DesktopArchEnum, - variant: DesktopVariantEnum, - format: DesktopFormatEnum, -}); - -export type DesktopVariantRedirectParam = z.infer; - -export const DesktopVariantVersionedRedirectParam = z.object({ - channel: DesktopChannelEnum, - plat: DesktopPlatformEnum, - arch: DesktopArchEnum, - variant: DesktopVariantEnum, - version: VersionString, - format: DesktopFormatEnum, -}); - -export type DesktopVariantVersionedRedirectParam = z.infer; - const DesktopChecksumFormat = z .string() .regex(/^(setup|dmg|zip|appimage|deb|rpm|tar_gz|portable)\.sha256$/u) @@ -153,27 +121,6 @@ export const DesktopVersionedChecksumRedirectParam = z.object({ export type DesktopVersionedChecksumRedirectParam = z.infer; -export const DesktopVariantChecksumRedirectParam = z.object({ - channel: DesktopChannelEnum, - plat: DesktopPlatformEnum, - arch: DesktopArchEnum, - variant: DesktopVariantEnum, - format: DesktopChecksumFormat, -}); - -export type DesktopVariantChecksumRedirectParam = z.infer; - -export const DesktopVariantVersionedChecksumRedirectParam = z.object({ - channel: DesktopChannelEnum, - plat: DesktopPlatformEnum, - arch: DesktopArchEnum, - variant: DesktopVariantEnum, - version: VersionString, - format: DesktopChecksumFormat, -}); - -export type DesktopVariantVersionedChecksumRedirectParam = z.infer; - export const DesktopVersionsParam = z.object({ channel: DesktopChannelEnum, plat: DesktopPlatformEnum, @@ -182,15 +129,6 @@ export const DesktopVersionsParam = z.object({ export type DesktopVersionsParam = z.infer; -export const DesktopVariantVersionsParam = z.object({ - channel: DesktopChannelEnum, - plat: DesktopPlatformEnum, - arch: DesktopArchEnum, - variant: DesktopVariantEnum, -}); - -export type DesktopVariantVersionsParam = z.infer; - export const DesktopVersionsQuery = z.object({ limit: z.coerce.number().int().min(1).max(100).default(25).describe('Maximum number of versions to return'), before: VersionString.optional().describe('Return versions before this version'), @@ -208,9 +146,6 @@ const VersionFileResponse = z.object({ export const VersionInfoResponse = z.object({ version: z.string().describe('Semantic version string (e.g., 1.0.0)'), - variant: DesktopVariantEnum.nullable() - .optional() - .describe('Desktop build variant, when this is not the default build'), pub_date: z.string().describe('ISO 8601 date when this version was published'), minimum_system_version: z .string() @@ -224,31 +159,6 @@ export const VersionInfoResponse = z.object({ export type VersionInfoResponse = z.infer; -export const DesktopSourceChecksumResponse = z.object({ - sha256: z - .string() - .regex(/^[a-f0-9]{64}$/u) - .describe('SHA-256 hash of the latest Fluxer desktop source tarball'), - filename: z.string().describe('Filename for the latest Fluxer desktop source tarball'), - url: z.string().describe('Download URL for the latest Fluxer desktop source tarball'), - commit: z.string().optional().describe('Git commit used to produce the source tarball'), - desktop_version: VersionString.optional().describe('Desktop app version stamped into the source tarball'), - desktop_version_source: z - .object({ - channel: DesktopChannelEnum, - platform: DesktopPlatformEnum, - arch: DesktopArchEnum, - key: z.string().describe('Downloads bucket manifest key used to resolve the desktop version'), - pub_date: z.string().describe('ISO 8601 date when the referenced desktop app version was published'), - }) - .optional() - .describe('Downloads bucket manifest used to resolve the desktop version'), - published_at: z.string().describe('ISO 8601 date when this source tarball was published'), - size: z.number().int().nonnegative().optional().describe('Source tarball size in bytes'), -}); - -export type DesktopSourceChecksumResponse = z.infer; - export const DesktopVersionsResponse = z.object({ versions: z.array(VersionInfoResponse).describe('Array of available versions'), has_more: z.boolean().describe('Whether more versions are available to fetch'), diff --git a/tools/ci/src/common.rs b/tools/ci/src/common.rs index 2caa888b6..eaa1e7f77 100644 --- a/tools/ci/src/common.rs +++ b/tools/ci/src/common.rs @@ -1530,14 +1530,6 @@ pub(crate) fn count_files_min_depth(root: &Path, min_depth: usize) -> Result String { - value - .split_whitespace() - .next() - .unwrap_or_default() - .to_string() -} - pub(crate) fn title_case(value: &str) -> String { let mut chars = value.chars(); match chars.next() { diff --git a/tools/ci/src/desktop.rs b/tools/ci/src/desktop.rs index 46e252599..c74fabcf1 100644 --- a/tools/ci/src/desktop.rs +++ b/tools/ci/src/desktop.rs @@ -3,19 +3,16 @@ use crate::common::{ CalverEnv, CommandSpec, append_github_env, append_github_output, append_github_path, capture, collect_files, command_succeeds, copy_dir_contents, count_files, count_files_min_depth, - download_file, download_s3_prefix, env_bool, env_string, first_word, get_s3_object_bytes, - join_s3_key, output_bytes, output_text, parse_bool, path_to_s3_key, remove_dir_if_exists, - remove_file_if_exists, require_any_env, require_env, require_home, resolve_calver, run_command, - runner_temp, s3_client, title_case, trim_option, upload_directory_to_s3, - upload_directory_to_s3_overwrite, + download_file, download_s3_prefix, env_bool, env_string, join_s3_key, output_bytes, + output_text, parse_bool, path_to_s3_key, remove_dir_if_exists, remove_file_if_exists, + require_any_env, require_env, require_home, resolve_calver, run_command, runner_temp, + s3_client, title_case, trim_option, upload_directory_to_s3, upload_directory_to_s3_overwrite, }; use crate::functions::write_json_pretty; use anyhow::{Context, Result, anyhow, bail, ensure}; use aws_sdk_s3::Client as S3Client; -use chrono::{DateTime, Utc}; +use chrono::Utc; use clap::{Args, ValueEnum}; -use flate2::{Compression, GzBuilder, read::GzDecoder}; -use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; @@ -23,7 +20,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::ffi::{OsStr, OsString}; use std::fs::{self, File, OpenOptions}; -use std::io::{self, Cursor, Read, Write}; +use std::io::{self, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::thread; use std::time::{Duration, Instant}; @@ -37,64 +34,6 @@ const RUST_TOOLCHAIN: &str = "1.93.0"; const DEFAULT_DESKTOP_VARIANT: &str = "default"; const WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT: &str = "windows-game-capture"; -type DesktopFormat = (&'static str, &'static str); -type DesktopArchFormats = (&'static str, &'static [DesktopFormat]); -type DesktopDownloadSection = ( - &'static str, - &'static str, - &'static str, - &'static [DesktopArchFormats], -); - -const WINDOWS_DESKTOP_FORMATS: &[DesktopFormat] = - &[("setup", "Setup.exe"), ("portable", "Portable ZIP")]; -const MACOS_DESKTOP_FORMATS: &[DesktopFormat] = &[("dmg", "DMG"), ("zip", "ZIP")]; -const DESKTOP_WEBHOOK_CONTENT_LIMIT: usize = 2_000; -const LINUX_DESKTOP_FORMATS: &[DesktopFormat] = &[ - ("appimage", "AppImage"), - ("deb", "DEB"), - ("rpm", "RPM"), - ("tar_gz", "tar.gz"), -]; -const WINDOWS_DESKTOP_ARCHES: &[DesktopArchFormats] = &[ - ("x64", WINDOWS_DESKTOP_FORMATS), - ("arm64", WINDOWS_DESKTOP_FORMATS), -]; -const MACOS_DESKTOP_ARCHES: &[DesktopArchFormats] = &[ - ("x64", MACOS_DESKTOP_FORMATS), - ("arm64", MACOS_DESKTOP_FORMATS), -]; -const LINUX_DESKTOP_ARCHES: &[DesktopArchFormats] = &[ - ("x64", LINUX_DESKTOP_FORMATS), - ("arm64", LINUX_DESKTOP_FORMATS), -]; -const DESKTOP_DOWNLOAD_SECTIONS: &[DesktopDownloadSection] = &[ - ( - "win32", - DEFAULT_DESKTOP_VARIANT, - "Windows (`win32`)", - WINDOWS_DESKTOP_ARCHES, - ), - ( - "win32", - WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT, - "Windows Game Capture (`win32`)", - WINDOWS_DESKTOP_ARCHES, - ), - ( - "darwin", - DEFAULT_DESKTOP_VARIANT, - "macOS (`darwin`)", - MACOS_DESKTOP_ARCHES, - ), - ( - "linux", - DEFAULT_DESKTOP_VARIANT, - "Linux (`linux`)", - LINUX_DESKTOP_ARCHES, - ), -]; - #[derive(Debug, Args, Clone)] pub struct BuildDesktopArgs { #[arg(long, value_enum)] @@ -149,30 +88,26 @@ enum DesktopStep { BuildAppMacos, VerifyBundleId, BuildAppWindows, + ValidateWindowsSigningInputs, + WriteWindowsSigningMetadata, + ResolveWindowsUnpackedDir, + VerifyWindowsUnpackedSignatures, PackageAppWindowsVelopack, AnalyseVelopackPaths, BuildAppLinux, CreatePortableZipWindows, + VerifyWindowsSignedArtifacts, PrepareArtifactsWindows, PrepareArtifactsUnix, NormaliseUpdaterYaml, GenerateChecksumsUnix, GenerateChecksumsWindows, - BuildSourceTarball, UploadHandoff, - CheckSigningSecrets, - DownloadWindowsHandoff, - CheckWindowsArtifacts, - VerifyAuthenticode, - RegenerateSignedChecksums, - StageSignedWindowsArtifacts, DownloadHandoff, CleanupHandoff, BuildPayload, UploadPayload, - VerifySourceTarball, BuildSummary, - NotifyWebhook, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -192,13 +127,6 @@ const PLATFORMS: &[Platform] = &[ os: "blacksmith-32vcpu-windows-2025", electron_arch: "x64", }, - Platform { - platform: "windows", - arch: "x64", - desktop_variant: WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT, - os: "blacksmith-32vcpu-windows-2025", - electron_arch: "x64", - }, Platform { platform: "windows", arch: "arm64", @@ -206,13 +134,6 @@ const PLATFORMS: &[Platform] = &[ os: "blacksmith-32vcpu-windows-2025", electron_arch: "arm64", }, - Platform { - platform: "windows", - arch: "arm64", - desktop_variant: WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT, - os: "blacksmith-32vcpu-windows-2025", - electron_arch: "arm64", - }, Platform { platform: "macos", arch: "x64", @@ -287,10 +208,15 @@ pub async fn run(args: BuildDesktopArgs) -> Result<()> { DesktopStep::BuildAppMacos => build_app_step(DesktopBuildPlatform::Macos), DesktopStep::VerifyBundleId => verify_bundle_id_step(), DesktopStep::BuildAppWindows => build_app_step(DesktopBuildPlatform::Windows), + DesktopStep::ValidateWindowsSigningInputs => validate_windows_signing_inputs_step(), + DesktopStep::WriteWindowsSigningMetadata => write_windows_signing_metadata_step(), + DesktopStep::ResolveWindowsUnpackedDir => resolve_windows_unpacked_dir_step(), + DesktopStep::VerifyWindowsUnpackedSignatures => verify_windows_unpacked_signatures_step(), DesktopStep::PackageAppWindowsVelopack => package_app_windows_velopack_step(), DesktopStep::AnalyseVelopackPaths => analyse_velopack_paths_step(), DesktopStep::BuildAppLinux => build_app_step(DesktopBuildPlatform::Linux), DesktopStep::CreatePortableZipWindows => create_portable_zip_windows_step(), + DesktopStep::VerifyWindowsSignedArtifacts => verify_windows_signed_artifacts_step(), DesktopStep::PrepareArtifactsWindows => prepare_artifacts_windows_step(), DesktopStep::PrepareArtifactsUnix => prepare_artifacts_unix_step(), DesktopStep::NormaliseUpdaterYaml => normalise_updater_yaml_step(), @@ -308,21 +234,12 @@ pub async fn run(args: BuildDesktopArgs) -> Result<()> { ArtifactChecksumKind::Extension("nupkg"), ArtifactChecksumKind::Extension("zip"), ]), - DesktopStep::BuildSourceTarball => build_source_tarball_step(), DesktopStep::UploadHandoff => upload_handoff_step(false).await, - DesktopStep::CheckSigningSecrets => check_signing_secrets_step(), - DesktopStep::DownloadWindowsHandoff => download_windows_handoff_step().await, - DesktopStep::CheckWindowsArtifacts => check_windows_artifacts_step(), - DesktopStep::VerifyAuthenticode => verify_authenticode_step(), - DesktopStep::RegenerateSignedChecksums => regenerate_signed_checksums_step(), - DesktopStep::StageSignedWindowsArtifacts => stage_signed_windows_artifacts_step().await, DesktopStep::DownloadHandoff => download_handoff_step().await, DesktopStep::CleanupHandoff => cleanup_handoff_step().await, DesktopStep::BuildPayload => build_payload_step(), DesktopStep::UploadPayload => upload_payload_step().await, - DesktopStep::VerifySourceTarball => verify_source_tarball_step().await, DesktopStep::BuildSummary => build_summary_step(), - DesktopStep::NotifyWebhook => notify_webhook_step().await, } } @@ -439,44 +356,8 @@ fn set_matrix_step(args: &BuildDesktopArgs) -> Result<()> { .map(platform_json) .collect::>() .join(","); - let windows_x64 = selected_platform(&platforms, "windows", "x64").to_string(); - let windows_arm64 = selected_platform(&platforms, "windows", "arm64").to_string(); - let windows_x64_default = - selected_platform_variant(&platforms, "windows", "x64", DEFAULT_DESKTOP_VARIANT) - .to_string(); - let windows_arm64_default = - selected_platform_variant(&platforms, "windows", "arm64", DEFAULT_DESKTOP_VARIANT) - .to_string(); - let windows_game_capture_x64 = selected_platform_variant( - &platforms, - "windows", - "x64", - WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT, - ) - .to_string(); - let windows_game_capture_arm64 = selected_platform_variant( - &platforms, - "windows", - "arm64", - WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT, - ) - .to_string(); let matrix = format!("{{\"include\":[{include}]}}"); - append_github_output(&[ - ("matrix", matrix.as_str()), - ("windows_x64", windows_x64.as_str()), - ("windows_arm64", windows_arm64.as_str()), - ("windows_x64_default", windows_x64_default.as_str()), - ("windows_arm64_default", windows_arm64_default.as_str()), - ( - "windows_game_capture_x64", - windows_game_capture_x64.as_str(), - ), - ( - "windows_game_capture_arm64", - windows_game_capture_arm64.as_str(), - ), - ]) + append_github_output(&[("matrix", matrix.as_str())]) } fn selected_platforms(args: &BuildDesktopArgs) -> Result> { @@ -488,23 +369,6 @@ fn selected_platforms(args: &BuildDesktopArgs) -> Result> { .collect()) } -fn selected_platform(platforms: &[Platform], platform: &str, arch: &str) -> bool { - platforms - .iter() - .any(|item| item.platform == platform && item.arch == arch) -} - -fn selected_platform_variant( - platforms: &[Platform], - platform: &str, - arch: &str, - desktop_variant: &str, -) -> bool { - platforms.iter().any(|item| { - item.platform == platform && item.arch == arch && item.desktop_variant == desktop_variant - }) -} - fn skip_target_set(args: &BuildDesktopArgs) -> Result> { let raw = args .skip_targets @@ -516,9 +380,6 @@ fn skip_target_set(args: &BuildDesktopArgs) -> Result> { "windows", "windows-x64", "windows-arm64", - "windows-game-capture", - "windows-game-capture-x64", - "windows-game-capture-arm64", "macos", "macos-x64", "macos-arm64", @@ -556,15 +417,6 @@ fn skip_platform( if skip_targets.contains(platform.platform) || skip_targets.contains(platform_arch.as_str()) { return true; } - if platform.desktop_variant != DEFAULT_DESKTOP_VARIANT { - let variant_arch = format!("{}-{}", platform.desktop_variant, platform.arch); - if skip_targets.contains(platform.desktop_variant) - || skip_targets.contains(variant_arch.as_str()) - { - return true; - } - } - match platform.platform { "windows" => { flag(&args.skip_windows, "SKIP_WINDOWS") @@ -634,38 +486,6 @@ fn desktop_variant_path_segment(variant: &str) -> Option<&str> { } } -fn windows_artifact_dir(arch: &str, variant: &str) -> PathBuf { - let mut name = format!("windows-{arch}"); - if let Some(segment) = desktop_variant_path_segment(variant) { - name.push('-'); - name.push_str(segment); - } - Path::new("artifacts").join(name) -} - -fn expected_windows_artifacts(arch: &str, variant: &str) -> bool { - if env_string("EXPECT_WINDOWS_ARTIFACTS").is_some() { - return env_bool("EXPECT_WINDOWS_ARTIFACTS"); - } - let env_name = match (arch, variant) { - ("x64", DEFAULT_DESKTOP_VARIANT) => "EXPECT_WINDOWS_X64_DEFAULT", - ("arm64", DEFAULT_DESKTOP_VARIANT) => "EXPECT_WINDOWS_ARM64_DEFAULT", - ("x64", WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT) => "EXPECT_WINDOWS_GAME_CAPTURE_X64", - ("arm64", WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT) => "EXPECT_WINDOWS_GAME_CAPTURE_ARM64", - ("x64", _) => "EXPECT_WINDOWS_X64", - _ => "EXPECT_WINDOWS_ARM64", - }; - if env_string(env_name).is_some() { - return env_bool(env_name); - } - let fallback_env = if arch == "x64" { - "EXPECT_WINDOWS_X64" - } else { - "EXPECT_WINDOWS_ARM64" - }; - env_bool(fallback_env) -} - fn workspace_dir() -> PathBuf { env::var("GITHUB_WORKSPACE") .map(PathBuf::from) @@ -1583,6 +1403,106 @@ fn check_macho_arch(file: &Path, expected: &str, electron_arch: &str) -> Result< Ok(()) } +const WINDOWS_SIGNING_ENV: &[&str] = &[ + "AZURE_CLIENT_ID", + "AZURE_TENANT_ID", + "AZURE_SUBSCRIPTION_ID", + "AZURE_ARTIFACT_SIGNING_ENDPOINT", + "AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME", + "AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME", +]; +const VELOPACK_TRUSTED_SIGN_FILE_ENV: &str = "VELOPACK_TRUSTED_SIGN_FILE"; +const TRUSTED_SIGNING_EXCLUDED_CREDENTIALS: &[&str] = &[ + "ManagedIdentityCredential", + "WorkloadIdentityCredential", + "SharedTokenCacheCredential", + "VisualStudioCredential", + "VisualStudioCodeCredential", + "AzurePowerShellCredential", + "AzureDeveloperCliCredential", + "InteractiveBrowserCredential", +]; + +fn validate_windows_signing_inputs_step() -> Result<()> { + let missing = WINDOWS_SIGNING_ENV + .iter() + .copied() + .filter(|name| env_string(name).is_none()) + .collect::>(); + ensure!( + missing.is_empty(), + "Missing Windows code signing environment variables: {}. Windows releases are always signed; every Azure Trusted Signing input is mandatory and there is no unsigned fallback.", + missing.join(" ") + ); + println!( + "Windows code signing inputs present: {}", + WINDOWS_SIGNING_ENV.join(" ") + ); + Ok(()) +} + +#[derive(Debug, Serialize)] +struct TrustedSigningMetadata { + #[serde(rename = "Endpoint")] + endpoint: String, + #[serde(rename = "CodeSigningAccountName")] + code_signing_account_name: String, + #[serde(rename = "CertificateProfileName")] + certificate_profile_name: String, + #[serde(rename = "ExcludeCredentials")] + exclude_credentials: Vec<&'static str>, +} + +fn windows_trusted_signing_metadata_path() -> PathBuf { + runner_temp().join("velopack-trusted-signing.json") +} + +fn write_windows_signing_metadata_step() -> Result<()> { + validate_windows_signing_inputs_step()?; + let metadata = TrustedSigningMetadata { + endpoint: require_env("AZURE_ARTIFACT_SIGNING_ENDPOINT")?, + code_signing_account_name: require_env("AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME")?, + certificate_profile_name: require_env("AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME")?, + exclude_credentials: TRUSTED_SIGNING_EXCLUDED_CREDENTIALS.to_vec(), + }; + let path = windows_trusted_signing_metadata_path(); + write_json_pretty(&path, &metadata)?; + println!( + "Wrote Velopack Trusted Signing metadata to {} (never staged for upload).", + path.display() + ); + append_github_env(&[( + VELOPACK_TRUSTED_SIGN_FILE_ENV, + path.to_string_lossy().as_ref(), + )]) +} + +fn resolve_windows_unpacked_dir_step() -> Result<()> { + let build_channel = env::var("BUILD_CHANNEL").unwrap_or_else(|_| "stable".to_string()); + let arch = require_env("ARCH")?; + let config = windows_package_config(&build_channel, &arch); + let pack_dir = resolve_windows_unpacked_dir(&arch, &config.main_exe)?; + println!( + "Resolved unpacked Windows app directory: {}", + pack_dir.display() + ); + append_github_output(&[("unpacked_dir", pack_dir.to_string_lossy().as_ref())]) +} + +fn resolve_windows_unpacked_dir(arch: &str, main_exe: &str) -> Result { + let pack_dir = find_windows_unpacked_app(arch, main_exe) + .ok_or_else(|| anyhow!("Unable to find unpacked Windows app containing {main_exe}"))?; + let absolute = env::current_dir() + .context("Failed to resolve current directory")? + .join(pack_dir); + ensure!( + absolute.is_dir(), + "Unpacked Windows app directory does not exist: {}", + absolute.display() + ); + Ok(absolute) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct WindowsPackageConfig { pack_id: &'static str, @@ -1632,6 +1552,37 @@ fn package_app_windows_velopack_step() -> Result<()> { ) })?; let vpk = find_velopack_cli()?; + let trusted_sign_file = PathBuf::from(require_env(VELOPACK_TRUSTED_SIGN_FILE_ENV).context( + "Velopack packaging requires the Trusted Signing metadata written by the write_windows_signing_metadata step. Windows packages are never produced unsigned.", + )?); + ensure!( + trusted_sign_file.is_file(), + "Velopack Trusted Signing metadata file is missing: {}", + trusted_sign_file.display() + ); + let packaged = pack_and_validate_windows_velopack( + &vpk, + &config, + &version, + &arch, + &pack_dir, + &trusted_sign_file, + ); + let metadata_removed = remove_file_if_exists(&trusted_sign_file); + packaged?; + metadata_removed?; + print_directory(&config.output_dir) +} + +fn pack_and_validate_windows_velopack( + vpk: &Path, + config: &WindowsPackageConfig, + version: &str, + arch: &str, + pack_dir: &Path, + trusted_sign_file: &Path, +) -> Result<()> { + ensure_velopack_pack_supports(vpk, &["--azureTrustedSignFile", "--noPortable"])?; run_command(CommandSpec::new(vpk).args([ "--yes", @@ -1639,7 +1590,7 @@ fn package_app_windows_velopack_step() -> Result<()> { "--packId", config.pack_id, "--packVersion", - version.as_str(), + version, "--packDir", pack_dir.to_string_lossy().as_ref(), "--mainExe", @@ -1658,10 +1609,33 @@ fn package_app_windows_velopack_step() -> Result<()> { config.output_dir.to_string_lossy().as_ref(), "--delta", "BestSpeed", + "--noPortable", + "--azureTrustedSignFile", + trusted_sign_file.to_string_lossy().as_ref(), ]))?; - validate_velopack_output(&config, &version, &arch)?; - print_directory(&config.output_dir) + validate_velopack_output(config, version, arch) +} + +fn ensure_velopack_pack_supports(vpk: &Path, options: &[&str]) -> Result<()> { + let help = capture(CommandSpec::new(vpk).args(["pack", "--help"])) + .context("Failed to read `vpk pack --help` from the pinned Velopack CLI")?; + let text = format!( + "{}{}", + String::from_utf8_lossy(&help.stdout), + String::from_utf8_lossy(&help.stderr) + ); + let missing = options + .iter() + .filter(|option| !text.contains(**option)) + .copied() + .collect::>(); + ensure!( + missing.is_empty(), + "The pinned Velopack CLI does not support {}. Windows packages are never produced unsigned, so the pin must be updated or the packaging step reworked before releasing.", + missing.join(", ") + ); + Ok(()) } fn validate_velopack_output( @@ -1825,19 +1799,704 @@ fn create_portable_zip_windows_step() -> Result<()> { println!("No unpacked Windows app found; skipping portable ZIP."); return Ok(()); }; - fs::write(pack_dir.join(".portable"), "") - .with_context(|| format!("Failed to write {}", pack_dir.join(".portable").display()))?; + let portable_marker = pack_dir.join(".portable"); + fs::write(&portable_marker, "") + .with_context(|| format!("Failed to write {}", portable_marker.display()))?; let zip_name = format!("{}-{version}-portable-win-{arch}.zip", config.pack_title); let zip_path = PathBuf::from("dist-electron").join(zip_name); create_zip_from_dir(&pack_dir, &zip_path)?; + remove_file_if_exists(&portable_marker)?; let size_mb = fs::metadata(&zip_path)?.len() as f64 / 1024.0 / 1024.0; println!( - "Created portable ZIP: {} ({size_mb:.1} MB)", - zip_path.display() + "Created portable ZIP: {} ({size_mb:.1} MB); removed {} so installed builds are not marked portable.", + zip_path.display(), + portable_marker.display() ); Ok(()) } +const FLUXER_WINDOWS_SIGNER_COMMON_NAME: &str = "Fluxer Platform AB"; +const THIRD_PARTY_PUBLISHER_ALLOWLIST: &[&str] = &[]; +const KNOWN_OPTIONAL_WINDOWS_PE_INVENTORY: &[&str] = &["fluxer-vulkan-layer.win32-ia32-msvc.dll"]; +const WINDOWS_NATIVE_ADDON_STEMS: &[&str] = &[ + "webauthn", + "webrtc-sender", + "win-process-loopback", + "win-clipboard", + "win-shell", + "win-toast", + "windows-input-hook", + "platform-info", +]; + +fn expected_windows_pe_inventory(arch: &str, main_exe: &str) -> Vec { + let tag = format!("win32-{arch}-msvc"); + let mut names = vec![ + main_exe.to_string(), + format!("velopack_nodeffi_win_{arch}_msvc.node"), + format!("win-game-capture.{tag}.node"), + format!("fluxer-game-hook.{tag}.dll"), + format!("fluxer-inject-helper.{tag}.exe"), + format!("fluxer-vulkan-layer.{tag}.dll"), + ]; + names.extend( + WINDOWS_NATIVE_ADDON_STEMS + .iter() + .map(|stem| format!("{stem}.{tag}.node")), + ); + if arch == "x64" { + names.push("fluxer-game-hook.win32-ia32-msvc.dll".to_string()); + names.push("fluxer-inject-helper.win32-ia32-msvc.exe".to_string()); + } + names.sort(); + names.dedup(); + names +} + +fn is_pe_file(path: &Path) -> Result { + let mut file = + File::open(path).with_context(|| format!("Failed to open {}", path.display()))?; + let mut dos_header = [0u8; 0x40]; + if !read_exact_or_eof(&mut file, &mut dos_header, path)? { + return Ok(false); + } + if &dos_header[0..2] != b"MZ" { + return Ok(false); + } + let e_lfanew = u32::from_le_bytes([ + dos_header[0x3c], + dos_header[0x3d], + dos_header[0x3e], + dos_header[0x3f], + ]); + file.seek(SeekFrom::Start(u64::from(e_lfanew))) + .with_context(|| format!("Failed to seek in {}", path.display()))?; + let mut signature = [0u8; 4]; + if !read_exact_or_eof(&mut file, &mut signature, path)? { + return Ok(false); + } + Ok(&signature == b"PE\0\0") +} + +fn read_exact_or_eof(file: &mut File, buffer: &mut [u8], path: &Path) -> Result { + match file.read_exact(buffer) { + Ok(()) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => Ok(false), + Err(error) => Err(error).with_context(|| format!("Failed to read {}", path.display())), + } +} + +fn collect_pe_files(root: &Path) -> Result> { + let mut files = Vec::new(); + for path in collect_files(root)? { + if is_pe_file(&path)? { + files.push(path); + } + } + Ok(files) +} + +fn absolute_path(path: &Path) -> Result { + if path.is_absolute() { + return Ok(path.to_path_buf()); + } + Ok(env::current_dir() + .context("Failed to resolve current directory")? + .join(path)) +} + +fn relative_display(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +fn assert_expected_windows_pe_inventory( + root: &Path, + files: &[PathBuf], + arch: &str, + main_exe: &str, +) -> Result<()> { + let present = files + .iter() + .filter_map(|path| path.file_name().and_then(OsStr::to_str)) + .map(ToOwned::to_owned) + .collect::>(); + let expected = expected_windows_pe_inventory(arch, main_exe); + let missing = expected + .iter() + .filter(|name| !present.contains(*name)) + .cloned() + .collect::>(); + ensure!( + missing.is_empty(), + "{} is missing {} expected Windows binaries:\n{}", + root.display(), + missing.len(), + missing.join("\n") + ); + let contradictory = contradictory_optional_windows_pe_inventory(arch, main_exe); + ensure!( + contradictory.is_empty(), + "KNOWN_OPTIONAL_WINDOWS_PE_INVENTORY lists {} binary/binaries that {arch} also requires, so the inventory contradicts itself:\n{}", + contradictory.len(), + contradictory.join("\n") + ); + for name in KNOWN_OPTIONAL_WINDOWS_PE_INVENTORY { + println!( + "Known-optional Windows binary {name}: {}", + if present.contains(*name) { + "present" + } else { + "absent" + } + ); + } + let unlisted = present + .iter() + .filter(|name| { + !expected.iter().any(|value| value == *name) + && !KNOWN_OPTIONAL_WINDOWS_PE_INVENTORY.contains(&name.as_str()) + }) + .cloned() + .collect::>(); + println!( + "{}: {} expected, {} unlisted PE(s) shipped by glob (Electron runtime and cross-architecture native artifacts). Every one of them is signature-classified below; none may be unsigned or signed by an unknown publisher.", + root.display(), + expected.len(), + unlisted.len() + ); + for name in &unlisted { + println!("Unlisted Windows PE pending signature classification: {name}"); + } + Ok(()) +} + +fn contradictory_optional_windows_pe_inventory(arch: &str, main_exe: &str) -> Vec { + let expected = expected_windows_pe_inventory(arch, main_exe); + KNOWN_OPTIONAL_WINDOWS_PE_INVENTORY + .iter() + .filter(|name| expected.iter().any(|value| value == *name)) + .map(|name| (*name).to_string()) + .collect() +} + +#[derive(Debug, Clone, Deserialize)] +struct SignatureRow { + #[serde(rename = "Path")] + path: String, + #[serde(rename = "Status")] + status: String, + #[serde(rename = "Subject")] + subject: Option, + #[serde(rename = "Thumbprint")] + thumbprint: Option, + #[serde(rename = "TsSubject")] + ts_subject: Option, +} + +fn authenticode_report(files: &[PathBuf]) -> Result> { + let temp = TempDir::new().context("Failed to create Authenticode report temp directory")?; + let list_path = temp.path().join("paths.txt"); + let mut list = String::new(); + for file in files { + list.push_str(file.to_string_lossy().as_ref()); + list.push('\n'); + } + fs::write(&list_path, list) + .with_context(|| format!("Failed to write {}", list_path.display()))?; + + let script_path = temp.path().join("authenticode-report.ps1"); + fs::write(&script_path, authenticode_report_script(&list_path)) + .with_context(|| format!("Failed to write {}", script_path.display()))?; + + let output = capture(CommandSpec::new("powershell").args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + script_path.to_string_lossy().as_ref(), + ]))?; + ensure!( + output.status == 0, + "Get-AuthenticodeSignature failed with exit code {}", + output.status + ); + let stdout = String::from_utf8(output.stdout) + .context("Get-AuthenticodeSignature output was not UTF-8")?; + parse_authenticode_report(stdout.trim()) +} + +fn authenticode_report_script(list_path: &Path) -> String { + format!( + "$ErrorActionPreference = 'Stop'\n\ +[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false\n\ +$paths = @(Get-Content -LiteralPath '{}' -Encoding UTF8 | Where-Object {{ $_ -ne '' }})\n\ +$rows = @(Get-AuthenticodeSignature -LiteralPath $paths | Select-Object \ +@{{n='Path';e={{[string]$_.Path}}}}, \ +@{{n='Status';e={{[string]$_.Status}}}}, \ +@{{n='Subject';e={{if ($_.SignerCertificate) {{ [string]$_.SignerCertificate.Subject }} else {{ $null }}}}}}, \ +@{{n='Thumbprint';e={{if ($_.SignerCertificate) {{ [string]$_.SignerCertificate.Thumbprint }} else {{ $null }}}}}}, \ +@{{n='TsSubject';e={{if ($_.TimeStamperCertificate) {{ [string]$_.TimeStamperCertificate.Subject }} else {{ $null }}}}}})\n\ +ConvertTo-Json -InputObject $rows -Depth 3 -Compress\n", + list_path.display() + ) +} + +fn parse_authenticode_report(json: &str) -> Result> { + let json = json.trim_start_matches('\u{feff}').trim(); + ensure!( + !json.is_empty(), + "Get-AuthenticodeSignature produced no output." + ); + let value: Value = + serde_json::from_str(json).context("Failed to parse Get-AuthenticodeSignature JSON")?; + let rows = match value { + Value::Array(items) => items, + single => vec![single], + }; + rows.into_iter() + .map(|row| { + serde_json::from_value::(row) + .context("Failed to parse Get-AuthenticodeSignature row") + }) + .collect() +} + +fn certificate_common_name(subject: &str) -> Option<&str> { + subject + .split(", ") + .find_map(|component| component.strip_prefix("CN=")) +} + +fn assert_fluxer_signed(row: &SignatureRow) -> Result<()> { + ensure!( + row.status == "Valid", + "Authenticode status is {} (expected Valid)", + row.status + ); + ensure!( + row.ts_subject.is_some(), + "Authenticode signature carries no RFC3161 timestamp" + ); + let subject = row + .subject + .as_deref() + .ok_or_else(|| anyhow!("Authenticode signature has no signer certificate subject"))?; + let common_name = certificate_common_name(subject) + .ok_or_else(|| anyhow!("Signer subject has no CN= component: {subject}"))?; + ensure!( + common_name == FLUXER_WINDOWS_SIGNER_COMMON_NAME, + "Signer CN is '{common_name}', expected '{}' (thumbprint {})", + FLUXER_WINDOWS_SIGNER_COMMON_NAME, + row.thumbprint.as_deref().unwrap_or("unknown") + ); + Ok(()) +} + +fn assert_third_party_signed(row: &SignatureRow) -> Result<()> { + ensure!( + row.status == "Valid", + "Authenticode status is {} (expected Valid)", + row.status + ); + let subject = row + .subject + .as_deref() + .ok_or_else(|| anyhow!("Authenticode signature has no signer certificate subject"))?; + let common_name = certificate_common_name(subject) + .ok_or_else(|| anyhow!("Signer subject has no CN= component: {subject}"))?; + ensure!( + THIRD_PARTY_PUBLISHER_ALLOWLIST.contains(&common_name), + "Signer CN '{common_name}' is not an allowlisted third-party publisher" + ); + ensure!( + row.ts_subject.is_some(), + "Authenticode signature has no RFC3161 timestamp" + ); + Ok(()) +} + +fn assert_signed_by_known_publisher(row: &SignatureRow) -> Result<()> { + match assert_fluxer_signed(row) { + Ok(()) => Ok(()), + Err(fluxer_error) => assert_third_party_signed(row) + .map_err(|third_party_error| anyhow!("{fluxer_error}; {third_party_error}")), + } +} + +fn same_windows_path(reported: &str, expected: &Path) -> bool { + fn normalise(value: &str) -> String { + let replaced = value.replace('/', "\\"); + let trimmed = replaced.trim_start_matches(r"\\?\"); + trimmed.to_ascii_lowercase() + } + normalise(reported) == normalise(expected.to_string_lossy().as_ref()) +} + +fn find_signtool() -> Result { + if let Some(path) = env_string("SIGNTOOL_PATH") + .map(PathBuf::from) + .filter(|path| path.exists()) + { + return Ok(path); + } + let roots = [ + PathBuf::from(r"C:\Program Files (x86)\Windows Kits\10\bin"), + PathBuf::from(r"C:\Program Files\Windows Kits\10\bin"), + ]; + let host_leaf = signtool_host_arch_dir(); + let mut best: Option<((u8, [u32; 4]), PathBuf)> = None; + for root in &roots { + if !root.exists() { + continue; + } + for entry in WalkDir::new(root) + .into_iter() + .filter_map(std::result::Result::ok) + .filter(|entry| entry.file_type().is_file()) + { + let path = entry.into_path(); + if !path + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| name.eq_ignore_ascii_case("signtool.exe")) + { + continue; + } + let leaf_matches_host = path + .parent() + .and_then(Path::file_name) + .and_then(OsStr::to_str) + .is_some_and(|leaf| leaf.eq_ignore_ascii_case(host_leaf)); + let rank = ( + u8::from(leaf_matches_host), + windows_sdk_version_from_path(&path), + ); + if best.as_ref().is_none_or(|(current, _)| rank > *current) { + best = Some((rank, path)); + } + } + } + let (rank, path) = best.ok_or_else(|| { + anyhow!( + "Could not find signtool.exe under {} or {}. Install the Windows SDK Signing Tools on the runner, or set SIGNTOOL_PATH to an explicit signtool.exe.", + roots[0].display(), + roots[1].display() + ) + })?; + let (host_arch_match, sdk_version) = rank; + println!( + "Using signtool {} (SDK {}.{}.{}.{}, host architecture match: {})", + path.display(), + sdk_version[0], + sdk_version[1], + sdk_version[2], + sdk_version[3], + host_arch_match == 1 + ); + Ok(path) +} + +fn signtool_host_arch_dir() -> &'static str { + match env::consts::ARCH { + "aarch64" => "arm64", + "x86" => "x86", + _ => "x64", + } +} + +fn windows_sdk_version_from_path(path: &Path) -> [u32; 4] { + let mut best = [0u32; 4]; + for component in path.components() { + let Some(text) = component.as_os_str().to_str() else { + continue; + }; + let parts = text.split('.').collect::>(); + if parts.len() < 2 || parts.len() > 4 { + continue; + } + let mut version = [0u32; 4]; + let mut parsed = true; + for (index, part) in parts.iter().enumerate() { + match part.parse::() { + Ok(value) => version[index] = value, + Err(_) => { + parsed = false; + break; + } + } + } + if parsed && version > best { + best = version; + } + } + best +} + +fn verify_pe_signature(signtool: &Path, file: &Path) -> Result<()> { + let output = capture(CommandSpec::new(signtool).args([ + "verify", + "/pa", + "/all", + "/tw", + file.to_string_lossy().as_ref(), + ]))?; + ensure!( + output.status == 0, + "signtool verify /pa /all /tw failed with exit code {}", + output.status + ); + Ok(()) +} + +fn verify_windows_pe_signatures( + signtool: &Path, + label: &str, + root: &Path, + files: &[PathBuf], +) -> Result<()> { + ensure!( + !files.is_empty(), + "{label}: no Windows PE files found under {}. Refusing to publish an unverified inventory.", + root.display() + ); + let root = absolute_path(root)?; + let files = files + .iter() + .map(|file| absolute_path(file)) + .collect::>>()?; + let rows = authenticode_report(&files)?; + let mut failures = Vec::new(); + for file in &files { + let relative = relative_display(&root, file); + if let Err(error) = verify_pe_signature(signtool, file) { + failures.push(format!("{relative}: {error}")); + continue; + } + let Some(row) = rows + .iter() + .find(|row| same_windows_path(&row.path, file.as_path())) + else { + failures.push(format!( + "{relative}: Get-AuthenticodeSignature reported no row for this file" + )); + continue; + }; + if let Err(error) = assert_signed_by_known_publisher(row) { + failures.push(format!("{relative}: {error}")); + } + } + ensure!( + failures.is_empty(), + "{label}: {} of {} Windows binaries are not signed by '{}':\n{}", + failures.len(), + files.len(), + FLUXER_WINDOWS_SIGNER_COMMON_NAME, + failures.join("\n") + ); + println!( + "{label}: verified {} Windows binaries signed by '{}'.", + files.len(), + FLUXER_WINDOWS_SIGNER_COMMON_NAME + ); + Ok(()) +} + +fn verify_windows_unpacked_signatures_step() -> Result<()> { + let build_channel = env::var("BUILD_CHANNEL").unwrap_or_else(|_| "stable".to_string()); + let arch = require_env("ARCH")?; + let config = windows_package_config(&build_channel, &arch); + let pack_dir = resolve_windows_unpacked_dir(&arch, &config.main_exe)?; + let files = collect_pe_files(&pack_dir)?; + assert_expected_windows_pe_inventory(&pack_dir, &files, &arch, &config.main_exe)?; + ensure!( + files.iter().any(|file| extension_is(file, "node")), + "No .node addon was detected as a PE file under {}; the exe,dll,node signing filter would have been a silent no-op.", + pack_dir.display() + ); + ensure!( + files.iter().any(|file| extension_is(file, "dll")), + "No .dll was detected as a PE file under {}; the exe,dll,node signing filter would have been a silent no-op.", + pack_dir.display() + ); + let signtool = find_signtool()?; + verify_windows_pe_signatures(&signtool, "win-unpacked", &pack_dir, &files) +} + +fn short_extraction_root(key: &str) -> PathBuf { + let drive = env::var("SystemDrive").unwrap_or_else(|_| "C:".to_string()); + let base = PathBuf::from(format!("{}\\fxv", drive.trim_end_matches(['\\', '/']))); + let digest = hex::encode(Sha256::digest(key.as_bytes())); + base.join(&digest[..12]) +} + +fn extract_zip_safely(archive_path: &Path, destination: &Path) -> Result<()> { + let file = File::open(archive_path) + .with_context(|| format!("Failed to open {}", archive_path.display()))?; + let mut archive = zip::ZipArchive::new(file) + .with_context(|| format!("Failed to read zip {}", archive_path.display()))?; + fs::create_dir_all(destination) + .with_context(|| format!("Failed to create {}", destination.display()))?; + for index in 0..archive.len() { + let mut entry = archive.by_index(index)?; + let is_dir = entry.is_dir(); + let relative = entry.enclosed_name().ok_or_else(|| { + anyhow!( + "Refusing to extract unsafe archive path '{}' from {}", + entry.name(), + archive_path.display() + ) + })?; + let target = destination.join(relative); + if is_dir { + fs::create_dir_all(&target) + .with_context(|| format!("Failed to create {}", target.display()))?; + continue; + } + if let Some(parent) = target.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + let mut output = File::create(&target) + .with_context(|| format!("Failed to create {}", target.display()))?; + io::copy(&mut entry, &mut output) + .with_context(|| format!("Failed to extract {}", target.display()))?; + } + Ok(()) +} + +fn verify_windows_signed_artifacts_step() -> Result<()> { + let build_channel = env::var("BUILD_CHANNEL").unwrap_or_else(|_| "stable".to_string()); + let arch = require_env("ARCH")?; + let version = require_env("VERSION")?; + let config = windows_package_config(&build_channel, &arch); + + let nupkg = first_file_matching(&config.output_dir, |name| name.ends_with("-full.nupkg")) + .ok_or_else(|| { + anyhow!( + "No Velopack full nupkg found in {}", + config.output_dir.display() + ) + })?; + let setup_exe = config + .output_dir + .join(format!("{}-{version}-win-{arch}.exe", config.pack_title)); + ensure!( + setup_exe.is_file(), + "Velopack Setup.exe not found: {}", + setup_exe.display() + ); + let portable_zip = PathBuf::from("dist-electron").join(format!( + "{}-{version}-portable-win-{arch}.zip", + config.pack_title + )); + ensure!( + portable_zip.is_file(), + "Portable ZIP not found: {}", + portable_zip.display() + ); + + let staged_nupkgs = collect_files(&config.output_dir)? + .into_iter() + .filter(|path| extension_is(path, "nupkg")) + .collect::>(); + let delta_nupkgs = staged_nupkgs + .iter() + .filter(|path| { + path.file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| name.ends_with("-delta.nupkg")) + }) + .cloned() + .collect::>(); + let unclassified_nupkgs = staged_nupkgs + .iter() + .filter(|path| **path != nupkg && !delta_nupkgs.contains(path)) + .map(|path| path.display().to_string()) + .collect::>(); + ensure!( + unclassified_nupkgs.is_empty(), + "{} stages {} nupkg(s) that are neither the verified full package nor a delta package, so they would be published unverified:\n{}", + config.output_dir.display(), + unclassified_nupkgs.len(), + unclassified_nupkgs.join("\n") + ); + + let unverified_zips = collect_files(&config.output_dir)? + .into_iter() + .filter(|path| extension_is(path, "zip") && *path != portable_zip) + .map(|path| path.display().to_string()) + .collect::>(); + ensure!( + unverified_zips.is_empty(), + "{} stages {} zip(s) that are not the verified portable archive {}, so they would be published unverified:\n{}", + config.output_dir.display(), + unverified_zips.len(), + portable_zip.display(), + unverified_zips.join("\n") + ); + + let signtool = find_signtool()?; + let root = short_extraction_root(&format!("{}-{version}-{arch}", config.pack_id)); + remove_dir_if_exists(&root)?; + + let nupkg_root = root.join("n"); + extract_zip_safely(&nupkg, &nupkg_root)?; + let lib_app = nupkg_root.join("lib").join("app"); + ensure!( + lib_app.is_dir(), + "{} contains no lib/app tree.", + nupkg.display() + ); + ensure!( + lib_app.join("Squirrel.exe").is_file(), + "{} contains no lib/app/Squirrel.exe.", + nupkg.display() + ); + let execution_stub = format!("{}_ExecutionStub.exe", config.pack_title); + ensure!( + lib_app.join(&execution_stub).is_file(), + "{} contains no lib/app/{execution_stub}.", + nupkg.display() + ); + let nupkg_files = collect_pe_files(&lib_app)?; + assert_expected_windows_pe_inventory(&lib_app, &nupkg_files, &arch, &config.main_exe)?; + verify_windows_pe_signatures(&signtool, "nupkg lib/app", &lib_app, &nupkg_files)?; + + for (index, delta_nupkg) in delta_nupkgs.iter().enumerate() { + let delta_root = root.join(format!("d{index}")); + extract_zip_safely(delta_nupkg, &delta_root)?; + let delta_files = collect_pe_files(&delta_root)?; + let label = format!("delta nupkg {}", file_name_string(delta_nupkg)?); + if delta_files.is_empty() { + println!( + "{label}: contains no whole PE entries, only Velopack diffs; nothing to verify." + ); + continue; + } + verify_windows_pe_signatures(&signtool, &label, &delta_root, &delta_files)?; + } + + let portable_root = root.join("p"); + extract_zip_safely(&portable_zip, &portable_root)?; + let portable_files = collect_pe_files(&portable_root)?; + assert_expected_windows_pe_inventory(&portable_root, &portable_files, &arch, &config.main_exe)?; + verify_windows_pe_signatures(&signtool, "portable zip", &portable_root, &portable_files)?; + + let staged_installers = collect_pe_files(&config.output_dir)?; + ensure!( + staged_installers.contains(&setup_exe), + "Velopack output directory does not contain the renamed Setup executable {}", + setup_exe.display() + ); + verify_windows_pe_signatures(&signtool, "setup", &config.output_dir, &staged_installers)?; + + remove_dir_if_exists(&root) +} + fn prepare_artifacts_windows_step() -> Result<()> { let arch = require_env("ARCH")?; let staging = Path::new("upload_staging"); @@ -1967,252 +2626,6 @@ fn checksum_kind_matches(kind: ArtifactChecksumKind, name: &str) -> bool { } } -fn build_source_tarball_step() -> Result<()> { - let workdir = require_env("WORKDIR")?; - let workdir = PathBuf::from(workdir); - let commit = require_env("SOURCE_SHA")?; - let short_commit = commit.chars().take(12).collect::(); - let desktop_version = require_env("VERSION")?; - let published_at = require_env("PUB_DATE")?; - let build_channel = require_env("BUILD_CHANNEL")?; - let s3_prefix = require_env("S3_DESKTOP_PREFIX")?; - let filename = format!("fluxer_desktop-source-{desktop_version}-{short_commit}.tar.gz"); - let archive_dir = Path::new("source_staging").join("by-commit").join(&commit); - let required_linux_packaging = [ - "packaging/linux/app.fluxer.Fluxer.desktop", - "packaging/linux/app.fluxer.Fluxer.metainfo.xml", - "packaging/linux/app.fluxer.Fluxer.svg", - "packaging/linux/app.fluxer.FluxerCanary.desktop", - "packaging/linux/app.fluxer.FluxerCanary.metainfo.xml", - "packaging/linux/app.fluxer.FluxerCanary.svg", - ]; - - ensure!( - workdir.join("fluxer_desktop/LICENSE").exists(), - "Missing fluxer_desktop/LICENSE" - ); - for file in required_linux_packaging { - let path = workdir.join("fluxer_desktop").join(file); - ensure!( - path.exists(), - "Missing required packaging file {}", - path.display() - ); - } - run_command( - CommandSpec::new("desktop-file-validate").arg( - workdir - .join("fluxer_desktop/packaging/linux/app.fluxer.Fluxer.desktop") - .to_string_lossy() - .as_ref(), - ), - )?; - run_command( - CommandSpec::new("desktop-file-validate").arg( - workdir - .join("fluxer_desktop/packaging/linux/app.fluxer.FluxerCanary.desktop") - .to_string_lossy() - .as_ref(), - ), - )?; - for file in [ - "app.fluxer.Fluxer.metainfo.xml", - "app.fluxer.FluxerCanary.metainfo.xml", - ] { - run_command( - CommandSpec::new("appstreamcli").args([ - "validate", - "--no-net", - workdir - .join("fluxer_desktop/packaging/linux") - .join(file) - .to_string_lossy() - .as_ref(), - ]), - )?; - } - - remove_dir_if_exists(Path::new("source_staging"))?; - fs::create_dir_all(&archive_dir) - .with_context(|| format!("Failed to create {}", archive_dir.display()))?; - - let source_dir = TempDir::new().context("Failed to create source temp directory")?; - let prefix = format!("fluxer_desktop-{desktop_version}-{commit}/"); - let archive_bytes = output_bytes(CommandSpec::new("git").args([ - "-C", - workdir.to_string_lossy().as_ref(), - "archive", - "--format=tar", - &format!("--prefix={prefix}"), - "HEAD:fluxer_desktop", - ]))?; - tar::Archive::new(Cursor::new(archive_bytes)) - .unpack(source_dir.path()) - .context("Failed to unpack git archive")?; - - let source_root = source_dir - .path() - .join(format!("fluxer_desktop-{desktop_version}-{commit}")); - rewrite_package_version(&source_root.join("package.json"), &desktop_version)?; - - let tar_gz_path = archive_dir.join(&filename); - create_deterministic_tar_gz(&source_root, &tar_gz_path, &published_at)?; - let archived_package_version = package_version_from_tar_gz( - &tar_gz_path, - &format!("fluxer_desktop-{desktop_version}-{commit}/package.json"), - )?; - ensure!( - archived_package_version == desktop_version, - "Archived package version {archived_package_version} did not match {desktop_version}" - ); - - let sha256 = sha256_file(&tar_gz_path)?; - let size = fs::metadata(&tar_gz_path)?.len(); - fs::write( - archive_dir.join(format!("{filename}.sha256")), - format!("{sha256} {filename}\n"), - )?; - fs::copy( - &tar_gz_path, - Path::new("source_staging").join("latest.tar.gz"), - )?; - fs::write( - Path::new("source_staging").join("latest.tar.gz.sha256"), - format!("{sha256} {filename}\n"), - )?; - - let latest = SourceManifest { - filename: filename.clone(), - key: format!("{s3_prefix}/source/by-commit/{commit}/{filename}"), - sha256, - commit, - published_at: published_at.clone(), - size, - desktop_version: desktop_version.clone(), - desktop_version_source: DesktopVersionSource { - channel: build_channel.clone(), - platform: "linux".to_string(), - arch: "x64".to_string(), - key: format!("{s3_prefix}/{build_channel}/linux/x64/manifest.json"), - pub_date: published_at, - }, - }; - write_json_pretty(Path::new("source_staging/latest.json"), &latest)?; - - println!("Desktop source payload:"); - print_tree(Path::new("source_staging"), 4) -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -struct SourceManifest { - filename: String, - key: String, - sha256: String, - commit: String, - published_at: String, - size: u64, - desktop_version: String, - desktop_version_source: DesktopVersionSource, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -struct DesktopVersionSource { - channel: String, - platform: String, - arch: String, - key: String, - pub_date: String, -} - -fn rewrite_package_version(package_json: &Path, version: &str) -> Result<()> { - let mut package: Value = serde_json::from_str( - &fs::read_to_string(package_json) - .with_context(|| format!("Failed to read {}", package_json.display()))?, - ) - .with_context(|| format!("Failed to parse {}", package_json.display()))?; - package["version"] = Value::String(version.to_string()); - write_json_pretty(package_json, &package) -} - -fn create_deterministic_tar_gz( - source_root: &Path, - output: &Path, - published_at: &str, -) -> Result<()> { - let mtime = DateTime::parse_from_rfc3339(published_at) - .with_context(|| format!("Invalid PUB_DATE: {published_at}"))? - .timestamp() - .try_into() - .context("PUB_DATE timestamp did not fit u64")?; - let file = - File::create(output).with_context(|| format!("Failed to create {}", output.display()))?; - let encoder = GzBuilder::new() - .mtime(0) - .write(file, Compression::default()); - let mut builder = tar::Builder::new(encoder); - let parent = source_root - .parent() - .ok_or_else(|| anyhow!("source root has no parent: {}", source_root.display()))?; - let mut paths = WalkDir::new(source_root) - .follow_links(false) - .into_iter() - .collect::, _>>()?; - paths.sort_by(|a, b| a.path().cmp(b.path())); - - for entry in paths { - let path = entry.path(); - let archive_path = path.strip_prefix(parent)?; - let metadata = fs::symlink_metadata(path)?; - let mut header = tar::Header::new_gnu(); - header.set_mtime(mtime); - header.set_uid(0); - header.set_gid(0); - header.set_mode(if metadata.is_dir() { 0o755 } else { 0o644 }); - if metadata.is_dir() { - header.set_entry_type(tar::EntryType::Directory); - header.set_size(0); - header.set_cksum(); - builder.append_data(&mut header, archive_path, io::empty())?; - } else if metadata.file_type().is_symlink() { - let target = fs::read_link(path)?; - header.set_entry_type(tar::EntryType::Symlink); - header.set_size(0); - header.set_cksum(); - builder.append_link(&mut header, archive_path, target)?; - } else if metadata.is_file() { - header.set_entry_type(tar::EntryType::Regular); - header.set_size(metadata.len()); - header.set_cksum(); - let mut file = File::open(path)?; - builder.append_data(&mut header, archive_path, &mut file)?; - } - } - let encoder = builder.into_inner()?; - encoder.finish()?; - Ok(()) -} - -fn package_version_from_tar_gz(tar_gz: &Path, package_json_path: &str) -> Result { - let file = - File::open(tar_gz).with_context(|| format!("Failed to open {}", tar_gz.display()))?; - let decoder = GzDecoder::new(file); - let mut archive = tar::Archive::new(decoder); - for entry in archive.entries()? { - let mut entry = entry?; - if entry.path()?.to_string_lossy() == package_json_path { - let mut text = String::new(); - entry.read_to_string(&mut text)?; - let package: Value = serde_json::from_str(&text)?; - return package - .get("version") - .and_then(Value::as_str) - .map(ToOwned::to_owned) - .ok_or_else(|| anyhow!("package.json in archive has no version")); - } - } - bail!("{package_json_path} not found in {}", tar_gz.display()) -} - async fn upload_handoff_step(signed_windows_artifacts: bool) -> Result<()> { let client = s3_client(None).await?; let bucket = require_env("S3_BUCKET")?; @@ -2237,18 +2650,6 @@ async fn upload_handoff_step(signed_windows_artifacts: bool) -> Result<()> { let artifact_prefix = join_s3_key(&prefix, &artifact_name); println!("Uploading {artifact_count} desktop artifact file(s) to {artifact_prefix}"); upload_directory_to_s3(&client, &bucket, &artifact_prefix, staging, |_| true).await?; - - let source_staging = Path::new("source_staging"); - if !signed_windows_artifacts && source_staging.exists() { - let source_count = count_files(source_staging)?; - if source_count > 0 { - let source_name = format!("fluxer-desktop-{build_channel}-source-linux-x64"); - let source_prefix = join_s3_key(&prefix, &source_name); - println!("Uploading {source_count} desktop source file(s) to {source_prefix}"); - upload_directory_to_s3(&client, &bucket, &source_prefix, source_staging, |_| true) - .await?; - } - } Ok(()) } @@ -2270,101 +2671,6 @@ fn handoff_artifact_name( format!("fluxer-desktop-{build_channel}-{platform}-{arch}{variant_suffix}{signed_suffix}") } -fn check_signing_secrets_step() -> Result<()> { - if env_string("AZURE_CLIENT_ID").is_some() { - append_github_output(&[("enabled", "true")]) - } else { - println!("::notice::Windows code signing secrets not configured - skipping signing."); - append_github_output(&[("enabled", "false")]) - } -} - -async fn download_windows_handoff_step() -> Result<()> { - let client = s3_client(None).await?; - let bucket = require_env("S3_BUCKET")?; - let prefix = require_env("DESKTOP_HANDOFF_PREFIX")?; - let build_channel = require_env("BUILD_CHANNEL")?; - let arch = require_any_env(&["DESKTOP_ARCH", "ARCH"])?; - let desktop_variant = desktop_variant_from_env()?; - let artifact_name = - handoff_artifact_name(&build_channel, "windows", &arch, &desktop_variant, false); - let target = windows_artifact_dir(&arch, &desktop_variant); - remove_dir_if_exists(&target)?; - fs::create_dir_all(&target)?; - let artifact_prefix = join_s3_key(&prefix, &artifact_name); - download_s3_prefix(&client, &bucket, &artifact_prefix, &target).await -} - -fn check_windows_artifacts_step() -> Result<()> { - let arch = require_any_env(&["DESKTOP_ARCH", "ARCH"])?; - let desktop_variant = desktop_variant_from_env()?; - let artifact_path = windows_artifact_dir(&arch, &desktop_variant); - let found = artifact_path.exists() && count_files(&artifact_path)? > 0; - if !found && expected_windows_artifacts(&arch, &desktop_variant) { - bail!("Expected Windows artifacts for {arch} ({desktop_variant}), but none were found."); - } - append_github_output(&[("found", if found { "true" } else { "false" })])?; - if found { - println!("Found Windows artifacts for {arch} ({desktop_variant})."); - } else { - println!( - "No Windows artifacts found for {arch} ({desktop_variant}). Skipping signing for this variant." - ); - } - Ok(()) -} - -fn verify_authenticode_step() -> Result<()> { - let arch = require_any_env(&["DESKTOP_ARCH", "ARCH"])?; - let desktop_variant = desktop_variant_from_env()?; - let artifact_path = windows_artifact_dir(&arch, &desktop_variant); - let files = collect_files(&artifact_path)? - .into_iter() - .filter(|path| extension_is(path, "exe")) - .collect::>(); - ensure!( - !files.is_empty(), - "No executable files found to verify for {arch}." - ); - let signtool = find_signtool()?; - for file in files { - run_command(CommandSpec::new(&signtool).args([ - "verify", - "/pa", - "/all", - file.to_string_lossy().as_ref(), - ]))?; - } - Ok(()) -} - -fn regenerate_signed_checksums_step() -> Result<()> { - let arch = require_any_env(&["DESKTOP_ARCH", "ARCH"])?; - let desktop_variant = desktop_variant_from_env()?; - let artifact_path = windows_artifact_dir(&arch, &desktop_variant); - for file in collect_files(&artifact_path)? - .into_iter() - .filter(|path| extension_is(path, "exe")) - { - let hash = sha256_file(&file)?; - let name = file_name_string(&file)?; - fs::write(file.with_file_name(format!("{name}.sha256")), &hash)?; - println!("Regenerated checksum for {}", file.display()); - } - Ok(()) -} - -async fn stage_signed_windows_artifacts_step() -> Result<()> { - let arch = require_any_env(&["DESKTOP_ARCH", "ARCH"])?; - let desktop_variant = desktop_variant_from_env()?; - let source = windows_artifact_dir(&arch, &desktop_variant); - let staging = Path::new("upload_staging"); - remove_dir_if_exists(staging)?; - fs::create_dir_all(staging)?; - copy_dir_contents(&source, staging)?; - upload_handoff_step(true).await -} - async fn download_handoff_step() -> Result<()> { let client = s3_client(None).await?; let bucket = require_env("S3_BUCKET")?; @@ -2433,18 +2739,6 @@ fn build_payload_step() -> Result<()> { write_json_pretty(&dest.join("manifest.json"), &manifest)?; } - let source_artifact_root = artifacts.join(format!("fluxer-desktop-{channel}-source-linux-x64")); - let source_artifact = if source_artifact_root.join("latest.json").exists() { - source_artifact_root.clone() - } else { - source_artifact_root.join("source_staging") - }; - if source_artifact.join("latest.json").exists() { - let source_dest = payload_root.join("source"); - fs::create_dir_all(&source_dest)?; - copy_dir_contents(&source_artifact, &source_dest)?; - } - println!("Payload tree:"); print_tree(&payload_root, 6) } @@ -2454,7 +2748,6 @@ struct ArtifactIdentity { platform: String, arch: String, desktop_variant: String, - source: bool, signed: bool, } @@ -2465,15 +2758,6 @@ fn parse_artifact_dir_name(base: &str, channel: &str) -> Option Option Result<()> { let s3_prefix = require_env("S3_DESKTOP_PREFIX")?; let bucket = require_env("S3_BUCKET")?; let payload_root = Path::new("s3_payload").join(&s3_prefix); - let overwrite_existing = should_overwrite_payload(&s3_prefix, env_bool("TEST_BUILD")); + let overwrite_binaries = should_overwrite_payload(&s3_prefix, env_bool("TEST_BUILD")); println!("Uploading desktop binaries and checksums first (prefix: {s3_prefix})..."); upload_payload_directory( @@ -2721,17 +3001,19 @@ async fn upload_payload_step() -> Result<()> { &bucket, &s3_prefix, &payload_root, - overwrite_existing, + overwrite_binaries, |relative| !is_payload_metadata_key(relative), ) .await?; - println!("Uploading manifests and updater metadata last..."); + println!( + "Uploading manifests and updater metadata last, overwriting the previous release feed..." + ); upload_payload_directory( &client, &bucket, &s3_prefix, &payload_root, - overwrite_existing, + true, is_payload_metadata_key, ) .await @@ -2769,119 +3051,6 @@ fn is_payload_metadata_key(relative: &Path) -> bool { || name.starts_with("RELEASES") || (name.starts_with("releases") && name.ends_with(".json")) || (name.starts_with("assets") && name.ends_with(".json")) - || relative - .to_string_lossy() - .replace('\\', "/") - .ends_with("source/latest.json") -} - -async fn verify_source_tarball_step() -> Result<()> { - let client = s3_client(None).await?; - let s3_prefix = require_env("S3_DESKTOP_PREFIX")?; - let manifest_path = Path::new("s3_payload") - .join(&s3_prefix) - .join("source") - .join("latest.json"); - if !manifest_path.exists() { - println!("No desktop source tarball payload present; skipping source verification."); - return Ok(()); - } - - let manifest: SourceManifest = serde_json::from_str( - &fs::read_to_string(&manifest_path) - .with_context(|| format!("Failed to read {}", manifest_path.display()))?, - )?; - ensure!( - !manifest.filename.is_empty() - && !manifest.sha256.is_empty() - && !manifest.desktop_version.is_empty() - && !manifest.commit.is_empty(), - "Desktop source manifest is missing filename, sha256, desktop_version, or commit." - ); - - let bucket = require_env("S3_BUCKET")?; - let remote_base = join_s3_key(&s3_prefix, "source"); - let remote_sha256 = first_word(&String::from_utf8( - get_s3_object_bytes( - &client, - &bucket, - &join_s3_key( - &remote_base, - &format!("by-commit/{}/{}.sha256", manifest.commit, manifest.filename), - ), - ) - .await? - .to_vec(), - )?); - let latest_sha256 = first_word(&String::from_utf8( - get_s3_object_bytes( - &client, - &bucket, - &join_s3_key(&remote_base, "latest.tar.gz.sha256"), - ) - .await? - .to_vec(), - )?); - let remote_manifest: SourceManifest = serde_json::from_slice( - &get_s3_object_bytes(&client, &bucket, &join_s3_key(&remote_base, "latest.json")).await?, - )?; - ensure!( - remote_sha256 == manifest.sha256, - "Remote by-commit checksum mismatch" - ); - ensure!( - latest_sha256 == manifest.sha256, - "Remote latest checksum mismatch" - ); - ensure!( - remote_manifest.sha256 == manifest.sha256, - "Remote manifest checksum mismatch" - ); - ensure!( - remote_manifest.desktop_version == manifest.desktop_version, - "Remote manifest desktop version mismatch" - ); - - let tar_bytes = get_s3_object_bytes( - &client, - &bucket, - &join_s3_key( - &remote_base, - &format!("by-commit/{}/{}", manifest.commit, manifest.filename), - ), - ) - .await?; - let package_version = package_version_from_tar_gz_bytes( - &tar_bytes, - &format!( - "fluxer_desktop-{}-{}/package.json", - manifest.desktop_version, manifest.commit - ), - )?; - ensure!( - package_version == manifest.desktop_version, - "Remote source tarball package version mismatch" - ); - Ok(()) -} - -fn package_version_from_tar_gz_bytes(bytes: &[u8], package_json_path: &str) -> Result { - let decoder = GzDecoder::new(Cursor::new(bytes)); - let mut archive = tar::Archive::new(decoder); - for entry in archive.entries()? { - let mut entry = entry?; - if entry.path()?.to_string_lossy() == package_json_path { - let mut text = String::new(); - entry.read_to_string(&mut text)?; - let package: Value = serde_json::from_str(&text)?; - return package - .get("version") - .and_then(Value::as_str) - .map(ToOwned::to_owned) - .ok_or_else(|| anyhow!("package.json in archive has no version")); - } - } - bail!("{package_json_path} not found in source tarball") } fn build_summary_step() -> Result<()> { @@ -2920,228 +3089,6 @@ fn build_summary_step() -> Result<()> { Ok(()) } -async fn notify_webhook_step() -> Result<()> { - let version = require_env("VERSION")?; - let channel = env::var("DISPLAY_CHANNEL") - .ok() - .filter(|value| !value.is_empty()) - .or_else(|| env::var("CHANNEL").ok()) - .unwrap_or_default(); - let test_build = env_bool("TEST_BUILD"); - - if !should_notify_desktop_webhook(&channel) { - println!("Skipping desktop notification for channel={channel}, test_build={test_build}."); - return Ok(()); - } - - let webhook_url = env::var("FLUXER_WEBHOOK_URL") - .unwrap_or_default() - .trim() - .to_string(); - if webhook_url.is_empty() { - println!("FLUXER_WEBHOOK_URL is not set; skipping desktop canary notification."); - return Ok(()); - } - - let messages = desktop_webhook_messages(&version, test_build, None)?; - let message_count = messages.len(); - for (index, message) in messages.into_iter().enumerate() { - let response = Client::new() - .post(&webhook_url) - .header("User-Agent", "fluxer-ci-desktop") - .json(&json!({ - "content": message, - "allowed_mentions": {"parse": []}, - })) - .send() - .await - .context("Failed to send desktop webhook")?; - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - bail!( - "Desktop webhook part {}/{} returned {status}: {body}", - index + 1, - message_count - ); - } - println!( - "Desktop canary notification part {}/{} sent ({status}).", - index + 1, - message_count - ); - } - Ok(()) -} - -fn should_notify_desktop_webhook(channel: &str) -> bool { - channel == "canary" -} - -fn desktop_payload_root() -> PathBuf { - let s3_prefix = env::var("S3_DESKTOP_PREFIX").unwrap_or_else(|_| "desktop".to_string()); - let channel = env::var("CHANNEL").unwrap_or_else(|_| "canary".to_string()); - PathBuf::from("s3_payload").join(s3_prefix).join(channel) -} - -fn desktop_manifest_formats( - payload_root: &Path, - platform: &str, - arch: &str, - desktop_variant: &str, -) -> Result> { - let mut manifest_path = payload_root.join(platform).join(arch); - if let Some(segment) = desktop_variant_path_segment(desktop_variant) { - manifest_path = manifest_path.join(segment); - } - manifest_path = manifest_path.join("manifest.json"); - if !manifest_path.exists() { - return Ok(BTreeSet::new()); - } - - let manifest: Value = serde_json::from_str( - &fs::read_to_string(&manifest_path) - .with_context(|| format!("Failed to read {}", manifest_path.display()))?, - ) - .with_context(|| format!("Failed to parse {}", manifest_path.display()))?; - let files = manifest - .get("files") - .and_then(Value::as_object) - .ok_or_else(|| { - anyhow!( - "Invalid desktop manifest files object: {}", - manifest_path.display() - ) - })?; - Ok(files.keys().cloned().collect()) -} - -fn desktop_download_url( - platform: &str, - arch: &str, - desktop_variant: &str, - version: &str, - format_name: &str, - test_build: bool, -) -> String { - let test_query = if test_build { "?test=1" } else { "" }; - let variant_segment = desktop_variant_path_segment(desktop_variant) - .map(|variant| format!("/{variant}")) - .unwrap_or_default(); - format!( - "{PUBLIC_DL_BASE}/desktop/canary/{platform}/{arch}{variant_segment}/{version}/{format_name}{test_query}" - ) -} - -#[cfg(test)] -fn desktop_download_table( - version: &str, - test_build: bool, - payload_root: Option<&Path>, -) -> Result { - Ok(desktop_download_sections(version, test_build, payload_root)?.join("\n\n")) -} - -fn desktop_webhook_messages( - version: &str, - test_build: bool, - payload_root: Option<&Path>, -) -> Result> { - let title = if test_build { - "Canary Desktop Test Build Ready" - } else { - "Canary Desktop Build Ready" - }; - let mut messages = Vec::new(); - let mut current = format!("## {title}\n\nDesktop app version: `{version}`"); - for section in desktop_download_sections(version, test_build, payload_root)? { - append_desktop_webhook_section(&mut messages, &mut current, §ion)?; - } - if !current.is_empty() { - messages.push(current); - } - Ok(messages) -} - -fn append_desktop_webhook_section( - messages: &mut Vec, - current: &mut String, - section: &str, -) -> Result<()> { - let separator = if current.is_empty() { "" } else { "\n\n" }; - let candidate = format!("{current}{separator}{section}"); - if candidate.chars().count() <= DESKTOP_WEBHOOK_CONTENT_LIMIT { - *current = candidate; - return Ok(()); - } - - if !current.is_empty() { - messages.push(std::mem::take(current)); - } - ensure!( - section.chars().count() <= DESKTOP_WEBHOOK_CONTENT_LIMIT, - "Desktop webhook section exceeds {DESKTOP_WEBHOOK_CONTENT_LIMIT} characters." - ); - current.push_str(section); - Ok(()) -} - -fn desktop_download_sections( - version: &str, - test_build: bool, - payload_root: Option<&Path>, -) -> Result> { - let root; - let payload_root = match payload_root { - Some(path) => path, - None => { - root = desktop_payload_root(); - root.as_path() - } - }; - let mut rendered_sections = Vec::new(); - for (platform, desktop_variant, heading, arch_groups) in DESKTOP_DOWNLOAD_SECTIONS { - let mut rows = Vec::new(); - for (arch, formats) in *arch_groups { - let available_formats = - desktop_manifest_formats(payload_root, platform, arch, desktop_variant)?; - for (format_name, label) in *formats { - if available_formats.contains(*format_name) { - rows.push(format!( - "| {arch} | {label} | {} |", - desktop_download_url( - platform, - arch, - desktop_variant, - version, - format_name, - test_build - ) - )); - } - } - } - - if !rows.is_empty() { - let mut table = vec![ - "| Arch | Format | URL |".to_string(), - "|---|---|---|".to_string(), - ]; - table.extend(rows); - rendered_sections.push(format!("## {heading}\n\n{}", table.join("\n"))); - } - } - - if rendered_sections.is_empty() { - bail!( - "No desktop manifests found under {}; refusing to send an empty notification.", - payload_root.display() - ); - } - - Ok(rendered_sections) -} - fn find_dist_file(dist: &Path, predicate: F) -> Option where F: Fn(&str) -> bool, @@ -3308,33 +3255,6 @@ fn print_tree(root: &Path, max_depth: usize) -> Result<()> { Ok(()) } -fn find_signtool() -> Result { - if let Some(path) = env_string("SIGNTOOL_PATH") - .map(PathBuf::from) - .filter(|path| path.exists()) - { - return Ok(path); - } - let roots = [ - PathBuf::from(r"C:\Program Files (x86)\Windows Kits\10\bin"), - PathBuf::from(r"C:\Program Files\Windows Kits\10\bin"), - ]; - for root in roots { - if !root.exists() { - continue; - } - if let Some(path) = find_first(&root, |path| { - path.file_name() - .and_then(OsStr::to_str) - .is_some_and(|name| name.eq_ignore_ascii_case("signtool.exe")) - && path.to_string_lossy().contains("x64") - }) { - return Ok(path); - } - } - Ok(PathBuf::from("signtool.exe")) -} - #[cfg(test)] mod tests { use super::*; @@ -3379,12 +3299,6 @@ mod tests { assert!(!should_overwrite_payload("desktop-test", false)); } - #[test] - fn desktop_webhook_notifies_canary_uploads_and_tests() { - assert!(should_notify_desktop_webhook("canary")); - assert!(!should_notify_desktop_webhook("stable")); - } - #[test] fn resolves_explicit_calver_with_precedence() { let calver_env = CalverEnv { @@ -3435,13 +3349,31 @@ mod tests { selected, vec![ "{\"platform\":\"windows\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"blacksmith-32vcpu-windows-2025\",\"electron_arch\":\"arm64\"}", - "{\"platform\":\"windows\",\"arch\":\"arm64\",\"desktop_variant\":\"windows-game-capture\",\"os\":\"blacksmith-32vcpu-windows-2025\",\"electron_arch\":\"arm64\"}", "{\"platform\":\"linux\",\"arch\":\"x64\",\"desktop_variant\":\"default\",\"os\":\"blacksmith-32vcpu-ubuntu-2404\",\"electron_arch\":\"x64\"}", "{\"platform\":\"linux\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"blacksmith-32vcpu-ubuntu-2404-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(), 6); + assert_eq!( + selected + .iter() + .filter(|platform| platform.platform == "windows") + .count(), + 2 + ); + assert!( + selected + .iter() + .all(|platform| platform.desktop_variant == DEFAULT_DESKTOP_VARIANT) + ); + } + #[test] fn matrix_skip_targets_filter_platforms_and_arches() { let mut args = matrix_args(); @@ -3457,13 +3389,27 @@ mod tests { selected, vec![ "{\"platform\":\"windows\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"blacksmith-32vcpu-windows-2025\",\"electron_arch\":\"arm64\"}", - "{\"platform\":\"windows\",\"arch\":\"arm64\",\"desktop_variant\":\"windows-game-capture\",\"os\":\"blacksmith-32vcpu-windows-2025\",\"electron_arch\":\"arm64\"}", "{\"platform\":\"linux\",\"arch\":\"x64\",\"desktop_variant\":\"default\",\"os\":\"blacksmith-32vcpu-ubuntu-2404\",\"electron_arch\":\"x64\"}", "{\"platform\":\"linux\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"blacksmith-32vcpu-ubuntu-2404-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(), 4); + } + #[test] fn matrix_skip_targets_reject_unknown_values() { let mut args = matrix_args(); @@ -3474,6 +3420,25 @@ mod tests { assert!(error.to_string().contains("Unknown desktop skip target")); } + #[test] + fn matrix_skip_targets_reject_retired_windows_game_capture_variant() { + for target in [ + WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT, + "windows-game-capture-x64", + "windows-game-capture-arm64", + ] { + let mut args = matrix_args(); + args.skip_targets = Some(target.to_string()); + + let error = selected_platforms(&args).unwrap_err(); + + assert!( + error.to_string().contains("Unknown desktop skip target"), + "{target} should no longer be a recognised skip target" + ); + } + } + #[test] fn s3_key_join_and_path_conversion_are_platform_neutral() { assert_eq!( @@ -3497,7 +3462,6 @@ mod tests { let root = temp.path(); write_file(&root.join("canary/linux/x64/Fluxer.AppImage"), "app"); write_file(&root.join("canary/linux/x64/manifest.json"), "{}"); - write_file(&root.join("source/latest.json"), "{}"); write_file(&root.join("canary/darwin/x64/releases.json"), "{}"); let binaries = directory_upload_plan("desktop", root, |relative| { @@ -3519,7 +3483,6 @@ mod tests { vec![ "desktop/canary/darwin/x64/releases.json", "desktop/canary/linux/x64/manifest.json", - "desktop/source/latest.json", ] ); } @@ -3532,7 +3495,6 @@ mod tests { platform: "windows".to_string(), arch: "arm64".to_string(), desktop_variant: DEFAULT_DESKTOP_VARIANT.to_string(), - source: false, signed: false, } ); @@ -3546,23 +3508,16 @@ mod tests { platform: "windows".to_string(), arch: "x64".to_string(), desktop_variant: WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT.to_string(), - source: false, signed: true, } ); assert!(parse_artifact_dir_name("fluxer-desktop-stable-linux-x64", "canary").is_none()); - assert!( - parse_artifact_dir_name("fluxer-desktop-canary-source-linux-x64", "canary") - .unwrap() - .source - ); assert_eq!( parse_artifact_dir_name("fluxer-desktop-canary-windows-x64-signed", "canary").unwrap(), ArtifactIdentity { platform: "windows".to_string(), arch: "x64".to_string(), desktop_variant: DEFAULT_DESKTOP_VARIANT.to_string(), - source: false, signed: true, } ); @@ -3649,7 +3604,6 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" ) .unwrap(); fs::create_dir_all(artifacts.join("fluxer-desktop-canary-linux-x64")).unwrap(); - fs::create_dir_all(artifacts.join("fluxer-desktop-canary-source-linux-x64")).unwrap(); fs::create_dir_all(artifacts.join("unrelated")).unwrap(); let selected = payload_artifact_dirs(artifacts, "canary") @@ -3672,7 +3626,6 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" platform: "linux".to_string(), arch: "x64".to_string(), desktop_variant: DEFAULT_DESKTOP_VARIANT.to_string(), - source: false, signed: false, }, ), @@ -3682,7 +3635,6 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" platform: "windows".to_string(), arch: "x64".to_string(), desktop_variant: DEFAULT_DESKTOP_VARIANT.to_string(), - source: false, signed: true, }, ), @@ -3692,7 +3644,6 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" platform: "windows".to_string(), arch: "x64".to_string(), desktop_variant: WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT.to_string(), - source: false, signed: true, }, ), @@ -3822,90 +3773,15 @@ export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;\n" } #[test] - fn deterministic_tarball_rewrites_and_reads_package_version() { - let temp = tempfile::tempdir().unwrap(); - let source_root = temp.path().join("fluxer_desktop-2026.520.1-abcdef"); - write_file( - &source_root.join("package.json"), - r#"{"name":"fluxer","version":"0.0.0"}"#, - ); - rewrite_package_version(&source_root.join("package.json"), "2026.520.1").unwrap(); - let archive = temp.path().join("source.tar.gz"); - - create_deterministic_tar_gz(&source_root, &archive, "2026-05-20T01:02:03Z").unwrap(); - - assert_eq!( - package_version_from_tar_gz(&archive, "fluxer_desktop-2026.520.1-abcdef/package.json") - .unwrap(), - "2026.520.1" - ); - assert_eq!(sha256_file(&archive).unwrap().len(), 64); - } - - #[test] - fn desktop_download_table_uses_manifest_files() { - let temp = tempfile::tempdir().unwrap(); - let manifest_dir = temp.path().join("win32").join("x64"); - fs::create_dir_all(&manifest_dir).unwrap(); - fs::write( - manifest_dir.join("manifest.json"), - r#"{"files":{"setup":"Fluxer.exe","portable":"Fluxer.zip"}}"#, - ) - .unwrap(); - let variant_manifest_dir = temp - .path() - .join("win32") - .join("x64") - .join(WINDOWS_GAME_CAPTURE_DESKTOP_VARIANT); - fs::create_dir_all(&variant_manifest_dir).unwrap(); - fs::write( - variant_manifest_dir.join("manifest.json"), - r#"{"files":{"setup":"Fluxer Game Capture.exe"}}"#, - ) - .unwrap(); - - let table = desktop_download_table("2026.520.1", true, Some(temp.path())).unwrap(); - assert!(table.contains("## Windows (`win32`)")); - assert!(table.contains("## Windows Game Capture (`win32`)")); - assert!(table.contains("| Arch | Format | URL |")); - assert!(table.contains("|---|---|---|")); - assert!(table.contains("| x64 | Setup.exe | https://api.fluxer.app/dl/desktop/canary/win32/x64/2026.520.1/setup?test=1 |")); - assert!(table.contains("| x64 | Portable ZIP | https://api.fluxer.app/dl/desktop/canary/win32/x64/2026.520.1/portable?test=1 |")); - assert!(table.contains("| x64 | Setup.exe | https://api.fluxer.app/dl/desktop/canary/win32/x64/windows-game-capture/2026.520.1/setup?test=1 |")); - assert!(!table.contains("SHA-256")); - } - - #[test] - fn desktop_webhook_messages_split_under_receiver_limit() { - let temp = tempfile::tempdir().unwrap(); - let manifest = r#"{"files":{"setup":"Fluxer.exe","portable":"Fluxer.zip","dmg":"Fluxer.dmg","zip":"Fluxer.zip","appimage":"Fluxer.AppImage","deb":"Fluxer.deb","rpm":"Fluxer.rpm","tar_gz":"Fluxer.tar.gz"}}"#; - for (platform, desktop_variant, _, arch_groups) in DESKTOP_DOWNLOAD_SECTIONS { - for (arch, _) in *arch_groups { - let mut manifest_dir = temp.path().join(platform).join(arch); - if let Some(segment) = desktop_variant_path_segment(desktop_variant) { - manifest_dir = manifest_dir.join(segment); - } - write_file(&manifest_dir.join("manifest.json"), manifest); + 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" + ); } } - - let messages = - desktop_webhook_messages("2026.614.181201", true, Some(temp.path())).unwrap(); - assert!(messages.len() > 1); - assert!( - messages - .iter() - .all(|message| message.chars().count() <= DESKTOP_WEBHOOK_CONTENT_LIMIT) - ); - - let combined = messages.join("\n\n"); - assert!(combined.starts_with("## Canary Desktop Test Build Ready")); - assert!(combined.contains("Desktop app version: `2026.614.181201`")); - assert!(combined.contains("## Windows (`win32`)")); - assert!(combined.contains("## Windows Game Capture (`win32`)")); - assert!(combined.contains("## macOS (`darwin`)")); - assert!(combined.contains("## Linux (`linux`)")); - assert!(combined.contains("| Arch | Format | URL |")); - assert!(!combined.contains("SHA-256")); } } diff --git a/tools/ci/src/desktop_native.rs b/tools/ci/src/desktop_native.rs index f7b335c5f..9066b647f 100644 --- a/tools/ci/src/desktop_native.rs +++ b/tools/ci/src/desktop_native.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::common::{CommandSpec, command_succeeds, output_text, run_command}; +use crate::common::{ + CommandSpec, command_succeeds, output_text, remove_file_if_exists, run_command, +}; use anyhow::{Context, Result, anyhow, bail, ensure}; use clap::Args; use sha2::{Digest, Sha256}; @@ -738,6 +740,14 @@ fn build_win_game_capture_vulkan_layer( required, )?; let manifest_path = root.join(format!("fluxer-vulkan-layer.{}.json", arch.tag)); + if !root.join(&layer_dll_name).exists() { + remove_file_if_exists(&manifest_path)?; + println!( + "[win-game-capture] no {layer_dll_name}; not emitting {} so packages never ship a manifest pointing at an absent layer", + manifest_path.display() + ); + return Ok(()); + } fs::write(&manifest_path, vulkan_layer_manifest(&layer_dll_name)) .with_context(|| format!("Failed to write {}", manifest_path.display()))?; println!("[win-game-capture] emitted {}", manifest_path.display()); diff --git a/tools/dev/src/desktop.rs b/tools/dev/src/desktop.rs index b53f83ed8..1ca85c2f4 100644 --- a/tools/dev/src/desktop.rs +++ b/tools/dev/src/desktop.rs @@ -173,7 +173,41 @@ pub fn smoke_build_desktop() -> Result<()> { .map(drop) } +fn packages_for_windows(args: &[String]) -> bool { + if args.iter().any(|arg| arg == "--win") { + return true; + } + if args.iter().any(|arg| arg == "--mac" || arg == "--linux") { + return false; + } + cfg!(target_os = "windows") +} + +fn host_electron_arch() -> Result<&'static str> { + match env::consts::ARCH { + "x86_64" => Ok("x64"), + "aarch64" => Ok("arm64"), + other => bail!( + "cannot package the desktop app for Windows on host architecture {other}; set ELECTRON_ARCH to x64 or arm64" + ), + } +} + +fn packaging_env(args: &[String]) -> Result)>> { + if !packages_for_windows(args) + || env::var_os("ELECTRON_ARCH").is_some() + || args.iter().any(|arg| arg == "--x64" || arg == "--arm64") + { + return Ok(Vec::new()); + } + Ok(vec![( + "ELECTRON_ARCH".to_owned(), + Some(host_electron_arch()?.to_owned()), + )]) +} + pub fn package_desktop(args: &[String]) -> Result<()> { + let env = packaging_env(args)?; build_desktop(false)?; let mut builder_args = vec![ "pnpm".to_owned(), @@ -189,6 +223,7 @@ pub fn package_desktop(args: &[String]) -> Result<()> { &refs, RunOptions { cwd: DESKTOP_DIR.as_path(), + env, ..RunOptions::default() }, )