feat(desktop): sign every Windows release artifact (#1548)

This commit is contained in:
Hampus
2026-08-10 23:22:19 +02:00
committed by GitHub
parent 873c203b5d
commit 6e66c92dca
89 changed files with 2408 additions and 2740 deletions
+81 -170
View File
@@ -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
-33
View File
@@ -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
@@ -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<HonoEnv>, checksum: DesktopChecksumFi
}
export function DownloadController(routes: Hono<HonoEnv>): 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),
+5 -181
View File
@@ -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<string, VersionFile>;
@@ -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<DesktopSourceInfo | null> {
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<string | null> {
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<string | null> {
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<VersionInfo | null> {
@@ -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<DesktopChecksumFile | null> {
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<DesktopSourceManifest | null> {
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<string | null> {
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<VersionInfo | null> {
@@ -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<boolean> {
@@ -1293,7 +1118,6 @@ export class DownloadService {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
variant?: DesktopVariant;
filename: string;
test?: boolean;
}): string | null {
@@ -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,
@@ -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) {
@@ -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<DesktopInfo>;
getGpuInfo?: () => Promise<GpuInfo>;
getAppMetrics?: () => Promise<AppMetricsSnapshot>;
@@ -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<ClientInfo
return {
desktopVersion: normalize(desktopInfo.version),
desktopChannel: normalize(desktopInfo.channel),
desktopBuildVariant: normalize(desktopInfo.buildVariant),
desktopArch: normalizeArchitectureValue(desktopInfo.hardwareArch ?? desktopInfo.arch),
desktopOS: normalize(desktopInfo.os),
desktopRunningUnderRosetta: desktopInfo.runningUnderRosetta,
@@ -312,14 +303,9 @@ export function formatClientBuildInfo(info: ClientInfo, options: {unknownLabel?:
const desktopChannel = info.desktopChannel ? formatReleaseChannelLabel(info.desktopChannel) : null;
const hasDesktopBuild = Boolean(info.desktopVersion);
const primaryDesktopChannel = desktopChannel ?? releaseChannel;
const desktopBuildVariant =
info.desktopBuildVariant && info.desktopBuildVariant !== 'default'
? formatDesktopBuildVariantLabel(info.desktopBuildVariant)
: null;
const webChannelPrefix = hasDesktopBuild && primaryDesktopChannel === releaseChannel ? '' : `${releaseChannel} `;
const parts = [
info.desktopVersion ? `${primaryDesktopChannel} Desktop ${info.desktopVersion}` : '',
desktopBuildVariant ? `Desktop variant ${desktopBuildVariant}` : '',
`${webChannelPrefix}Web ${buildVersion}`,
osDescription,
shouldShowBrowserInfo ? browserInfo : '',
@@ -406,7 +392,6 @@ export async function getGatewayClientProperties(geo?: {latitude?: string | null
build_version: Config.PUBLIC_BUILD_VERSION ?? 'dev',
desktop_app_version: info.desktopVersion ?? null,
desktop_app_channel: info.desktopChannel ?? null,
desktop_app_variant: info.desktopBuildVariant ?? null,
desktop_arch: info.desktopArch ?? info.arch ?? null,
desktop_os: info.desktopOS ?? info.osName ?? null,
e2ee_capable: isLiveKitE2EECapable(),
@@ -4414,6 +4414,10 @@ class MediaEngineFacade extends Store {
options?: DeviceScreenShareCaptureOptions,
publishOptions?: TrackPublishOptions,
): Promise<void> {
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<boolean> {
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<boolean> {
if (!isNativeVoiceEngineSelected()) {
return voiceEngineV2AppScreenShareExecutionAdapter.replaceActiveDeviceScreenShare(
this.room,
options,
publishOptions,
);
}
return voiceEngineV2AppScreenShareExecutionAdapter.replaceActiveNativeDeviceScreenShare(
await this.getNativeDeviceScreenShareCaptureOptions(options),
publishOptions,
@@ -55,7 +55,10 @@ let nativeSupportPromise: Promise<boolean> | null = null;
let nativePrewarmPromise: Promise<void> | 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();
}
@@ -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();
});
});
@@ -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<Voi
}
export async function isVoiceEngineV2AppNativeScreenShareBridgeAvailable(): Promise<boolean> {
if (!isNativeVoiceEngineSelected()) return false;
const capabilities = await getVoiceEngineV2AppNativeBridgeCapabilities();
return capabilities?.screenShare === true;
}
export async function isVoiceEngineV2AppNativeScreenShareEncodingUpdateAvailable(): Promise<boolean> {
if (!isNativeVoiceEngineSelected()) return false;
const capabilities = await getVoiceEngineV2AppNativeBridgeCapabilities();
return capabilities?.screenShareEncodingUpdate === true;
}
export async function isVoiceEngineV2AppNativeScreenShareAudioBridgeAvailable(): Promise<boolean> {
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';
@@ -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<boolean> {
if (!isNativeVoiceEngineSelected()) return false;
const electronApi = getElectronAPI();
const api = electronApi?.nativeScreenCapture ?? getNativeScreenCaptureApi();
if (!api) return false;
@@ -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) {
@@ -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);
});
});
-4
View File
@@ -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<void>;
downloadFile(url: string, suggestedName: string): Promise<DownloadResult>;
onUpdaterEvent(callback: (event: UpdaterEvent) => void): () => void;
+46 -57
View File
@@ -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',
+1
View File
@@ -158,6 +158,7 @@ export declare function getAvailability(): AvailabilityInfo;
export declare function listSources(): Promise<Array<ScreenCaptureSourceDescriptor>>;
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;
@@ -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,
@@ -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');
},
);
@@ -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(() => {});
@@ -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<usize> {
token.parse::<usize>().ok()
}
fn resolve_game_capture_target(source_id: &str, source_kind: &str) -> Result<HWND, String> {
pub(crate) fn resolve_game_capture_target(
source_id: &str,
source_kind: &str,
) -> Result<HWND, String> {
if let Some(hwnd) = parse_hwnd_source_id(source_id) {
return Ok(hwnd);
}
@@ -883,7 +905,7 @@ fn target_process_id(hwnd: HWND) -> Result<u32, String> {
}
}
fn target_process_is_32_bit(target_pid: u32) -> Result<bool, String> {
fn target_process_machine(target_pid: u32) -> Result<IMAGE_FILE_MACHINE, String> {
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<bool, String> {
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<bool, String> {
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<bool, String> {
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());
}
}
@@ -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 {
@@ -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,
}
);
}
}
@@ -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<CaptureInner>, 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<CaptureInner>, 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<CaptureInner>, frame_interval: std::time::Durati
emit_lifecycle(inner, "closed-clean", "capture stopped");
}
fn emit_stall_signal(inner: &Arc<CaptureInner>, 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<CaptureInner>,
ctx: &WgcLoopContext,
+6 -18
View File
@@ -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',
-10
View File
@@ -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';
+1 -5
View File
@@ -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<DesktopInfo>;
getGpuInfo: () => Promise<GpuInfo>;
getOpenH264Status: () => Promise<OpenH264Status>;
+1 -9
View File
@@ -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 {
@@ -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();
+16 -41
View File
@@ -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);
-2
View File
@@ -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,
-2
View File
@@ -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,
},
@@ -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',
);
});
});
+13 -15
View File
@@ -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,
+1 -4
View File
@@ -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';
@@ -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';
}
@@ -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<string> {
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<string>;
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);
}
-2
View File
@@ -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<DesktopInfo> => ipcRenderer.invoke('get-desktop-info'),
getGpuInfo: (): Promise<GpuInfo> => ipcRenderer.invoke('get-gpu-info'),
getAppMetrics: (): Promise<AppMetricsSnapshot> => ipcRenderer.invoke('get-app-metrics'),
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+7 -9
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+9 -10
View File
@@ -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
+2 -5
View File
@@ -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",
"",
@@ -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.",
};
);
+1 -1
View File
@@ -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"),
+2 -3
View File
@@ -22,6 +22,7 @@ use std::{
type HmacSha256 = Hmac<Sha256>;
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 {
+7 -13
View File
@@ -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<String> {
fn legacy_marketing_redirect(config: &MarketingConfig, uri: &Uri) -> Option<Response> {
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<Resp
Some(Redirect::permanent(&target).into_response())
}
_ if uri.path().starts_with("/channels/") => {
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<AppState>, 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 =
+29 -39
View File
@@ -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)
+3 -3
View File
@@ -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))
+69 -14
View File
@@ -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]
+1 -1
View File
@@ -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<PathBuf> = Vec::new();
let mut link_files: Vec<PathBuf> = Vec::new();
+77 -50
View File
@@ -2862,6 +2862,33 @@ mod tests {
std::fs::read(&out).ok()
}
fn ffmpeg_gen_rotated_mp4(display_rotation: &str, source_args: &[&str]) -> Option<Vec<u8>> {
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;
};
+4 -3
View File
@@ -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),
@@ -62,17 +62,6 @@ export const DesktopFormatEnum = withOpenApiType(
export type DesktopFormat = z.infer<typeof DesktopFormatEnum>;
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<typeof DesktopVariantEnum>;
const VersionString = z
.string()
.regex(/^\d+\.\d+\.\d+$/u)
@@ -107,27 +96,6 @@ export const DesktopVersionedRedirectParam = z.object({
export type DesktopVersionedRedirectParam = z.infer<typeof DesktopVersionedRedirectParam>;
export const DesktopVariantRedirectParam = z.object({
channel: DesktopChannelEnum,
plat: DesktopPlatformEnum,
arch: DesktopArchEnum,
variant: DesktopVariantEnum,
format: DesktopFormatEnum,
});
export type DesktopVariantRedirectParam = z.infer<typeof DesktopVariantRedirectParam>;
export const DesktopVariantVersionedRedirectParam = z.object({
channel: DesktopChannelEnum,
plat: DesktopPlatformEnum,
arch: DesktopArchEnum,
variant: DesktopVariantEnum,
version: VersionString,
format: DesktopFormatEnum,
});
export type DesktopVariantVersionedRedirectParam = z.infer<typeof DesktopVariantVersionedRedirectParam>;
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<typeof DesktopVersionedChecksumRedirectParam>;
export const DesktopVariantChecksumRedirectParam = z.object({
channel: DesktopChannelEnum,
plat: DesktopPlatformEnum,
arch: DesktopArchEnum,
variant: DesktopVariantEnum,
format: DesktopChecksumFormat,
});
export type DesktopVariantChecksumRedirectParam = z.infer<typeof DesktopVariantChecksumRedirectParam>;
export const DesktopVariantVersionedChecksumRedirectParam = z.object({
channel: DesktopChannelEnum,
plat: DesktopPlatformEnum,
arch: DesktopArchEnum,
variant: DesktopVariantEnum,
version: VersionString,
format: DesktopChecksumFormat,
});
export type DesktopVariantVersionedChecksumRedirectParam = z.infer<typeof DesktopVariantVersionedChecksumRedirectParam>;
export const DesktopVersionsParam = z.object({
channel: DesktopChannelEnum,
plat: DesktopPlatformEnum,
@@ -182,15 +129,6 @@ export const DesktopVersionsParam = z.object({
export type DesktopVersionsParam = z.infer<typeof DesktopVersionsParam>;
export const DesktopVariantVersionsParam = z.object({
channel: DesktopChannelEnum,
plat: DesktopPlatformEnum,
arch: DesktopArchEnum,
variant: DesktopVariantEnum,
});
export type DesktopVariantVersionsParam = z.infer<typeof DesktopVariantVersionsParam>;
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<typeof VersionInfoResponse>;
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<typeof DesktopSourceChecksumResponse>;
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'),
-8
View File
@@ -1530,14 +1530,6 @@ pub(crate) fn count_files_min_depth(root: &Path, min_depth: usize) -> Result<usi
.count())
}
pub(crate) fn first_word(value: &str) -> 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() {
+930 -1054
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -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());
+35
View File
@@ -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<Vec<(String, Option<String>)>> {
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()
},
)