mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(desktop): harden capture and release packaging (#2049)
This commit is contained in:
@@ -524,6 +524,7 @@ jobs:
|
|||||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
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_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_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
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
|
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||||
--step build_payload
|
--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
|
- name: Upload payload to S3
|
||||||
run: >-
|
run: >-
|
||||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||||
--step upload_payload
|
--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
|
- name: Build summary
|
||||||
run: >-
|
run: >-
|
||||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||||
@@ -577,9 +590,17 @@ jobs:
|
|||||||
- upload
|
- upload
|
||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
environment: desktop-releases
|
environment: desktop-releases
|
||||||
timeout-minutes: 10
|
timeout-minutes: 60
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
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:
|
steps:
|
||||||
- name: Checkout source
|
- name: Checkout source
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
@@ -590,6 +611,12 @@ jobs:
|
|||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
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
|
- name: Create token
|
||||||
id: create-token
|
id: create-token
|
||||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||||
@@ -614,6 +641,7 @@ jobs:
|
|||||||
--build-version "${VERSION}"
|
--build-version "${VERSION}"
|
||||||
--source-sha "${SOURCE_SHA}"
|
--source-sha "${SOURCE_SHA}"
|
||||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||||
|
--asset-dir release_assets
|
||||||
)
|
)
|
||||||
if [[ "${CHANNEL}" == "canary" ]]; then
|
if [[ "${CHANNEL}" == "canary" ]]; then
|
||||||
release_args+=(--prerelease)
|
release_args+=(--prerelease)
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
|||||||
import {Button} from '@app/features/ui/button/Button';
|
import {Button} from '@app/features/ui/button/Button';
|
||||||
import {buildAppProtocolUrl} from '@app/features/ui/utils/AppProtocol';
|
import {buildAppProtocolUrl} from '@app/features/ui/utils/AppProtocol';
|
||||||
import {isDesktop, openExternalUrl} from '@app/features/ui/utils/NativeUtils';
|
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 {msg} from '@lingui/core/macro';
|
||||||
import {Trans, useLingui} from '@lingui/react/macro';
|
import {Trans, useLingui} from '@lingui/react/macro';
|
||||||
import {ArrowSquareOutIcon} from '@phosphor-icons/react';
|
import {ArrowSquareOutIcon} from '@phosphor-icons/react';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import {useEffect, useState} from 'react';
|
import {useState} from 'react';
|
||||||
|
|
||||||
interface DesktopDeepLinkPromptProps {
|
interface DesktopDeepLinkPromptProps {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -36,25 +35,9 @@ const FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR = msg({
|
|||||||
export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({code, kind, preferLogin = false}) => {
|
export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({code, kind, preferLogin = false}) => {
|
||||||
const {i18n} = useLingui();
|
const {i18n} = useLingui();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [desktopAvailable, setDesktopAvailable] = useState<boolean | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const isMobileBrowser = Platform.isMobileBrowser;
|
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 (isDesktop() || isMobileBrowser) return null;
|
||||||
if (shouldProbeDesktopAvailability && desktopAvailable !== true) return null;
|
|
||||||
const getPath = (): string => {
|
const getPath = (): string => {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'invite':
|
case 'invite':
|
||||||
@@ -69,20 +52,12 @@ export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({cod
|
|||||||
const handleOpen = async () => {
|
const handleOpen = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
if (useProtocolLaunch) {
|
try {
|
||||||
try {
|
await openExternalUrl(buildAppProtocolUrl(path));
|
||||||
await openExternalUrl(buildAppProtocolUrl(path));
|
} catch {
|
||||||
} catch {
|
|
||||||
setError(i18n._(FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR));
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await navigateInDesktop(path);
|
|
||||||
setIsLoading(false);
|
|
||||||
if (!result.success) {
|
|
||||||
setError(i18n._(FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR));
|
setError(i18n._(FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -180,10 +180,6 @@ export function handleDeepLinkUrl(rawUrl: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleRpcNavigation(path: string): void {
|
|
||||||
RouterUtils.transitionTo(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
let listenerStarted = false;
|
let listenerStarted = false;
|
||||||
|
|
||||||
export async function startDeepLinkHandling(): Promise<void> {
|
export async function startDeepLinkHandling(): Promise<void> {
|
||||||
@@ -206,17 +202,6 @@ export async function startDeepLinkHandling(): Promise<void> {
|
|||||||
logger.error(' Failed to handle URL', url, error);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -344,7 +344,6 @@ export interface ElectronAPI {
|
|||||||
pasteFromClipboard: () => Promise<void>;
|
pasteFromClipboard: () => Promise<void>;
|
||||||
onDeepLink: (callback: (url: string) => void) => () => void;
|
onDeepLink: (callback: (url: string) => void) => () => void;
|
||||||
getInitialDeepLink: () => Promise<string | null>;
|
getInitialDeepLink: () => Promise<string | null>;
|
||||||
onRpcNavigate: (callback: (path: string) => void) => () => void;
|
|
||||||
autostartEnable: () => Promise<void>;
|
autostartEnable: () => Promise<void>;
|
||||||
autostartDisable: () => Promise<void>;
|
autostartDisable: () => Promise<void>;
|
||||||
autostartIsEnabled: () => Promise<boolean>;
|
autostartIsEnabled: () => Promise<boolean>;
|
||||||
|
|||||||
@@ -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<T = unknown> {
|
|
||||||
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<T>(port: number, endpoint: string, options?: RequestInit): Promise<RpcResponse<T> | 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<T>;
|
|
||||||
} 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<HealthResponse>(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<HealthResponse>(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<NavigateResponse>(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;
|
|
||||||
}
|
|
||||||
Vendored
-1
@@ -483,7 +483,6 @@ export interface ElectronAPI {
|
|||||||
passkeyIsSupported?(): Promise<boolean>;
|
passkeyIsSupported?(): Promise<boolean>;
|
||||||
passkeyRegister?(options: unknown, requestContext?: {pin?: string}): Promise<RegistrationResponseJSON>;
|
passkeyRegister?(options: unknown, requestContext?: {pin?: string}): Promise<RegistrationResponseJSON>;
|
||||||
passkeyAuthenticate?(options: unknown, requestContext?: {pin?: string}): Promise<AuthenticationResponseJSON>;
|
passkeyAuthenticate?(options: unknown, requestContext?: {pin?: string}): Promise<AuthenticationResponseJSON>;
|
||||||
onRpcNavigate?(callback: (path: string) => void): () => void;
|
|
||||||
getOpenH264Status?(): Promise<OpenH264Status>;
|
getOpenH264Status?(): Promise<OpenH264Status>;
|
||||||
setOpenH264Enabled?(enabled: boolean): Promise<OpenH264Status>;
|
setOpenH264Enabled?(enabled: boolean): Promise<OpenH264Status>;
|
||||||
virtmic?: VirtmicApi;
|
virtmic?: VirtmicApi;
|
||||||
|
|||||||
@@ -68,8 +68,6 @@ const CONNECT_SOURCES: &[&str] = &[
|
|||||||
"https://challenges.cloudflare.com",
|
"https://challenges.cloudflare.com",
|
||||||
"https://fluxerstatus.com",
|
"https://fluxerstatus.com",
|
||||||
"https://fluxer.media",
|
"https://fluxer.media",
|
||||||
"http://127.0.0.1:21863",
|
|
||||||
"http://127.0.0.1:21864",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const WORKER_SOURCES: &[&str] = &["https://*.fluxer.app", "blob:"];
|
const WORKER_SOURCES: &[&str] = &["https://*.fluxer.app", "blob:"];
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ if (targetNativeArch === 'universal' && targetPlatform !== 'darwin') {
|
|||||||
|
|
||||||
const targetArchs = electronArch && electronArch !== 'universal' ? [electronArch] : supportedTargetArchs;
|
const targetArchs = electronArch && electronArch !== 'universal' ? [electronArch] : supportedTargetArchs;
|
||||||
const macTargetArchs = targetNativeArch ? [targetNativeArch] : supportedTargetArchs;
|
const macTargetArchs = targetNativeArch ? [targetNativeArch] : supportedTargetArchs;
|
||||||
|
const winGameCaptureTargetArchs =
|
||||||
|
targetPlatform === 'win32' && targetNativeArch ? [targetNativeArch] : supportedTargetArchs;
|
||||||
const winTargets = [
|
const winTargets = [
|
||||||
{
|
{
|
||||||
target: 'dir',
|
target: 'dir',
|
||||||
@@ -150,11 +152,9 @@ const nativeRuntimeFilePatterns = [
|
|||||||
'node_modules/@fluxer/win-game-capture/package.json',
|
'node_modules/@fluxer/win-game-capture/package.json',
|
||||||
'node_modules/@fluxer/win-game-capture/index.js',
|
'node_modules/@fluxer/win-game-capture/index.js',
|
||||||
'node_modules/@fluxer/win-game-capture/loader-diagnostics.cjs',
|
'node_modules/@fluxer/win-game-capture/loader-diagnostics.cjs',
|
||||||
'node_modules/@fluxer/win-game-capture/*.node',
|
...winGameCaptureTargetArchs.map(
|
||||||
'node_modules/@fluxer/win-game-capture/*.dll',
|
(arch) => `node_modules/@fluxer/win-game-capture/win-game-capture.win32-${arch}-msvc.node`,
|
||||||
'node_modules/@fluxer/win-game-capture/*.exe',
|
),
|
||||||
'node_modules/@fluxer/win-game-capture/compatibility.json',
|
|
||||||
'node_modules/@fluxer/win-game-capture/fluxer-vulkan-layer.*.json',
|
|
||||||
'node_modules/@fluxer/win-clipboard/package.json',
|
'node_modules/@fluxer/win-clipboard/package.json',
|
||||||
'node_modules/@fluxer/win-clipboard/index.js',
|
'node_modules/@fluxer/win-clipboard/index.js',
|
||||||
'node_modules/@fluxer/win-clipboard/loader-diagnostics.cjs',
|
'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/*/loader-diagnostics.cjs',
|
||||||
'node_modules/.pnpm/@fluxer+*/node_modules/@fluxer/*/pure.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-process-loopback@*/node_modules/@fluxer/win-process-loopback/*.node',
|
||||||
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.node',
|
...winGameCaptureTargetArchs.map(
|
||||||
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.dll',
|
(arch) =>
|
||||||
'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/win-game-capture.win32-${arch}-msvc.node`,
|
||||||
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/compatibility.json',
|
),
|
||||||
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/fluxer-vulkan-layer.*.json',
|
|
||||||
'node_modules/.pnpm/@fluxer+win-clipboard@*/node_modules/@fluxer/win-clipboard/*.node',
|
'node_modules/.pnpm/@fluxer+win-clipboard@*/node_modules/@fluxer/win-clipboard/*.node',
|
||||||
'node_modules/.pnpm/@fluxer+win-shell@*/node_modules/@fluxer/win-shell/*.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',
|
'node_modules/.pnpm/@fluxer+win-toast@*/node_modules/@fluxer/win-toast/*.node',
|
||||||
@@ -352,6 +351,21 @@ function pnpmStoreDirName(packageName) {
|
|||||||
return packageName.replace('/', '+');
|
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) {
|
function platformNativeExcludes(platform, arch) {
|
||||||
const keepFluxerPackages = new Set(fluxerNativePackagesByPlatform[platform] ?? []);
|
const keepFluxerPackages = new Set(fluxerNativePackagesByPlatform[platform] ?? []);
|
||||||
const fluxerPackageExcludes = fluxerNativePackages
|
const fluxerPackageExcludes = fluxerNativePackages
|
||||||
@@ -371,6 +385,7 @@ function platformNativeExcludes(platform, arch) {
|
|||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
...fluxerPackageExcludes,
|
...fluxerPackageExcludes,
|
||||||
|
...windowsGameCaptureArtifactExcludes(arch),
|
||||||
...velopackNativeFiles
|
...velopackNativeFiles
|
||||||
.filter((fileName) => fileName !== keepVelopackNativeFile)
|
.filter((fileName) => fileName !== keepVelopackNativeFile)
|
||||||
.map((fileName) => `!node_modules/velopack/lib/native/${fileName}`),
|
.map((fileName) => `!node_modules/velopack/lib/native/${fileName}`),
|
||||||
@@ -403,22 +418,11 @@ function platformTag(platform, arch) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addWindowsGameCaptureArtifacts(artifacts, tag, arch) {
|
function addWindowsGameCaptureArtifacts(artifacts, tag) {
|
||||||
const add = (relativePath) => {
|
artifacts.push({
|
||||||
artifacts.push({
|
packageName: '@fluxer/win-game-capture',
|
||||||
packageName: '@fluxer/win-game-capture',
|
relativePath: `win-game-capture.${tag}.node`,
|
||||||
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 expectedNativeRuntimeArtifacts(platform, arch) {
|
function expectedNativeRuntimeArtifacts(platform, arch) {
|
||||||
@@ -477,7 +481,7 @@ function expectedNativeRuntimeArtifactsForArch(platform, arch) {
|
|||||||
packageName: '@fluxer/win-process-loopback',
|
packageName: '@fluxer/win-process-loopback',
|
||||||
relativePath: `win-process-loopback.${tag}.node`,
|
relativePath: `win-process-loopback.${tag}.node`,
|
||||||
});
|
});
|
||||||
addWindowsGameCaptureArtifacts(artifacts, tag, arch);
|
addWindowsGameCaptureArtifacts(artifacts, tag);
|
||||||
artifacts.push({
|
artifacts.push({
|
||||||
packageName: '@fluxer/win-clipboard',
|
packageName: '@fluxer/win-clipboard',
|
||||||
relativePath: `win-clipboard.${tag}.node`,
|
relativePath: `win-clipboard.${tag}.node`,
|
||||||
@@ -1305,10 +1309,9 @@ module.exports = {
|
|||||||
asarUnpack: [
|
asarUnpack: [
|
||||||
'**/*.node',
|
'**/*.node',
|
||||||
'node_modules/@fluxer/win-process-loopback/*.node',
|
'node_modules/@fluxer/win-process-loopback/*.node',
|
||||||
'node_modules/@fluxer/win-game-capture/*.node',
|
...winGameCaptureTargetArchs.map(
|
||||||
'node_modules/@fluxer/win-game-capture/*.dll',
|
(arch) => `node_modules/@fluxer/win-game-capture/win-game-capture.win32-${arch}-msvc.node`,
|
||||||
'node_modules/@fluxer/win-game-capture/*.exe',
|
),
|
||||||
'node_modules/@fluxer/win-game-capture/*.json',
|
|
||||||
'node_modules/@fluxer/win-clipboard/*.node',
|
'node_modules/@fluxer/win-clipboard/*.node',
|
||||||
'node_modules/@fluxer/win-shell/*.node',
|
'node_modules/@fluxer/win-shell/*.node',
|
||||||
'node_modules/@fluxer/win-toast/*.node',
|
'node_modules/@fluxer/win-toast/*.node',
|
||||||
@@ -1331,10 +1334,10 @@ module.exports = {
|
|||||||
'node_modules/@fluxer/webauthn/*.node',
|
'node_modules/@fluxer/webauthn/*.node',
|
||||||
'node_modules/@fluxer/webauthn/*.so*',
|
'node_modules/@fluxer/webauthn/*.so*',
|
||||||
'node_modules/.pnpm/@fluxer+win-process-loopback@*/node_modules/@fluxer/win-process-loopback/*.node',
|
'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',
|
...winGameCaptureTargetArchs.map(
|
||||||
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.dll',
|
(arch) =>
|
||||||
'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/win-game-capture.win32-${arch}-msvc.node`,
|
||||||
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.json',
|
),
|
||||||
'node_modules/.pnpm/@fluxer+win-clipboard@*/node_modules/@fluxer/win-clipboard/*.node',
|
'node_modules/.pnpm/@fluxer+win-clipboard@*/node_modules/@fluxer/win-clipboard/*.node',
|
||||||
'node_modules/.pnpm/@fluxer+win-shell@*/node_modules/@fluxer/win-shell/*.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',
|
'node_modules/.pnpm/@fluxer+win-toast@*/node_modules/@fluxer/win-toast/*.node',
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ publish = false
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
game-capture-hook = []
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
|||||||
@@ -18,19 +18,7 @@
|
|||||||
"index.d.ts",
|
"index.d.ts",
|
||||||
"loader-diagnostics.cjs",
|
"loader-diagnostics.cjs",
|
||||||
"win-game-capture.win32-x64-msvc.node",
|
"win-game-capture.win32-x64-msvc.node",
|
||||||
"win-game-capture.win32-arm64-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"
|
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon",
|
"build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon",
|
||||||
|
|||||||
@@ -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<HWND> {
|
||||||
|
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::<isize>().ok()?
|
||||||
|
};
|
||||||
|
let hwnd = value as HWND;
|
||||||
|
(unsafe { IsWindow(hwnd) } != 0).then_some(hwnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_screen_ordinal(source_id: &str) -> Option<usize> {
|
||||||
|
let token = source_id.strip_prefix("screen:")?.split(':').next()?;
|
||||||
|
token.parse::<usize>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_game_capture_target(
|
||||||
|
source_id: &str,
|
||||||
|
source_kind: &str,
|
||||||
|
) -> Result<HWND, String> {
|
||||||
|
if let Some(hwnd) = parse_hwnd_source_id(source_id) {
|
||||||
|
return Ok(hwnd);
|
||||||
|
}
|
||||||
|
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<HMONITOR, String> {
|
||||||
|
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<HMONITOR> {
|
||||||
|
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<HMONITOR>);
|
||||||
|
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<RECT> {
|
||||||
|
let mut info = MONITORINFO {
|
||||||
|
cbSize: std::mem::size_of::<MONITORINFO>() 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<HWND> {
|
||||||
|
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<HWND> {
|
||||||
|
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<HWND> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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::<IDXGIDevice>() {
|
||||||
|
let _ = unsafe { dxgi_device.SetGPUThreadPriority(7) };
|
||||||
|
}
|
||||||
|
Ok((device, context))
|
||||||
|
}
|
||||||
@@ -394,6 +394,7 @@ pub(crate) fn resolve_output_size(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
#[cfg(feature = "game-capture-hook")]
|
||||||
pub(crate) fn wall_clock_us() -> i64 {
|
pub(crate) fn wall_clock_us() -> i64 {
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ use crate::game_capture_abi::{ENV_FORCE_CPU, ENV_VERBOSE, env_flag_enabled};
|
|||||||
use crate::nv12_gpu::Nv12GpuConverter;
|
use crate::nv12_gpu::Nv12GpuConverter;
|
||||||
use crate::{
|
use crate::{
|
||||||
CaptureInner,
|
CaptureInner,
|
||||||
|
capture_target::{monitor_rect, resolve_game_capture_target},
|
||||||
compatibility::{InjectionPolicy, injection_policy_for_window},
|
compatibility::{InjectionPolicy, injection_policy_for_window},
|
||||||
|
d3d11_device::create_shared_texture_device,
|
||||||
dxgi_capture::{resolve_output_size, wall_clock_us},
|
dxgi_capture::{resolve_output_size, wall_clock_us},
|
||||||
emit_lifecycle, emit_shared_texture_frame,
|
emit_lifecycle, emit_shared_texture_frame,
|
||||||
game_capture_abi::{
|
game_capture_abi::{
|
||||||
@@ -26,26 +28,18 @@ use std::{
|
|||||||
sync::{Arc, Mutex, atomic::Ordering},
|
sync::{Arc, Mutex, atomic::Ordering},
|
||||||
};
|
};
|
||||||
use windows::Win32::{
|
use windows::Win32::{
|
||||||
Foundation::{HANDLE as WinHandle, HMODULE},
|
Foundation::HANDLE as WinHandle,
|
||||||
Graphics::{
|
Graphics::{
|
||||||
Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN},
|
Direct3D11::{D3D11_TEXTURE2D_DESC, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D},
|
||||||
Direct3D11::{
|
Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1},
|
||||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC,
|
|
||||||
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D,
|
|
||||||
},
|
|
||||||
Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIDevice, IDXGIFactory1},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use windows::core::Interface;
|
|
||||||
use windows_sys::Win32::{
|
use windows_sys::Win32::{
|
||||||
Foundation::{
|
Foundation::{
|
||||||
CloseHandle, FreeLibrary, HANDLE, HINSTANCE, HMODULE as WinSysHmodule, HWND,
|
CloseHandle, FreeLibrary, HANDLE, HINSTANCE, HMODULE as WinSysHmodule, HWND,
|
||||||
INVALID_HANDLE_VALUE, LPARAM, RECT, WAIT_ABANDONED, WAIT_OBJECT_0, WAIT_TIMEOUT,
|
INVALID_HANDLE_VALUE, LPARAM, RECT, WAIT_ABANDONED, WAIT_OBJECT_0, WAIT_TIMEOUT,
|
||||||
},
|
},
|
||||||
Graphics::Gdi::{
|
Graphics::Gdi::{MONITOR_DEFAULTTONEAREST, MonitorFromRect},
|
||||||
EnumDisplayMonitors, GetMonitorInfoW, HMONITOR, MONITOR_DEFAULTTONEAREST, MONITORINFO,
|
|
||||||
MonitorFromRect,
|
|
||||||
},
|
|
||||||
System::{
|
System::{
|
||||||
Diagnostics::Debug::WriteProcessMemory,
|
Diagnostics::Debug::WriteProcessMemory,
|
||||||
LibraryLoader::{GetModuleHandleW, GetProcAddress, LoadLibraryW},
|
LibraryLoader::{GetModuleHandleW, GetProcAddress, LoadLibraryW},
|
||||||
@@ -67,10 +61,8 @@ use windows_sys::Win32::{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
UI::WindowsAndMessaging::{
|
UI::WindowsAndMessaging::{
|
||||||
EnumWindows, GW_OWNER, GWL_STYLE, GetClientRect, GetForegroundWindow, GetWindow,
|
GetClientRect, GetWindowRect, GetWindowThreadProcessId, HHOOK, IsWindow, IsWindowVisible,
|
||||||
GetWindowLongPtrW, GetWindowRect, GetWindowThreadProcessId, HHOOK, IsWindow,
|
PostThreadMessageW, SetWindowsHookExW, UnhookWindowsHookEx, WH_GETMESSAGE, WM_NULL,
|
||||||
IsWindowVisible, PostThreadMessageW, SetWindowsHookExW, UnhookWindowsHookEx, WH_GETMESSAGE,
|
|
||||||
WM_NULL, WS_BORDER, WS_MAXIMIZE,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -812,87 +804,6 @@ fn inject_via_set_windows_hook(
|
|||||||
Ok(InstalledWindowsHook { hook, module })
|
Ok(InstalledWindowsHook { hook, module })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_hwnd_source_id(source_id: &str) -> Option<HWND> {
|
|
||||||
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::<isize>().ok()?
|
|
||||||
};
|
|
||||||
let hwnd = value as HWND;
|
|
||||||
if unsafe { IsWindow(hwnd) } != 0 {
|
|
||||||
Some(hwnd)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_screen_ordinal(source_id: &str) -> Option<usize> {
|
|
||||||
let token = source_id.strip_prefix("screen:")?.split(':').next()?;
|
|
||||||
token.parse::<usize>().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resolve_game_capture_target(
|
|
||||||
source_id: &str,
|
|
||||||
source_kind: &str,
|
|
||||||
) -> Result<HWND, String> {
|
|
||||||
if let Some(hwnd) = parse_hwnd_source_id(source_id) {
|
|
||||||
return Ok(hwnd);
|
|
||||||
}
|
|
||||||
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<HMONITOR, String> {
|
|
||||||
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<HMONITOR> {
|
|
||||||
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<HMONITOR>);
|
|
||||||
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<u32, String> {
|
fn target_process_id(hwnd: HWND) -> Result<u32, String> {
|
||||||
let mut pid = 0u32;
|
let mut pid = 0u32;
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1039,155 +950,6 @@ fn choose_shared_buffer_dimensions(
|
|||||||
Some((buffer_width, buffer_height))
|
Some((buffer_width, buffer_height))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn monitor_rect(monitor: HMONITOR) -> Option<RECT> {
|
|
||||||
let mut info = MONITORINFO {
|
|
||||||
cbSize: std::mem::size_of::<MONITORINFO>() 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<HWND> {
|
|
||||||
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<HWND> {
|
|
||||||
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<HWND> {
|
|
||||||
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::<IDXGIDevice>() {
|
|
||||||
let _ = unsafe { dxgi_device.SetGPUThreadPriority(7) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SHARED_TEXTURE_ADAPTER_LIMIT: u32 = 16;
|
const SHARED_TEXTURE_ADAPTER_LIMIT: u32 = 16;
|
||||||
|
|
||||||
fn shared_texture_adapter_candidates() -> Vec<Option<IDXGIAdapter>> {
|
fn shared_texture_adapter_candidates() -> Vec<Option<IDXGIAdapter>> {
|
||||||
|
|||||||
@@ -3,13 +3,17 @@
|
|||||||
#![deny(clippy::all)]
|
#![deny(clippy::all)]
|
||||||
#![allow(unsafe_op_in_unsafe_fn)]
|
#![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;
|
mod compatibility;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod d3d11_device;
|
||||||
#[cfg(any(target_os = "windows", test))]
|
#[cfg(any(target_os = "windows", test))]
|
||||||
mod dxgi_capture;
|
mod dxgi_capture;
|
||||||
pub mod encoder_attach;
|
pub mod encoder_attach;
|
||||||
mod fallback;
|
mod fallback;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
|
||||||
mod game_capture;
|
mod game_capture;
|
||||||
mod game_capture_abi;
|
mod game_capture_abi;
|
||||||
mod gpu_priority;
|
mod gpu_priority;
|
||||||
@@ -19,7 +23,7 @@ mod nv12_gpu;
|
|||||||
mod sources;
|
mod sources;
|
||||||
#[cfg(any(target_os = "windows", test))]
|
#[cfg(any(target_os = "windows", test))]
|
||||||
mod stall;
|
mod stall;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
|
||||||
mod vulkan_layer_registry;
|
mod vulkan_layer_registry;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
mod wgc_capture;
|
mod wgc_capture;
|
||||||
@@ -39,7 +43,7 @@ use dxgi_capture::DxgiCaptureSession;
|
|||||||
use fluxer_encoder_ring::EncoderFrameRate;
|
use fluxer_encoder_ring::EncoderFrameRate;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
use fluxer_screen_frame_bus::EnqueueOutcome;
|
use fluxer_screen_frame_bus::EnqueueOutcome;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
|
||||||
use game_capture::GameCaptureSession;
|
use game_capture::GameCaptureSession;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
@@ -48,7 +52,6 @@ use wgc_capture::WgcCaptureSession;
|
|||||||
|
|
||||||
const LIFECYCLE_QUEUE_LIMIT: usize = 8;
|
const LIFECYCLE_QUEUE_LIMIT: usize = 8;
|
||||||
const START_OPTION_UNSUPPORTED_LIMIT: usize = 4;
|
const START_OPTION_UNSUPPORTED_LIMIT: usize = 4;
|
||||||
const GAME_CAPTURE_HOOK_FORCE_DISABLED: bool = true;
|
|
||||||
|
|
||||||
type LifecycleTsfn = Arc<
|
type LifecycleTsfn = Arc<
|
||||||
ThreadsafeFunction<
|
ThreadsafeFunction<
|
||||||
@@ -226,7 +229,7 @@ pub struct CaptureInner {
|
|||||||
pub session: Mutex<Option<DxgiCaptureSession>>,
|
pub session: Mutex<Option<DxgiCaptureSession>>,
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub(crate) wgc_session: Mutex<Option<WgcCaptureSession>>,
|
pub(crate) wgc_session: Mutex<Option<WgcCaptureSession>>,
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
|
||||||
pub game_session: Mutex<Option<Arc<GameCaptureSession>>>,
|
pub game_session: Mutex<Option<Arc<GameCaptureSession>>>,
|
||||||
pub running: std::sync::atomic::AtomicBool,
|
pub running: std::sync::atomic::AtomicBool,
|
||||||
pub fallback: Mutex<Option<fallback::FallbackTracker>>,
|
pub fallback: Mutex<Option<fallback::FallbackTracker>>,
|
||||||
@@ -421,6 +424,7 @@ pub(crate) fn note_media_frame_without_sink(inner: &CaptureInner, message: &'sta
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[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) {
|
pub(crate) fn note_cpu_fallback_frame_dropped(inner: &CaptureInner, message: &'static str) {
|
||||||
inner
|
inner
|
||||||
.cpu_fallback_frames_dropped
|
.cpu_fallback_frames_dropped
|
||||||
@@ -470,7 +474,7 @@ impl ScreenCapture {
|
|||||||
session: Mutex::new(None),
|
session: Mutex::new(None),
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
wgc_session: Mutex::new(None),
|
wgc_session: Mutex::new(None),
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
|
||||||
game_session: Mutex::new(None),
|
game_session: Mutex::new(None),
|
||||||
running: std::sync::atomic::AtomicBool::new(false),
|
running: std::sync::atomic::AtomicBool::new(false),
|
||||||
fallback: Mutex::new(None),
|
fallback: Mutex::new(None),
|
||||||
@@ -586,43 +590,48 @@ impl ScreenCapture {
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
let frame_sink = frame_sink_counter_snapshot(&self.inner);
|
let frame_sink = frame_sink_counter_snapshot(&self.inner);
|
||||||
let guard = self.inner.game_session.lock();
|
#[cfg(feature = "game-capture-hook")]
|
||||||
if let Some(session) = guard.as_ref() {
|
{
|
||||||
let requested_injection_method = session.requested_injection_method().to_string();
|
let guard = self.inner.game_session.lock();
|
||||||
let injection_method = session.used_injection_method().to_string();
|
if let Some(session) = guard.as_ref() {
|
||||||
if let Some(info) = session.read_shared_info() {
|
let requested_injection_method =
|
||||||
return Some(CaptureDiagnostics {
|
session.requested_injection_method().to_string();
|
||||||
state: info.state,
|
let injection_method = session.used_injection_method().to_string();
|
||||||
api_type: info.api_type,
|
if let Some(info) = session.read_shared_info() {
|
||||||
transport: info.transport,
|
return Some(CaptureDiagnostics {
|
||||||
fallback_reason: info.fallback_reason,
|
state: info.state,
|
||||||
capture_flags: info.capture_flags,
|
api_type: info.api_type,
|
||||||
width: info.width,
|
transport: info.transport,
|
||||||
height: info.height,
|
fallback_reason: info.fallback_reason,
|
||||||
dxgi_format: info.dxgi_format,
|
capture_flags: info.capture_flags,
|
||||||
frame_counter: info.frame_counter as f64,
|
width: info.width,
|
||||||
dropped_frame_counter: info.dropped_frame_counter as f64,
|
height: info.height,
|
||||||
last_present_timestamp_us: info.last_present_timestamp_us as f64,
|
dxgi_format: info.dxgi_format,
|
||||||
last_error: info.last_error,
|
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,
|
requested_injection_method,
|
||||||
injection_method,
|
injection_method,
|
||||||
active_strategy: snapshot.active_strategy,
|
current_start_options(&self.inner),
|
||||||
last_fallback_reason: snapshot.last_fallback_reason,
|
frame_sink,
|
||||||
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,
|
|
||||||
current_start_options(&self.inner),
|
|
||||||
frame_sink,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Some(strategy_only_diagnostics(
|
Some(strategy_only_diagnostics(
|
||||||
&snapshot,
|
&snapshot,
|
||||||
@@ -669,7 +678,7 @@ impl ScreenCapture {
|
|||||||
|
|
||||||
#[napi(js_name = "getSharedTextureHandle")]
|
#[napi(js_name = "getSharedTextureHandle")]
|
||||||
pub fn get_shared_texture_handle(&self) -> Option<SharedTextureHandleInfo> {
|
pub fn get_shared_texture_handle(&self) -> Option<SharedTextureHandleInfo> {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
|
||||||
{
|
{
|
||||||
let guard = self.inner.game_session.lock();
|
let guard = self.inner.game_session.lock();
|
||||||
let session = guard.as_ref()?;
|
let session = guard.as_ref()?;
|
||||||
@@ -684,7 +693,7 @@ impl ScreenCapture {
|
|||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(all(target_os = "windows", feature = "game-capture-hook")))]
|
||||||
{
|
{
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -706,6 +715,9 @@ impl ScreenCapture {
|
|||||||
*guard = None;
|
*guard = None;
|
||||||
let mut wgc_guard = self.inner.wgc_session.lock();
|
let mut wgc_guard = self.inner.wgc_session.lock();
|
||||||
*wgc_guard = None;
|
*wgc_guard = None;
|
||||||
|
}
|
||||||
|
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
|
||||||
|
{
|
||||||
let mut game_guard = self.inner.game_session.lock();
|
let mut game_guard = self.inner.game_session.lock();
|
||||||
*game_guard = None;
|
*game_guard = None;
|
||||||
}
|
}
|
||||||
@@ -955,7 +967,8 @@ impl ScreenCapture {
|
|||||||
return Err(napi::Error::from_reason("Capture already running"));
|
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(
|
return self.start_windows_game(
|
||||||
source_id,
|
source_id,
|
||||||
source_kind,
|
source_kind,
|
||||||
@@ -991,7 +1004,7 @@ impl ScreenCapture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let hwnd = if source_kind == "game" {
|
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| {
|
.map_err(|e| {
|
||||||
napi::Error::from_reason(format!("Failed to resolve game capture target: {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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn start_windows_game(
|
fn start_windows_game(
|
||||||
&self,
|
&self,
|
||||||
@@ -1278,7 +1292,7 @@ pub fn is_supported() -> bool {
|
|||||||
|
|
||||||
#[napi(js_name = "isGameCaptureHookAvailable")]
|
#[napi(js_name = "isGameCaptureHookAvailable")]
|
||||||
pub fn is_game_capture_hook_available() -> bool {
|
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")]
|
#[napi(js_name = "getAvailability")]
|
||||||
@@ -1314,37 +1328,37 @@ pub fn restore_gpu_scheduling_priority(process_id: Option<u32>) -> Result<()> {
|
|||||||
|
|
||||||
#[napi(js_name = "registerVulkanLayerManifest")]
|
#[napi(js_name = "registerVulkanLayerManifest")]
|
||||||
pub fn register_vulkan_layer_manifest(manifest_path: String) -> Result<()> {
|
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)
|
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;
|
let _ = manifest_path;
|
||||||
Err(napi::Error::from_reason(
|
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")]
|
#[napi(js_name = "unregisterVulkanLayerManifest")]
|
||||||
pub fn unregister_vulkan_layer_manifest(manifest_path: String) -> Result<()> {
|
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)
|
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;
|
let _ = manifest_path;
|
||||||
Err(napi::Error::from_reason(
|
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")]
|
#[napi(js_name = "getVulkanLayerRegistrationState")]
|
||||||
pub fn get_vulkan_layer_registration_state(manifest_path: String) -> VulkanLayerRegistrationState {
|
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);
|
let state = vulkan_layer_registry::registration_state(&manifest_path);
|
||||||
VulkanLayerRegistrationState {
|
VulkanLayerRegistrationState {
|
||||||
@@ -1354,7 +1368,7 @@ pub fn get_vulkan_layer_registration_state(manifest_path: String) -> VulkanLayer
|
|||||||
manifest_path: state.manifest_path,
|
manifest_path: state.manifest_path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(all(target_os = "windows", feature = "game-capture-hook")))]
|
||||||
{
|
{
|
||||||
VulkanLayerRegistrationState {
|
VulkanLayerRegistrationState {
|
||||||
registered: false,
|
registered: false,
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ impl WgcCaptureSession {
|
|||||||
requested_height: Option<u32>,
|
requested_height: Option<u32>,
|
||||||
) -> Result<Self, String> {
|
) -> Result<Self, String> {
|
||||||
ensure_winrt_initialized();
|
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
|
let dxgi_device: IDXGIDevice = device
|
||||||
.cast()
|
.cast()
|
||||||
.map_err(|e| format!("IDXGIDevice cast: {e}"))?;
|
.map_err(|e| format!("IDXGIDevice cast: {e}"))?;
|
||||||
|
|||||||
@@ -157,43 +157,58 @@ function collectRuntimeArtifactPaths(packageDir) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isNativeRuntimeSidecar(fileName) {
|
function isNativeRuntimeSidecar(fileName) {
|
||||||
return (
|
return fileName.endsWith('.node') || /\.so(?:\.|$)/.test(fileName) || /\.(?:dll|exe)$/i.test(fileName);
|
||||||
fileName.endsWith('.node') ||
|
|
||||||
/\.so(?:\.|$)/.test(fileName) ||
|
|
||||||
/\.(?:dll|exe)$/i.test(fileName) ||
|
|
||||||
isWindowsNativeRuntimeManifest(fileName)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWindowsNativeRuntimeManifest(fileName) {
|
function electronArch() {
|
||||||
return (
|
return process.env.ELECTRON_ARCH || process.env.npm_config_arch || process.arch;
|
||||||
fileName === 'compatibility.json' || /^fluxer-vulkan-layer\.win32-(?:x64|ia32|arm64)-msvc\.json$/i.test(fileName)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addWinGameCaptureRuntimeArtifacts(artifacts, tag, arch) {
|
function primaryWinGameCaptureNodeFileName() {
|
||||||
const add = (relativePath) => {
|
const arch = electronArch();
|
||||||
artifacts.push({
|
const tag = platformTag(process.platform, arch);
|
||||||
label: '@fluxer/win-game-capture',
|
if (!tag || process.platform !== 'win32') {
|
||||||
relativePath,
|
throw new Error(`Cannot resolve the primary win-game-capture node for ${process.platform}/${arch}`);
|
||||||
runtimeFiles: [],
|
}
|
||||||
});
|
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({
|
artifacts.push({
|
||||||
label: '@fluxer/win-game-capture',
|
label: '@fluxer/win-game-capture',
|
||||||
relativePath: `win-game-capture.${tag}.node`,
|
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}) {
|
function copyRuntimeArtifactsToInstalledPackages({label, packageDir}) {
|
||||||
|
const primaryNodeFileName = label === '@fluxer/win-game-capture' ? primaryWinGameCaptureNodeFileName() : null;
|
||||||
|
if (primaryNodeFileName) {
|
||||||
|
removeStaleWinGameCaptureArtifacts(packageDir, primaryNodeFileName);
|
||||||
|
}
|
||||||
const artifacts = collectRuntimeArtifactPaths(packageDir);
|
const artifacts = collectRuntimeArtifactPaths(packageDir);
|
||||||
if (artifacts.length === 0) {
|
if (artifacts.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -206,6 +221,9 @@ function copyRuntimeArtifactsToInstalledPackages({label, packageDir}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const installedPackageDir of installedPackageDirs) {
|
for (const installedPackageDir of installedPackageDirs) {
|
||||||
|
if (primaryNodeFileName) {
|
||||||
|
removeStaleWinGameCaptureArtifacts(installedPackageDir, primaryNodeFileName);
|
||||||
|
}
|
||||||
for (const artifact of artifacts) {
|
for (const artifact of artifacts) {
|
||||||
const sourcePath = path.join(packageDir, artifact);
|
const sourcePath = path.join(packageDir, artifact);
|
||||||
const targetPath = path.join(installedPackageDir, artifact);
|
const targetPath = path.join(installedPackageDir, artifact);
|
||||||
@@ -225,7 +243,7 @@ function platformTag(platform, arch) {
|
|||||||
return null;
|
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') {
|
if (platform === 'darwin' && arch === 'universal') {
|
||||||
return [
|
return [
|
||||||
...expectedNativeRuntimeArtifactsForArch(platform, 'arm64'),
|
...expectedNativeRuntimeArtifactsForArch(platform, 'arm64'),
|
||||||
@@ -284,7 +302,7 @@ function expectedNativeRuntimeArtifactsForArch(platform, arch) {
|
|||||||
label: '@fluxer/win-process-loopback',
|
label: '@fluxer/win-process-loopback',
|
||||||
relativePath: `win-process-loopback.${tag}.node`,
|
relativePath: `win-process-loopback.${tag}.node`,
|
||||||
});
|
});
|
||||||
addWinGameCaptureRuntimeArtifacts(artifacts, tag, arch);
|
addWinGameCaptureRuntimeArtifacts(artifacts, tag);
|
||||||
artifacts.push({
|
artifacts.push({
|
||||||
label: '@fluxer/win-clipboard',
|
label: '@fluxer/win-clipboard',
|
||||||
relativePath: `win-clipboard.${tag}.node`,
|
relativePath: `win-clipboard.${tag}.node`,
|
||||||
|
|||||||
@@ -688,7 +688,6 @@ export interface ElectronAPI {
|
|||||||
pasteFromClipboard: () => Promise<void>;
|
pasteFromClipboard: () => Promise<void>;
|
||||||
onDeepLink: (callback: (url: string) => void) => () => void;
|
onDeepLink: (callback: (url: string) => void) => () => void;
|
||||||
getInitialDeepLink: () => Promise<string | null>;
|
getInitialDeepLink: () => Promise<string | null>;
|
||||||
onRpcNavigate: (callback: (path: string) => void) => () => void;
|
|
||||||
autostartEnable: () => Promise<void>;
|
autostartEnable: () => Promise<void>;
|
||||||
autostartDisable: () => Promise<void>;
|
autostartDisable: () => Promise<void>;
|
||||||
autostartIsEnabled: () => Promise<boolean>;
|
autostartIsEnabled: () => Promise<boolean>;
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<RpcRequest | null> => {
|
|
||||||
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<void> => {
|
|
||||||
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<void> => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!server) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
server.close((err) => {
|
|
||||||
if (err) {
|
|
||||||
log.error('[RPC] Error closing server:', err);
|
|
||||||
}
|
|
||||||
server = null;
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -68,7 +68,6 @@ import {
|
|||||||
import {runNativeModulePreflight} from '@electron/main/NativeModulePreflight';
|
import {runNativeModulePreflight} from '@electron/main/NativeModulePreflight';
|
||||||
import {cleanupNativeScreenCapture, registerNativeScreenCaptureHandlers} from '@electron/main/NativeScreenCapture';
|
import {cleanupNativeScreenCapture, registerNativeScreenCaptureHandlers} from '@electron/main/NativeScreenCapture';
|
||||||
import {appendOpenH264Switches} from '@electron/main/OpenH264Manager';
|
import {appendOpenH264Switches} from '@electron/main/OpenH264Manager';
|
||||||
import {startRpcServer, stopRpcServer} from '@electron/main/RpcServer';
|
|
||||||
import {cleanupLinuxChromiumSpellcheckDictionaries} from '@electron/main/Spellcheck';
|
import {cleanupLinuxChromiumSpellcheckDictionaries} from '@electron/main/Spellcheck';
|
||||||
import {registerUpdater} from '@electron/main/Updater';
|
import {registerUpdater} from '@electron/main/Updater';
|
||||||
import {
|
import {
|
||||||
@@ -430,9 +429,6 @@ if (launchConfigurationError) {
|
|||||||
showWindow();
|
showWindow();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
void startRpcServer().catch((error: unknown) => {
|
|
||||||
log.error('[RPC] Failed to start RPC server:', error);
|
|
||||||
});
|
|
||||||
log.info('App initialized successfully');
|
log.info('App initialized successfully');
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
@@ -478,7 +474,7 @@ if (launchConfigurationError) {
|
|||||||
cleanupNativeHardwareEncoderHandlers();
|
cleanupNativeHardwareEncoderHandlers();
|
||||||
cleanupVirtmic();
|
cleanupVirtmic();
|
||||||
destroyDesktopTray();
|
destroyDesktopTray();
|
||||||
const asyncCleanups: Array<Promise<unknown>> = [stopRpcServer()];
|
const asyncCleanups: Array<Promise<unknown>> = [];
|
||||||
if (netLog.currentlyLogging) {
|
if (netLog.currentlyLogging) {
|
||||||
asyncCleanups.push(
|
asyncCleanups.push(
|
||||||
netLog.stopLogging().catch((error) => {
|
netLog.stopLogging().catch((error) => {
|
||||||
|
|||||||
@@ -425,15 +425,6 @@ const api: ElectronAPI = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getInitialDeepLink: (): Promise<string | null> => ipcRenderer.invoke('get-initial-deep-link'),
|
getInitialDeepLink: (): Promise<string | null> => 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<void> => ipcRenderer.invoke('autostart-enable'),
|
autostartEnable: (): Promise<void> => ipcRenderer.invoke('autostart-enable'),
|
||||||
autostartDisable: (): Promise<void> => ipcRenderer.invoke('autostart-disable'),
|
autostartDisable: (): Promise<void> => ipcRenderer.invoke('autostart-disable'),
|
||||||
autostartIsEnabled: (): Promise<boolean> => ipcRenderer.invoke('autostart-is-enabled'),
|
autostartIsEnabled: (): Promise<boolean> => ipcRenderer.invoke('autostart-is-enabled'),
|
||||||
|
|||||||
+479
-56
@@ -10,6 +10,7 @@ use crate::common::{
|
|||||||
upload_directory_to_s3, upload_s3_plan_append_only, upload_s3_plan_overwrite,
|
upload_directory_to_s3, upload_s3_plan_append_only, upload_s3_plan_overwrite,
|
||||||
};
|
};
|
||||||
use crate::functions::write_json_pretty;
|
use crate::functions::write_json_pretty;
|
||||||
|
use crate::release::DESKTOP_RELEASE_ASSET_SUFFIXES;
|
||||||
use anyhow::{Context, Result, anyhow, bail, ensure};
|
use anyhow::{Context, Result, anyhow, bail, ensure};
|
||||||
use aws_sdk_s3::Client as S3Client;
|
use aws_sdk_s3::Client as S3Client;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -110,6 +111,9 @@ enum DesktopStep {
|
|||||||
DownloadHandoff,
|
DownloadHandoff,
|
||||||
CleanupHandoff,
|
CleanupHandoff,
|
||||||
BuildPayload,
|
BuildPayload,
|
||||||
|
PrepareReleaseAssets,
|
||||||
|
UploadReleaseAssets,
|
||||||
|
DownloadReleaseAssets,
|
||||||
UploadPayload,
|
UploadPayload,
|
||||||
BuildSummary,
|
BuildSummary,
|
||||||
}
|
}
|
||||||
@@ -235,6 +239,9 @@ pub async fn run(args: BuildDesktopArgs) -> Result<()> {
|
|||||||
DesktopStep::DownloadHandoff => download_handoff_step().await,
|
DesktopStep::DownloadHandoff => download_handoff_step().await,
|
||||||
DesktopStep::CleanupHandoff => cleanup_handoff_step().await,
|
DesktopStep::CleanupHandoff => cleanup_handoff_step().await,
|
||||||
DesktopStep::BuildPayload => build_payload_step(),
|
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::UploadPayload => upload_payload_step().await,
|
||||||
DesktopStep::BuildSummary => build_summary_step(),
|
DesktopStep::BuildSummary => build_summary_step(),
|
||||||
}
|
}
|
||||||
@@ -1401,22 +1408,6 @@ fn install_rust_windows_targets_step() -> Result<()> {
|
|||||||
RUST_TOOLCHAIN,
|
RUST_TOOLCHAIN,
|
||||||
target,
|
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") {
|
if let Ok(user_profile) = env::var("USERPROFILE") {
|
||||||
let cargo_bin = PathBuf::from(user_profile).join(".cargo").join("bin");
|
let cargo_bin = PathBuf::from(user_profile).join(".cargo").join("bin");
|
||||||
if cargo_bin.exists() && env::var("GITHUB_PATH").is_ok() {
|
if cargo_bin.exists() && env::var("GITHUB_PATH").is_ok() {
|
||||||
@@ -2011,7 +2002,7 @@ fn pack_and_validate_windows_velopack(
|
|||||||
"--outputDir",
|
"--outputDir",
|
||||||
config.output_dir.to_string_lossy().as_ref(),
|
config.output_dir.to_string_lossy().as_ref(),
|
||||||
"--delta",
|
"--delta",
|
||||||
"BestSpeed",
|
"None",
|
||||||
"--azureTrustedSignFile",
|
"--azureTrustedSignFile",
|
||||||
trusted_sign_file.to_string_lossy().as_ref(),
|
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 legacy_releases = config.output_dir.join("RELEASES");
|
||||||
let velopack_releases = config.output_dir.join("releases.win.json");
|
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 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::<Vec<_>>();
|
||||||
|
|
||||||
ensure!(
|
ensure!(
|
||||||
legacy_releases.exists(),
|
legacy_releases.exists(),
|
||||||
@@ -2072,6 +2072,12 @@ fn validate_velopack_output(
|
|||||||
velopack_releases.exists(),
|
velopack_releases.exists(),
|
||||||
"Velopack did not produce releases.win.json for Windows updates."
|
"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(|| {
|
let full_nupkg = full_nupkg.ok_or_else(|| {
|
||||||
anyhow!("Velopack did not produce a full nupkg payload for Windows updates.")
|
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",
|
"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] = &[
|
const WINDOWS_NATIVE_ADDON_STEMS: &[&str] = &[
|
||||||
"hardware-encoder",
|
"hardware-encoder",
|
||||||
"webauthn",
|
"webauthn",
|
||||||
@@ -2261,33 +2275,26 @@ fn expected_windows_pe_inventory(arch: &str, main_exe: &str) -> Vec<String> {
|
|||||||
main_exe.to_string(),
|
main_exe.to_string(),
|
||||||
format!("velopack_nodeffi_win_{arch}_msvc.node"),
|
format!("velopack_nodeffi_win_{arch}_msvc.node"),
|
||||||
format!("win-game-capture.{tag}.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(
|
names.extend(
|
||||||
WINDOWS_NATIVE_ADDON_STEMS
|
WINDOWS_NATIVE_ADDON_STEMS
|
||||||
.iter()
|
.iter()
|
||||||
.map(|stem| format!("{stem}.{tag}.node")),
|
.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.sort();
|
||||||
names.dedup();
|
names.dedup();
|
||||||
names
|
names
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_pe_file(path: &Path) -> Result<bool> {
|
fn read_pe_machine(path: &Path) -> Result<Option<u16>> {
|
||||||
let mut file =
|
let mut file =
|
||||||
File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
|
File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
|
||||||
let mut dos_header = [0u8; 0x40];
|
let mut dos_header = [0u8; 0x40];
|
||||||
if !read_exact_or_eof(&mut file, &mut dos_header, path)? {
|
if !read_exact_or_eof(&mut file, &mut dos_header, path)? {
|
||||||
return Ok(false);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
if &dos_header[0..2] != b"MZ" {
|
if &dos_header[0..2] != b"MZ" {
|
||||||
return Ok(false);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let e_lfanew = u32::from_le_bytes([
|
let e_lfanew = u32::from_le_bytes([
|
||||||
dos_header[0x3c],
|
dos_header[0x3c],
|
||||||
@@ -2299,9 +2306,20 @@ fn is_pe_file(path: &Path) -> Result<bool> {
|
|||||||
.with_context(|| format!("Failed to seek in {}", path.display()))?;
|
.with_context(|| format!("Failed to seek in {}", path.display()))?;
|
||||||
let mut signature = [0u8; 4];
|
let mut signature = [0u8; 4];
|
||||||
if !read_exact_or_eof(&mut file, &mut signature, path)? {
|
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<bool> {
|
||||||
|
Ok(read_pe_machine(path)?.is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_exact_or_eof(file: &mut File, buffer: &mut [u8], path: &Path) -> Result<bool> {
|
fn read_exact_or_eof(file: &mut File, buffer: &mut [u8], path: &Path) -> Result<bool> {
|
||||||
@@ -2361,6 +2379,61 @@ fn percent_decode_archive_name(name: &str) -> String {
|
|||||||
String::from_utf8(decoded).unwrap_or_else(|_| name.to_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(
|
fn assert_expected_windows_pe_inventory(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
files: &[PathBuf],
|
files: &[PathBuf],
|
||||||
@@ -2385,6 +2458,17 @@ fn assert_expected_windows_pe_inventory(
|
|||||||
missing.len(),
|
missing.len(),
|
||||||
missing.join("\n")
|
missing.join("\n")
|
||||||
);
|
);
|
||||||
|
let forbidden = present
|
||||||
|
.iter()
|
||||||
|
.filter_map(|name| windows_pe_inventory_violation(name, arch))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
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);
|
let contradictory = contradictory_optional_windows_pe_inventory(arch, main_exe);
|
||||||
ensure!(
|
ensure!(
|
||||||
contradictory.is_empty(),
|
contradictory.is_empty(),
|
||||||
@@ -2411,7 +2495,7 @@ fn assert_expected_windows_pe_inventory(
|
|||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
println!(
|
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(),
|
root.display(),
|
||||||
expected.len(),
|
expected.len(),
|
||||||
unlisted.len()
|
unlisted.len()
|
||||||
@@ -2422,6 +2506,231 @@ fn assert_expected_windows_pe_inventory(
|
|||||||
Ok(())
|
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::<Vec<_>>()
|
||||||
|
.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<u32> {
|
||||||
|
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<String>,
|
||||||
|
) -> 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<Vec<String>> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
fn contradictory_optional_windows_pe_inventory(arch: &str, main_exe: &str) -> Vec<String> {
|
||||||
let expected = expected_windows_pe_inventory(arch, main_exe);
|
let expected = expected_windows_pe_inventory(arch, main_exe);
|
||||||
KNOWN_OPTIONAL_WINDOWS_PE_INVENTORY
|
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 config = windows_package_config(&build_channel, &arch);
|
||||||
let pack_dir = resolve_windows_unpacked_dir(&arch, &config.main_exe)?;
|
let pack_dir = resolve_windows_unpacked_dir(&arch, &config.main_exe)?;
|
||||||
let files = collect_pe_files(&pack_dir)?;
|
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)?;
|
assert_expected_windows_pe_inventory(&pack_dir, &files, &arch, &config.main_exe)?;
|
||||||
ensure!(
|
ensure!(
|
||||||
files.iter().any(|file| extension_is(file, "node")),
|
files.iter().any(|file| extension_is(file, "node")),
|
||||||
@@ -2870,23 +3181,14 @@ fn verify_windows_signed_artifacts_step() -> Result<()> {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|path| extension_is(path, "nupkg"))
|
.filter(|path| extension_is(path, "nupkg"))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
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::<Vec<_>>();
|
|
||||||
let unclassified_nupkgs = staged_nupkgs
|
let unclassified_nupkgs = staged_nupkgs
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|path| **path != nupkg && !delta_nupkgs.contains(path))
|
.filter(|path| **path != nupkg)
|
||||||
.map(|path| path.display().to_string())
|
.map(|path| path.display().to_string())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
ensure!(
|
ensure!(
|
||||||
unclassified_nupkgs.is_empty(),
|
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(),
|
config.output_dir.display(),
|
||||||
unclassified_nupkgs.len(),
|
unclassified_nupkgs.len(),
|
||||||
unclassified_nupkgs.join("\n")
|
unclassified_nupkgs.join("\n")
|
||||||
@@ -2930,26 +3232,16 @@ fn verify_windows_signed_artifacts_step() -> Result<()> {
|
|||||||
nupkg.display()
|
nupkg.display()
|
||||||
);
|
);
|
||||||
let nupkg_files = collect_pe_files(&lib_app)?;
|
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)?;
|
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)?;
|
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");
|
let portable_root = root.join("p");
|
||||||
extract_zip_safely(&portable_zip, &portable_root)?;
|
extract_zip_safely(&portable_zip, &portable_root)?;
|
||||||
let portable_files = collect_pe_files(&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)?;
|
assert_expected_windows_pe_inventory(&portable_root, &portable_files, &arch, &config.main_exe)?;
|
||||||
verify_windows_pe_signatures(&signtool, "portable zip", &portable_root, &portable_files)?;
|
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)
|
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::<BTreeSet<_>>();
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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<String> {
|
||||||
|
let name = source_name
|
||||||
|
.chars()
|
||||||
|
.map(|character| {
|
||||||
|
if character.is_ascii_whitespace() {
|
||||||
|
'.'
|
||||||
|
} else {
|
||||||
|
character
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct ArtifactIdentity {
|
struct ArtifactIdentity {
|
||||||
platform: String,
|
platform: String,
|
||||||
|
|||||||
+75
-229
@@ -1,8 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::common::{
|
use crate::common::{CommandSpec, command_succeeds, output_text, run_command};
|
||||||
CommandSpec, command_succeeds, output_text, remove_file_if_exists, run_command,
|
|
||||||
};
|
|
||||||
use crate::desktop::MACOS_UNIVERSAL_ARCH;
|
use crate::desktop::MACOS_UNIVERSAL_ARCH;
|
||||||
use anyhow::{Context, Result, anyhow, bail, ensure};
|
use anyhow::{Context, Result, anyhow, bail, ensure};
|
||||||
use clap::Args;
|
use clap::Args;
|
||||||
@@ -309,7 +307,9 @@ fn build_desktop_native_addon(addon_root: &Path, addon: &DesktopNativeAddon) ->
|
|||||||
DesktopNativeSpecialBuild::Webauthn => {
|
DesktopNativeSpecialBuild::Webauthn => {
|
||||||
copy_webauthn_linux_shared_libraries(&built.out_file, addon_root)?
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -365,6 +365,9 @@ fn build_rust_node_addon_for_arch(
|
|||||||
OsString::from("--manifest-path"),
|
OsString::from("--manifest-path"),
|
||||||
OsString::from("Cargo.toml"),
|
OsString::from("Cargo.toml"),
|
||||||
];
|
];
|
||||||
|
if addon.special == DesktopNativeSpecialBuild::WinGameCapture {
|
||||||
|
args.push(OsString::from("--no-default-features"));
|
||||||
|
}
|
||||||
if !addon.features.is_empty() {
|
if !addon.features.is_empty() {
|
||||||
args.push(OsString::from("--features"));
|
args.push(OsString::from("--features"));
|
||||||
args.push(OsString::from(addon.features.join(",")));
|
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)?;
|
sign_macos_node_addon(&out_file, platform)?;
|
||||||
assert_no_redistributable_runtime_imports(&out_file, platform)?;
|
assert_no_redistributable_runtime_imports(&out_file, platform)?;
|
||||||
|
assert_no_disabled_win_game_capture_capabilities(&out_file, addon, platform)?;
|
||||||
Ok(BuiltNodeAddon {
|
Ok(BuiltNodeAddon {
|
||||||
out_file,
|
out_file,
|
||||||
source,
|
source,
|
||||||
@@ -668,235 +672,43 @@ fn should_bundle_linux_library(library_name: &str) -> bool {
|
|||||||
.any(|prefix| library_name.starts_with(prefix))
|
.any(|prefix| library_name.starts_with(prefix))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
fn remove_stale_win_game_capture_outputs(root: &Path, primary_node: &Path) -> Result<()> {
|
||||||
struct HookLayerArch {
|
let primary_node_name = primary_node
|
||||||
arch: &'static str,
|
.file_name()
|
||||||
target: &'static str,
|
.and_then(OsStr::to_str)
|
||||||
tag: &'static str,
|
.ok_or_else(|| {
|
||||||
}
|
anyhow!(
|
||||||
|
"Invalid win-game-capture output path: {}",
|
||||||
const WIN_GAME_CAPTURE_ARCHES: &[HookLayerArch] = &[
|
primary_node.display()
|
||||||
HookLayerArch {
|
)
|
||||||
arch: "x64",
|
})?;
|
||||||
target: "x86_64-pc-windows-msvc",
|
let mut stale = fs::read_dir(root)
|
||||||
tag: "win32-x64-msvc",
|
.with_context(|| format!("Failed to read {}", root.display()))?
|
||||||
},
|
.map(|entry| entry.map(|entry| entry.path()))
|
||||||
HookLayerArch {
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
arch: "ia32",
|
stale.retain(|path| {
|
||||||
target: "i686-pc-windows-msvc",
|
if !path.is_file() {
|
||||||
tag: "win32-ia32-msvc",
|
return false;
|
||||||
},
|
}
|
||||||
HookLayerArch {
|
let Some(file_name) = path.file_name().and_then(OsStr::to_str) else {
|
||||||
arch: "arm64",
|
return false;
|
||||||
target: "aarch64-pc-windows-msvc",
|
};
|
||||||
tag: "win32-arm64-msvc",
|
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.")
|
||||||
fn build_win_game_capture_artifacts(root: &Path) -> Result<()> {
|
&& file_name.ends_with(".node")
|
||||||
let primary_arch = electron_arch();
|
&& file_name != primary_node_name)
|
||||||
for arch in WIN_GAME_CAPTURE_ARCHES {
|
});
|
||||||
let required_for_primary_runtime =
|
stale.sort();
|
||||||
arch.arch == primary_arch || (primary_arch == "x64" && arch.arch == "ia32");
|
for path in stale {
|
||||||
build_win_game_capture_hook(root, arch, required_for_primary_runtime)?;
|
fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?;
|
||||||
build_win_game_capture_vulkan_layer(root, arch, arch.arch == primary_arch)?;
|
println!("[win-game-capture] removed stale output {}", path.display());
|
||||||
build_win_game_capture_inject_helper(root, arch, required_for_primary_runtime)?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_win_game_capture_hook(root: &Path, arch: &HookLayerArch, required: bool) -> Result<()> {
|
#[cfg(test)]
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn vulkan_layer_manifest(layer_dll_name: &str) -> String {
|
fn vulkan_layer_manifest(layer_dll_name: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{{\n\
|
"{{\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::<Vec<_>>();
|
||||||
|
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<String> {
|
fn find_redistributable_runtime_imports(file_path: &Path) -> Vec<String> {
|
||||||
read_pe_imports(file_path)
|
read_pe_imports(file_path)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
+389
-197
@@ -5,123 +5,29 @@ use anyhow::{Context, Result, bail, ensure};
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use clap::{Args, Subcommand};
|
use clap::{Args, Subcommand};
|
||||||
use serde::Deserialize;
|
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_REPOSITORY: &str = "fluxerapp/fluxer";
|
||||||
const RELEASE_COMPARE_URL: &str = "https://github.com/fluxerapp/fluxer/compare";
|
const RELEASE_COMPARE_URL: &str = "https://github.com/fluxerapp/fluxer/compare";
|
||||||
const DESKTOP_DOWNLOAD_URL: &str = "https://api.fluxer.app/dl/desktop";
|
pub(crate) const DESKTOP_RELEASE_ASSET_SUFFIXES: &[&str] = &[
|
||||||
|
"linux-aarch64.rpm",
|
||||||
struct DesktopDownload {
|
"linux-amd64.deb",
|
||||||
arch: &'static str,
|
"linux-arm64.AppImage",
|
||||||
label: &'static str,
|
"linux-arm64.deb",
|
||||||
format: &'static str,
|
"linux-arm64.tar.gz",
|
||||||
}
|
"linux-x64.tar.gz",
|
||||||
|
"linux-x86_64.AppImage",
|
||||||
struct DesktopPlatform {
|
"linux-x86_64.rpm",
|
||||||
name: &'static str,
|
"mac-universal.dmg",
|
||||||
slug: &'static str,
|
"mac-universal.zip",
|
||||||
downloads: &'static [DesktopDownload],
|
"portable-win-arm64.zip",
|
||||||
}
|
"portable-win-x64.zip",
|
||||||
|
"win-arm64.exe",
|
||||||
const DESKTOP_PLATFORMS: &[DesktopPlatform] = &[
|
"win-x64.exe",
|
||||||
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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Debug, Args, Clone)]
|
#[derive(Debug, Args, Clone)]
|
||||||
@@ -148,6 +54,8 @@ struct PublishArgs {
|
|||||||
previous_sha: Option<String>,
|
previous_sha: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
prerelease: bool,
|
prerelease: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
asset_dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -175,7 +83,24 @@ struct ReleaseDetail {
|
|||||||
body: Option<String>,
|
body: Option<String>,
|
||||||
draft: bool,
|
draft: bool,
|
||||||
prerelease: bool,
|
prerelease: bool,
|
||||||
assets: Vec<Value>,
|
assets: Vec<PublishedReleaseAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PublishedReleaseAsset {
|
||||||
|
name: String,
|
||||||
|
label: Option<String>,
|
||||||
|
size: u64,
|
||||||
|
digest: Option<String>,
|
||||||
|
state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct LocalReleaseAsset {
|
||||||
|
path: PathBuf,
|
||||||
|
name: String,
|
||||||
|
size: u64,
|
||||||
|
digest: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -207,12 +132,7 @@ fn publish(args: PublishArgs) -> Result<()> {
|
|||||||
let summaries = release_summaries()?;
|
let summaries = release_summaries()?;
|
||||||
let qualified = qualified_releases(&summaries, &args.component)?;
|
let qualified = qualified_releases(&summaries, &args.component)?;
|
||||||
let existing_release = qualified.iter().find(|release| release.tag == tag);
|
let existing_release = qualified.iter().find(|release| release.tag == tag);
|
||||||
if let Some(existing) = summaries.iter().find(|release| release.tag_name == tag) {
|
let existing_summary = 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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing_release.is_none()
|
if existing_release.is_none()
|
||||||
&& let Some(newer) = qualified
|
&& 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.component,
|
||||||
&args.build_version,
|
&args.build_version,
|
||||||
&previous_sha,
|
args.asset_dir.as_deref(),
|
||||||
&source_sha,
|
)?;
|
||||||
);
|
if existing_summary.is_some_and(|release| !release.is_draft) {
|
||||||
if summaries.iter().any(|release| release.tag_name == tag) {
|
ensure!(
|
||||||
verify_existing_release(&tag, &title, &body, &source_sha, args.prerelease)?;
|
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.");
|
println!("Release {tag} already exists with the expected state.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure!(
|
if let Some(existing) = existing_summary {
|
||||||
!tag_exists(&tag)?,
|
ensure!(
|
||||||
"Refusing to publish {tag}: the tag already exists without a matching GitHub Release"
|
existing.published_at.is_none(),
|
||||||
);
|
"Draft release {tag} unexpectedly has a publication timestamp"
|
||||||
|
);
|
||||||
let mut command = CommandSpec::new("gh")
|
} else {
|
||||||
.args(["release", "create", &tag])
|
ensure!(
|
||||||
.args(["--repo", RELEASE_REPOSITORY])
|
!tag_exists(&tag)?,
|
||||||
.args(["--title", &title])
|
"Refusing to publish {tag}: the tag already exists without a matching GitHub Release"
|
||||||
.args(["--notes", &body])
|
);
|
||||||
.args(["--target", &source_sha])
|
create_draft_release(&tag, &title, &body, &source_sha, args.prerelease)?;
|
||||||
.args(["--latest=false"]);
|
|
||||||
if args.prerelease {
|
|
||||||
command = command.arg("--prerelease");
|
|
||||||
}
|
}
|
||||||
run_command(command)?;
|
upload_draft_release_assets(&tag, &title, &body, &source_sha, args.prerelease, &assets)?;
|
||||||
verify_existing_release(&tag, &title, &body, &source_sha, args.prerelease)
|
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<()> {
|
fn validate_component(component: &str) -> Result<()> {
|
||||||
@@ -425,20 +381,226 @@ fn tag_exists(tag: &str) -> Result<bool> {
|
|||||||
Ok(refs.iter().any(|git_ref| git_ref.name == expected))
|
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<Vec<LocalReleaseAsset>> {
|
||||||
|
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::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
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::<BTreeSet<_>>();
|
||||||
|
let actual_names = assets
|
||||||
|
.iter()
|
||||||
|
.map(|asset| asset.name.clone())
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
ensure!(
|
||||||
|
actual_names == expected_names,
|
||||||
|
"Desktop release asset inventory mismatch: expected {expected_names:?}, found {actual_names:?}"
|
||||||
|
);
|
||||||
|
Ok(assets)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_file(path: &Path) -> Result<String> {
|
||||||
|
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,
|
tag: &str,
|
||||||
title: &str,
|
title: &str,
|
||||||
body: &str,
|
body: &str,
|
||||||
source_sha: &str,
|
source_sha: &str,
|
||||||
prerelease: bool,
|
prerelease: bool,
|
||||||
) -> Result<()> {
|
) -> 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<ReleaseDetail> {
|
||||||
let output = output_text(
|
let output = output_text(
|
||||||
CommandSpec::new("gh")
|
CommandSpec::new("gh")
|
||||||
.arg("api")
|
.arg("api")
|
||||||
.arg(format!("repos/{RELEASE_REPOSITORY}/releases/tags/{tag}")),
|
.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::<BTreeMap<_, _>>();
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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!(
|
ensure!(
|
||||||
release.tag_name == tag,
|
release.tag_name == tag,
|
||||||
"Release {tag} has a mismatched tag"
|
"Release {tag} has a mismatched tag"
|
||||||
@@ -451,26 +613,93 @@ fn verify_existing_release(
|
|||||||
release.body.as_deref().unwrap_or_default() == body,
|
release.body.as_deref().unwrap_or_default() == body,
|
||||||
"Release {tag} has a mismatched 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!(
|
ensure!(
|
||||||
release.prerelease == prerelease,
|
release.prerelease == prerelease,
|
||||||
"Release {tag} has a mismatched prerelease state"
|
"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)?;
|
let target_sha = resolve_commit_sha(&release.target_commitish)?;
|
||||||
ensure!(
|
ensure!(
|
||||||
target_sha == source_sha,
|
target_sha == source_sha,
|
||||||
"Release {tag} target resolves to {target_sha}, expected {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::<Vec<_>>();
|
||||||
|
let published_names = published_by_name.keys().copied().collect::<Vec<_>>();
|
||||||
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,51 +711,14 @@ fn release_title(component: &str, version: &str) -> String {
|
|||||||
format!("{component} {version}")
|
format!("{component} {version}")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn release_body(component: &str, version: &str, previous_sha: &str, source_sha: &str) -> String {
|
fn release_body(previous_sha: &str, source_sha: &str) -> String {
|
||||||
let changes = format!(
|
format!(
|
||||||
"Changes: [`{}..{}`]({RELEASE_COMPARE_URL}/{previous_sha}..{source_sha})",
|
"Changes: [`{}..{}`]({RELEASE_COMPARE_URL}/{previous_sha}..{source_sha})",
|
||||||
&previous_sha[..7],
|
&previous_sha[..7],
|
||||||
&source_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> {
|
fn desktop_channel(component: &str) -> Option<&str> {
|
||||||
component.strip_prefix("fluxer-desktop-")
|
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::<Vec<_>>()
|
|
||||||
.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::<Vec<_>>()
|
|
||||||
.join("\n");
|
|
||||||
format!(
|
|
||||||
"## {name} (`{slug}`)\n\n| Arch | Format | URL |\n|---|---|---|\n{rows}",
|
|
||||||
name = platform.name,
|
|
||||||
slug = platform.slug,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ use anyhow::{Context, Result, bail};
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::thread;
|
use std::time::Duration;
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
const CANARY_APP_NAME: &str = "Fluxer Canary";
|
const CANARY_APP_NAME: &str = "Fluxer Canary";
|
||||||
const CANARY_BUNDLE_ID: &str = "app.fluxer.canary";
|
const CANARY_BUNDLE_ID: &str = "app.fluxer.canary";
|
||||||
const CANARY_RPC_PORT: u16 = 21864;
|
|
||||||
const MACOS_DEV_ELECTRON_USAGE_DESCRIPTIONS: &[(&str, &str)] = &[
|
const MACOS_DEV_ELECTRON_USAGE_DESCRIPTIONS: &[(&str, &str)] = &[
|
||||||
(
|
(
|
||||||
"NSMicrophoneUsageDescription",
|
"NSMicrophoneUsageDescription",
|
||||||
@@ -466,7 +464,6 @@ fn stop_running_canary_on_host() -> Result<()> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
run_best_effort("pkill", &["-TERM", "-x", CANARY_APP_NAME]);
|
run_best_effort("pkill", &["-TERM", "-x", CANARY_APP_NAME]);
|
||||||
terminate_rpc_port_processes(CANARY_RPC_PORT)?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,60 +476,6 @@ fn run_best_effort(program: &str, args: &[&str]) {
|
|||||||
.status();
|
.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<Vec<String>> {
|
|
||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
Reference in New Issue
Block a user