From 374db9ed2b37205739546080ae14efac99b1e405 Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 27 Aug 2026 23:54:38 +0200 Subject: [PATCH] fix(desktop): harden capture and release packaging (#2049) --- .github/workflows/build-desktop.yaml | 30 +- .../auth/flow/DesktopDeepLinkPrompt.tsx | 37 +- .../navigation/utils/DeepLinkUtils.ts | 15 - .../src/features/platform/types/Electron.ts | 1 - .../features/voice/utils/DesktopRpcClient.ts | 117 ---- fluxer_app/src/types/electron.d.ts | 1 - fluxer_app_proxy/src/csp.rs | 2 - fluxer_desktop/electron-builder.config.cjs | 73 +-- .../native/win-game-capture/Cargo.toml | 4 + .../native/win-game-capture/package.json | 14 +- .../win-game-capture/src/capture_target.rs | 201 ++++++ .../win-game-capture/src/d3d11_device.rs | 46 ++ .../win-game-capture/src/dxgi_capture.rs | 1 + .../win-game-capture/src/game_capture.rs | 254 +------- .../native/win-game-capture/src/lib.rs | 122 ++-- .../win-game-capture/src/wgc_capture.rs | 2 +- fluxer_desktop/scripts/build.mjs | 74 ++- fluxer_desktop/src/common/Types.ts | 1 - fluxer_desktop/src/main/RpcServer.ts | 217 ------- fluxer_desktop/src/main/index.ts | 6 +- fluxer_desktop/src/preload/index.ts | 9 - tools/ci/src/desktop.rs | 535 ++++++++++++++-- tools/ci/src/desktop_native.rs | 304 +++------ tools/ci/src/release.rs | 586 ++++++++++++------ tools/dev/src/desktop.rs | 59 +- 25 files changed, 1394 insertions(+), 1317 deletions(-) delete mode 100644 fluxer_app/src/features/voice/utils/DesktopRpcClient.ts create mode 100644 fluxer_desktop/native/win-game-capture/src/capture_target.rs create mode 100644 fluxer_desktop/native/win-game-capture/src/d3d11_device.rs delete mode 100644 fluxer_desktop/src/main/RpcServer.ts diff --git a/.github/workflows/build-desktop.yaml b/.github/workflows/build-desktop.yaml index e7ae4aa8a..60364144d 100644 --- a/.github/workflows/build-desktop.yaml +++ b/.github/workflows/build-desktop.yaml @@ -524,6 +524,7 @@ jobs: SOURCE_SHA: ${{ needs.meta.outputs.source_sha }} S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }} DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }} + DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }} S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }} S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }} PUBLIC_DL_BASE: https://api.fluxer.app/dl @@ -553,11 +554,23 @@ jobs: cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop --step build_payload + - name: Prepare GitHub release assets + if: needs.meta.outputs.test_build != 'true' + run: >- + cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop + --step prepare_release_assets + - name: Upload payload to S3 run: >- cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop --step upload_payload + - name: Upload GitHub release asset handoff + if: needs.meta.outputs.test_build != 'true' + run: >- + cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop + --step upload_release_assets + - name: Build summary run: >- cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop @@ -577,9 +590,17 @@ jobs: - upload runs-on: ubuntu-24.04-arm environment: desktop-releases - timeout-minutes: 10 + timeout-minutes: 60 permissions: contents: write + env: + CHANNEL: ${{ needs.meta.outputs.build_channel }} + VERSION: ${{ needs.meta.outputs.version }} + DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }} + S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }} + S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }} + AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }} steps: - name: Checkout source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 @@ -590,6 +611,12 @@ jobs: uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: "1.93.0" + + - name: Download GitHub release assets + run: >- + cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop + --step download_release_assets + - name: Create token id: create-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 @@ -614,6 +641,7 @@ jobs: --build-version "${VERSION}" --source-sha "${SOURCE_SHA}" --previous-sha "${RELEASE_BASELINE_SHA}" + --asset-dir release_assets ) if [[ "${CHANNEL}" == "canary" ]]; then release_args+=(--prerelease) diff --git a/fluxer_app/src/features/auth/flow/DesktopDeepLinkPrompt.tsx b/fluxer_app/src/features/auth/flow/DesktopDeepLinkPrompt.tsx index ae3fa4d87..fbaaff0c0 100644 --- a/fluxer_app/src/features/auth/flow/DesktopDeepLinkPrompt.tsx +++ b/fluxer_app/src/features/auth/flow/DesktopDeepLinkPrompt.tsx @@ -8,12 +8,11 @@ import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {Button} from '@app/features/ui/button/Button'; import {buildAppProtocolUrl} from '@app/features/ui/utils/AppProtocol'; import {isDesktop, openExternalUrl} from '@app/features/ui/utils/NativeUtils'; -import {checkDesktopAvailable, navigateInDesktop} from '@app/features/voice/utils/DesktopRpcClient'; import {msg} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; import {ArrowSquareOutIcon} from '@phosphor-icons/react'; import type React from 'react'; -import {useEffect, useState} from 'react'; +import {useState} from 'react'; interface DesktopDeepLinkPromptProps { code: string; @@ -36,25 +35,9 @@ const FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR = msg({ export const DesktopDeepLinkPrompt: React.FC = ({code, kind, preferLogin = false}) => { const {i18n} = useLingui(); const [isLoading, setIsLoading] = useState(false); - const [desktopAvailable, setDesktopAvailable] = useState(null); const [error, setError] = useState(null); const isMobileBrowser = Platform.isMobileBrowser; - const useProtocolLaunch = kind === 'invite'; - const shouldProbeDesktopAvailability = !useProtocolLaunch; - useEffect(() => { - if (isDesktop() || !shouldProbeDesktopAvailability) return; - let cancelled = false; - checkDesktopAvailable().then(({available}) => { - if (!cancelled) { - setDesktopAvailable(available); - } - }); - return () => { - cancelled = true; - }; - }, [shouldProbeDesktopAvailability]); if (isDesktop() || isMobileBrowser) return null; - if (shouldProbeDesktopAvailability && desktopAvailable !== true) return null; const getPath = (): string => { switch (kind) { case 'invite': @@ -69,20 +52,12 @@ export const DesktopDeepLinkPrompt: React.FC = ({cod const handleOpen = async () => { setIsLoading(true); setError(null); - if (useProtocolLaunch) { - try { - await openExternalUrl(buildAppProtocolUrl(path)); - } catch { - setError(i18n._(FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR)); - } finally { - setIsLoading(false); - } - return; - } - const result = await navigateInDesktop(path); - setIsLoading(false); - if (!result.success) { + try { + await openExternalUrl(buildAppProtocolUrl(path)); + } catch { setError(i18n._(FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR)); + } finally { + setIsLoading(false); } }; return ( diff --git a/fluxer_app/src/features/navigation/utils/DeepLinkUtils.ts b/fluxer_app/src/features/navigation/utils/DeepLinkUtils.ts index f4a62796d..b421353d2 100644 --- a/fluxer_app/src/features/navigation/utils/DeepLinkUtils.ts +++ b/fluxer_app/src/features/navigation/utils/DeepLinkUtils.ts @@ -180,10 +180,6 @@ export function handleDeepLinkUrl(rawUrl: string): boolean { return true; } -export function handleRpcNavigation(path: string): void { - RouterUtils.transitionTo(path); -} - let listenerStarted = false; export async function startDeepLinkHandling(): Promise { @@ -206,17 +202,6 @@ export async function startDeepLinkHandling(): Promise { logger.error(' Failed to handle URL', url, error); } }); - if (typeof electronApi.onRpcNavigate === 'function') { - electronApi.onRpcNavigate((path: string) => { - try { - handleRpcNavigation(path); - } catch (error) { - logger.error(' Failed to handle RPC navigation', path, error); - } - }); - } else { - logger.warn(' onRpcNavigate not available on this host version'); - } return; } } diff --git a/fluxer_app/src/features/platform/types/Electron.ts b/fluxer_app/src/features/platform/types/Electron.ts index 71766784f..5127b81b2 100644 --- a/fluxer_app/src/features/platform/types/Electron.ts +++ b/fluxer_app/src/features/platform/types/Electron.ts @@ -344,7 +344,6 @@ export interface ElectronAPI { pasteFromClipboard: () => Promise; onDeepLink: (callback: (url: string) => void) => () => void; getInitialDeepLink: () => Promise; - onRpcNavigate: (callback: (path: string) => void) => () => void; autostartEnable: () => Promise; autostartDisable: () => Promise; autostartIsEnabled: () => Promise; diff --git a/fluxer_app/src/features/voice/utils/DesktopRpcClient.ts b/fluxer_app/src/features/voice/utils/DesktopRpcClient.ts deleted file mode 100644 index 691f73a18..000000000 --- a/fluxer_app/src/features/voice/utils/DesktopRpcClient.ts +++ /dev/null @@ -1,117 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import Config from '@app/features/app/config/Config'; - -const RPC_PORT_STABLE = 21863; -const RPC_PORT_CANARY = 21864; -const RPC_PORTS = - Config.PUBLIC_RELEASE_CHANNEL === 'canary' ? [RPC_PORT_CANARY, RPC_PORT_STABLE] : [RPC_PORT_STABLE, RPC_PORT_CANARY]; - -interface RpcResponse { - success: boolean; - data?: T; - error?: string; -} - -interface HealthResponse { - status: string; - channel: string; - version: string; - platform: string; -} - -interface NavigateResponse { - navigated: boolean; - path: string; -} - -let cachedAvailablePort: number | null = null; -let lastHealthCheck = 0; - -const HEALTH_CHECK_CACHE_MS = 5000; - -async function rpcFetch(port: number, endpoint: string, options?: RequestInit): Promise | null> { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 2000); - try { - const response = await fetch(`http://127.0.0.1:${port}${endpoint}`, { - ...options, - signal: controller.signal, - }); - return (await response.json()) as RpcResponse; - } catch { - return null; - } finally { - clearTimeout(timeout); - } -} - -export async function checkDesktopAvailable(): Promise<{ - available: boolean; - port: number | null; - info: HealthResponse | null; -}> { - const now = Date.now(); - if (cachedAvailablePort !== null && now - lastHealthCheck < HEALTH_CHECK_CACHE_MS) { - const result = await rpcFetch(cachedAvailablePort, '/health'); - if (result?.success && result.data) { - return {available: true, port: cachedAvailablePort, info: result.data}; - } - cachedAvailablePort = null; - } - for (const port of RPC_PORTS) { - const result = await rpcFetch(port, '/health'); - if (result?.success && result.data) { - cachedAvailablePort = port; - lastHealthCheck = now; - return {available: true, port, info: result.data}; - } - } - cachedAvailablePort = null; - return {available: false, port: null, info: null}; -} - -export async function navigateInDesktop(path: string): Promise<{ - success: boolean; - error?: string; -}> { - const {available, port} = await checkDesktopAvailable(); - if (!available || port === null) { - return {success: false, error: 'desktop_unavailable'}; - } - const result = await rpcFetch(port, '/navigate', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({method: 'navigate', params: {path}}), - }); - if (!result) { - return {success: false, error: 'desktop_rpc_unreachable'}; - } - if (!result.success) { - return {success: false, error: result.error ?? 'desktop_rpc_unknown'}; - } - return {success: true}; -} - -export async function focusDesktop(): Promise<{ - success: boolean; - error?: string; -}> { - const {available, port} = await checkDesktopAvailable(); - if (!available || port === null) { - return {success: false, error: 'desktop_unavailable'}; - } - const result = await rpcFetch(port, '/focus', {method: 'POST'}); - if (!result) { - return {success: false, error: 'desktop_rpc_unreachable'}; - } - if (!result.success) { - return {success: false, error: result.error ?? 'desktop_rpc_unknown'}; - } - return {success: true}; -} - -export function resetDesktopRpcCache(): void { - cachedAvailablePort = null; - lastHealthCheck = 0; -} diff --git a/fluxer_app/src/types/electron.d.ts b/fluxer_app/src/types/electron.d.ts index 1f83d3099..6a06012b5 100644 --- a/fluxer_app/src/types/electron.d.ts +++ b/fluxer_app/src/types/electron.d.ts @@ -483,7 +483,6 @@ export interface ElectronAPI { passkeyIsSupported?(): Promise; passkeyRegister?(options: unknown, requestContext?: {pin?: string}): Promise; passkeyAuthenticate?(options: unknown, requestContext?: {pin?: string}): Promise; - onRpcNavigate?(callback: (path: string) => void): () => void; getOpenH264Status?(): Promise; setOpenH264Enabled?(enabled: boolean): Promise; virtmic?: VirtmicApi; diff --git a/fluxer_app_proxy/src/csp.rs b/fluxer_app_proxy/src/csp.rs index 5bf96f6a4..c011265cd 100644 --- a/fluxer_app_proxy/src/csp.rs +++ b/fluxer_app_proxy/src/csp.rs @@ -68,8 +68,6 @@ const CONNECT_SOURCES: &[&str] = &[ "https://challenges.cloudflare.com", "https://fluxerstatus.com", "https://fluxer.media", - "http://127.0.0.1:21863", - "http://127.0.0.1:21864", ]; const WORKER_SOURCES: &[&str] = &["https://*.fluxer.app", "blob:"]; diff --git a/fluxer_desktop/electron-builder.config.cjs b/fluxer_desktop/electron-builder.config.cjs index e0c431d6f..3b48c23b8 100644 --- a/fluxer_desktop/electron-builder.config.cjs +++ b/fluxer_desktop/electron-builder.config.cjs @@ -47,6 +47,8 @@ if (targetNativeArch === 'universal' && targetPlatform !== 'darwin') { const targetArchs = electronArch && electronArch !== 'universal' ? [electronArch] : supportedTargetArchs; const macTargetArchs = targetNativeArch ? [targetNativeArch] : supportedTargetArchs; +const winGameCaptureTargetArchs = + targetPlatform === 'win32' && targetNativeArch ? [targetNativeArch] : supportedTargetArchs; const winTargets = [ { target: 'dir', @@ -150,11 +152,9 @@ const nativeRuntimeFilePatterns = [ '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', + ...winGameCaptureTargetArchs.map( + (arch) => `node_modules/@fluxer/win-game-capture/win-game-capture.win32-${arch}-msvc.node`, + ), 'node_modules/@fluxer/win-clipboard/package.json', 'node_modules/@fluxer/win-clipboard/index.js', 'node_modules/@fluxer/win-clipboard/loader-diagnostics.cjs', @@ -224,11 +224,10 @@ const nativeRuntimeFilePatterns = [ 'node_modules/.pnpm/@fluxer+*/node_modules/@fluxer/*/loader-diagnostics.cjs', 'node_modules/.pnpm/@fluxer+*/node_modules/@fluxer/*/pure.cjs', 'node_modules/.pnpm/@fluxer+win-process-loopback@*/node_modules/@fluxer/win-process-loopback/*.node', - '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', + ...winGameCaptureTargetArchs.map( + (arch) => + `node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/win-game-capture.win32-${arch}-msvc.node`, + ), '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', @@ -352,6 +351,21 @@ function pnpmStoreDirName(packageName) { return packageName.replace('/', '+'); } +function windowsGameCaptureArtifactExcludes(arch) { + const packageRoots = [ + 'node_modules/@fluxer/win-game-capture', + 'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture', + ]; + const excludedNodeArchs = [...supportedTargetArchs, 'ia32'].filter((candidate) => candidate !== arch); + return packageRoots.flatMap((packageRoot) => [ + `!${packageRoot}/compatibility.json`, + `!${packageRoot}/fluxer-game-hook.*`, + `!${packageRoot}/fluxer-inject-helper.*`, + `!${packageRoot}/fluxer-vulkan-layer.*`, + ...excludedNodeArchs.map((excludedArch) => `!${packageRoot}/win-game-capture.win32-${excludedArch}-msvc.node`), + ]); +} + function platformNativeExcludes(platform, arch) { const keepFluxerPackages = new Set(fluxerNativePackagesByPlatform[platform] ?? []); const fluxerPackageExcludes = fluxerNativePackages @@ -371,6 +385,7 @@ function platformNativeExcludes(platform, arch) { } return [ ...fluxerPackageExcludes, + ...windowsGameCaptureArtifactExcludes(arch), ...velopackNativeFiles .filter((fileName) => fileName !== keepVelopackNativeFile) .map((fileName) => `!node_modules/velopack/lib/native/${fileName}`), @@ -403,22 +418,11 @@ function platformTag(platform, arch) { return null; } -function addWindowsGameCaptureArtifacts(artifacts, tag, arch) { - const add = (relativePath) => { - artifacts.push({ - packageName: '@fluxer/win-game-capture', - relativePath, - }); - }; - add(`win-game-capture.${tag}.node`); - add(`fluxer-game-hook.${tag}.dll`); - add(`fluxer-inject-helper.${tag}.exe`); - add(`fluxer-vulkan-layer.${tag}.dll`); - add(`fluxer-vulkan-layer.${tag}.json`); - if (arch === 'x64') { - add('fluxer-game-hook.win32-ia32-msvc.dll'); - add('fluxer-inject-helper.win32-ia32-msvc.exe'); - } +function addWindowsGameCaptureArtifacts(artifacts, tag) { + artifacts.push({ + packageName: '@fluxer/win-game-capture', + relativePath: `win-game-capture.${tag}.node`, + }); } function expectedNativeRuntimeArtifacts(platform, arch) { @@ -477,7 +481,7 @@ function expectedNativeRuntimeArtifactsForArch(platform, arch) { packageName: '@fluxer/win-process-loopback', relativePath: `win-process-loopback.${tag}.node`, }); - addWindowsGameCaptureArtifacts(artifacts, tag, arch); + addWindowsGameCaptureArtifacts(artifacts, tag); artifacts.push({ packageName: '@fluxer/win-clipboard', relativePath: `win-clipboard.${tag}.node`, @@ -1305,10 +1309,9 @@ module.exports = { asarUnpack: [ '**/*.node', 'node_modules/@fluxer/win-process-loopback/*.node', - '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', + ...winGameCaptureTargetArchs.map( + (arch) => `node_modules/@fluxer/win-game-capture/win-game-capture.win32-${arch}-msvc.node`, + ), 'node_modules/@fluxer/win-clipboard/*.node', 'node_modules/@fluxer/win-shell/*.node', 'node_modules/@fluxer/win-toast/*.node', @@ -1331,10 +1334,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', - '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', + ...winGameCaptureTargetArchs.map( + (arch) => + `node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/win-game-capture.win32-${arch}-msvc.node`, + ), 'node_modules/.pnpm/@fluxer+win-clipboard@*/node_modules/@fluxer/win-clipboard/*.node', 'node_modules/.pnpm/@fluxer+win-shell@*/node_modules/@fluxer/win-shell/*.node', 'node_modules/.pnpm/@fluxer+win-toast@*/node_modules/@fluxer/win-toast/*.node', diff --git a/fluxer_desktop/native/win-game-capture/Cargo.toml b/fluxer_desktop/native/win-game-capture/Cargo.toml index 82a7de508..4bc257cec 100644 --- a/fluxer_desktop/native/win-game-capture/Cargo.toml +++ b/fluxer_desktop/native/win-game-capture/Cargo.toml @@ -8,6 +8,10 @@ publish = false [workspace] resolver = "2" +[features] +default = [] +game-capture-hook = [] + [lib] crate-type = ["cdylib", "rlib"] diff --git a/fluxer_desktop/native/win-game-capture/package.json b/fluxer_desktop/native/win-game-capture/package.json index 27a7e5cbd..fa11788fa 100644 --- a/fluxer_desktop/native/win-game-capture/package.json +++ b/fluxer_desktop/native/win-game-capture/package.json @@ -18,19 +18,7 @@ "index.d.ts", "loader-diagnostics.cjs", "win-game-capture.win32-x64-msvc.node", - "win-game-capture.win32-arm64-msvc.node", - "fluxer-game-hook.win32-x64-msvc.dll", - "fluxer-game-hook.win32-ia32-msvc.dll", - "fluxer-game-hook.win32-arm64-msvc.dll", - "fluxer-inject-helper.win32-x64-msvc.exe", - "fluxer-inject-helper.win32-ia32-msvc.exe", - "fluxer-inject-helper.win32-arm64-msvc.exe", - "fluxer-vulkan-layer.win32-x64-msvc.dll", - "fluxer-vulkan-layer.win32-x64-msvc.json", - "fluxer-vulkan-layer.win32-ia32-msvc.dll", - "fluxer-vulkan-layer.win32-ia32-msvc.json", - "fluxer-vulkan-layer.win32-arm64-msvc.dll", - "fluxer-vulkan-layer.win32-arm64-msvc.json" + "win-game-capture.win32-arm64-msvc.node" ], "scripts": { "build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon", diff --git a/fluxer_desktop/native/win-game-capture/src/capture_target.rs b/fluxer_desktop/native/win-game-capture/src/capture_target.rs new file mode 100644 index 000000000..fd27e7021 --- /dev/null +++ b/fluxer_desktop/native/win-game-capture/src/capture_target.rs @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::ptr::{null, null_mut}; + +use windows_sys::Win32::{ + Foundation::{HWND, LPARAM, RECT}, + Graphics::Gdi::{ + EnumDisplayMonitors, GetMonitorInfoW, HMONITOR, MONITOR_DEFAULTTONEAREST, MONITORINFO, + MonitorFromRect, + }, + System::Threading::GetCurrentProcessId, + UI::WindowsAndMessaging::{ + EnumWindows, GW_OWNER, GWL_STYLE, GetForegroundWindow, GetWindow, GetWindowLongPtrW, + GetWindowRect, GetWindowThreadProcessId, IsWindow, IsWindowVisible, WS_BORDER, WS_MAXIMIZE, + }, +}; + +const MONITOR_LIMIT: usize = 16; + +fn parse_hwnd_source_id(source_id: &str) -> Option { + let token = source_id.strip_prefix("window:")?.split(':').next()?; + let value = if let Some(hex) = token + .strip_prefix("0x") + .or_else(|| token.strip_prefix("0X")) + { + isize::from_str_radix(hex, 16).ok()? + } else { + token.parse::().ok()? + }; + let hwnd = value as HWND; + (unsafe { IsWindow(hwnd) } != 0).then_some(hwnd) +} + +fn parse_screen_ordinal(source_id: &str) -> Option { + let token = source_id.strip_prefix("screen:")?.split(':').next()?; + token.parse::().ok() +} + +pub(crate) fn resolve_game_capture_target( + source_id: &str, + source_kind: &str, +) -> Result { + if let Some(hwnd) = parse_hwnd_source_id(source_id) { + return Ok(hwnd); + } + if source_kind != "game" && source_kind != "screen" { + return Err(format!( + "invalid game capture source: {source_kind}:{source_id}" + )); + } + let monitor = monitor_for_screen_source(source_id)?; + find_fullscreen_window_on_monitor(monitor) + .or_else(find_foreground_fullscreen_window) + .or_else(find_fullscreen_window_on_any_monitor) + .ok_or_else(|| "no fullscreen game window found on selected display".to_string()) +} + +fn monitor_for_screen_source(source_id: &str) -> Result { + let ordinal = parse_screen_ordinal(source_id).unwrap_or(0); + let monitors = enumerate_monitors(); + monitors + .get(ordinal) + .copied() + .or_else(|| monitors.first().copied()) + .ok_or_else(|| "no monitors available for game capture".to_string()) +} + +fn enumerate_monitors() -> Vec { + unsafe extern "system" fn enum_monitor( + monitor: HMONITOR, + _hdc: windows_sys::Win32::Graphics::Gdi::HDC, + _rect: *mut RECT, + param: LPARAM, + ) -> i32 { + let monitors = &mut *(param as *mut Vec); + if monitors.len() >= MONITOR_LIMIT { + return 0; + } + monitors.push(monitor); + 1 + } + let mut monitors = Vec::with_capacity(MONITOR_LIMIT); + unsafe { + EnumDisplayMonitors( + null_mut(), + null(), + Some(enum_monitor), + &mut monitors as *mut _ as LPARAM, + ); + } + assert!(monitors.len() <= MONITOR_LIMIT, "monitor targets bounded"); + monitors +} + +pub(crate) fn monitor_rect(monitor: HMONITOR) -> Option { + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + rcMonitor: RECT::default(), + rcWork: RECT::default(), + dwFlags: 0, + }; + if unsafe { GetMonitorInfoW(monitor, &mut info) } == 0 { + return None; + } + Some(info.rcMonitor) +} + +fn rect_matches_monitor(window_rect: &RECT, monitor_rect: &RECT) -> bool { + const TOLERANCE: i32 = 2; + (window_rect.left - monitor_rect.left).abs() <= TOLERANCE + && (window_rect.top - monitor_rect.top).abs() <= TOLERANCE + && (window_rect.right - monitor_rect.right).abs() <= TOLERANCE + && (window_rect.bottom - monitor_rect.bottom).abs() <= TOLERANCE +} + +fn is_regular_maximized_window(hwnd: HWND) -> bool { + let style = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) } as u32; + (style & WS_MAXIMIZE) != 0 && (style & WS_BORDER) != 0 +} + +fn is_fullscreen_window_on_monitor(hwnd: HWND, monitor: HMONITOR, monitor_rect: &RECT) -> bool { + if unsafe { IsWindowVisible(hwnd) } == 0 { + return false; + } + if !unsafe { GetWindow(hwnd, GW_OWNER) }.is_null() { + return false; + } + let mut pid = 0u32; + unsafe { + GetWindowThreadProcessId(hwnd, &mut pid); + } + if pid == 0 || pid == unsafe { GetCurrentProcessId() } { + return false; + } + if is_regular_maximized_window(hwnd) { + return false; + } + let mut rect = RECT::default(); + if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 { + return false; + } + let window_monitor = unsafe { MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST) }; + window_monitor == monitor && rect_matches_monitor(&rect, monitor_rect) +} + +fn find_foreground_fullscreen_window() -> Option { + let hwnd = unsafe { GetForegroundWindow() }; + if hwnd.is_null() { + return None; + } + let mut rect = RECT::default(); + if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 { + return None; + } + let monitor = unsafe { MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST) }; + let monitor_rect = monitor_rect(monitor)?; + is_fullscreen_window_on_monitor(hwnd, monitor, &monitor_rect).then_some(hwnd) +} + +fn find_fullscreen_window_on_any_monitor() -> Option { + for monitor in enumerate_monitors() { + if let Some(hwnd) = find_fullscreen_window_on_monitor(monitor) { + return Some(hwnd); + } + } + None +} + +fn find_fullscreen_window_on_monitor(monitor: HMONITOR) -> Option { + struct Search { + monitor: HMONITOR, + monitor_rect: RECT, + result: HWND, + own_pid: u32, + } + unsafe extern "system" fn enum_window(hwnd: HWND, param: LPARAM) -> i32 { + let search = &mut *(param as *mut Search); + let mut pid = 0u32; + GetWindowThreadProcessId(hwnd, &mut pid); + if pid == 0 || pid == search.own_pid { + return 1; + } + if !is_fullscreen_window_on_monitor(hwnd, search.monitor, &search.monitor_rect) { + return 1; + } + search.result = hwnd; + 0 + } + + let monitor_rect = monitor_rect(monitor)?; + let mut search = Search { + monitor, + monitor_rect, + result: null_mut(), + own_pid: unsafe { GetCurrentProcessId() }, + }; + unsafe { + let _ = EnumWindows(Some(enum_window), &mut search as *mut _ as LPARAM); + } + (!search.result.is_null()).then_some(search.result) +} diff --git a/fluxer_desktop/native/win-game-capture/src/d3d11_device.rs b/fluxer_desktop/native/win-game-capture/src/d3d11_device.rs new file mode 100644 index 000000000..1bc423e9f --- /dev/null +++ b/fluxer_desktop/native/win-game-capture/src/d3d11_device.rs @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use windows::Win32::{ + Foundation::HMODULE, + Graphics::{ + Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN}, + Direct3D11::{ + D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, D3D11CreateDevice, ID3D11Device, + ID3D11DeviceContext, + }, + Dxgi::{IDXGIAdapter, IDXGIDevice}, + }, +}; +use windows::core::Interface; + +pub(crate) fn create_shared_texture_device( + adapter: Option<&IDXGIAdapter>, +) -> Result<(ID3D11Device, ID3D11DeviceContext), String> { + let mut device = None; + let mut context = None; + let driver_type = if adapter.is_some() { + D3D_DRIVER_TYPE_UNKNOWN + } else { + D3D_DRIVER_TYPE_HARDWARE + }; + unsafe { + D3D11CreateDevice( + adapter, + driver_type, + HMODULE::default(), + D3D11_CREATE_DEVICE_BGRA_SUPPORT, + None, + D3D11_SDK_VERSION, + Some(&mut device), + None, + Some(&mut context), + ) + .map_err(|error| format!("D3D11CreateDevice for shared capture texture: {error}"))?; + } + let device = device.ok_or("D3D11 shared texture device was None")?; + let context = context.ok_or("D3D11 shared texture context was None")?; + if let Ok(dxgi_device) = device.cast::() { + let _ = unsafe { dxgi_device.SetGPUThreadPriority(7) }; + } + Ok((device, context)) +} diff --git a/fluxer_desktop/native/win-game-capture/src/dxgi_capture.rs b/fluxer_desktop/native/win-game-capture/src/dxgi_capture.rs index 6a4835057..1b762c3c8 100644 --- a/fluxer_desktop/native/win-game-capture/src/dxgi_capture.rs +++ b/fluxer_desktop/native/win-game-capture/src/dxgi_capture.rs @@ -394,6 +394,7 @@ pub(crate) fn resolve_output_size( } #[cfg(target_os = "windows")] +#[cfg(feature = "game-capture-hook")] pub(crate) fn wall_clock_us() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() diff --git a/fluxer_desktop/native/win-game-capture/src/game_capture.rs b/fluxer_desktop/native/win-game-capture/src/game_capture.rs index 192aeaddf..9f93235df 100644 --- a/fluxer_desktop/native/win-game-capture/src/game_capture.rs +++ b/fluxer_desktop/native/win-game-capture/src/game_capture.rs @@ -5,7 +5,9 @@ use crate::game_capture_abi::{ENV_FORCE_CPU, ENV_VERBOSE, env_flag_enabled}; use crate::nv12_gpu::Nv12GpuConverter; use crate::{ CaptureInner, + capture_target::{monitor_rect, resolve_game_capture_target}, compatibility::{InjectionPolicy, injection_policy_for_window}, + d3d11_device::create_shared_texture_device, dxgi_capture::{resolve_output_size, wall_clock_us}, emit_lifecycle, emit_shared_texture_frame, game_capture_abi::{ @@ -26,26 +28,18 @@ use std::{ sync::{Arc, Mutex, atomic::Ordering}, }; use windows::Win32::{ - Foundation::{HANDLE as WinHandle, HMODULE}, + Foundation::HANDLE as WinHandle, Graphics::{ - Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN}, - Direct3D11::{ - D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, - D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D, - }, - Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIDevice, IDXGIFactory1}, + Direct3D11::{D3D11_TEXTURE2D_DESC, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D}, + Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1}, }, }; -use windows::core::Interface; use windows_sys::Win32::{ Foundation::{ CloseHandle, FreeLibrary, HANDLE, HINSTANCE, HMODULE as WinSysHmodule, HWND, INVALID_HANDLE_VALUE, LPARAM, RECT, WAIT_ABANDONED, WAIT_OBJECT_0, WAIT_TIMEOUT, }, - Graphics::Gdi::{ - EnumDisplayMonitors, GetMonitorInfoW, HMONITOR, MONITOR_DEFAULTTONEAREST, MONITORINFO, - MonitorFromRect, - }, + Graphics::Gdi::{MONITOR_DEFAULTTONEAREST, MonitorFromRect}, System::{ Diagnostics::Debug::WriteProcessMemory, LibraryLoader::{GetModuleHandleW, GetProcAddress, LoadLibraryW}, @@ -67,10 +61,8 @@ use windows_sys::Win32::{ }, }, UI::WindowsAndMessaging::{ - EnumWindows, GW_OWNER, GWL_STYLE, GetClientRect, GetForegroundWindow, GetWindow, - GetWindowLongPtrW, GetWindowRect, GetWindowThreadProcessId, HHOOK, IsWindow, - IsWindowVisible, PostThreadMessageW, SetWindowsHookExW, UnhookWindowsHookEx, WH_GETMESSAGE, - WM_NULL, WS_BORDER, WS_MAXIMIZE, + GetClientRect, GetWindowRect, GetWindowThreadProcessId, HHOOK, IsWindow, IsWindowVisible, + PostThreadMessageW, SetWindowsHookExW, UnhookWindowsHookEx, WH_GETMESSAGE, WM_NULL, }, }; @@ -812,87 +804,6 @@ fn inject_via_set_windows_hook( Ok(InstalledWindowsHook { hook, module }) } -fn parse_hwnd_source_id(source_id: &str) -> Option { - let token = source_id.strip_prefix("window:")?.split(':').next()?; - let value = if let Some(hex) = token - .strip_prefix("0x") - .or_else(|| token.strip_prefix("0X")) - { - isize::from_str_radix(hex, 16).ok()? - } else { - token.parse::().ok()? - }; - let hwnd = value as HWND; - if unsafe { IsWindow(hwnd) } != 0 { - Some(hwnd) - } else { - None - } -} - -fn parse_screen_ordinal(source_id: &str) -> Option { - let token = source_id.strip_prefix("screen:")?.split(':').next()?; - token.parse::().ok() -} - -pub(crate) fn resolve_game_capture_target( - source_id: &str, - source_kind: &str, -) -> Result { - if let Some(hwnd) = parse_hwnd_source_id(source_id) { - return Ok(hwnd); - } - if source_kind == "game" || source_kind == "screen" { - let monitor = monitor_for_screen_source(source_id)?; - if let Some(hwnd) = find_fullscreen_window_on_monitor(monitor) { - return Ok(hwnd); - } - if let Some(hwnd) = find_foreground_fullscreen_window() { - return Ok(hwnd); - } - if let Some(hwnd) = find_fullscreen_window_on_any_monitor() { - return Ok(hwnd); - } - return Err("no fullscreen game window found on selected display".into()); - } - Err(format!( - "invalid game capture source: {source_kind}:{source_id}" - )) -} - -fn monitor_for_screen_source(source_id: &str) -> Result { - let ordinal = parse_screen_ordinal(source_id).unwrap_or(0); - let monitors = enumerate_monitors(); - monitors - .get(ordinal) - .copied() - .or_else(|| monitors.first().copied()) - .ok_or_else(|| "no monitors available for game capture".to_string()) -} - -fn enumerate_monitors() -> Vec { - unsafe extern "system" fn enum_monitor( - monitor: HMONITOR, - _hdc: windows_sys::Win32::Graphics::Gdi::HDC, - _rect: *mut RECT, - param: LPARAM, - ) -> i32 { - let monitors = &mut *(param as *mut Vec); - monitors.push(monitor); - 1 - } - let mut monitors = Vec::new(); - unsafe { - EnumDisplayMonitors( - null_mut(), - null(), - Some(enum_monitor), - &mut monitors as *mut _ as LPARAM, - ); - } - monitors -} - fn target_process_id(hwnd: HWND) -> Result { let mut pid = 0u32; unsafe { @@ -1039,155 +950,6 @@ fn choose_shared_buffer_dimensions( Some((buffer_width, buffer_height)) } -fn monitor_rect(monitor: HMONITOR) -> Option { - let mut info = MONITORINFO { - cbSize: std::mem::size_of::() as u32, - rcMonitor: RECT::default(), - rcWork: RECT::default(), - dwFlags: 0, - }; - if unsafe { GetMonitorInfoW(monitor, &mut info) } == 0 { - return None; - } - Some(info.rcMonitor) -} - -fn rect_matches_monitor(window_rect: &RECT, monitor_rect: &RECT) -> bool { - let tolerance = 2; - (window_rect.left - monitor_rect.left).abs() <= tolerance - && (window_rect.top - monitor_rect.top).abs() <= tolerance - && (window_rect.right - monitor_rect.right).abs() <= tolerance - && (window_rect.bottom - monitor_rect.bottom).abs() <= tolerance -} - -fn is_regular_maximized_window(hwnd: HWND) -> bool { - let style = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) } as u32; - (style & WS_MAXIMIZE) != 0 && (style & WS_BORDER) != 0 -} - -fn is_fullscreen_window_on_monitor(hwnd: HWND, monitor: HMONITOR, monitor_rect: &RECT) -> bool { - if unsafe { IsWindowVisible(hwnd) } == 0 || !unsafe { GetWindow(hwnd, GW_OWNER) }.is_null() { - return false; - } - let mut pid = 0u32; - unsafe { - GetWindowThreadProcessId(hwnd, &mut pid); - } - if pid == 0 || pid == unsafe { GetCurrentProcessId() } { - return false; - } - if is_regular_maximized_window(hwnd) { - return false; - } - let mut rect = RECT::default(); - if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 { - return false; - } - let window_monitor = unsafe { MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST) }; - window_monitor == monitor && rect_matches_monitor(&rect, monitor_rect) -} - -fn find_foreground_fullscreen_window() -> Option { - let hwnd = unsafe { GetForegroundWindow() }; - if hwnd.is_null() { - return None; - } - let mut rect = RECT::default(); - if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 { - return None; - } - let monitor = unsafe { MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST) }; - let monitor_rect = monitor_rect(monitor)?; - if is_fullscreen_window_on_monitor(hwnd, monitor, &monitor_rect) { - Some(hwnd) - } else { - None - } -} - -fn find_fullscreen_window_on_any_monitor() -> Option { - for monitor in enumerate_monitors() { - if let Some(hwnd) = find_fullscreen_window_on_monitor(monitor) { - return Some(hwnd); - } - } - None -} - -fn find_fullscreen_window_on_monitor(monitor: HMONITOR) -> Option { - struct Search { - monitor: HMONITOR, - monitor_rect: RECT, - result: HWND, - own_pid: u32, - } - unsafe extern "system" fn enum_window(hwnd: HWND, param: LPARAM) -> i32 { - let search = &mut *(param as *mut Search); - let mut pid = 0u32; - GetWindowThreadProcessId(hwnd, &mut pid); - if pid != 0 - && pid != search.own_pid - && is_fullscreen_window_on_monitor(hwnd, search.monitor, &search.monitor_rect) - { - search.result = hwnd; - return 0; - } - 1 - } - - let monitor_rect = monitor_rect(monitor)?; - let mut search = Search { - monitor, - monitor_rect, - result: null_mut(), - own_pid: unsafe { GetCurrentProcessId() }, - }; - unsafe { - let _ = EnumWindows(Some(enum_window), &mut search as *mut _ as LPARAM); - } - if search.result.is_null() { - None - } else { - Some(search.result) - } -} - -pub(crate) fn create_shared_texture_device( - adapter: Option<&IDXGIAdapter>, -) -> Result<(ID3D11Device, ID3D11DeviceContext), String> { - let mut device = None; - let mut context = None; - let driver_type = if adapter.is_some() { - D3D_DRIVER_TYPE_UNKNOWN - } else { - D3D_DRIVER_TYPE_HARDWARE - }; - unsafe { - D3D11CreateDevice( - adapter, - driver_type, - HMODULE::default(), - D3D11_CREATE_DEVICE_BGRA_SUPPORT, - None, - D3D11_SDK_VERSION, - Some(&mut device), - None, - Some(&mut context), - ) - .map_err(|e| format!("D3D11CreateDevice for shared game texture: {e}"))?; - } - let device = device.ok_or("D3D11 shared texture device was None")?; - let context = context.ok_or("D3D11 shared texture context was None")?; - set_gpu_thread_priority(&device); - Ok((device, context)) -} - -fn set_gpu_thread_priority(device: &ID3D11Device) { - if let Ok(dxgi_device) = device.cast::() { - let _ = unsafe { dxgi_device.SetGPUThreadPriority(7) }; - } -} - const SHARED_TEXTURE_ADAPTER_LIMIT: u32 = 16; fn shared_texture_adapter_candidates() -> Vec> { diff --git a/fluxer_desktop/native/win-game-capture/src/lib.rs b/fluxer_desktop/native/win-game-capture/src/lib.rs index b9cbabbf8..14ef095b1 100644 --- a/fluxer_desktop/native/win-game-capture/src/lib.rs +++ b/fluxer_desktop/native/win-game-capture/src/lib.rs @@ -3,13 +3,17 @@ #![deny(clippy::all)] #![allow(unsafe_op_in_unsafe_fn)] -#[cfg(any(target_os = "windows", test))] +#[cfg(target_os = "windows")] +mod capture_target; +#[cfg(any(all(target_os = "windows", feature = "game-capture-hook"), test))] mod compatibility; +#[cfg(target_os = "windows")] +mod d3d11_device; #[cfg(any(target_os = "windows", test))] mod dxgi_capture; pub mod encoder_attach; mod fallback; -#[cfg(target_os = "windows")] +#[cfg(all(target_os = "windows", feature = "game-capture-hook"))] mod game_capture; mod game_capture_abi; mod gpu_priority; @@ -19,7 +23,7 @@ mod nv12_gpu; mod sources; #[cfg(any(target_os = "windows", test))] mod stall; -#[cfg(target_os = "windows")] +#[cfg(all(target_os = "windows", feature = "game-capture-hook"))] mod vulkan_layer_registry; #[cfg(target_os = "windows")] mod wgc_capture; @@ -39,7 +43,7 @@ use dxgi_capture::DxgiCaptureSession; use fluxer_encoder_ring::EncoderFrameRate; #[cfg(target_os = "windows")] use fluxer_screen_frame_bus::EnqueueOutcome; -#[cfg(target_os = "windows")] +#[cfg(all(target_os = "windows", feature = "game-capture-hook"))] use game_capture::GameCaptureSession; #[cfg(target_os = "windows")] use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -48,7 +52,6 @@ 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< @@ -226,7 +229,7 @@ pub struct CaptureInner { pub session: Mutex>, #[cfg(target_os = "windows")] pub(crate) wgc_session: Mutex>, - #[cfg(target_os = "windows")] + #[cfg(all(target_os = "windows", feature = "game-capture-hook"))] pub game_session: Mutex>>, pub running: std::sync::atomic::AtomicBool, pub fallback: Mutex>, @@ -421,6 +424,7 @@ pub(crate) fn note_media_frame_without_sink(inner: &CaptureInner, message: &'sta } #[cfg(target_os = "windows")] +#[cfg(all(target_os = "windows", feature = "game-capture-hook"))] pub(crate) fn note_cpu_fallback_frame_dropped(inner: &CaptureInner, message: &'static str) { inner .cpu_fallback_frames_dropped @@ -470,7 +474,7 @@ impl ScreenCapture { session: Mutex::new(None), #[cfg(target_os = "windows")] wgc_session: Mutex::new(None), - #[cfg(target_os = "windows")] + #[cfg(all(target_os = "windows", feature = "game-capture-hook"))] game_session: Mutex::new(None), running: std::sync::atomic::AtomicBool::new(false), fallback: Mutex::new(None), @@ -586,43 +590,48 @@ impl ScreenCapture { #[cfg(target_os = "windows")] { let frame_sink = frame_sink_counter_snapshot(&self.inner); - let guard = self.inner.game_session.lock(); - if let Some(session) = guard.as_ref() { - let requested_injection_method = session.requested_injection_method().to_string(); - let injection_method = session.used_injection_method().to_string(); - if let Some(info) = session.read_shared_info() { - return Some(CaptureDiagnostics { - state: info.state, - api_type: info.api_type, - transport: info.transport, - fallback_reason: info.fallback_reason, - capture_flags: info.capture_flags, - width: info.width, - height: info.height, - dxgi_format: info.dxgi_format, - frame_counter: info.frame_counter as f64, - dropped_frame_counter: info.dropped_frame_counter as f64, - last_present_timestamp_us: info.last_present_timestamp_us as f64, - last_error: info.last_error, + #[cfg(feature = "game-capture-hook")] + { + let guard = self.inner.game_session.lock(); + if let Some(session) = guard.as_ref() { + let requested_injection_method = + session.requested_injection_method().to_string(); + let injection_method = session.used_injection_method().to_string(); + if let Some(info) = session.read_shared_info() { + return Some(CaptureDiagnostics { + state: info.state, + api_type: info.api_type, + transport: info.transport, + fallback_reason: info.fallback_reason, + capture_flags: info.capture_flags, + width: info.width, + height: info.height, + dxgi_format: info.dxgi_format, + frame_counter: info.frame_counter as f64, + dropped_frame_counter: info.dropped_frame_counter as f64, + last_present_timestamp_us: info.last_present_timestamp_us as f64, + last_error: info.last_error, + requested_injection_method, + injection_method, + active_strategy: snapshot.active_strategy, + last_fallback_reason: snapshot.last_fallback_reason, + start_options: current_start_options(&self.inner), + frame_sink_accepted: frame_sink.accepted as f64, + frame_sink_coalesced: frame_sink.coalesced as f64, + frame_sink_rejected: frame_sink.rejected as f64, + media_frames_dropped_without_sink: frame_sink.dropped_without_sink + as f64, + cpu_fallback_frames_dropped: frame_sink.cpu_fallback_dropped as f64, + }); + } + return Some(strategy_only_diagnostics( + &snapshot, requested_injection_method, injection_method, - active_strategy: snapshot.active_strategy, - last_fallback_reason: snapshot.last_fallback_reason, - start_options: current_start_options(&self.inner), - frame_sink_accepted: frame_sink.accepted as f64, - frame_sink_coalesced: frame_sink.coalesced as f64, - frame_sink_rejected: frame_sink.rejected as f64, - media_frames_dropped_without_sink: frame_sink.dropped_without_sink as f64, - cpu_fallback_frames_dropped: frame_sink.cpu_fallback_dropped as f64, - }); + current_start_options(&self.inner), + frame_sink, + )); } - return Some(strategy_only_diagnostics( - &snapshot, - requested_injection_method, - injection_method, - current_start_options(&self.inner), - frame_sink, - )); } Some(strategy_only_diagnostics( &snapshot, @@ -669,7 +678,7 @@ impl ScreenCapture { #[napi(js_name = "getSharedTextureHandle")] pub fn get_shared_texture_handle(&self) -> Option { - #[cfg(target_os = "windows")] + #[cfg(all(target_os = "windows", feature = "game-capture-hook"))] { let guard = self.inner.game_session.lock(); let session = guard.as_ref()?; @@ -684,7 +693,7 @@ impl ScreenCapture { } None } - #[cfg(not(target_os = "windows"))] + #[cfg(not(all(target_os = "windows", feature = "game-capture-hook")))] { None } @@ -706,6 +715,9 @@ impl ScreenCapture { *guard = None; let mut wgc_guard = self.inner.wgc_session.lock(); *wgc_guard = None; + } + #[cfg(all(target_os = "windows", feature = "game-capture-hook"))] + { let mut game_guard = self.inner.game_session.lock(); *game_guard = None; } @@ -955,7 +967,8 @@ impl ScreenCapture { return Err(napi::Error::from_reason("Capture already running")); } - if source_kind == "game" && !GAME_CAPTURE_HOOK_FORCE_DISABLED { + #[cfg(feature = "game-capture-hook")] + if source_kind == "game" { return self.start_windows_game( source_id, source_kind, @@ -991,7 +1004,7 @@ impl ScreenCapture { } let hwnd = if source_kind == "game" { - let target = game_capture::resolve_game_capture_target(&source_id, &source_kind) + let target = capture_target::resolve_game_capture_target(&source_id, &source_kind) .map_err(|e| { napi::Error::from_reason(format!("Failed to resolve game capture target: {e}")) })?; @@ -1115,6 +1128,7 @@ impl ScreenCapture { }) } + #[cfg(feature = "game-capture-hook")] #[allow(clippy::too_many_arguments)] fn start_windows_game( &self, @@ -1278,7 +1292,7 @@ pub fn is_supported() -> bool { #[napi(js_name = "isGameCaptureHookAvailable")] pub fn is_game_capture_hook_available() -> bool { - cfg!(target_os = "windows") && !GAME_CAPTURE_HOOK_FORCE_DISABLED + cfg!(all(target_os = "windows", feature = "game-capture-hook")) } #[napi(js_name = "getAvailability")] @@ -1314,37 +1328,37 @@ pub fn restore_gpu_scheduling_priority(process_id: Option) -> Result<()> { #[napi(js_name = "registerVulkanLayerManifest")] pub fn register_vulkan_layer_manifest(manifest_path: String) -> Result<()> { - #[cfg(target_os = "windows")] + #[cfg(all(target_os = "windows", feature = "game-capture-hook"))] { vulkan_layer_registry::register_manifest(&manifest_path).map_err(napi::Error::from_reason) } - #[cfg(not(target_os = "windows"))] + #[cfg(not(all(target_os = "windows", feature = "game-capture-hook")))] { let _ = manifest_path; Err(napi::Error::from_reason( - "Vulkan game capture layer only supported on Windows", + "Vulkan game capture layer is not included in this build", )) } } #[napi(js_name = "unregisterVulkanLayerManifest")] pub fn unregister_vulkan_layer_manifest(manifest_path: String) -> Result<()> { - #[cfg(target_os = "windows")] + #[cfg(all(target_os = "windows", feature = "game-capture-hook"))] { vulkan_layer_registry::unregister_manifest(&manifest_path).map_err(napi::Error::from_reason) } - #[cfg(not(target_os = "windows"))] + #[cfg(not(all(target_os = "windows", feature = "game-capture-hook")))] { let _ = manifest_path; Err(napi::Error::from_reason( - "Vulkan game capture layer only supported on Windows", + "Vulkan game capture layer is not included in this build", )) } } #[napi(js_name = "getVulkanLayerRegistrationState")] pub fn get_vulkan_layer_registration_state(manifest_path: String) -> VulkanLayerRegistrationState { - #[cfg(target_os = "windows")] + #[cfg(all(target_os = "windows", feature = "game-capture-hook"))] { let state = vulkan_layer_registry::registration_state(&manifest_path); VulkanLayerRegistrationState { @@ -1354,7 +1368,7 @@ pub fn get_vulkan_layer_registration_state(manifest_path: String) -> VulkanLayer manifest_path: state.manifest_path, } } - #[cfg(not(target_os = "windows"))] + #[cfg(not(all(target_os = "windows", feature = "game-capture-hook")))] { VulkanLayerRegistrationState { registered: false, diff --git a/fluxer_desktop/native/win-game-capture/src/wgc_capture.rs b/fluxer_desktop/native/win-game-capture/src/wgc_capture.rs index 8a041287e..abaac249a 100644 --- a/fluxer_desktop/native/win-game-capture/src/wgc_capture.rs +++ b/fluxer_desktop/native/win-game-capture/src/wgc_capture.rs @@ -208,7 +208,7 @@ impl WgcCaptureSession { requested_height: Option, ) -> Result { ensure_winrt_initialized(); - let (device, context) = crate::game_capture::create_shared_texture_device(None)?; + let (device, context) = crate::d3d11_device::create_shared_texture_device(None)?; let dxgi_device: IDXGIDevice = device .cast() .map_err(|e| format!("IDXGIDevice cast: {e}"))?; diff --git a/fluxer_desktop/scripts/build.mjs b/fluxer_desktop/scripts/build.mjs index 0f509ed14..c317d6521 100644 --- a/fluxer_desktop/scripts/build.mjs +++ b/fluxer_desktop/scripts/build.mjs @@ -157,43 +157,58 @@ function collectRuntimeArtifactPaths(packageDir) { } function isNativeRuntimeSidecar(fileName) { - return ( - fileName.endsWith('.node') || - /\.so(?:\.|$)/.test(fileName) || - /\.(?:dll|exe)$/i.test(fileName) || - isWindowsNativeRuntimeManifest(fileName) - ); + return fileName.endsWith('.node') || /\.so(?:\.|$)/.test(fileName) || /\.(?:dll|exe)$/i.test(fileName); } -function isWindowsNativeRuntimeManifest(fileName) { - return ( - fileName === 'compatibility.json' || /^fluxer-vulkan-layer\.win32-(?:x64|ia32|arm64)-msvc\.json$/i.test(fileName) - ); +function electronArch() { + return process.env.ELECTRON_ARCH || process.env.npm_config_arch || process.arch; } -function addWinGameCaptureRuntimeArtifacts(artifacts, tag, arch) { - const add = (relativePath) => { - artifacts.push({ - label: '@fluxer/win-game-capture', - relativePath, - runtimeFiles: [], - }); - }; +function primaryWinGameCaptureNodeFileName() { + const arch = electronArch(); + const tag = platformTag(process.platform, arch); + if (!tag || process.platform !== 'win32') { + throw new Error(`Cannot resolve the primary win-game-capture node for ${process.platform}/${arch}`); + } + return `win-game-capture.${tag}.node`; +} + +function removeStaleWinGameCaptureArtifacts(packageDir, primaryNodeFileName) { + if (!fs.existsSync(packageDir)) return; + const stale = fs + .readdirSync(packageDir, {withFileTypes: true}) + .filter((entry) => { + if (!entry.isFile()) return false; + return ( + entry.name.startsWith('fluxer-game-hook.') || + entry.name.startsWith('fluxer-inject-helper.') || + entry.name.startsWith('fluxer-vulkan-layer.') || + (entry.name.startsWith('win-game-capture.') && + entry.name.endsWith('.node') && + entry.name !== primaryNodeFileName) + ); + }) + .map((entry) => entry.name) + .sort(); + for (const fileName of stale) { + const artifactPath = path.join(packageDir, fileName); + fs.rmSync(artifactPath); + console.log(` Removed stale @fluxer/win-game-capture artifact ${path.relative(ROOT_DIR, artifactPath)}`); + } +} + +function addWinGameCaptureRuntimeArtifacts(artifacts, tag) { artifacts.push({ label: '@fluxer/win-game-capture', relativePath: `win-game-capture.${tag}.node`, }); - add(`fluxer-game-hook.${tag}.dll`); - add(`fluxer-inject-helper.${tag}.exe`); - add(`fluxer-vulkan-layer.${tag}.dll`); - add(`fluxer-vulkan-layer.${tag}.json`); - if (arch === 'x64') { - add('fluxer-game-hook.win32-ia32-msvc.dll'); - add('fluxer-inject-helper.win32-ia32-msvc.exe'); - } } function copyRuntimeArtifactsToInstalledPackages({label, packageDir}) { + const primaryNodeFileName = label === '@fluxer/win-game-capture' ? primaryWinGameCaptureNodeFileName() : null; + if (primaryNodeFileName) { + removeStaleWinGameCaptureArtifacts(packageDir, primaryNodeFileName); + } const artifacts = collectRuntimeArtifactPaths(packageDir); if (artifacts.length === 0) { return; @@ -206,6 +221,9 @@ function copyRuntimeArtifactsToInstalledPackages({label, packageDir}) { return; } for (const installedPackageDir of installedPackageDirs) { + if (primaryNodeFileName) { + removeStaleWinGameCaptureArtifacts(installedPackageDir, primaryNodeFileName); + } for (const artifact of artifacts) { const sourcePath = path.join(packageDir, artifact); const targetPath = path.join(installedPackageDir, artifact); @@ -225,7 +243,7 @@ function platformTag(platform, arch) { return null; } -function expectedNativeRuntimeArtifacts(platform = process.platform, arch = process.env.ELECTRON_ARCH || process.arch) { +function expectedNativeRuntimeArtifacts(platform = process.platform, arch = electronArch()) { if (platform === 'darwin' && arch === 'universal') { return [ ...expectedNativeRuntimeArtifactsForArch(platform, 'arm64'), @@ -284,7 +302,7 @@ function expectedNativeRuntimeArtifactsForArch(platform, arch) { label: '@fluxer/win-process-loopback', relativePath: `win-process-loopback.${tag}.node`, }); - addWinGameCaptureRuntimeArtifacts(artifacts, tag, arch); + addWinGameCaptureRuntimeArtifacts(artifacts, tag); artifacts.push({ label: '@fluxer/win-clipboard', relativePath: `win-clipboard.${tag}.node`, diff --git a/fluxer_desktop/src/common/Types.ts b/fluxer_desktop/src/common/Types.ts index a035b0116..293988c8c 100644 --- a/fluxer_desktop/src/common/Types.ts +++ b/fluxer_desktop/src/common/Types.ts @@ -688,7 +688,6 @@ export interface ElectronAPI { pasteFromClipboard: () => Promise; onDeepLink: (callback: (url: string) => void) => () => void; getInitialDeepLink: () => Promise; - onRpcNavigate: (callback: (path: string) => void) => () => void; autostartEnable: () => Promise; autostartDisable: () => Promise; autostartIsEnabled: () => Promise; diff --git a/fluxer_desktop/src/main/RpcServer.ts b/fluxer_desktop/src/main/RpcServer.ts deleted file mode 100644 index 287ebb25d..000000000 --- a/fluxer_desktop/src/main/RpcServer.ts +++ /dev/null @@ -1,217 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import http from 'node:http'; -import {BUILD_CHANNEL} from '@electron/common/BuildChannel'; -import {CANARY_APP_URL, STABLE_APP_URL} from '@electron/common/Constants'; -import {getCustomAppUrl} from '@electron/common/DesktopConfig'; -import {getMainWindow, showWindow} from '@electron/main/Window'; -import {app} from 'electron'; -import log from 'electron-log'; - -const RPC_PORT = BUILD_CHANNEL === 'canary' ? 21864 : 21863; -const ALLOWED_ORIGINS = [STABLE_APP_URL, CANARY_APP_URL]; -const isAllowedOrigin = (origin?: string): boolean => { - if (!origin) return false; - if (ALLOWED_ORIGINS.includes(origin)) return true; - const customUrl = getCustomAppUrl(); - return customUrl != null && origin === customUrl; -}; -const refererMatchesAllowedOrigin = (referer?: string): boolean => { - if (!referer) return false; - if (ALLOWED_ORIGINS.some((allowed) => referer.startsWith(allowed))) return true; - const customUrl = getCustomAppUrl(); - return customUrl != null && referer.startsWith(customUrl); -}; - -let server: http.Server | null = null; - -interface RpcRequest { - method: string; - params?: Record; -} - -interface RpcResponse { - success: boolean; - data?: unknown; - error?: string; -} - -const sendJson = (res: http.ServerResponse, status: number, data: RpcResponse) => { - res.writeHead(status, {'Content-Type': 'application/json'}); - res.end(JSON.stringify(data)); -}; -const rejectIfDisallowedPage = (req: http.IncomingMessage, res: http.ServerResponse): boolean => { - const origin = req.headers.origin; - const referer = req.headers.referer; - if (!origin && !referer) { - res.writeHead(403); - res.end(); - return true; - } - if (origin && !isAllowedOrigin(origin)) { - res.writeHead(403); - res.end(); - return true; - } - if (referer && !refererMatchesAllowedOrigin(referer)) { - res.writeHead(403); - res.end(); - return true; - } - if (origin && referer && !referer.startsWith(origin)) { - res.writeHead(403); - res.end(); - return true; - } - return false; -}; -const handleCors = (req: http.IncomingMessage, res: http.ServerResponse): boolean => { - const origin = req.headers.origin; - if (origin && !isAllowedOrigin(origin)) { - res.writeHead(403); - res.end(); - return true; - } - if (origin && isAllowedOrigin(origin)) { - res.setHeader('Access-Control-Allow-Origin', origin); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - res.setHeader('Access-Control-Max-Age', '86400'); - } - if (req.method === 'OPTIONS') { - res.writeHead(204); - res.end(); - return true; - } - return false; -}; -const parseBody = (req: http.IncomingMessage): Promise => { - return new Promise((resolve) => { - if (req.method !== 'POST') { - resolve(null); - return; - } - let body = ''; - req.on('data', (chunk) => { - body += chunk; - if (body.length > 1024 * 1024) { - resolve(null); - } - }); - req.on('end', () => { - try { - resolve(JSON.parse(body) as RpcRequest); - } catch { - resolve(null); - } - }); - req.on('error', () => resolve(null)); - }); -}; -const handleHealth = (_req: http.IncomingMessage, res: http.ServerResponse) => { - sendJson(res, 200, { - success: true, - data: { - status: 'ok', - channel: BUILD_CHANNEL, - version: app.getVersion(), - platform: process.platform, - }, - }); -}; -const handleNavigate = async (req: http.IncomingMessage, res: http.ServerResponse) => { - const body = await parseBody(req); - if (!body?.params?.path || typeof body.params['path'] !== 'string') { - sendJson(res, 400, {success: false, error: 'Missing or invalid path parameter'}); - return; - } - const path = body.params['path']; - const mainWindow = getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) { - sendJson(res, 503, {success: false, error: 'Main window not available'}); - return; - } - mainWindow.webContents.send('rpc-navigate', path); - showWindow(); - sendJson(res, 200, {success: true, data: {navigated: true, path}}); -}; -const handleFocus = (_req: http.IncomingMessage, res: http.ServerResponse) => { - const mainWindow = getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) { - sendJson(res, 503, {success: false, error: 'Main window not available'}); - return; - } - showWindow(); - sendJson(res, 200, {success: true, data: {focused: true}}); -}; -const requestHandler = async (req: http.IncomingMessage, res: http.ServerResponse) => { - const remoteAddress = req.socket.remoteAddress; - if (remoteAddress !== '127.0.0.1' && remoteAddress !== '::1' && remoteAddress !== '::ffff:127.0.0.1') { - res.writeHead(403); - res.end(); - return; - } - if (rejectIfDisallowedPage(req, res)) { - return; - } - if (handleCors(req, res)) { - return; - } - const url = req.url ?? '/'; - try { - switch (url) { - case '/health': - handleHealth(req, res); - break; - case '/navigate': - await handleNavigate(req, res); - break; - case '/focus': - handleFocus(req, res); - break; - default: - sendJson(res, 404, {success: false, error: 'Not found'}); - } - } catch (error) { - log.error('[RPC] Request handler error:', error); - sendJson(res, 500, {success: false, error: 'Internal server error'}); - } -}; -export const startRpcServer = (): Promise => { - return new Promise((resolve, reject) => { - if (server) { - resolve(); - return; - } - server = http.createServer(requestHandler); - server.on('error', (error: NodeJS.ErrnoException) => { - if (error.code === 'EADDRINUSE') { - log.warn(`[RPC] Port ${RPC_PORT} already in use, RPC server disabled`); - server = null; - resolve(); - } else { - log.error('[RPC] Server error:', error); - reject(error); - } - }); - server.listen(RPC_PORT, '127.0.0.1', () => { - log.info(`[RPC] Server listening on http://127.0.0.1:${RPC_PORT}`); - resolve(); - }); - }); -}; -export const stopRpcServer = (): Promise => { - return new Promise((resolve) => { - if (!server) { - resolve(); - return; - } - server.close((err) => { - if (err) { - log.error('[RPC] Error closing server:', err); - } - server = null; - resolve(); - }); - }); -}; diff --git a/fluxer_desktop/src/main/index.ts b/fluxer_desktop/src/main/index.ts index 8a68a8e43..442e269fc 100644 --- a/fluxer_desktop/src/main/index.ts +++ b/fluxer_desktop/src/main/index.ts @@ -68,7 +68,6 @@ import { import {runNativeModulePreflight} from '@electron/main/NativeModulePreflight'; import {cleanupNativeScreenCapture, registerNativeScreenCaptureHandlers} from '@electron/main/NativeScreenCapture'; import {appendOpenH264Switches} from '@electron/main/OpenH264Manager'; -import {startRpcServer, stopRpcServer} from '@electron/main/RpcServer'; import {cleanupLinuxChromiumSpellcheckDictionaries} from '@electron/main/Spellcheck'; import {registerUpdater} from '@electron/main/Updater'; import { @@ -430,9 +429,6 @@ if (launchConfigurationError) { showWindow(); } }); - void startRpcServer().catch((error: unknown) => { - log.error('[RPC] Failed to start RPC server:', error); - }); log.info('App initialized successfully'); }) .catch((error: unknown) => { @@ -478,7 +474,7 @@ if (launchConfigurationError) { cleanupNativeHardwareEncoderHandlers(); cleanupVirtmic(); destroyDesktopTray(); - const asyncCleanups: Array> = [stopRpcServer()]; + const asyncCleanups: Array> = []; if (netLog.currentlyLogging) { asyncCleanups.push( netLog.stopLogging().catch((error) => { diff --git a/fluxer_desktop/src/preload/index.ts b/fluxer_desktop/src/preload/index.ts index 8bfb8e5dd..589af70d7 100644 --- a/fluxer_desktop/src/preload/index.ts +++ b/fluxer_desktop/src/preload/index.ts @@ -425,15 +425,6 @@ const api: ElectronAPI = { }; }, getInitialDeepLink: (): Promise => ipcRenderer.invoke('get-initial-deep-link'), - onRpcNavigate: (callback: (path: string) => void): (() => void) => { - const handler = (_event: Electron.IpcRendererEvent, path: string): void => { - callback(path); - }; - ipcRenderer.on('rpc-navigate', handler); - return () => { - ipcRenderer.removeListener('rpc-navigate', handler); - }; - }, autostartEnable: (): Promise => ipcRenderer.invoke('autostart-enable'), autostartDisable: (): Promise => ipcRenderer.invoke('autostart-disable'), autostartIsEnabled: (): Promise => ipcRenderer.invoke('autostart-is-enabled'), diff --git a/tools/ci/src/desktop.rs b/tools/ci/src/desktop.rs index 20628d0fd..666aaa420 100644 --- a/tools/ci/src/desktop.rs +++ b/tools/ci/src/desktop.rs @@ -10,6 +10,7 @@ use crate::common::{ upload_directory_to_s3, upload_s3_plan_append_only, upload_s3_plan_overwrite, }; use crate::functions::write_json_pretty; +use crate::release::DESKTOP_RELEASE_ASSET_SUFFIXES; use anyhow::{Context, Result, anyhow, bail, ensure}; use aws_sdk_s3::Client as S3Client; use chrono::Utc; @@ -110,6 +111,9 @@ enum DesktopStep { DownloadHandoff, CleanupHandoff, BuildPayload, + PrepareReleaseAssets, + UploadReleaseAssets, + DownloadReleaseAssets, UploadPayload, BuildSummary, } @@ -235,6 +239,9 @@ pub async fn run(args: BuildDesktopArgs) -> Result<()> { DesktopStep::DownloadHandoff => download_handoff_step().await, DesktopStep::CleanupHandoff => cleanup_handoff_step().await, DesktopStep::BuildPayload => build_payload_step(), + DesktopStep::PrepareReleaseAssets => prepare_release_assets_step(), + DesktopStep::UploadReleaseAssets => upload_release_assets_step().await, + DesktopStep::DownloadReleaseAssets => download_release_assets_step().await, DesktopStep::UploadPayload => upload_payload_step().await, DesktopStep::BuildSummary => build_summary_step(), } @@ -1401,22 +1408,6 @@ fn install_rust_windows_targets_step() -> Result<()> { RUST_TOOLCHAIN, target, ]))?; - if arch == "x64" { - run_command(CommandSpec::new("rustup").args([ - "target", - "add", - "--toolchain", - RUST_TOOLCHAIN, - "i686-pc-windows-msvc", - ]))?; - run_command(CommandSpec::new("rustup").args([ - "target", - "add", - "--toolchain", - RUST_TOOLCHAIN, - "aarch64-pc-windows-msvc", - ]))?; - } if let Ok(user_profile) = env::var("USERPROFILE") { let cargo_bin = PathBuf::from(user_profile).join(".cargo").join("bin"); if cargo_bin.exists() && env::var("GITHUB_PATH").is_ok() { @@ -2011,7 +2002,7 @@ fn pack_and_validate_windows_velopack( "--outputDir", config.output_dir.to_string_lossy().as_ref(), "--delta", - "BestSpeed", + "None", "--azureTrustedSignFile", trusted_sign_file.to_string_lossy().as_ref(), ]))?; @@ -2063,6 +2054,15 @@ fn validate_velopack_output( let legacy_releases = config.output_dir.join("RELEASES"); let velopack_releases = config.output_dir.join("releases.win.json"); let full_nupkg = first_file_matching(&config.output_dir, |name| name.ends_with("-full.nupkg")); + let delta_nupkgs = collect_files(&config.output_dir)? + .into_iter() + .filter(|path| { + path.file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| name.ends_with("-delta.nupkg")) + }) + .map(|path| path.display().to_string()) + .collect::>(); ensure!( legacy_releases.exists(), @@ -2072,6 +2072,12 @@ fn validate_velopack_output( velopack_releases.exists(), "Velopack did not produce releases.win.json for Windows updates." ); + ensure!( + delta_nupkgs.is_empty(), + "Velopack produced {} disabled delta package(s), which cannot be independently verified against the signed full package:\n{}", + delta_nupkgs.len(), + delta_nupkgs.join("\n") + ); let full_nupkg = full_nupkg.ok_or_else(|| { anyhow!("Velopack did not produce a full nupkg payload for Windows updates.") })?; @@ -2243,7 +2249,15 @@ const THIRD_PARTY_WINDOWS_SIGNATURE_ALLOWLIST: &[(&str, &str)] = &[ "CN=Microsoft Windows, O=Microsoft Corporation, L=Redmond, S=Washington, C=US", ), ]; -const KNOWN_OPTIONAL_WINDOWS_PE_INVENTORY: &[&str] = &["fluxer-vulkan-layer.win32-ia32-msvc.dll"]; +const KNOWN_OPTIONAL_WINDOWS_PE_INVENTORY: &[&str] = &[]; +const FORBIDDEN_WINDOWS_GAME_CAPTURE_ARTIFACT_PREFIXES: &[&str] = &[ + "fluxer-game-hook.", + "fluxer-inject-helper.", + "fluxer-vulkan-layer.", + "fluxer_game_hook.", + "fluxer_inject_helper.", + "fluxer_vulkan_layer.", +]; const WINDOWS_NATIVE_ADDON_STEMS: &[&str] = &[ "hardware-encoder", "webauthn", @@ -2261,33 +2275,26 @@ fn expected_windows_pe_inventory(arch: &str, main_exe: &str) -> Vec { main_exe.to_string(), format!("velopack_nodeffi_win_{arch}_msvc.node"), format!("win-game-capture.{tag}.node"), - format!("fluxer-game-hook.{tag}.dll"), - format!("fluxer-inject-helper.{tag}.exe"), - format!("fluxer-vulkan-layer.{tag}.dll"), ]; names.extend( WINDOWS_NATIVE_ADDON_STEMS .iter() .map(|stem| format!("{stem}.{tag}.node")), ); - if arch == "x64" { - names.push("fluxer-game-hook.win32-ia32-msvc.dll".to_string()); - names.push("fluxer-inject-helper.win32-ia32-msvc.exe".to_string()); - } names.sort(); names.dedup(); names } -fn is_pe_file(path: &Path) -> Result { +fn read_pe_machine(path: &Path) -> Result> { let mut file = File::open(path).with_context(|| format!("Failed to open {}", path.display()))?; let mut dos_header = [0u8; 0x40]; if !read_exact_or_eof(&mut file, &mut dos_header, path)? { - return Ok(false); + return Ok(None); } if &dos_header[0..2] != b"MZ" { - return Ok(false); + return Ok(None); } let e_lfanew = u32::from_le_bytes([ dos_header[0x3c], @@ -2299,9 +2306,20 @@ fn is_pe_file(path: &Path) -> Result { .with_context(|| format!("Failed to seek in {}", path.display()))?; let mut signature = [0u8; 4]; if !read_exact_or_eof(&mut file, &mut signature, path)? { - return Ok(false); + return Ok(None); } - Ok(&signature == b"PE\0\0") + if &signature != b"PE\0\0" { + return Ok(None); + } + let mut machine = [0u8; 2]; + if !read_exact_or_eof(&mut file, &mut machine, path)? { + return Ok(None); + } + Ok(Some(u16::from_le_bytes(machine))) +} + +fn is_pe_file(path: &Path) -> Result { + Ok(read_pe_machine(path)?.is_some()) } fn read_exact_or_eof(file: &mut File, buffer: &mut [u8], path: &Path) -> Result { @@ -2361,6 +2379,61 @@ fn percent_decode_archive_name(name: &str) -> String { String::from_utf8(decoded).unwrap_or_else(|_| name.to_string()) } +fn windows_pe_machine_arch(machine: u16) -> Option<&'static str> { + match machine { + 0x014c => Some("ia32"), + 0x8664 => Some("x64"), + 0xaa64 => Some("arm64"), + _ => None, + } +} + +fn assert_windows_native_pe_machines( + root: &Path, + files: &[PathBuf], + expected_arch: &str, + main_exe: &str, +) -> Result<()> { + let mut violations = Vec::new(); + let normalized_main_exe = main_exe.to_ascii_lowercase(); + for path in files { + let Some(file_name) = path.file_name().and_then(OsStr::to_str) else { + continue; + }; + let normalized_name = percent_decode_archive_name(file_name).to_ascii_lowercase(); + let file_arch = if normalized_name == normalized_main_exe { + Some(expected_arch) + } else { + windows_native_pe_arch(&normalized_name) + }; + let Some(file_arch) = file_arch else { + continue; + }; + let machine = read_pe_machine(path)?.ok_or_else(|| { + anyhow!( + "{} was collected as a PE file but no COFF Machine field was readable", + path.display() + ) + })?; + let actual_arch = windows_pe_machine_arch(machine); + if actual_arch != Some(file_arch) || actual_arch != Some(expected_arch) { + violations.push(format!( + "{}: file policy expects {file_arch}, COFF Machine is 0x{machine:04x} ({}) and package architecture is {expected_arch}", + relative_display(root, path), + actual_arch.unwrap_or("unknown") + )); + } + } + ensure!( + violations.is_empty(), + "{} contains {} Windows native binary/binaries with invalid machine architecture:\n{}", + root.display(), + violations.len(), + violations.join("\n") + ); + Ok(()) +} + fn assert_expected_windows_pe_inventory( root: &Path, files: &[PathBuf], @@ -2385,6 +2458,17 @@ fn assert_expected_windows_pe_inventory( missing.len(), missing.join("\n") ); + let forbidden = present + .iter() + .filter_map(|name| windows_pe_inventory_violation(name, arch)) + .collect::>(); + ensure!( + forbidden.is_empty(), + "{} contains {} forbidden Windows native binaries:\n{}", + root.display(), + forbidden.len(), + forbidden.join("\n") + ); let contradictory = contradictory_optional_windows_pe_inventory(arch, main_exe); ensure!( contradictory.is_empty(), @@ -2411,7 +2495,7 @@ fn assert_expected_windows_pe_inventory( .cloned() .collect::>(); println!( - "{}: {} expected, {} unlisted PE(s) shipped by glob (Electron runtime and cross-architecture native artifacts). Every one of them is signature-classified below; none may be unsigned or signed by an unknown publisher.", + "{}: {} expected, {} unlisted PE(s) shipped by glob (Electron runtime and third-party binaries). Every one of them is signature-classified below; none may be unsigned or signed by an unknown publisher.", root.display(), expected.len(), unlisted.len() @@ -2422,6 +2506,231 @@ fn assert_expected_windows_pe_inventory( Ok(()) } +const ASAR_HEADER_LIMIT: usize = 64 * 1024 * 1024; +const ASAR_ENTRY_LIMIT: usize = 1_000_000; +const ASAR_NESTING_LIMIT: usize = 256; + +fn windows_package_file_policy_violation(relative: &str, expected_arch: &str) -> bool { + let normalized_relative = relative + .replace('\\', "/") + .split('/') + .map(percent_decode_archive_name) + .collect::>() + .join("/") + .replace('\\', "/") + .to_ascii_lowercase(); + let normalized_name = normalized_relative + .rsplit('/') + .next() + .unwrap_or_default() + .to_string(); + if FORBIDDEN_WINDOWS_GAME_CAPTURE_ARTIFACT_PREFIXES + .iter() + .any(|prefix| normalized_name.starts_with(prefix)) + { + return true; + } + if normalized_name == "compatibility.json" + && normalized_relative + .split('/') + .any(|component| component == "win-game-capture") + { + return true; + } + windows_native_pe_arch(&normalized_name).is_some_and(|arch| arch != expected_arch) +} + +fn read_u32_le(bytes: &[u8], offset: usize) -> Result { + let end = offset + .checked_add(4) + .ok_or_else(|| anyhow!("ASAR header offset overflow"))?; + let value = bytes + .get(offset..end) + .ok_or_else(|| anyhow!("ASAR header is truncated at byte {offset}"))?; + let value = <[u8; 4]>::try_from(value) + .map_err(|_| anyhow!("ASAR header field at byte {offset} is not four bytes"))?; + Ok(u32::from_le_bytes(value)) +} + +fn collect_asar_policy_violations( + node: &Value, + expected_arch: &str, + violations: &mut Vec, +) -> Result<()> { + let mut stack = vec![(node, String::new(), 0usize)]; + let mut entry_count = 0usize; + while let Some((current, prefix, depth)) = stack.pop() { + ensure!( + depth <= ASAR_NESTING_LIMIT, + "ASAR header nesting exceeds {ASAR_NESTING_LIMIT} levels under {prefix:?}" + ); + let files = current + .get("files") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("ASAR header node {prefix:?} has no files object"))?; + for (name, entry) in files { + entry_count = entry_count + .checked_add(1) + .ok_or_else(|| anyhow!("ASAR entry count overflow"))?; + ensure!( + entry_count <= ASAR_ENTRY_LIMIT, + "ASAR header contains more than {ASAR_ENTRY_LIMIT} entries" + ); + let decoded_name = percent_decode_archive_name(name); + ensure!( + !name.is_empty() + && name != "." + && name != ".." + && !name.contains('/') + && !name.contains('\\') + && decoded_name != "." + && decoded_name != ".." + && !decoded_name.contains('/') + && !decoded_name.contains('\\'), + "ASAR header contains invalid entry name {name:?} under {prefix:?}" + ); + let relative = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + if windows_package_file_policy_violation(&relative, expected_arch) { + violations.push(relative.clone()); + } + if entry.get("files").is_some() { + stack.push((entry, relative, depth + 1)); + } + } + } + Ok(()) +} + +fn asar_policy_violations(path: &Path, expected_arch: &str) -> Result> { + let mut file = + File::open(path).with_context(|| format!("Failed to open {}", path.display()))?; + let file_size = file + .metadata() + .with_context(|| format!("Failed to stat {}", path.display()))? + .len(); + let mut prefix = [0u8; 16]; + file.read_exact(&mut prefix) + .with_context(|| format!("Failed to read ASAR header prefix from {}", path.display()))?; + let size_pickle_payload = read_u32_le(&prefix, 0)?; + let header_pickle_size = read_u32_le(&prefix, 4)?; + let header_pickle_payload = read_u32_le(&prefix, 8)?; + let header_json_size = read_u32_le(&prefix, 12)?; + let padded_header_json_size = header_json_size + .checked_add(3) + .map(|size| size & !3) + .ok_or_else(|| anyhow!("{} ASAR JSON header size overflow", path.display()))?; + let expected_header_pickle_payload = padded_header_json_size + .checked_add(4) + .ok_or_else(|| anyhow!("{} ASAR pickle payload size overflow", path.display()))?; + ensure!( + size_pickle_payload == 4 + && header_pickle_payload.checked_add(4) == Some(header_pickle_size) + && header_pickle_payload == expected_header_pickle_payload, + "{} has an invalid ASAR pickle header", + path.display() + ); + let header_json_size = usize::try_from(header_json_size).context("ASAR header is too large")?; + ensure!( + header_json_size > 0 && header_json_size <= ASAR_HEADER_LIMIT, + "{} ASAR JSON header size {} is outside 1..={ASAR_HEADER_LIMIT}", + path.display(), + header_json_size + ); + let header_pickle_size = + usize::try_from(header_pickle_size).context("ASAR pickle header is too large")?; + let archive_payload_offset = 8usize + .checked_add(header_pickle_size) + .ok_or_else(|| anyhow!("{} ASAR header size overflow", path.display()))?; + ensure!( + u64::try_from(archive_payload_offset).unwrap_or(u64::MAX) <= file_size, + "{} ASAR header extends beyond the {}-byte archive", + path.display(), + file_size + ); + let mut header_json = vec![0u8; header_json_size]; + file.read_exact(&mut header_json) + .with_context(|| format!("Failed to read ASAR JSON header from {}", path.display()))?; + let header: Value = serde_json::from_slice(&header_json) + .with_context(|| format!("Failed to parse ASAR JSON header from {}", path.display()))?; + let mut violations = Vec::new(); + collect_asar_policy_violations(&header, expected_arch, &mut violations)?; + Ok(violations) +} + +fn assert_windows_package_file_policy(root: &Path, expected_arch: &str) -> Result<()> { + let mut forbidden = Vec::new(); + for path in collect_files(root)? { + let relative = relative_display(root, &path); + if windows_package_file_policy_violation(&relative, expected_arch) { + forbidden.push(relative.clone()); + } + if path + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| { + percent_decode_archive_name(name) + .to_ascii_lowercase() + .ends_with(".asar") + }) + { + forbidden.extend( + asar_policy_violations(&path, expected_arch)? + .into_iter() + .map(|entry| format!("{relative}!/{entry}")), + ); + } + } + forbidden.sort(); + forbidden.dedup(); + ensure!( + forbidden.is_empty(), + "{} contains {} forbidden Windows package file(s):\n{}", + root.display(), + forbidden.len(), + forbidden.join("\n") + ); + Ok(()) +} + +fn windows_pe_inventory_violation(name: &str, expected_arch: &str) -> Option { + let normalized = name.to_ascii_lowercase(); + if FORBIDDEN_WINDOWS_GAME_CAPTURE_ARTIFACT_PREFIXES + .iter() + .any(|prefix| normalized.starts_with(prefix)) + { + return Some(format!( + "{name}: disabled game-capture hook sidecars must not ship" + )); + } + let packaged_arch = windows_native_pe_arch(&normalized)?; + if packaged_arch == expected_arch { + return None; + } + Some(format!( + "{name}: native architecture is {packaged_arch}, expected {expected_arch}" + )) +} + +fn windows_native_pe_arch(name: &str) -> Option<&'static str> { + for (marker, arch) in [ + (".win32-x64-msvc.", "x64"), + (".win32-arm64-msvc.", "arm64"), + (".win32-ia32-msvc.", "ia32"), + ("_win_x64_msvc.", "x64"), + ("_win_arm64_msvc.", "arm64"), + ("_win_x86_msvc.", "ia32"), + ] { + if name.contains(marker) { + return Some(arch); + } + } + None +} + fn contradictory_optional_windows_pe_inventory(arch: &str, main_exe: &str) -> Vec { let expected = expected_windows_pe_inventory(arch, main_exe); KNOWN_OPTIONAL_WINDOWS_PE_INVENTORY @@ -2778,6 +3087,8 @@ fn verify_windows_unpacked_signatures_step() -> Result<()> { let config = windows_package_config(&build_channel, &arch); let pack_dir = resolve_windows_unpacked_dir(&arch, &config.main_exe)?; let files = collect_pe_files(&pack_dir)?; + assert_windows_package_file_policy(&pack_dir, &arch)?; + assert_windows_native_pe_machines(&pack_dir, &files, &arch, &config.main_exe)?; assert_expected_windows_pe_inventory(&pack_dir, &files, &arch, &config.main_exe)?; ensure!( files.iter().any(|file| extension_is(file, "node")), @@ -2870,23 +3181,14 @@ fn verify_windows_signed_artifacts_step() -> Result<()> { .into_iter() .filter(|path| extension_is(path, "nupkg")) .collect::>(); - let delta_nupkgs = staged_nupkgs - .iter() - .filter(|path| { - path.file_name() - .and_then(OsStr::to_str) - .is_some_and(|name| name.ends_with("-delta.nupkg")) - }) - .cloned() - .collect::>(); let unclassified_nupkgs = staged_nupkgs .iter() - .filter(|path| **path != nupkg && !delta_nupkgs.contains(path)) + .filter(|path| **path != nupkg) .map(|path| path.display().to_string()) .collect::>(); ensure!( unclassified_nupkgs.is_empty(), - "{} stages {} nupkg(s) that are neither the verified full package nor a delta package, so they would be published unverified:\n{}", + "{} stages {} nupkg(s) other than the verified full package, so they would be published unverified:\n{}", config.output_dir.display(), unclassified_nupkgs.len(), unclassified_nupkgs.join("\n") @@ -2930,26 +3232,16 @@ fn verify_windows_signed_artifacts_step() -> Result<()> { nupkg.display() ); let nupkg_files = collect_pe_files(&lib_app)?; + assert_windows_package_file_policy(&lib_app, &arch)?; + assert_windows_native_pe_machines(&lib_app, &nupkg_files, &arch, &config.main_exe)?; assert_expected_windows_pe_inventory(&lib_app, &nupkg_files, &arch, &config.main_exe)?; verify_windows_pe_signatures(&signtool, "nupkg lib/app", &lib_app, &nupkg_files)?; - for (index, delta_nupkg) in delta_nupkgs.iter().enumerate() { - let delta_root = root.join(format!("d{index}")); - extract_zip_safely(delta_nupkg, &delta_root)?; - let delta_files = collect_pe_files(&delta_root)?; - let label = format!("delta nupkg {}", file_name_string(delta_nupkg)?); - if delta_files.is_empty() { - println!( - "{label}: contains no whole PE entries, only Velopack diffs; nothing to verify." - ); - continue; - } - verify_windows_pe_signatures(&signtool, &label, &delta_root, &delta_files)?; - } - let portable_root = root.join("p"); extract_zip_safely(&portable_zip, &portable_root)?; let portable_files = collect_pe_files(&portable_root)?; + assert_windows_package_file_policy(&portable_root, &arch)?; + assert_windows_native_pe_machines(&portable_root, &portable_files, &arch, &config.main_exe)?; assert_expected_windows_pe_inventory(&portable_root, &portable_files, &arch, &config.main_exe)?; verify_windows_pe_signatures(&signtool, "portable zip", &portable_root, &portable_files)?; @@ -3212,6 +3504,137 @@ fn build_payload_step() -> Result<()> { print_tree(&payload_root, 6) } +fn prepare_release_assets_step() -> Result<()> { + let channel = require_env("CHANNEL")?; + let version = require_env("VERSION")?; + let product = match channel.as_str() { + "stable" => "Fluxer", + "canary" => "Fluxer.Canary", + other => bail!("Unsupported desktop release channel {other:?}"), + }; + let expected_asset_names = DESKTOP_RELEASE_ASSET_SUFFIXES + .iter() + .map(|suffix| format!("{product}-{version}-{suffix}")) + .collect::>(); + let artifacts = Path::new("artifacts"); + let release_assets = Path::new("release_assets"); + remove_dir_if_exists(release_assets)?; + fs::create_dir_all(release_assets)?; + + let mut asset_names = BTreeSet::new(); + for (dir, identity) in payload_artifact_dirs(artifacts, &channel)? { + ensure!( + identity.desktop_variant == DEFAULT_DESKTOP_VARIANT, + "GitHub release asset naming is undefined for desktop variant {:?}", + identity.desktop_variant + ); + let platform = match identity.platform.as_str() { + "windows" => "win32", + "macos" => "darwin", + "linux" => "linux", + other => bail!("Unsupported desktop release platform {other:?}"), + }; + let candidates = manifest_candidates(&dir, platform, &identity.arch)?; + let candidate_kinds = candidates + .iter() + .map(|(kind, _)| kind.as_str()) + .collect::>(); + let expected_kinds = match platform { + "win32" => vec!["setup", "portable"], + "darwin" => vec!["dmg", "zip"], + "linux" => vec!["appimage", "deb", "rpm", "tar_gz"], + _ => unreachable!(), + }; + ensure!( + candidate_kinds == expected_kinds, + "Incomplete shipped artifact set for {}/{:?}: expected {:?}, found {:?}", + identity.platform, + identity.arch, + expected_kinds, + candidate_kinds + ); + + for (_, source) in candidates { + let source_name = file_name_string(&source)?; + let asset_name = clean_release_asset_name(&source_name)?; + ensure!( + asset_names.insert(asset_name.clone()), + "Duplicate GitHub release asset name {asset_name:?}" + ); + let destination = release_assets.join(&asset_name); + let copied = fs::copy(&source, &destination).with_context(|| { + format!( + "Failed to copy shipped artifact {} to {}", + source.display(), + destination.display() + ) + })?; + ensure!( + copied > 0, + "Copied empty GitHub release asset {}", + destination.display() + ); + } + } + ensure!( + asset_names == expected_asset_names, + "GitHub release asset inventory mismatch: expected {expected_asset_names:?}, found {asset_names:?}" + ); + println!("GitHub release asset tree:"); + print_tree(release_assets, 2) +} + +fn clean_release_asset_name(source_name: &str) -> Result { + let name = source_name + .chars() + .map(|character| { + if character.is_ascii_whitespace() { + '.' + } else { + character + } + }) + .collect::(); + ensure!( + name.bytes() + .all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_') }), + "Cannot produce a clean GitHub release asset name from {source_name:?}" + ); + Ok(name) +} + +async fn upload_release_assets_step() -> Result<()> { + let client = s3_client(None).await?; + let bucket = require_env("S3_BUCKET")?; + let prefix = require_env("DESKTOP_RELEASE_ASSETS_PREFIX")?; + let release_assets = Path::new("release_assets"); + ensure!( + release_assets.is_dir(), + "GitHub release asset directory is missing" + ); + ensure!( + count_files(release_assets)? > 0, + "GitHub release asset directory is empty" + ); + upload_directory_to_s3(&client, &bucket, &prefix, release_assets, |_| true).await +} + +async fn download_release_assets_step() -> Result<()> { + let client = s3_client(None).await?; + let bucket = require_env("S3_BUCKET")?; + let prefix = require_env("DESKTOP_RELEASE_ASSETS_PREFIX")?; + let release_assets = Path::new("release_assets"); + remove_dir_if_exists(release_assets)?; + fs::create_dir_all(release_assets)?; + download_s3_prefix(&client, &bucket, &prefix, release_assets).await?; + ensure!( + count_files(release_assets)? > 0, + "No GitHub release assets were downloaded from {prefix}" + ); + println!("Downloaded GitHub release asset tree:"); + print_tree(release_assets, 2) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct ArtifactIdentity { platform: String, diff --git a/tools/ci/src/desktop_native.rs b/tools/ci/src/desktop_native.rs index 0f44f1ba0..3290ac42e 100644 --- a/tools/ci/src/desktop_native.rs +++ b/tools/ci/src/desktop_native.rs @@ -1,8 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::common::{ - CommandSpec, command_succeeds, output_text, remove_file_if_exists, run_command, -}; +use crate::common::{CommandSpec, command_succeeds, output_text, run_command}; use crate::desktop::MACOS_UNIVERSAL_ARCH; use anyhow::{Context, Result, anyhow, bail, ensure}; use clap::Args; @@ -309,7 +307,9 @@ fn build_desktop_native_addon(addon_root: &Path, addon: &DesktopNativeAddon) -> DesktopNativeSpecialBuild::Webauthn => { copy_webauthn_linux_shared_libraries(&built.out_file, addon_root)? } - DesktopNativeSpecialBuild::WinGameCapture => build_win_game_capture_artifacts(addon_root)?, + DesktopNativeSpecialBuild::WinGameCapture => { + remove_stale_win_game_capture_outputs(addon_root, &built.out_file)? + } } Ok(()) } @@ -365,6 +365,9 @@ fn build_rust_node_addon_for_arch( OsString::from("--manifest-path"), OsString::from("Cargo.toml"), ]; + if addon.special == DesktopNativeSpecialBuild::WinGameCapture { + args.push(OsString::from("--no-default-features")); + } if !addon.features.is_empty() { args.push(OsString::from("--features")); args.push(OsString::from(addon.features.join(","))); @@ -413,6 +416,7 @@ fn build_rust_node_addon_for_arch( ); sign_macos_node_addon(&out_file, platform)?; assert_no_redistributable_runtime_imports(&out_file, platform)?; + assert_no_disabled_win_game_capture_capabilities(&out_file, addon, platform)?; Ok(BuiltNodeAddon { out_file, source, @@ -668,235 +672,43 @@ fn should_bundle_linux_library(library_name: &str) -> bool { .any(|prefix| library_name.starts_with(prefix)) } -#[derive(Debug, Clone, Copy)] -struct HookLayerArch { - arch: &'static str, - target: &'static str, - tag: &'static str, -} - -const WIN_GAME_CAPTURE_ARCHES: &[HookLayerArch] = &[ - HookLayerArch { - arch: "x64", - target: "x86_64-pc-windows-msvc", - tag: "win32-x64-msvc", - }, - HookLayerArch { - arch: "ia32", - target: "i686-pc-windows-msvc", - tag: "win32-ia32-msvc", - }, - HookLayerArch { - arch: "arm64", - target: "aarch64-pc-windows-msvc", - tag: "win32-arm64-msvc", - }, -]; - -fn build_win_game_capture_artifacts(root: &Path) -> Result<()> { - let primary_arch = electron_arch(); - for arch in WIN_GAME_CAPTURE_ARCHES { - let required_for_primary_runtime = - arch.arch == primary_arch || (primary_arch == "x64" && arch.arch == "ia32"); - build_win_game_capture_hook(root, arch, required_for_primary_runtime)?; - build_win_game_capture_vulkan_layer(root, arch, arch.arch == primary_arch)?; - build_win_game_capture_inject_helper(root, arch, required_for_primary_runtime)?; +fn remove_stale_win_game_capture_outputs(root: &Path, primary_node: &Path) -> Result<()> { + let primary_node_name = primary_node + .file_name() + .and_then(OsStr::to_str) + .ok_or_else(|| { + anyhow!( + "Invalid win-game-capture output path: {}", + primary_node.display() + ) + })?; + let mut stale = fs::read_dir(root) + .with_context(|| format!("Failed to read {}", root.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::, _>>()?; + stale.retain(|path| { + if !path.is_file() { + return false; + } + let Some(file_name) = path.file_name().and_then(OsStr::to_str) else { + return false; + }; + file_name.starts_with("fluxer-game-hook.") + || file_name.starts_with("fluxer-inject-helper.") + || file_name.starts_with("fluxer-vulkan-layer.") + || (file_name.starts_with("win-game-capture.") + && file_name.ends_with(".node") + && file_name != primary_node_name) + }); + stale.sort(); + for path in stale { + fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?; + println!("[win-game-capture] removed stale output {}", path.display()); } Ok(()) } -fn build_win_game_capture_hook(root: &Path, arch: &HookLayerArch, required: bool) -> Result<()> { - build_win_game_capture_extra( - "game-capture hook", - root, - &root.join("hook"), - "fluxer_game_hook", - &format!("fluxer-game-hook.{}.dll", arch.tag), - arch, - required, - ) -} - -fn build_win_game_capture_vulkan_layer( - root: &Path, - arch: &HookLayerArch, - required: bool, -) -> Result<()> { - let layer_dll_name = format!("fluxer-vulkan-layer.{}.dll", arch.tag); - build_win_game_capture_extra( - "Vulkan game-capture layer", - root, - &root.join("vulkan-layer"), - "fluxer_vulkan_layer", - &layer_dll_name, - arch, - 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()); - Ok(()) -} - -fn build_win_game_capture_inject_helper( - root: &Path, - arch: &HookLayerArch, - required: bool, -) -> Result<()> { - let helper_root = root.join("inject-helper"); - ensure_rust_target_or_skip(arch, "inject-helper", required)?; - if !try_cargo_build("inject-helper", &helper_root, arch.target) { - if required { - bail!( - "[win-game-capture] required {} inject-helper build failed", - arch.arch - ); - } - return Ok(()); - } - let helper_source = helper_root - .join("target") - .join(arch.target) - .join("release") - .join("fluxer-inject-helper.exe"); - let helper_out = root.join(format!("fluxer-inject-helper.{}.exe", arch.tag)); - copy_optional_win_game_capture_artifact( - &helper_source, - &helper_out, - &format!("{} inject-helper", arch.arch), - required, - ) -} - -fn build_win_game_capture_extra( - label: &str, - root: &Path, - cargo_root: &Path, - crate_name: &str, - output_name: &str, - arch: &HookLayerArch, - required: bool, -) -> Result<()> { - ensure_rust_target_or_skip(arch, label, required)?; - if !try_cargo_build(label, cargo_root, arch.target) { - if required { - bail!( - "[win-game-capture] required {} {} build failed", - arch.arch, - label - ); - } - return Ok(()); - } - let source = cargo_root - .join("target") - .join(arch.target) - .join("release") - .join(cargo_dynamic_library_file_name(crate_name, "win32")?); - let output = root.join(output_name); - copy_optional_win_game_capture_artifact( - &source, - &output, - &format!("{} {label}", arch.arch), - required, - ) -} - -fn ensure_rust_target_or_skip(arch: &HookLayerArch, label: &str, required: bool) -> Result<()> { - if rust_target_installed(arch.target) { - return Ok(()); - } - let message = format!( - "[win-game-capture] {} {} {}: rust target {} not installed", - if required { - "missing required" - } else { - "skipping" - }, - arch.arch, - label, - arch.target - ); - if required { - bail!(message); - } - eprintln!("{message}"); - Ok(()) -} - -fn rust_target_installed(target: &str) -> bool { - let rustup = env::var_os("RUSTUP").unwrap_or_else(|| OsString::from("rustup")); - match output_text(CommandSpec::new(rustup).args(["target", "list", "--installed"])) { - Ok(output) => output.lines().any(|line| line.trim() == target), - Err(error) => { - eprintln!( - "[win-game-capture] could not query rustup for target {target}; assuming present: {error:#}" - ); - true - } - } -} - -fn try_cargo_build(label: &str, cwd: &Path, target: &str) -> bool { - println!("[win-game-capture] building {label} target={target}"); - run_command( - CommandSpec::new(resolve_cargo_bin()) - .args([ - OsString::from("build"), - OsString::from("--release"), - OsString::from("--target"), - OsString::from(target), - OsString::from("--manifest-path"), - cwd.join("Cargo.toml").into_os_string(), - ]) - .current_dir(cwd), - ) - .map(|_| true) - .unwrap_or_else(|error| { - eprintln!( - "[win-game-capture] skipping {label} for {target}: cargo build failed: {error:#}" - ); - false - }) -} - -fn copy_optional_win_game_capture_artifact( - source: &Path, - output: &Path, - label: &str, - required: bool, -) -> Result<()> { - if !source.exists() { - let message = format!( - "[win-game-capture] expected {} after {label} build", - source.display() - ); - if required { - bail!(message); - } - eprintln!("{message}; skipping copy"); - return Ok(()); - } - fs::copy(source, output).with_context(|| { - format!( - "Failed to copy {} to {}", - source.display(), - output.display() - ) - })?; - println!("[win-game-capture] emitted {}", output.display()); - Ok(()) -} - +#[cfg(test)] fn vulkan_layer_manifest(layer_dll_name: &str) -> String { format!( "{{\n\ @@ -935,6 +747,40 @@ fn assert_no_redistributable_runtime_imports(node_file_path: &Path, platform: &s ) } +const DISABLED_WIN_GAME_CAPTURE_BINARY_MARKERS: &[&[u8]] = &[ + b"CreateRemoteThread", + b"VirtualAllocEx", + b"VirtualFreeEx", + b"WriteProcessMemory", + b"SetWindowsHookExW", + b"fluxer-inject-helper.", +]; + +fn assert_no_disabled_win_game_capture_capabilities( + node_file_path: &Path, + addon: &DesktopNativeAddon, + platform: &str, +) -> Result<()> { + if platform != "win32" || addon.special != DesktopNativeSpecialBuild::WinGameCapture { + return Ok(()); + } + let binary = fs::read(node_file_path) + .with_context(|| format!("Failed to read {}", node_file_path.display()))?; + let present = DISABLED_WIN_GAME_CAPTURE_BINARY_MARKERS + .iter() + .copied() + .filter(|marker| binary.windows(marker.len()).any(|window| window == *marker)) + .map(|marker| String::from_utf8_lossy(marker).into_owned()) + .collect::>(); + ensure!( + present.is_empty(), + "{} contains disabled game-capture injection capabilities:\n{}", + node_file_path.display(), + present.join("\n") + ); + Ok(()) +} + fn find_redistributable_runtime_imports(file_path: &Path) -> Vec { read_pe_imports(file_path) .into_iter() diff --git a/tools/ci/src/release.rs b/tools/ci/src/release.rs index 690d5a0d2..0260d81f3 100644 --- a/tools/ci/src/release.rs +++ b/tools/ci/src/release.rs @@ -5,123 +5,29 @@ use anyhow::{Context, Result, bail, ensure}; use chrono::{DateTime, Utc}; use clap::{Args, Subcommand}; use serde::Deserialize; -use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Path, PathBuf}; const RELEASE_REPOSITORY: &str = "fluxerapp/fluxer"; const RELEASE_COMPARE_URL: &str = "https://github.com/fluxerapp/fluxer/compare"; -const DESKTOP_DOWNLOAD_URL: &str = "https://api.fluxer.app/dl/desktop"; - -struct DesktopDownload { - arch: &'static str, - label: &'static str, - format: &'static str, -} - -struct DesktopPlatform { - name: &'static str, - slug: &'static str, - downloads: &'static [DesktopDownload], -} - -const DESKTOP_PLATFORMS: &[DesktopPlatform] = &[ - DesktopPlatform { - name: "Windows", - slug: "win32", - downloads: &[ - DesktopDownload { - arch: "x64", - label: "Setup.exe", - format: "setup", - }, - DesktopDownload { - arch: "x64", - label: "Portable ZIP", - format: "portable", - }, - DesktopDownload { - arch: "arm64", - label: "Setup.exe", - format: "setup", - }, - DesktopDownload { - arch: "arm64", - label: "Portable ZIP", - format: "portable", - }, - ], - }, - DesktopPlatform { - name: "macOS", - slug: "darwin", - downloads: &[ - DesktopDownload { - arch: "x64", - label: "DMG", - format: "dmg", - }, - DesktopDownload { - arch: "x64", - label: "ZIP", - format: "zip", - }, - DesktopDownload { - arch: "arm64", - label: "DMG", - format: "dmg", - }, - DesktopDownload { - arch: "arm64", - label: "ZIP", - format: "zip", - }, - ], - }, - DesktopPlatform { - name: "Linux", - slug: "linux", - downloads: &[ - DesktopDownload { - arch: "x64", - label: "AppImage", - format: "appimage", - }, - DesktopDownload { - arch: "x64", - label: "DEB", - format: "deb", - }, - DesktopDownload { - arch: "x64", - label: "RPM", - format: "rpm", - }, - DesktopDownload { - arch: "x64", - label: "tar.gz", - format: "tar_gz", - }, - DesktopDownload { - arch: "arm64", - label: "AppImage", - format: "appimage", - }, - DesktopDownload { - arch: "arm64", - label: "DEB", - format: "deb", - }, - DesktopDownload { - arch: "arm64", - label: "RPM", - format: "rpm", - }, - DesktopDownload { - arch: "arm64", - label: "tar.gz", - format: "tar_gz", - }, - ], - }, +pub(crate) const DESKTOP_RELEASE_ASSET_SUFFIXES: &[&str] = &[ + "linux-aarch64.rpm", + "linux-amd64.deb", + "linux-arm64.AppImage", + "linux-arm64.deb", + "linux-arm64.tar.gz", + "linux-x64.tar.gz", + "linux-x86_64.AppImage", + "linux-x86_64.rpm", + "mac-universal.dmg", + "mac-universal.zip", + "portable-win-arm64.zip", + "portable-win-x64.zip", + "win-arm64.exe", + "win-x64.exe", ]; #[derive(Debug, Args, Clone)] @@ -148,6 +54,8 @@ struct PublishArgs { previous_sha: Option, #[arg(long)] prerelease: bool, + #[arg(long)] + asset_dir: Option, } #[derive(Debug, Deserialize)] @@ -175,7 +83,24 @@ struct ReleaseDetail { body: Option, draft: bool, prerelease: bool, - assets: Vec, + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct PublishedReleaseAsset { + name: String, + label: Option, + size: u64, + digest: Option, + state: String, +} + +#[derive(Debug)] +struct LocalReleaseAsset { + path: PathBuf, + name: String, + size: u64, + digest: String, } #[derive(Debug, Deserialize)] @@ -207,12 +132,7 @@ fn publish(args: PublishArgs) -> Result<()> { let summaries = release_summaries()?; let qualified = qualified_releases(&summaries, &args.component)?; let existing_release = qualified.iter().find(|release| release.tag == tag); - if let Some(existing) = summaries.iter().find(|release| release.tag_name == tag) { - ensure!( - !existing.is_draft && existing.published_at.is_some(), - "Release {tag} exists but is not a published, non-draft component release" - ); - } + let existing_summary = summaries.iter().find(|release| release.tag_name == tag); if existing_release.is_none() && let Some(newer) = qualified @@ -271,35 +191,71 @@ fn publish(args: PublishArgs) -> Result<()> { } }; - let body = release_body( + let body = release_body(&previous_sha, &source_sha); + let assets = local_release_assets( &args.component, &args.build_version, - &previous_sha, - &source_sha, - ); - if summaries.iter().any(|release| release.tag_name == tag) { - verify_existing_release(&tag, &title, &body, &source_sha, args.prerelease)?; + args.asset_dir.as_deref(), + )?; + if existing_summary.is_some_and(|release| !release.is_draft) { + ensure!( + existing_summary + .and_then(|release| release.published_at.as_ref()) + .is_some(), + "Published release {tag} is missing its publication timestamp" + ); + verify_release( + &tag, + &title, + &body, + &source_sha, + args.prerelease, + false, + &assets, + )?; println!("Release {tag} already exists with the expected state."); return Ok(()); } - ensure!( - !tag_exists(&tag)?, - "Refusing to publish {tag}: the tag already exists without a matching GitHub Release" - ); - - let mut command = CommandSpec::new("gh") - .args(["release", "create", &tag]) - .args(["--repo", RELEASE_REPOSITORY]) - .args(["--title", &title]) - .args(["--notes", &body]) - .args(["--target", &source_sha]) - .args(["--latest=false"]); - if args.prerelease { - command = command.arg("--prerelease"); + if let Some(existing) = existing_summary { + ensure!( + existing.published_at.is_none(), + "Draft release {tag} unexpectedly has a publication timestamp" + ); + } else { + ensure!( + !tag_exists(&tag)?, + "Refusing to publish {tag}: the tag already exists without a matching GitHub Release" + ); + create_draft_release(&tag, &title, &body, &source_sha, args.prerelease)?; } - run_command(command)?; - verify_existing_release(&tag, &title, &body, &source_sha, args.prerelease) + upload_draft_release_assets(&tag, &title, &body, &source_sha, args.prerelease, &assets)?; + verify_release( + &tag, + &title, + &body, + &source_sha, + args.prerelease, + true, + &assets, + )?; + run_command( + CommandSpec::new("gh") + .args(["release", "edit", &tag]) + .args(["--repo", RELEASE_REPOSITORY]) + .arg("--draft=false") + .arg(format!("--prerelease={}", args.prerelease)) + .arg("--latest=false"), + )?; + verify_release( + &tag, + &title, + &body, + &source_sha, + args.prerelease, + false, + &assets, + ) } fn validate_component(component: &str) -> Result<()> { @@ -425,20 +381,226 @@ fn tag_exists(tag: &str) -> Result { Ok(refs.iter().any(|git_ref| git_ref.name == expected)) } -fn verify_existing_release( +fn local_release_assets( + component: &str, + version: &str, + asset_dir: Option<&Path>, +) -> Result> { + let Some(channel) = desktop_channel(component) else { + ensure!( + asset_dir.is_none(), + "Release assets are supported only for desktop components" + ); + return Ok(Vec::new()); + }; + let asset_dir = asset_dir.context("Desktop releases require --asset-dir")?; + ensure!( + asset_dir.is_dir(), + "Desktop release asset directory does not exist: {}", + asset_dir.display() + ); + let product = match channel { + "stable" => "Fluxer", + "canary" => "Fluxer.Canary", + other => bail!("Unsupported desktop release channel {other:?}"), + }; + let prefix = format!("{product}-{version}-"); + let mut entries = fs::read_dir(asset_dir) + .with_context(|| { + format!( + "Failed to read desktop release assets in {}", + asset_dir.display() + ) + })? + .collect::, _>>()?; + entries.sort_by_key(std::fs::DirEntry::file_name); + ensure!( + !entries.is_empty(), + "Desktop release asset directory is empty: {}", + asset_dir.display() + ); + + let mut assets = Vec::with_capacity(entries.len()); + for entry in entries { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path) + .with_context(|| format!("Failed to inspect release asset {}", path.display()))?; + ensure!( + metadata.file_type().is_file(), + "Release asset must be a regular file: {}", + path.display() + ); + ensure!( + metadata.len() > 0, + "Release asset is empty: {}", + path.display() + ); + let name = entry + .file_name() + .into_string() + .map_err(|name| anyhow::anyhow!("Release asset name is not valid UTF-8: {name:?}"))?; + ensure!( + name.bytes() + .all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_') }), + "Release asset name is not clean and URL-safe: {name:?}" + ); + let suffix = name + .strip_prefix(&prefix) + .with_context(|| format!("Release asset {name:?} must start with {prefix:?}"))?; + ensure!( + DESKTOP_RELEASE_ASSET_SUFFIXES.contains(&suffix), + "Release asset {name:?} is not a supported shipped desktop artifact" + ); + assets.push(LocalReleaseAsset { + digest: sha256_file(&path)?, + path, + name, + size: metadata.len(), + }); + } + let expected_names = DESKTOP_RELEASE_ASSET_SUFFIXES + .iter() + .map(|suffix| format!("{prefix}{suffix}")) + .collect::>(); + let actual_names = assets + .iter() + .map(|asset| asset.name.clone()) + .collect::>(); + ensure!( + actual_names == expected_names, + "Desktop release asset inventory mismatch: expected {expected_names:?}, found {actual_names:?}" + ); + Ok(assets) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path) + .with_context(|| format!("Failed to open release asset {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .with_context(|| format!("Failed to read release asset {}", path.display()))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hex::encode(hasher.finalize())) +} + +fn create_draft_release( tag: &str, title: &str, body: &str, source_sha: &str, prerelease: bool, ) -> Result<()> { + let mut command = CommandSpec::new("gh") + .args(["release", "create", tag]) + .args(["--repo", RELEASE_REPOSITORY]) + .args(["--title", title]) + .args(["--notes", body]) + .args(["--target", source_sha]) + .args(["--latest=false", "--draft"]); + if prerelease { + command = command.arg("--prerelease"); + } + run_command(command) +} + +fn release_detail(tag: &str) -> Result { let output = output_text( CommandSpec::new("gh") .arg("api") .arg(format!("repos/{RELEASE_REPOSITORY}/releases/tags/{tag}")), )?; - let release: ReleaseDetail = - serde_json::from_str(&output).with_context(|| format!("Failed to parse release {tag}"))?; + serde_json::from_str(&output).with_context(|| format!("Failed to parse release {tag}")) +} + +fn upload_draft_release_assets( + tag: &str, + title: &str, + body: &str, + source_sha: &str, + prerelease: bool, + expected_assets: &[LocalReleaseAsset], +) -> Result<()> { + let release = release_detail(tag)?; + verify_release_metadata(tag, &release, title, body, source_sha, prerelease, true)?; + let expected_by_name = expected_assets + .iter() + .map(|asset| (asset.name.as_str(), asset)) + .collect::>(); + let mut published_by_name = BTreeMap::new(); + for asset in &release.assets { + ensure!( + expected_by_name.contains_key(asset.name.as_str()), + "Draft release {tag} contains unexpected asset {:?}", + asset.name + ); + ensure!( + published_by_name + .insert(asset.name.as_str(), asset) + .is_none(), + "Draft release {tag} contains duplicate asset name {:?}", + asset.name + ); + } + let pending = expected_assets + .iter() + .filter(|expected| { + published_by_name + .get(expected.name.as_str()) + .is_none_or(|published| !release_asset_matches(published, expected)) + }) + .collect::>(); + if pending.is_empty() { + return Ok(()); + } + let mut command = CommandSpec::new("gh") + .args(["release", "upload", tag]) + .args(["--repo", RELEASE_REPOSITORY]) + .arg("--clobber"); + for asset in pending { + command = command.arg(&asset.path); + } + run_command(command) +} + +fn verify_release( + tag: &str, + title: &str, + body: &str, + source_sha: &str, + prerelease: bool, + draft: bool, + expected_assets: &[LocalReleaseAsset], +) -> Result<()> { + let release = release_detail(tag)?; + verify_release_metadata(tag, &release, title, body, source_sha, prerelease, draft)?; + verify_release_assets(tag, &release.assets, expected_assets)?; + if draft { + return Ok(()); + } + let tag_sha = resolve_commit_sha(tag)?; + ensure!( + tag_sha == source_sha, + "Release tag {tag} targets {tag_sha}, expected {source_sha}" + ); + Ok(()) +} + +fn verify_release_metadata( + tag: &str, + release: &ReleaseDetail, + title: &str, + body: &str, + source_sha: &str, + prerelease: bool, + draft: bool, +) -> Result<()> { ensure!( release.tag_name == tag, "Release {tag} has a mismatched tag" @@ -451,26 +613,93 @@ fn verify_existing_release( release.body.as_deref().unwrap_or_default() == body, "Release {tag} has a mismatched body" ); - ensure!(!release.draft, "Release {tag} is unexpectedly a draft"); + ensure!( + release.draft == draft, + "Release {tag} has draft state {}, expected {draft}", + release.draft + ); ensure!( release.prerelease == prerelease, "Release {tag} has a mismatched prerelease state" ); - ensure!( - release.assets.is_empty(), - "Release {tag} has assets; asset-free publication is required" - ); - - let tag_sha = resolve_commit_sha(tag)?; - ensure!( - tag_sha == source_sha, - "Release tag {tag} targets {tag_sha}, expected {source_sha}" - ); let target_sha = resolve_commit_sha(&release.target_commitish)?; ensure!( target_sha == source_sha, "Release {tag} target resolves to {target_sha}, expected {source_sha}" ); + if draft && tag_exists(tag)? { + let tag_sha = resolve_commit_sha(tag)?; + ensure!( + tag_sha == source_sha, + "Draft release tag {tag} targets {tag_sha}, expected {source_sha}" + ); + } + Ok(()) +} + +fn release_asset_matches(published: &PublishedReleaseAsset, expected: &LocalReleaseAsset) -> bool { + let expected_digest = format!("sha256:{}", expected.digest); + published.label.as_deref().unwrap_or_default().is_empty() + && published.state == "uploaded" + && published.size == expected.size + && published.digest.as_deref() == Some(expected_digest.as_str()) +} + +fn verify_release_assets( + tag: &str, + published_assets: &[PublishedReleaseAsset], + expected_assets: &[LocalReleaseAsset], +) -> Result<()> { + let mut published_by_name = BTreeMap::new(); + for asset in published_assets { + ensure!( + published_by_name + .insert(asset.name.as_str(), asset) + .is_none(), + "Release {tag} contains duplicate asset name {:?}", + asset.name + ); + } + let expected_names = expected_assets + .iter() + .map(|asset| asset.name.as_str()) + .collect::>(); + let published_names = published_by_name.keys().copied().collect::>(); + ensure!( + published_names == expected_names, + "Release {tag} asset inventory mismatch: expected {expected_names:?}, published {published_names:?}" + ); + for expected in expected_assets { + let published = published_by_name + .get(expected.name.as_str()) + .with_context(|| format!("Release {tag} is missing asset {:?}", expected.name))?; + ensure!( + published.label.as_deref().unwrap_or_default().is_empty(), + "Release {tag} asset {:?} has unexpected label {:?}", + expected.name, + published.label + ); + ensure!( + published.state == "uploaded", + "Release {tag} asset {:?} is in unexpected state {:?}", + expected.name, + published.state + ); + ensure!( + published.size == expected.size, + "Release {tag} asset {:?} has size {}, expected {}", + expected.name, + published.size, + expected.size + ); + let expected_digest = format!("sha256:{}", expected.digest); + ensure!( + published.digest.as_deref() == Some(expected_digest.as_str()), + "Release {tag} asset {:?} has digest {:?}, expected {expected_digest}", + expected.name, + published.digest + ); + } Ok(()) } @@ -482,51 +711,14 @@ fn release_title(component: &str, version: &str) -> String { format!("{component} {version}") } -fn release_body(component: &str, version: &str, previous_sha: &str, source_sha: &str) -> String { - let changes = format!( +fn release_body(previous_sha: &str, source_sha: &str) -> String { + format!( "Changes: [`{}..{}`]({RELEASE_COMPARE_URL}/{previous_sha}..{source_sha})", &previous_sha[..7], &source_sha[..7] - ); - match desktop_channel(component) { - Some(channel) => format!( - "{changes}\n\n{}", - desktop_download_sections(channel, version) - ), - None => changes, - } + ) } fn desktop_channel(component: &str) -> Option<&str> { component.strip_prefix("fluxer-desktop-") } - -fn desktop_download_sections(channel: &str, version: &str) -> String { - DESKTOP_PLATFORMS - .iter() - .map(|platform| desktop_download_section(channel, version, platform)) - .collect::>() - .join("\n\n") -} - -fn desktop_download_section(channel: &str, version: &str, platform: &DesktopPlatform) -> String { - let rows = platform - .downloads - .iter() - .map(|download| { - format!( - "| {arch} | {label} | {DESKTOP_DOWNLOAD_URL}/{channel}/{slug}/{arch}/{version}/{format} |", - arch = download.arch, - label = download.label, - slug = platform.slug, - format = download.format, - ) - }) - .collect::>() - .join("\n"); - format!( - "## {name} (`{slug}`)\n\n| Arch | Format | URL |\n|---|---|---|\n{rows}", - name = platform.name, - slug = platform.slug, - ) -} diff --git a/tools/dev/src/desktop.rs b/tools/dev/src/desktop.rs index 2cd17113b..62775a072 100644 --- a/tools/dev/src/desktop.rs +++ b/tools/dev/src/desktop.rs @@ -7,13 +7,11 @@ use anyhow::{Context, Result, bail}; use std::env; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use std::thread; -use std::time::{Duration, Instant}; +use std::time::Duration; use url::Url; const CANARY_APP_NAME: &str = "Fluxer Canary"; const CANARY_BUNDLE_ID: &str = "app.fluxer.canary"; -const CANARY_RPC_PORT: u16 = 21864; const MACOS_DEV_ELECTRON_USAGE_DESCRIPTIONS: &[(&str, &str)] = &[ ( "NSMicrophoneUsageDescription", @@ -466,7 +464,6 @@ fn stop_running_canary_on_host() -> Result<()> { ], ); run_best_effort("pkill", &["-TERM", "-x", CANARY_APP_NAME]); - terminate_rpc_port_processes(CANARY_RPC_PORT)?; Ok(()) } @@ -479,60 +476,6 @@ fn run_best_effort(program: &str, args: &[&str]) { .status(); } -fn terminate_rpc_port_processes(port: u16) -> Result<()> { - let mut pids = pids_listening_on_tcp_port(port)?; - if pids.is_empty() { - return Ok(()); - } - kill_pids("-TERM", &pids)?; - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - thread::sleep(Duration::from_millis(250)); - pids = pids_listening_on_tcp_port(port)?; - if pids.is_empty() { - return Ok(()); - } - } - kill_pids("-KILL", &pids) -} - -fn pids_listening_on_tcp_port(port: u16) -> Result> { - let output = Command::new("lsof") - .args(["-ti", &format!("tcp:{port}")]) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .context("failed to run lsof while stopping Fluxer Canary")?; - if !output.status.success() { - return Ok(Vec::new()); - } - Ok(String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(ToOwned::to_owned) - .collect()) -} - -fn kill_pids(signal: &str, pids: &[String]) -> Result<()> { - if pids.is_empty() { - return Ok(()); - } - let status = Command::new("kill") - .arg(signal) - .args(pids) - .status() - .context("failed to run kill while stopping Fluxer Canary")?; - if status.success() { - Ok(()) - } else { - bail!( - "failed to stop Fluxer Canary process(es): {}", - pids.join(", ") - ) - } -} - #[cfg(test)] mod tests { use super::*;