Compare commits

...
Author SHA1 Message Date
HampusandGitHub 53ddca725e fix(desktop): deduplicate macOS release feeds (#2056) 2026-08-28 19:16:35 +02:00
HampusandGitHub 094fb0d1c8 feat(downloads): route desktop releases through GitHub 2026-08-28 18:19:08 +02:00
HampusandGitHub dc230926a4 fix(desktop): skip glibc check for directory packs 2026-08-28 15:09:00 +02:00
HampusandGitHub 026ace6747 fix(ci): publish draft releases by ID (#2050) 2026-08-28 00:38:09 +02:00
HampusandGitHub 374db9ed2b fix(desktop): harden capture and release packaging (#2049) 2026-08-27 23:54:38 +02:00
5ee59c4675 chore(i18n): update public marketing catalogs (#2047)
Co-authored-by: Jiralite <[email protected]>
2026-08-27 17:49:52 +01:00
33605171a8 chore(marketing): advance pointer 530c44e → 0c78170 (#2046)
Co-authored-by: Jiralite <[email protected]>
2026-08-27 17:49:38 +01:00
HampusandGitHub 89fac5b088 fix(api): use webhook ID for mention author (#2045) 2026-08-27 17:01:26 +02:00
HampusandGitHub 4a34b942b7 fix(api): key mention chunks by content (#2044) 2026-08-27 16:02:24 +02:00
HampusandGitHub fc3065ebe4 fix(desktop): build with compatible PipeWire headers (#2043) 2026-08-27 15:18:15 +02:00
HampusandGitHub 23493b4ac2 fix(desktop): build libfido2 on Linux runners (#2042) 2026-08-27 14:44:44 +02:00
HampusandGitHub 13344096b7 fix(desktop): keep Linux builds compatible with glibc 2.35 (#2041) 2026-08-27 13:41:08 +02:00
HampusandGitHub a800430997 fix(app): sort interface languages by locale code (#2040) 2026-08-27 13:30:39 +02:00
HampusandGitHub 509562e6da feat(app): delete attachments from the media viewer (#2039) 2026-08-27 13:26:18 +02:00
HampusandGitHub 88d85919f1 fix(app): trim pasted friend tags (#2038) 2026-08-27 13:12:12 +02:00
HampusandGitHub 154e223284 fix(app): keep sticker sizes fixed while resizing the expression picker 2026-08-27 12:59:38 +02:00
91 changed files with 3284 additions and 1491 deletions
+42 -1
View File
@@ -524,6 +524,7 @@ jobs:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
PUBLIC_DL_BASE: https://api.fluxer.app/dl
@@ -553,11 +554,29 @@ jobs:
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_payload
- name: Prepare GitHub release assets
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step prepare_release_assets
- name: Publish GitHub release descriptor
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_descriptor
- name: Upload payload to S3
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step upload_payload
- name: Upload GitHub release asset handoff
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step upload_release_assets
- name: Build summary
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
@@ -577,9 +596,17 @@ jobs:
- upload
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 10
timeout-minutes: 60
permissions:
contents: write
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
@@ -590,6 +617,12 @@ jobs:
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- name: Download GitHub release assets
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step download_release_assets
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
@@ -614,8 +647,16 @@ jobs:
--build-version "${VERSION}"
--source-sha "${SOURCE_SHA}"
--previous-sha "${RELEASE_BASELINE_SHA}"
--asset-dir release_assets
)
if [[ "${CHANNEL}" == "canary" ]]; then
release_args+=(--prerelease)
fi
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
- name: Publish GitHub release readiness marker
env:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_marker
+31 -5
View File
@@ -23,6 +23,7 @@ type CacheEntry = {
};
const CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_MAX_ENTRIES = 10_000;
const geoipCache = new Map<string, CacheEntry>();
let maxmindReader: Reader<CityResponse> | null = null;
@@ -85,6 +86,32 @@ function isAsciiUpperAlpha2(value: string): boolean {
);
}
function getCachedGeoipResult(cacheKey: string, normalizedIp: string): GeoipResult | null {
const cached = geoipCache.get(cacheKey);
if (!cached) {
return null;
}
if (Date.now() >= cached.expiresAt) {
geoipCache.delete(cacheKey);
return null;
}
geoipCache.delete(cacheKey);
geoipCache.set(cacheKey, cached);
return {...cached.result, normalizedIp};
}
function setCachedGeoipResult(cacheKey: string, result: GeoipResult): void {
geoipCache.delete(cacheKey);
if (geoipCache.size >= CACHE_MAX_ENTRIES) {
const oldestKey = geoipCache.keys().next().value;
if (oldestKey === undefined) {
throw new Error('GeoIP cache reached capacity without an entry to evict');
}
geoipCache.delete(oldestKey);
}
geoipCache.set(cacheKey, {result, expiresAt: Date.now() + CACHE_TTL_MS});
}
async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult> {
try {
const reader = await ensureReader(dbPath);
@@ -108,14 +135,13 @@ async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult
}
async function resolveGeoip(clean: string, dbPath: string): Promise<GeoipResult> {
const now = Date.now();
const cacheKey = getSameIpDecisionKey(clean) ?? clean;
const cached = geoipCache.get(cacheKey);
if (cached && now < cached.expiresAt) {
return {...cached.result, normalizedIp: clean};
const cached = getCachedGeoipResult(cacheKey, clean);
if (cached) {
return cached;
}
const result = await lookupMaxmind(clean, dbPath);
geoipCache.set(cacheKey, {result, expiresAt: now + CACHE_TTL_MS});
setCachedGeoipResult(cacheKey, result);
return result;
}
+16
View File
@@ -85,6 +85,18 @@ function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
return Array.from(normalized);
}
function normalizeCountryCodes(values: Array<string>, configName: string): ReadonlySet<string> {
const normalized = new Set<string>();
for (const value of values) {
const countryCode = value.trim().toUpperCase();
if (!/^[A-Z]{2}$/u.test(countryCode)) {
throw new Error(`${configName} contains an invalid ISO 3166-1 alpha-2 country code: ${value}`);
}
normalized.add(countryCode);
}
return normalized;
}
function mapPushProviderApps(
apps:
| Array<{
@@ -142,6 +154,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
nodeEnv: master.env === 'test' ? 'development' : master.env,
port: master.services.api.port,
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
desktopGitHubRedirectCountries: normalizeCountryCodes(
master.services.api.desktop_github_redirect_countries,
'FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES',
),
cassandra: {
hosts: cassandraSource?.hosts.join(',') ?? '',
port: cassandraSource?.port ?? 9042,
@@ -1191,7 +1191,7 @@ export class MessageSendService {
await this.deps.mentionService.handleMentionTasks({
guildId: channel.guildId,
message,
authorId: createUserID(0n),
authorId: createUserID(BigInt(webhook.id)),
mentionHere: mentionData?.mentionHere ?? false,
});
await this.deps.dispatchService.dispatchMessageCreate({
+1
View File
@@ -35,6 +35,7 @@ export interface APIConfig {
nodeEnv: 'development' | 'production';
port: number;
ipBanExemptIps: Array<string>;
desktopGitHubRedirectCountries: ReadonlySet<string>;
cassandra: {
hosts: string;
port: number;
@@ -0,0 +1,202 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {DesktopArch, DesktopChannel, DesktopPlatform} from '@fluxer/schema/src/domains/download/DownloadSchemas';
import {isJsonRecord} from '../utils/JsonBoundaryUtils';
const DESKTOP_BUCKET_PREFIX = 'desktop';
const MIN_RELEASE_ROUTE_COUNT = 28;
const MAX_RELEASE_ROUTE_COUNT = 128;
const MIN_RELEASE_ASSET_COUNT = 24;
interface DesktopReleaseAsset {
storage_key: string;
release_asset: string;
sha256: string;
size: number;
}
interface DesktopReleaseDescriptor {
schema_version: 1;
channel: DesktopChannel;
version: string;
release_tag: string;
source_sha: string;
assets: Array<DesktopReleaseAsset>;
}
interface DesktopReleaseReadiness {
schema_version: 1;
channel: DesktopChannel;
version: string;
release_tag: string;
source_sha: string;
descriptor_sha256: string;
}
interface DesktopArtifactScope {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
}
export function parseDesktopArtifactScope(key: string): DesktopArtifactScope | null {
const segments = key.split('/');
if (segments.length !== 5 || segments[0] !== DESKTOP_BUCKET_PREFIX || segments[4].length === 0) {
return null;
}
const [, channel, plat, arch] = segments;
if (
(channel !== 'stable' && channel !== 'canary') ||
(plat !== 'win32' && plat !== 'darwin' && plat !== 'linux') ||
(arch !== 'x64' && arch !== 'arm64')
) {
return null;
}
return {channel, plat, arch};
}
function parseDesktopReleaseAsset(value: unknown): DesktopReleaseAsset | null {
if (
!isJsonRecord(value) ||
typeof value.storage_key !== 'string' ||
typeof value.release_asset !== 'string' ||
typeof value.sha256 !== 'string' ||
typeof value.size !== 'number'
) {
return null;
}
if (
!/^desktop\/(?:stable|canary)\/(?:win32|darwin|linux)\/(?:x64|arm64)\/[A-Za-z0-9._-]+$/u.test(value.storage_key) ||
!/^[A-Za-z0-9._-]+$/u.test(value.release_asset) ||
!/^[a-f0-9]{64}$/u.test(value.sha256) ||
!Number.isSafeInteger(value.size) ||
value.size <= 0
) {
return null;
}
return {
storage_key: value.storage_key,
release_asset: value.release_asset,
sha256: value.sha256,
size: value.size,
};
}
export function parseDesktopReleaseDescriptor(value: unknown): DesktopReleaseDescriptor | null {
if (
!isJsonRecord(value) ||
value.schema_version !== 1 ||
(value.channel !== 'stable' && value.channel !== 'canary') ||
typeof value.version !== 'string' ||
!/^\d+\.\d+\.\d+$/u.test(value.version) ||
typeof value.release_tag !== 'string' ||
typeof value.source_sha !== 'string' ||
!/^[a-f0-9]{40}$/u.test(value.source_sha) ||
!Array.isArray(value.assets) ||
value.assets.length < MIN_RELEASE_ROUTE_COUNT ||
value.assets.length > MAX_RELEASE_ROUTE_COUNT
) {
return null;
}
const expectedTag = `fluxer-desktop-${value.channel}@${value.version}`;
const expectedStoragePrefix = `desktop/${value.channel}/`;
const expectedReleasePrefix = `${value.channel === 'canary' ? 'Fluxer-Canary' : 'Fluxer'}-${value.version}-`;
const descriptorName = `${expectedReleasePrefix}release-manifest.json`;
if (value.release_tag !== expectedTag) {
return null;
}
const storageKeys = new Set<string>();
const routeCounts = new Map<string, number>();
const releaseAssets = new Map<string, {sha256: string; size: number}>();
const releaseAssetNames = new Map<string, string>([[descriptorName.toLowerCase(), descriptorName]]);
const assets: Array<DesktopReleaseAsset> = [];
for (const rawAsset of value.assets) {
const asset = parseDesktopReleaseAsset(rawAsset);
if (
!asset ||
!asset.storage_key.startsWith(expectedStoragePrefix) ||
!asset.release_asset.startsWith(expectedReleasePrefix) ||
storageKeys.has(asset.storage_key)
) {
return null;
}
storageKeys.add(asset.storage_key);
const [, , platform, arch, filename] = asset.storage_key.split('/');
const platformToken = platform === 'win32' ? 'win' : platform === 'darwin' ? 'mac' : 'linux';
const releaseFilename =
platform === 'darwin' && filename.toLowerCase() === 'releases.json' ? 'releases.json' : filename;
const expectedReleaseAsset = filename.startsWith(expectedReleasePrefix)
? filename
: `${expectedReleasePrefix}${platformToken}-${arch}-${releaseFilename}`;
if (
asset.release_asset !== expectedReleaseAsset ||
asset.release_asset.toLowerCase() === descriptorName.toLowerCase()
) {
return null;
}
const caseFoldedReleaseAsset = asset.release_asset.toLowerCase();
const existingReleaseAssetName = releaseAssetNames.get(caseFoldedReleaseAsset);
if (existingReleaseAssetName && existingReleaseAssetName !== asset.release_asset) {
return null;
}
releaseAssetNames.set(caseFoldedReleaseAsset, asset.release_asset);
const scope = `${platform}/${arch}`;
routeCounts.set(scope, (routeCounts.get(scope) ?? 0) + 1);
const existing = releaseAssets.get(asset.release_asset);
if (existing && (existing.sha256 !== asset.sha256 || existing.size !== asset.size)) {
return null;
}
releaseAssets.set(asset.release_asset, {sha256: asset.sha256, size: asset.size});
assets.push(asset);
}
if (releaseAssets.size < MIN_RELEASE_ASSET_COUNT || releaseAssets.size > MAX_RELEASE_ROUTE_COUNT) {
return null;
}
const expectedRouteCounts = new Map([
['darwin/arm64', 4],
['darwin/x64', 4],
['linux/arm64', 4],
['linux/x64', 4],
['win32/arm64', 6],
['win32/x64', 6],
]);
if (
routeCounts.size !== expectedRouteCounts.size ||
Array.from(expectedRouteCounts).some(([scope, count]) => (routeCounts.get(scope) ?? 0) < count)
) {
return null;
}
return {
schema_version: 1,
channel: value.channel,
version: value.version,
release_tag: value.release_tag,
source_sha: value.source_sha,
assets,
};
}
export function parseDesktopReleaseReadiness(value: unknown): DesktopReleaseReadiness | null {
if (
!isJsonRecord(value) ||
value.schema_version !== 1 ||
(value.channel !== 'stable' && value.channel !== 'canary') ||
typeof value.version !== 'string' ||
!/^\d+\.\d+\.\d+$/u.test(value.version) ||
typeof value.release_tag !== 'string' ||
typeof value.source_sha !== 'string' ||
!/^[a-f0-9]{40}$/u.test(value.source_sha) ||
typeof value.descriptor_sha256 !== 'string' ||
!/^[a-f0-9]{64}$/u.test(value.descriptor_sha256)
) {
return null;
}
return {
schema_version: 1,
channel: value.channel,
version: value.version,
release_tag: value.release_tag,
source_sha: value.source_sha,
descriptor_sha256: value.descriptor_sha256,
};
}
@@ -17,6 +17,7 @@ import {Config} from '../Config';
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
import type {HonoEnv} from '../types/HonoEnv';
import {Validator} from '../Validator';
import {resolveArtifactRoute} from './DownloadRouting';
import type {DesktopChecksumFile, DownloadService, DownloadStreamResult} from './DownloadService';
import {
DESKTOP_REDIRECT_PREFIX,
@@ -29,6 +30,17 @@ function artifactFilename(key: string, filenameOverride?: string): string {
return filenameOverride ?? key.split('/').pop() ?? 'download';
}
function artifactRedirectResponse(location: string, cacheControl = 'no-store'): Response {
return new Response(null, {
status: 302,
headers: new Headers({
Location: location,
'Cache-Control': cacheControl,
'Accept-Ranges': 'bytes',
}),
});
}
function setCommonArtifactHeaders(
headers: Headers,
key: string,
@@ -87,8 +99,12 @@ async function streamArtifactResponse(
cacheControl: string,
filenameOverride?: string,
): Promise<Response> {
const route = await resolveArtifactRoute({request: ctx.req.raw, downloadService, key, cacheControl});
if (route.kind === 'redirect') {
return artifactRedirectResponse(route.location, route.cacheControl);
}
if (ctx.req.method === 'HEAD') {
return headArtifactResponse(ctx, downloadService, key, cacheControl, filenameOverride);
return headArtifactResponse(ctx, downloadService, key, route.cacheControl, filenameOverride);
}
if (downloadService.isPresignedDownloadEnabled()) {
const location = await downloadService.getPresignedDownloadRedirect({
@@ -99,14 +115,7 @@ async function streamArtifactResponse(
if (!location) {
return ctx.text('Not Found', 404);
}
return new Response(null, {
status: 302,
headers: new Headers({
Location: location,
'Cache-Control': 'no-store',
'Accept-Ranges': 'bytes',
}),
});
return artifactRedirectResponse(location);
}
const range = ctx.req.header('range') ?? undefined;
let result: DownloadStreamResult | null;
@@ -117,7 +126,7 @@ async function streamArtifactResponse(
const headers = new Headers();
headers.set('Accept-Ranges', 'bytes');
headers.set('Content-Range', `bytes */${error.totalSize}`);
headers.set('Cache-Control', cacheControl);
headers.set('Cache-Control', route.cacheControl);
return new Response(null, {status: 416, headers});
}
throw error;
@@ -129,7 +138,7 @@ async function streamArtifactResponse(
setCommonArtifactHeaders(
headers,
key,
cacheControl,
route.cacheControl,
filenameOverride,
result.contentType,
result.contentDisposition,
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '../Config';
import {Logger} from '../Logger';
import {lookupGeoip} from '../utils/IpUtils';
import {parseDesktopArtifactScope} from './DesktopReleaseContract';
import type {DownloadService, GitHubDesktopReleaseResolution} from './DownloadService';
const COUNTRY_DEPENDENT_CACHE_CONTROL = 'private, no-store';
type ArtifactRoute =
| {kind: 'storage'; cacheControl: string}
| {kind: 'redirect'; cacheControl: string; location: string};
export async function resolveArtifactRoute(params: {
request: Request;
downloadService: DownloadService;
key: string;
cacheControl: string;
}): Promise<ArtifactRoute> {
if (Config.instance.selfHosted) {
return {kind: 'storage', cacheControl: params.cacheControl};
}
if (Config.desktopGitHubRedirectCountries.size === 0) {
return {kind: 'storage', cacheControl: params.cacheControl};
}
if (!parseDesktopArtifactScope(params.key)) {
return {kind: 'storage', cacheControl: params.cacheControl};
}
const geoip = await lookupGeoip(params.request);
const countryCode = geoip.countryCode?.trim().toUpperCase();
if (!countryCode || !Config.desktopGitHubRedirectCountries.has(countryCode)) {
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
}
let release: GitHubDesktopReleaseResolution;
try {
release = await params.downloadService.resolveGitHubDesktopRelease(params.key);
} catch (error) {
Logger.error({error, key: params.key}, 'Failed to resolve GitHub desktop download route');
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
}
if (release.kind === 'not_current') {
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
}
if (release.kind === 'ready') {
return {
kind: 'redirect',
cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL,
location: release.location,
};
}
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
}
+115 -27
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {posix} from 'node:path';
import {Readable} from 'node:stream';
import {S3ServiceException} from '@aws-sdk/client-s3';
@@ -12,6 +13,11 @@ import type {
import {Config} from '../Config';
import type {IStorageService} from '../infrastructure/IStorageService';
import {isJsonRecord, parseJsonUnknown} from '../utils/JsonBoundaryUtils';
import {
parseDesktopArtifactScope,
parseDesktopReleaseDescriptor,
parseDesktopReleaseReadiness,
} from './DesktopReleaseContract';
export const DOWNLOAD_PREFIX = '/dl';
export const DESKTOP_REDIRECT_PREFIX = `${DOWNLOAD_PREFIX}/desktop`;
@@ -46,6 +52,8 @@ function isUnsatisfiableRangeError(error: unknown): boolean {
const DESKTOP_BUCKET_PREFIX = 'desktop';
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
const DEFAULT_API_CLIENT_BASE_URL = 'https://api.fluxer.app';
const GITHUB_RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/fluxerapp/fluxer/releases/download';
const GITHUB_RELEASE_MARKER_DIRECTORY = 'github-releases';
function desktopBucketPrefix(test?: boolean): string {
return test ? DESKTOP_TEST_BUCKET_PREFIX : DESKTOP_BUCKET_PREFIX;
@@ -176,9 +184,70 @@ interface ManifestFilenameResolutionParams extends LatestFilenameLookupParams {
filename: string;
}
export type GitHubDesktopReleaseResolution =
| {kind: 'not_current'}
| {kind: 'awaiting_release'}
| {kind: 'ready'; location: string};
export class DownloadService {
constructor(private readonly storageService: IStorageService) {}
async resolveGitHubDesktopRelease(key: string): Promise<GitHubDesktopReleaseResolution> {
const scope = parseDesktopArtifactScope(key);
if (!scope) {
return {kind: 'not_current'};
}
const manifestKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${scope.plat}/${scope.arch}/manifest.json`;
const manifest = await this.readOptionalJsonObjectFromStorage(manifestKey);
if (
!isDesktopManifest(manifest) ||
manifest.channel !== scope.channel ||
manifest.platform !== scope.plat ||
manifest.arch !== scope.arch
) {
return {kind: 'not_current'};
}
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`;
const descriptorText = await this.readOptionalTextFromStorage(descriptorKey);
if (descriptorText == null) {
return {kind: 'not_current'};
}
const descriptor = parseDesktopReleaseDescriptor(parseJsonUnknown(descriptorText));
if (
!descriptor ||
descriptor.channel !== scope.channel ||
descriptor.version !== manifest.version ||
descriptor.release_tag !== `fluxer-desktop-${scope.channel}@${manifest.version}`
) {
throw new Error(`Invalid GitHub desktop release descriptor: ${descriptorKey}`);
}
const releaseAsset = descriptor.assets.find((asset) => asset.storage_key === key);
if (!releaseAsset) {
return {kind: 'not_current'};
}
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.ready.json`;
const marker = await this.readOptionalJsonObjectFromStorage(markerKey);
if (marker == null) {
return {kind: 'awaiting_release'};
}
const readiness = parseDesktopReleaseReadiness(marker);
const descriptorSha256 = createHash('sha256').update(descriptorText).digest('hex');
if (
!readiness ||
readiness.channel !== descriptor.channel ||
readiness.version !== descriptor.version ||
readiness.release_tag !== descriptor.release_tag ||
readiness.source_sha !== descriptor.source_sha ||
readiness.descriptor_sha256 !== descriptorSha256
) {
throw new Error(`Invalid GitHub desktop release readiness marker: ${markerKey}`);
}
return {
kind: 'ready',
location: `${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${encodeURIComponent(descriptor.release_tag)}/${encodeURIComponent(releaseAsset.release_asset)}`,
};
}
async resolveLatestDesktopKey(params: {
channel: DesktopChannel;
plat: DesktopPlatform;
@@ -659,6 +728,11 @@ export class DownloadService {
}
private async readJsonObjectFromStorage(key: string): Promise<unknown | null> {
const text = await this.readTextFromStorage(key);
return text == null ? null : parseJsonUnknown(text);
}
private async readTextFromStorage(key: string): Promise<string | null> {
const streamResult = await this.storageService.streamObject({
bucket: Config.s3.buckets.downloads,
key,
@@ -667,8 +741,29 @@ export class DownloadService {
return null;
}
const body = Readable.toWeb(streamResult.body);
const text = await new Response(body as ReadableStream).text();
return parseJsonUnknown(text);
return new Response(body as ReadableStream).text();
}
private async readOptionalJsonObjectFromStorage(key: string): Promise<unknown | null> {
try {
return await this.readJsonObjectFromStorage(key);
} catch (error) {
if (isStorageNotFoundError(error)) {
return null;
}
throw error;
}
}
private async readOptionalTextFromStorage(key: string): Promise<string | null> {
try {
return await this.readTextFromStorage(key);
} catch (error) {
if (isStorageNotFoundError(error)) {
return null;
}
throw error;
}
}
private isValidSha256(value: string): boolean {
@@ -774,9 +869,11 @@ export class DownloadService {
const {ext, arch: archMap} = mapping;
const filenames = new Set<string>();
for (const archSuffix of this.getArchTokens(archMap[arch as 'x64' | 'arm64'])) {
const modernFilename = this.buildModernArtifactFilename(channel, version, plat, archSuffix, ext);
if (modernFilename) {
filenames.add(modernFilename);
for (const productName of this.getModernProductNames(channel)) {
filenames.add(`${productName}-${version}-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`);
if (format === 'portable') {
filenames.add(`${productName}-${version}-portable-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`);
}
}
if (format === 'setup') {
filenames.add(`fluxer-${channel}-${version}-${archSuffix}-setup${ext}`);
@@ -784,9 +881,6 @@ export class DownloadService {
filenames.add(`fluxer-${version}-${archSuffix}-setup${ext}`);
filenames.add(`Fluxer-${version}-${archSuffix}-Setup${ext}`);
} else if (format === 'portable') {
filenames.add(
`${this.getModernProductName(channel)}-${version}-portable-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`,
);
filenames.add(`fluxer-${channel}-${version}-portable-${archSuffix}${ext}`);
filenames.add(`Fluxer-${version}-portable-${archSuffix}${ext}`);
} else {
@@ -816,7 +910,6 @@ export class DownloadService {
}
const {ext, arch: archMap} = mapping;
const escapedExt = this.escapeRegex(ext);
const escapedModernFilenamePrefix = this.escapeRegex(this.getModernProductName(channel));
const modernPlatformToken = MODERN_PLATFORM_TOKENS[plat];
for (const archSuffix of this.getArchTokens(archMap[arch as 'x64' | 'arm64'])) {
const patterns = [
@@ -828,18 +921,23 @@ export class DownloadService {
`^[Ff]luxer-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(archSuffix)}(?:-[Ss]etup)?${escapedExt}$`,
'u',
),
new RegExp(
`^${escapedModernFilenamePrefix}-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
'iu',
),
];
if (format === 'portable') {
for (const productName of this.getModernProductNames(channel)) {
const escapedProductName = this.escapeRegex(productName);
patterns.push(
new RegExp(
`^${escapedModernFilenamePrefix}-(\\d+\\.\\d+\\.\\d+)-portable-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
`^${escapedProductName}-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
'iu',
),
);
if (format === 'portable') {
patterns.push(
new RegExp(
`^${escapedProductName}-(\\d+\\.\\d+\\.\\d+)-portable-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
'iu',
),
);
}
}
for (const pattern of patterns) {
const match = filename.match(pattern);
@@ -852,18 +950,8 @@ export class DownloadService {
return null;
}
private getModernProductName(channel: DesktopChannel): string {
return channel === 'canary' ? 'Fluxer Canary' : 'Fluxer';
}
private buildModernArtifactFilename(
channel: DesktopChannel,
version: string,
plat: DesktopPlatform,
archToken: string,
ext: string,
): string {
return `${this.getModernProductName(channel)}-${version}-${MODERN_PLATFORM_TOKENS[plat]}-${archToken}${ext}`;
private getModernProductNames(channel: DesktopChannel): Array<string> {
return channel === 'canary' ? ['Fluxer-Canary', 'Fluxer Canary'] : ['Fluxer'];
}
private getArchTokens(archToken: string | Array<string>): Array<string> {
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import type {WorkerTaskHandler, WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import {z} from 'zod';
import {
@@ -19,6 +20,7 @@ import {Logger} from '../../Logger';
import {getWorkerDependencies} from '../WorkerContext';
const MENTION_CHUNK_SIZE = 250;
const MENTION_CHUNK_KEY_DIGEST_LENGTH = 32;
const MENTION_CHUNK_ENQUEUE_CONCURRENCY = 16;
const MENTION_SOURCE_PAGE_SIZE = 5000;
const PayloadSchema = z.object({
@@ -100,6 +102,14 @@ function toMentionChunkEntry(entry: GatewayMentionSourceEntry): MentionChunkEntr
};
}
function mentionChunkJobKey(messageId: string, chunk: Array<MentionChunkEntry>): string {
const digest = createHash('sha256');
for (const entry of chunk) {
digest.update(`${entry.userId}:${entry.direct ? 1 : 0}:${entry.role ? 1 : 0}:${entry.everyone ? 1 : 0}\n`);
}
return `mention-chunk:${messageId}:${digest.digest('hex').slice(0, MENTION_CHUNK_KEY_DIGEST_LENGTH)}`;
}
async function enqueueMentionChunks({
chunks,
channelId,
@@ -122,7 +132,6 @@ async function enqueueMentionChunks({
await Promise.all(
chunkSlice.map((chunk, localIndex) => {
const index = firstChunkIndex + offset + localIndex;
const jobKeySuffix = chunkCount === undefined ? `${index}` : `${index}:${chunkCount}`;
return addJob(
'handleMentionChunk',
{
@@ -134,7 +143,7 @@ async function enqueueMentionChunks({
mentions: chunk,
},
{
jobKey: `mention-chunk:${messageId}:${jobKeySuffix}`,
jobKey: mentionChunkJobKey(messageId, chunk),
skipLedger: true,
},
);
@@ -8,12 +8,11 @@ import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {Button} from '@app/features/ui/button/Button';
import {buildAppProtocolUrl} from '@app/features/ui/utils/AppProtocol';
import {isDesktop, openExternalUrl} from '@app/features/ui/utils/NativeUtils';
import {checkDesktopAvailable, navigateInDesktop} from '@app/features/voice/utils/DesktopRpcClient';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {ArrowSquareOutIcon} from '@phosphor-icons/react';
import type React from 'react';
import {useEffect, useState} from 'react';
import {useState} from 'react';
interface DesktopDeepLinkPromptProps {
code: string;
@@ -36,25 +35,9 @@ const FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR = msg({
export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({code, kind, preferLogin = false}) => {
const {i18n} = useLingui();
const [isLoading, setIsLoading] = useState(false);
const [desktopAvailable, setDesktopAvailable] = useState<boolean | null>(null);
const [error, setError] = useState<string | null>(null);
const isMobileBrowser = Platform.isMobileBrowser;
const useProtocolLaunch = kind === 'invite';
const shouldProbeDesktopAvailability = !useProtocolLaunch;
useEffect(() => {
if (isDesktop() || !shouldProbeDesktopAvailability) return;
let cancelled = false;
checkDesktopAvailable().then(({available}) => {
if (!cancelled) {
setDesktopAvailable(available);
}
});
return () => {
cancelled = true;
};
}, [shouldProbeDesktopAvailability]);
if (isDesktop() || isMobileBrowser) return null;
if (shouldProbeDesktopAvailability && desktopAvailable !== true) return null;
const getPath = (): string => {
switch (kind) {
case 'invite':
@@ -69,20 +52,12 @@ export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({cod
const handleOpen = async () => {
setIsLoading(true);
setError(null);
if (useProtocolLaunch) {
try {
await openExternalUrl(buildAppProtocolUrl(path));
} catch {
setError(i18n._(FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR));
} finally {
setIsLoading(false);
}
return;
}
const result = await navigateInDesktop(path);
setIsLoading(false);
if (!result.success) {
try {
await openExternalUrl(buildAppProtocolUrl(path));
} catch {
setError(i18n._(FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR));
} finally {
setIsLoading(false);
}
};
return (
@@ -14,10 +14,11 @@ import {useVirtualRows} from '@app/features/channel/components/sticker_picker/ho
import {StickerPickerCategoryList} from '@app/features/channel/components/sticker_picker/StickerPickerCategoryList';
import {
buildStickerRowOffsets,
getStickerRowHeight,
getFixedStickerRowHeight,
getStickerGridColumns,
getStickerRowWindow,
STICKER_GRID_TRACK_WIDTH,
STICKER_SECTION_GAP,
STICKERS_PER_ROW,
type StickerRowKind,
} from '@app/features/channel/components/sticker_picker/StickerPickerConstants';
import {StickerPickerInspector} from '@app/features/channel/components/sticker_picker/StickerPickerInspector';
@@ -74,12 +75,12 @@ export const StickersPicker = observer(
const [selectedColumn, setSelectedColumn] = useState(-1);
const [shouldScrollOnSelection, setShouldScrollOnSelection] = useState(false);
const scrollerRef = useRef<ScrollerHandle>(null);
const {scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef);
const {viewportSize, scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef);
const searchInputRef = useRef<HTMLInputElement>(null);
const stickerRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const channel = channelId ? (Channels.getChannel(channelId) ?? null) : null;
const rowListRef = useRef<HTMLDivElement>(null);
const [listMetrics, setListMetrics] = useState({origin: 0, viewportHeight: 0, gridWidth: 0});
const [listMetrics, setListMetrics] = useState({origin: 0, viewportHeight: 0});
const [stickerDataVersion, setStickerDataVersion] = useState(0);
const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version);
const {shouldAnimate: shouldAnimateStickerPreview} = useStickerAnimation();
@@ -138,13 +139,26 @@ export const StickersPicker = observer(
allUpsell.accessibleItems,
renderedStickers,
);
const zoomLevel = Accessibility.zoomLevel;
const gridColumns = useMemo(() => getStickerGridColumns(viewportSize.width), [viewportSize.width, zoomLevel]);
const previousGridColumnsRef = useRef(gridColumns);
useLayoutEffect(() => {
if (previousGridColumnsRef.current === gridColumns) {
return;
}
previousGridColumnsRef.current = gridColumns;
setHoveredSticker(null);
setSelectedRow(-1);
setSelectedColumn(-1);
setShouldScrollOnSelection(false);
}, [gridColumns]);
const pickerRows = useVirtualRows(
searchTerm,
renderedStickers,
favoriteStickers,
frequentlyUsedStickers,
stickersByGuildId,
STICKERS_PER_ROW,
gridColumns,
);
const hasNoStickersAtAll = allItems.length === 0;
const isSearching = searchTerm.trim().length > 0;
@@ -198,12 +212,8 @@ export const StickersPicker = observer(
}
return {stickerRowIndexes: indexes, stickerRowStarts: starts, categoryRowIndexes: categories};
}, [pickerRows]);
const zoomLevel = Accessibility.zoomLevel;
const remScale = getAppRemScale();
const stickerRowHeight = useMemo(
() => getStickerRowHeight(listMetrics.gridWidth, STICKERS_PER_ROW, remScale),
[listMetrics.gridWidth, remScale, zoomLevel],
);
const stickerRowHeight = useMemo(() => getFixedStickerRowHeight(remScale), [remScale, zoomLevel]);
const rowOffsets = useMemo(() => {
const rowKinds = pickerRows.map((row): StickerRowKind => row.type);
return buildStickerRowOffsets(rowKinds, {remScale, stickerRowHeight, sectionGap: STICKER_SECTION_GAP});
@@ -219,11 +229,8 @@ export const StickersPicker = observer(
rowListNode.getBoundingClientRect().top - scrollerNode.getBoundingClientRect().top + scrollerNode.scrollTop,
);
const viewportHeight = scrollerNode.clientHeight;
const gridWidth = rowListNode.clientWidth;
setListMetrics((current) =>
current.origin === origin && current.viewportHeight === viewportHeight && current.gridWidth === gridWidth
? current
: {origin, viewportHeight, gridWidth},
current.origin === origin && current.viewportHeight === viewportHeight ? current : {origin, viewportHeight},
);
});
const rowWindow = useMemo(
@@ -394,7 +401,8 @@ export const StickersPicker = observer(
row={row}
handleHover={handleHover}
handleSelect={handleStickerSelect}
gridColumns={STICKERS_PER_ROW}
gridColumns={gridColumns}
cellTrackWidth={STICKER_GRID_TRACK_WIDTH}
selectedRow={selectedRow}
selectedColumn={selectedColumn}
stickerRowIndex={stickerRowIndexes[rowIndex]!}
@@ -125,7 +125,7 @@ export const AddFriendForm: React.FC<AddFriendFormProps> = observer(({onSuccess}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const [username, discriminator] = parseInput(input);
const [username, discriminator] = parseInput(input['trim']());
if (!username || !discriminator || !/^\d{4}$/.test(discriminator)) {
setResultStatus('error');
setErrorCode(APIErrorCodes.NO_USERS_WITH_FLUXERTAG_EXIST);
@@ -208,9 +208,10 @@ export const AttachmentGridItem: FC<AttachmentGridItemProps> = observer(
messageId: message?.id,
message,
sourceChannel: messageViewContext?.channel,
allowAttachmentDelete: !isPreview && snapshotIndex === undefined,
});
},
[attachment, message, messageViewContext?.channel, mediaAttachments, shouldBlur],
[attachment, message, messageViewContext?.channel, mediaAttachments, shouldBlur, isPreview, snapshotIndex],
);
const openInBrowser = useOpenInBrowserOnMiddleClick(attachment.url ?? attachment.proxy_url ?? '', !shouldBlur);
const handleFavoriteClick = useCallback(
@@ -181,6 +181,7 @@ const useImagePreview = ({
message,
sourceChannel,
providerName,
allowAttachmentDelete = false,
}: {
proxyUrl: string;
embedUrl: string;
@@ -195,6 +196,7 @@ const useImagePreview = ({
message?: Message;
sourceChannel?: Channel | null;
providerName?: string;
allowAttachmentDelete?: boolean;
}): {viewerItem: MediaViewerItem; openPreview: (event: React.MouseEvent | React.KeyboardEvent) => void} => {
const viewerItem = useMemo<MediaViewerItem>(
() => ({
@@ -223,9 +225,10 @@ const useImagePreview = ({
messageId,
message,
sourceChannel,
allowAttachmentDelete,
});
},
[viewerItem, channelId, messageId, message, sourceChannel],
[viewerItem, channelId, messageId, message, sourceChannel, allowAttachmentDelete],
);
return {viewerItem, openPreview};
};
@@ -461,6 +464,7 @@ export const EmbedGifv: FC<
message,
sourceChannel: messageViewContext?.channel,
providerName,
allowAttachmentDelete: !isPreview && snapshotIndex === undefined,
});
const handleDeleteClick = useDeleteAttachment(message, attachmentId);
const handleDownloadClick = useCallback(
@@ -795,6 +799,7 @@ export const EmbedGif: FC<
contentHash,
message,
sourceChannel: messageViewContext?.channel,
allowAttachmentDelete: !isPreview && snapshotIndex === undefined,
});
const {scheduleViewerWarm, cancelViewerWarm} = useMediaViewerHoverWarm(viewerItem, {
allowAnimated: gifAutoPlay,
@@ -104,6 +104,7 @@ interface ImagePreviewHandlerProps {
message?: Message;
animated?: boolean;
mediaAttachments?: ReadonlyArray<MessageAttachment>;
allowAttachmentDelete?: boolean;
onViewerWarmEnter?: () => void;
onViewerWarmLeave?: () => void;
children: React.ReactNode;
@@ -143,6 +144,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
message,
animated,
mediaAttachments = NO_MEDIA_ATTACHMENTS,
allowAttachmentDelete = false,
onViewerWarmEnter,
onViewerWarmLeave,
children,
@@ -176,6 +178,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
messageId,
message,
sourceChannel: messageViewContext?.channel,
allowAttachmentDelete,
});
} else {
MediaViewerCommands.openMediaViewer(
@@ -187,6 +190,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
naturalHeight,
type: 'image' as const,
contentHash,
attachmentId,
embedIndex,
expiresAt: undefined,
expired: undefined,
@@ -199,6 +203,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
messageId,
message,
sourceChannel: messageViewContext?.channel,
allowAttachmentDelete,
},
);
}
@@ -217,6 +222,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
message,
messageViewContext?.channel,
mediaAttachments,
allowAttachmentDelete,
],
);
const openInBrowser = useOpenInBrowserOnMiddleClick(originalSrc || src);
@@ -381,6 +387,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
[originalSrc, src],
);
const handleDeleteClick = useDeleteAttachment(message, attachmentId);
const allowAttachmentDelete = !isPreview && snapshotIndex === undefined;
const [mediaSheetOpen, setMediaSheetOpen] = useState(false);
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
@@ -524,6 +531,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
attachmentId={attachmentId}
message={message}
mediaAttachments={mediaAttachments}
allowAttachmentDelete={allowAttachmentDelete}
animated={animated}
onViewerWarmEnter={scheduleViewerWarm}
onViewerWarmLeave={cancelViewerWarm}
@@ -272,6 +272,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
[src],
);
const handleDeleteClick = useDeleteAttachment(message, attachmentId);
const allowAttachmentDelete = !isPreview && snapshotIndex === undefined;
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
if (!message) return;
@@ -396,6 +397,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
messageId,
message,
sourceChannel: messageViewContext?.channel,
allowAttachmentDelete,
});
} else {
MediaViewerCommands.openMediaViewer(
@@ -407,12 +409,13 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
naturalHeight: viewerVideoDimensions.height,
type: 'video' as const,
contentHash,
attachmentId,
embedIndex,
duration,
},
],
0,
{channelId, messageId, message, sourceChannel: messageViewContext?.channel},
{channelId, messageId, message, sourceChannel: messageViewContext?.channel, allowAttachmentDelete},
);
}
}, [
@@ -420,6 +423,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
messageId,
message,
messageViewContext?.channel,
allowAttachmentDelete,
mediaAttachments,
attachmentId,
effectiveSrc,
@@ -451,6 +455,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
messageId,
message,
sourceChannel: messageViewContext?.channel,
allowAttachmentDelete,
});
} else {
MediaViewerCommands.openMediaViewer(
@@ -462,13 +467,14 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
naturalHeight: viewerVideoDimensions.height,
type: 'video' as const,
contentHash,
attachmentId,
embedIndex,
duration,
initialTime: currentTime,
},
],
0,
{channelId, messageId, message, sourceChannel: messageViewContext?.channel},
{channelId, messageId, message, sourceChannel: messageViewContext?.channel, allowAttachmentDelete},
);
}
setIsPlayingInline(false);
@@ -477,6 +483,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
messageId,
message,
messageViewContext?.channel,
allowAttachmentDelete,
mediaAttachments,
attachmentId,
effectiveSrc,
@@ -1,9 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx';
export const STICKERS_PER_ROW = 4;
export const STICKERS_PER_ROW_MOBILE = 5;
export const STICKER_CATEGORY_HEADER_HEIGHT = 32;
export const STICKER_SECTION_GAP = 12;
export const STICKER_GRID_TRACK_WIDTH = 120;
const STICKER_CATEGORY_HEADER_GAP = 8;
const STICKER_GRID_GAP = 8;
@@ -23,6 +26,20 @@ export interface StickerRowWindow {
lastRow: number;
}
export function getStickerGridColumns(containerWidth: number): number {
if (containerWidth <= 0) {
return STICKERS_PER_ROW;
}
const remScale = getRemScaleForDocument(typeof document === 'undefined' ? null : document);
const trackWidth = STICKER_GRID_TRACK_WIDTH * remScale;
const gap = STICKER_GRID_GAP * remScale;
return Math.max(1, Math.floor((containerWidth + gap) / (trackWidth + gap)));
}
export function getFixedStickerRowHeight(remScale: number): number {
return (STICKER_GRID_TRACK_WIDTH + STICKER_GRID_BLOCK_END_PADDING) * remScale;
}
export function getStickerRowHeight(gridWidth: number, gridColumns: number, remScale: number): number {
if (!(gridWidth > 0) || gridColumns <= 0) {
return 0;
@@ -20,6 +20,11 @@
padding: 0 0 0.5rem;
}
.stickerGridFixedTrack {
grid-auto-flow: column;
justify-content: start;
}
.stickerButton {
position: relative;
display: flex;
@@ -27,6 +27,7 @@ interface VirtualRowRendererProps {
handleHover: (sticker: GuildSticker | null, row?: number, column?: number) => void;
handleSelect: (sticker: GuildSticker, shiftKey?: boolean) => void;
gridColumns?: number;
cellTrackWidth?: number;
selectedRow: number;
selectedColumn: number;
stickerRowIndex: number;
@@ -126,6 +127,7 @@ const VirtualRowRendererBase: React.FC<VirtualRowRendererProps> = React.memo(
handleHover,
handleSelect,
gridColumns = 4,
cellTrackWidth,
selectedRow,
selectedColumn,
stickerRowIndex,
@@ -220,11 +222,14 @@ const VirtualRowRendererBase: React.FC<VirtualRowRendererProps> = React.memo(
);
}
if (row.type === 'sticker-row') {
const hasFixedTrack = cellTrackWidth != null && cellTrackWidth > 0;
return (
<div
className={styles.stickerGrid}
className={hasFixedTrack ? `${styles.stickerGrid} ${styles.stickerGridFixedTrack}` : styles.stickerGrid}
style={{
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
gridTemplateColumns: hasFixedTrack
? `repeat(auto-fill, ${remFromPx(cellTrackWidth)})`
: `repeat(${gridColumns}, minmax(0, 1fr))`,
}}
data-flx="channel.sticker-picker.virtual-row.virtual-row-renderer-base.sticker-grid"
>
@@ -286,6 +291,7 @@ interface VirtualRowWrapperProps {
handleHover: (sticker: GuildSticker | null, row?: number, column?: number) => void;
handleSelect: (sticker: GuildSticker, shiftKey?: boolean) => void;
gridColumns?: number;
cellTrackWidth?: number;
selectedRow: number;
selectedColumn: number;
stickerRowIndex: number;
@@ -300,6 +306,7 @@ export const VirtualRowWrapper: React.FC<VirtualRowWrapperProps> = observer(
handleHover,
handleSelect,
gridColumns,
cellTrackWidth,
selectedRow,
selectedColumn,
stickerRowIndex,
@@ -313,6 +320,7 @@ export const VirtualRowWrapper: React.FC<VirtualRowWrapperProps> = observer(
handleHover={handleHover}
handleSelect={handleSelect}
gridColumns={gridColumns}
cellTrackWidth={cellTrackWidth}
selectedRow={selectedRow}
selectedColumn={selectedColumn}
stickerRowIndex={stickerRowIndex}
@@ -95,6 +95,7 @@ export const MediaModal: FC<MediaModalProps> = observer(
onOpenInBrowser,
onCopyLink,
onCopyMedia,
onDeleteAttachment,
onReply,
onForward,
children,
@@ -636,6 +637,7 @@ export const MediaModal: FC<MediaModalProps> = observer(
onOpenInBrowser={onOpenInBrowser}
onCopyLink={onCopyLink}
onCopyMedia={onCopyMedia}
onDeleteAttachment={onDeleteAttachment}
onReset={handleResetMedia}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {useBottomSheetBackHandler} from '@app/features/app/hooks/useBottomSheetBackHandler';
import {deriveDefaultNameFromMessage} from '@app/features/channel/components/embeds/EmbedUtils';
import {useMessageActionMenuData} from '@app/features/channel/components/MessageActionMenu';
@@ -14,6 +15,7 @@ import type {ForwardModalSuccess} from '@app/features/messaging/components/modal
import {MediaModal} from '@app/features/messaging/components/modals/MediaModal';
import styles from '@app/features/messaging/components/modals/MediaViewerModal.module.css';
import {getMediaViewerPortalRoot} from '@app/features/messaging/components/modals/MediaViewerPortal';
import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment';
import {useMediaFavorite} from '@app/features/messaging/hooks/useMediaFavorite';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {formatAttachmentDate} from '@app/features/messaging/utils/AttachmentExpiryUtils';
@@ -273,7 +275,8 @@ const MobileMediaOptionsSheet: FC<MobileMediaOptionsSheetProps> = observer(funct
});
const MediaViewerModalComponent: FC = observer(() => {
const {i18n} = useLingui();
const {isOpen, items, currentIndex, channelId, messageId, message, sourceChannel} = MediaViewer;
const {isOpen, items, currentIndex, channelId, messageId, message, sourceChannel, allowAttachmentDelete} =
MediaViewer;
const {enabled: isMobile} = MobileLayout;
const [isMediaMenuOpen, setIsMediaMenuOpen] = useState(false);
const currentItem = items[currentIndex];
@@ -438,6 +441,22 @@ const MediaViewerModalComponent: FC = observer(() => {
() => (message ? untracked(() => getMessagePermissions(message, sourceChannel)) : null),
[message, sourceChannel],
);
const deletableAttachmentId =
Accessibility.showMediaDeleteButton &&
allowAttachmentDelete &&
permissions?.canDeleteAttachment &&
currentItem?.attachmentId &&
message?.attachments.some((attachment) => attachment.id === currentItem.attachmentId)
? currentItem.attachmentId
: undefined;
const handleAttachmentDeleted = useCallback(() => {
if (MediaViewer.isOpen && MediaViewer.items === items) {
MediaViewerCommands.closeMediaViewer();
}
}, [items]);
const handleDeleteAttachment = useDeleteAttachment(message, deletableAttachmentId, {
onDeleted: handleAttachmentDeleted,
});
const forwardMediaSelection = useMemo<MessageCommands.ForwardMediaSelection | undefined>(() => {
if (!currentItem) return undefined;
if (currentItem.attachmentId) {
@@ -672,6 +691,7 @@ const MediaViewerModalComponent: FC = observer(() => {
onOpenInBrowser={handleOpenInBrowser}
onCopyLink={handleCopyLink}
onCopyMedia={handleCopyMedia}
onDeleteAttachment={deletableAttachmentId ? handleDeleteAttachment : undefined}
onReply={permissions?.canSendMessages ? handleReply : undefined}
onForward={canForwardCurrentMedia ? handleForward : undefined}
enablePanZoom={currentItem.type === 'image' || currentItem.type === 'gif' || currentItem.type === 'gifv'}
@@ -3,6 +3,7 @@
import {
ADD_TO_FAVORITES_DESCRIPTOR,
COPY_LINK_DESCRIPTOR,
DELETE_ATTACHMENT_DESCRIPTOR,
REMOVE_FROM_FAVORITES_DESCRIPTOR,
ZOOM_IN_DESCRIPTOR,
ZOOM_OUT_DESCRIPTOR,
@@ -36,16 +37,17 @@ import {
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
StarIcon,
TrashIcon,
XIcon,
} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import {type FC, forwardRef, type ReactNode, type Ref} from 'react';
import {type FC, forwardRef, type MouseEventHandler, type ReactNode, type Ref} from 'react';
interface ControlButtonProps {
icon: ReactNode;
label: string;
onClick: () => void;
onClick: MouseEventHandler<HTMLButtonElement>;
variant?: 'default' | 'primary' | 'danger';
active?: boolean;
disabled?: boolean;
@@ -119,6 +121,7 @@ interface MediaOverlayActionsProps {
onOpenInBrowser?: () => void;
onCopyLink?: () => void;
onCopyMedia?: () => void;
onDeleteAttachment?: MouseEventHandler<HTMLButtonElement>;
onReset?: () => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
@@ -144,6 +147,7 @@ export const MediaOverlayActions: FC<MediaOverlayActionsProps> = observer(
onOpenInBrowser,
onCopyLink,
onCopyMedia,
onDeleteAttachment,
onReset,
onZoomIn,
onZoomOut,
@@ -250,6 +254,21 @@ export const MediaOverlayActions: FC<MediaOverlayActionsProps> = observer(
data-flx="messaging.media-modal.media-controls.media-overlay-actions.overlay-tooltip-button.download"
/>
)}
{onDeleteAttachment && (
<OverlayTooltipButton
icon={
<TrashIcon
size={20}
weight="bold"
data-flx="messaging.media-modal.media-overlay-actions.delete-attachment-icon"
/>
}
label={i18n._(DELETE_ATTACHMENT_DESCRIPTOR)}
onClick={onDeleteAttachment}
variant="danger"
data-flx="messaging.media-modal.media-controls.media-overlay-actions.overlay-tooltip-button.delete-attachment"
/>
)}
<div
className={styles.overlayActionGap}
aria-hidden="true"
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {msg} from '@lingui/core/macro';
import type {ReactNode} from 'react';
import type {MouseEventHandler, ReactNode} from 'react';
export type ZoomState = 'fit' | 'zoomed';
@@ -23,6 +23,7 @@ export interface MediaModalProps {
onOpenInBrowser?: () => void;
onCopyLink?: () => void;
onCopyMedia?: () => void;
onDeleteAttachment?: MouseEventHandler<HTMLButtonElement>;
onReply?: () => void;
onForward?: () => void;
onInfo?: () => void;
@@ -9,14 +9,26 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
import {Trans, useLingui} from '@lingui/react/macro';
import {useCallback} from 'react';
export function useDeleteAttachment(message: Message | null | undefined, attachmentId: string | null | undefined) {
interface DeleteAttachmentOptions {
onDeleted?: () => void;
}
export function useDeleteAttachment(
message: Message | null | undefined,
attachmentId: string | null | undefined,
options?: DeleteAttachmentOptions,
) {
const {i18n} = useLingui();
const onDeleted = options?.onDeleted;
return useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!message || !attachmentId) return;
const deleteAttachment = () => MessageCommands.deleteAttachment(message.channelId, message.id, attachmentId);
const deleteAttachment = async () => {
await MessageCommands.deleteAttachment(message.channelId, message.id, attachmentId);
onDeleted?.();
};
if (e.shiftKey) {
void deleteAttachment();
return;
@@ -39,6 +51,6 @@ export function useDeleteAttachment(message: Message | null | undefined, attachm
)),
);
},
[message, attachmentId, i18n],
[message, attachmentId, i18n, onDeleted],
);
}
@@ -180,10 +180,6 @@ export function handleDeepLinkUrl(rawUrl: string): boolean {
return true;
}
export function handleRpcNavigation(path: string): void {
RouterUtils.transitionTo(path);
}
let listenerStarted = false;
export async function startDeepLinkHandling(): Promise<void> {
@@ -206,17 +202,6 @@ export async function startDeepLinkHandling(): Promise<void> {
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;
}
}
@@ -344,7 +344,6 @@ export interface ElectronAPI {
pasteFromClipboard: () => Promise<void>;
onDeepLink: (callback: (url: string) => void) => () => void;
getInitialDeepLink: () => Promise<string | null>;
onRpcNavigate: (callback: (path: string) => void) => () => void;
autostartEnable: () => Promise<void>;
autostartDisable: () => Promise<void>;
autostartIsEnabled: () => Promise<boolean>;
@@ -9,6 +9,7 @@ interface MediaViewerOpenOptions {
messageId?: string;
message?: Message;
sourceChannel?: Channel | null;
allowAttachmentDelete?: boolean;
}
type MediaViewerIntent =
@@ -26,6 +27,7 @@ function dispatchMediaViewerIntent(intent: MediaViewerIntent): void {
intent.options?.messageId,
intent.options?.message,
intent.options?.sourceChannel,
intent.options?.allowAttachmentDelete,
);
return;
case 'close':
@@ -36,6 +36,7 @@ class MediaViewer {
messageId?: string = undefined;
message?: Message = undefined;
sourceChannel?: Channel = undefined;
allowAttachmentDelete: boolean = false;
constructor() {
makeAutoObservable(
@@ -56,6 +57,7 @@ class MediaViewer {
messageId?: string,
message?: Message,
sourceChannel?: Channel | null,
allowAttachmentDelete: boolean = false,
): void {
this.isOpen = true;
this.items = copyMediaViewerItems(items);
@@ -64,6 +66,7 @@ class MediaViewer {
this.messageId = messageId;
this.message = message;
this.sourceChannel = sourceChannel ?? undefined;
this.allowAttachmentDelete = allowAttachmentDelete;
}
close(): void {
@@ -74,6 +77,7 @@ class MediaViewer {
this.messageId = undefined;
this.message = undefined;
this.sourceChannel = undefined;
this.allowAttachmentDelete = false;
}
navigate(index: number): void {
@@ -314,5 +314,5 @@ export function getSortedLocales(): Array<TranslatedLocaleInfo> {
...locale,
name: i18n._(locale.name),
}))
.sort((a, b) => a.nativeName.localeCompare(b.nativeName));
.sort((a, b) => a.code.localeCompare(b.code));
}
@@ -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;
}
-1
View File
@@ -483,7 +483,6 @@ export interface ElectronAPI {
passkeyIsSupported?(): Promise<boolean>;
passkeyRegister?(options: unknown, requestContext?: {pin?: string}): Promise<RegistrationResponseJSON>;
passkeyAuthenticate?(options: unknown, requestContext?: {pin?: string}): Promise<AuthenticationResponseJSON>;
onRpcNavigate?(callback: (path: string) => void): () => void;
getOpenH264Status?(): Promise<OpenH264Status>;
setOpenH264Enabled?(enabled: boolean): Promise<OpenH264Status>;
virtmic?: VirtmicApi;
-2
View File
@@ -68,8 +68,6 @@ const CONNECT_SOURCES: &[&str] = &[
"https://challenges.cloudflare.com",
"https://fluxerstatus.com",
"https://fluxer.media",
"http://127.0.0.1:21863",
"http://127.0.0.1:21864",
];
const WORKER_SOURCES: &[&str] = &["https://*.fluxer.app", "blob:"];
+230 -43
View File
@@ -8,12 +8,14 @@ const path = require('node:path');
const {promisify} = require('node:util');
const execFileAsync = promisify(execFile);
const productName = isCanary ? 'Fluxer Canary' : 'Fluxer';
const artifactProductName = isCanary ? 'Fluxer-Canary' : 'Fluxer';
const appId = isCanary ? 'app.fluxer.canary' : 'app.fluxer';
const iconDir = isCanary ? 'icons-canary' : 'icons-stable';
const packageName = isCanary ? 'fluxer_desktop_canary' : 'fluxer_desktop';
const linuxPackageName = isCanary ? 'fluxer-canary' : 'fluxer';
const linuxDesktopActionIds = ['open-settings', 'new-dm'];
const linuxDesktopActionList = `${linuxDesktopActionIds.join(';')};`;
const linuxGlibcBaseline = Object.freeze({major: 2, minor: 35, patch: 0, name: 'GLIBC_2.35'});
const rpmBuildIdFilePrefix = '/usr/lib/.build-id';
const rpmBuildIdLinkFpmArgs = [
'--rpm-rpmbuild-define',
@@ -46,6 +48,8 @@ if (targetNativeArch === 'universal' && targetPlatform !== 'darwin') {
const targetArchs = electronArch && electronArch !== 'universal' ? [electronArch] : supportedTargetArchs;
const macTargetArchs = targetNativeArch ? [targetNativeArch] : supportedTargetArchs;
const winGameCaptureTargetArchs =
targetPlatform === 'win32' && targetNativeArch ? [targetNativeArch] : supportedTargetArchs;
const winTargets = [
{
target: 'dir',
@@ -149,11 +153,9 @@ const nativeRuntimeFilePatterns = [
'node_modules/@fluxer/win-game-capture/package.json',
'node_modules/@fluxer/win-game-capture/index.js',
'node_modules/@fluxer/win-game-capture/loader-diagnostics.cjs',
'node_modules/@fluxer/win-game-capture/*.node',
'node_modules/@fluxer/win-game-capture/*.dll',
'node_modules/@fluxer/win-game-capture/*.exe',
'node_modules/@fluxer/win-game-capture/compatibility.json',
'node_modules/@fluxer/win-game-capture/fluxer-vulkan-layer.*.json',
...winGameCaptureTargetArchs.map(
(arch) => `node_modules/@fluxer/win-game-capture/win-game-capture.win32-${arch}-msvc.node`,
),
'node_modules/@fluxer/win-clipboard/package.json',
'node_modules/@fluxer/win-clipboard/index.js',
'node_modules/@fluxer/win-clipboard/loader-diagnostics.cjs',
@@ -223,11 +225,10 @@ const nativeRuntimeFilePatterns = [
'node_modules/.pnpm/@fluxer+*/node_modules/@fluxer/*/loader-diagnostics.cjs',
'node_modules/.pnpm/@fluxer+*/node_modules/@fluxer/*/pure.cjs',
'node_modules/.pnpm/@fluxer+win-process-loopback@*/node_modules/@fluxer/win-process-loopback/*.node',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.node',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.dll',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.exe',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/compatibility.json',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/fluxer-vulkan-layer.*.json',
...winGameCaptureTargetArchs.map(
(arch) =>
`node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/win-game-capture.win32-${arch}-msvc.node`,
),
'node_modules/.pnpm/@fluxer+win-clipboard@*/node_modules/@fluxer/win-clipboard/*.node',
'node_modules/.pnpm/@fluxer+win-shell@*/node_modules/@fluxer/win-shell/*.node',
'node_modules/.pnpm/@fluxer+win-toast@*/node_modules/@fluxer/win-toast/*.node',
@@ -351,6 +352,21 @@ function pnpmStoreDirName(packageName) {
return packageName.replace('/', '+');
}
function windowsGameCaptureArtifactExcludes(arch) {
const packageRoots = [
'node_modules/@fluxer/win-game-capture',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture',
];
const excludedNodeArchs = [...supportedTargetArchs, 'ia32'].filter((candidate) => candidate !== arch);
return packageRoots.flatMap((packageRoot) => [
`!${packageRoot}/compatibility.json`,
`!${packageRoot}/fluxer-game-hook.*`,
`!${packageRoot}/fluxer-inject-helper.*`,
`!${packageRoot}/fluxer-vulkan-layer.*`,
...excludedNodeArchs.map((excludedArch) => `!${packageRoot}/win-game-capture.win32-${excludedArch}-msvc.node`),
]);
}
function platformNativeExcludes(platform, arch) {
const keepFluxerPackages = new Set(fluxerNativePackagesByPlatform[platform] ?? []);
const fluxerPackageExcludes = fluxerNativePackages
@@ -370,6 +386,7 @@ function platformNativeExcludes(platform, arch) {
}
return [
...fluxerPackageExcludes,
...windowsGameCaptureArtifactExcludes(arch),
...velopackNativeFiles
.filter((fileName) => fileName !== keepVelopackNativeFile)
.map((fileName) => `!node_modules/velopack/lib/native/${fileName}`),
@@ -402,22 +419,11 @@ function platformTag(platform, arch) {
return null;
}
function addWindowsGameCaptureArtifacts(artifacts, tag, arch) {
const add = (relativePath) => {
artifacts.push({
packageName: '@fluxer/win-game-capture',
relativePath,
});
};
add(`win-game-capture.${tag}.node`);
add(`fluxer-game-hook.${tag}.dll`);
add(`fluxer-inject-helper.${tag}.exe`);
add(`fluxer-vulkan-layer.${tag}.dll`);
add(`fluxer-vulkan-layer.${tag}.json`);
if (arch === 'x64') {
add('fluxer-game-hook.win32-ia32-msvc.dll');
add('fluxer-inject-helper.win32-ia32-msvc.exe');
}
function addWindowsGameCaptureArtifacts(artifacts, tag) {
artifacts.push({
packageName: '@fluxer/win-game-capture',
relativePath: `win-game-capture.${tag}.node`,
});
}
function expectedNativeRuntimeArtifacts(platform, arch) {
@@ -476,7 +482,7 @@ function expectedNativeRuntimeArtifactsForArch(platform, arch) {
packageName: '@fluxer/win-process-loopback',
relativePath: `win-process-loopback.${tag}.node`,
});
addWindowsGameCaptureArtifacts(artifacts, tag, arch);
addWindowsGameCaptureArtifacts(artifacts, tag);
artifacts.push({
packageName: '@fluxer/win-clipboard',
relativePath: `win-clipboard.${tag}.node`,
@@ -814,11 +820,179 @@ async function addLinuxLegacyBinarySymlink(context) {
}
}
async function isElfFile(filePath) {
const handle = await fs.open(filePath, 'r');
try {
const magic = Buffer.alloc(4);
const {bytesRead} = await handle.read(magic, 0, magic.length, 0);
return bytesRead === magic.length && magic.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
} finally {
await handle.close();
}
}
async function findPackagedElfFiles(rootDir) {
const results = [];
async function visit(directory) {
const entries = await fs.readdir(directory, {withFileTypes: true});
for (const entry of entries) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
await visit(entryPath);
} else if (entry.isFile() && (await isElfFile(entryPath))) {
results.push(entryPath);
}
}
}
await visit(rootDir);
return results.sort();
}
function compareGlibcVersions(left, right) {
for (const key of ['major', 'minor', 'patch']) {
if (left[key] !== right[key]) return left[key] - right[key];
}
return 0;
}
async function inspectElfGlibcRequirements(elfPath) {
let stdout;
try {
({stdout} = await execFileAsync('readelf', ['--version-info', '--dynamic', '--wide', elfPath], {
env: {...process.env, LC_ALL: 'C'},
maxBuffer: 16 * 1024 * 1024,
}));
} catch (error) {
if (error && error.code === 'ENOENT') {
throw new Error(`Cannot verify Linux glibc compatibility: readelf executable is not available.`);
}
const stderr = typeof error?.stderr === 'string' ? error.stderr.trim() : '';
throw new Error(`Cannot inspect ELF file ${elfPath}: ${stderr || error?.message || String(error)}`);
}
const versions = new Map();
const unsupportedRequirements = new Set();
const hasGlibcRequirement = /\bGLIBC_[A-Za-z0-9_.]+\b/.test(stdout);
let readingVersionNeeds = false;
let foundVersionNeeds = false;
let hasVersionNeedsTag = false;
let needsGlibc = false;
let usesDtRelr = false;
for (const line of stdout.split(/\r?\n/)) {
const trimmed = line.trim();
if (/\(VERNEED\)/.test(trimmed)) {
hasVersionNeedsTag = true;
}
if (/^0x0*(?:23|24|25)\s+\(/i.test(trimmed) || /\((?:RELR|RELRSZ|RELRENT)\)/.test(trimmed)) {
usesDtRelr = true;
}
if (/\(NEEDED\)/.test(trimmed)) {
const neededMatch = /\(NEEDED\)\s+Shared library: \[([^\]]+)\]/.exec(trimmed);
if (!neededMatch) {
throw new Error(`Cannot parse a required library for ELF file ${elfPath}: ${trimmed}`);
}
if (neededMatch[1] === 'libc.so.6') needsGlibc = true;
}
if (trimmed.startsWith('Version needs section ')) {
readingVersionNeeds = true;
foundVersionNeeds = true;
continue;
}
if (trimmed.startsWith('Version symbols section ') || trimmed.startsWith('Version definition section ')) {
readingVersionNeeds = false;
continue;
}
if (!readingVersionNeeds) continue;
const requirement = /\bName:\s+(\S+)/.exec(trimmed)?.[1];
if (!requirement?.startsWith('GLIBC_')) continue;
const match = /^GLIBC_(\d+)\.(\d+)(?:\.(\d+))?$/.exec(requirement);
if (!match) {
unsupportedRequirements.add(requirement);
continue;
}
const version = {
major: Number.parseInt(match[1], 10),
minor: Number.parseInt(match[2], 10),
patch: Number.parseInt(match[3] ?? '0', 10),
name: requirement,
};
if (![version.major, version.minor, version.patch].every(Number.isSafeInteger)) {
throw new Error(`readelf returned an invalid glibc version for ${elfPath}: ${requirement}`);
}
versions.set(version.name, version);
}
if ((hasGlibcRequirement || hasVersionNeedsTag) && !foundVersionNeeds) {
throw new Error(`Cannot verify glibc requirements for ELF file ${elfPath}: readelf omitted version needs.`);
}
if (needsGlibc && (!foundVersionNeeds || (versions.size === 0 && unsupportedRequirements.size === 0))) {
throw new Error(`Cannot verify glibc requirements for ELF file ${elfPath}: readelf returned no version needs.`);
}
return {
versions: Array.from(versions.values()),
unsupportedRequirements: Array.from(unsupportedRequirements).sort(),
usesDtRelr,
};
}
async function findLinuxGlibcCompatibilityViolations(elfFiles) {
const violations = [];
for (const elfPath of elfFiles) {
const {versions, unsupportedRequirements, usesDtRelr} = await inspectElfGlibcRequirements(elfPath);
const maximum = versions.sort(compareGlibcVersions).at(-1);
const requirements = [...unsupportedRequirements];
if (maximum && compareGlibcVersions(maximum, linuxGlibcBaseline) > 0) {
requirements.push(maximum.name);
}
if (usesDtRelr) requirements.push('DT_RELR (glibc 2.36+)');
if (requirements.length > 0) violations.push({elfPath, requirements});
}
return violations;
}
function throwLinuxGlibcCompatibilityError(violations, formatPath) {
if (violations.length === 0) return;
const lines = [
`Linux package exceeds the supported ${linuxGlibcBaseline.name} ABI baseline.`,
'Build Linux artifacts on Ubuntu 22.04 and keep every shipped ELF at or below that glibc requirement.',
];
for (const {elfPath, requirements} of violations) {
lines.push(` - ${formatPath(elfPath)} requires ${requirements.join(', ')}`);
}
throw new Error(lines.join('\n'));
}
function linuxDistributableTargetNames(context) {
if (!Array.isArray(context.targets)) {
throw new Error('Cannot verify Linux glibc compatibility: electron-builder did not provide a target list.');
}
return context.targets.map((target) => target.name).filter((name) => name !== 'dir');
}
async function verifyLinuxGlibcCompatibility(context) {
if (context.electronPlatformName !== 'linux') return;
const distributableTargets = linuxDistributableTargetNames(context);
if (distributableTargets.length === 0) {
console.log(
`Skipped the ${linuxGlibcBaseline.name} ABI baseline check: this pack produces no distributable Linux artifact.`,
);
return;
}
const elfFiles = await findPackagedElfFiles(context.appOutDir);
if (elfFiles.length === 0) {
throw new Error(`Linux package output contains no ELF files: ${context.appOutDir}`);
}
const violations = await findLinuxGlibcCompatibilityViolations(elfFiles);
throwLinuxGlibcCompatibilityError(violations, (elfPath) => path.relative(context.appOutDir, elfPath));
console.log(
`Verified ${elfFiles.length} packaged Linux ELF files against the ${linuxGlibcBaseline.name} ABI baseline.`,
);
}
async function afterPack(context) {
await copyMissingPackagedNativeArtifacts(context);
await cleanupNativeBuildIntermediates(context);
await addLinuxLegacyBinarySymlink(context);
await verifyPackagedNativeArtifacts(context);
await verifyLinuxGlibcCompatibility(context);
}
async function listRpmPackageFiles(artifactPath) {
@@ -922,12 +1096,19 @@ async function verifyLinuxPackagesContainAppArmorProfile(buildResult) {
async function readElfNeededLibraries(artifactPath) {
try {
const {stdout} = await execFileAsync('readelf', ['-d', artifactPath], {
env: {...process.env, LC_ALL: 'C'},
maxBuffer: 8 * 1024 * 1024,
});
return stdout
.split(/\r?\n/)
.map((line) => line.match(/\(NEEDED\)\s+Shared library: \[([^\]]+)\]/)?.[1])
.filter(Boolean);
const neededLibraries = [];
for (const line of stdout.split(/\r?\n/)) {
if (!/\(NEEDED\)/.test(line)) continue;
const match = /\(NEEDED\).*\[([^\]]+)\]/.exec(line);
if (!match) {
throw new Error(`Cannot parse a required library for AppImage artifact ${artifactPath}: ${line.trim()}`);
}
neededLibraries.push(match[1]);
}
return neededLibraries;
} catch (error) {
if (error && error.code === 'ENOENT') {
throw new Error(`Cannot inspect AppImage artifact ${artifactPath}: readelf executable is not available.`);
@@ -937,6 +1118,14 @@ async function readElfNeededLibraries(artifactPath) {
}
}
async function verifyAppImageArtifactsGlibcCompatibility(buildResult) {
const appImageArtifacts = (buildResult.artifactPaths ?? []).filter(
(artifactPath) => path.extname(artifactPath) === '.AppImage',
);
const violations = await findLinuxGlibcCompatibilityViolations(appImageArtifacts);
throwLinuxGlibcCompatibilityError(violations, (artifactPath) => path.basename(artifactPath));
}
async function verifyAppImageArtifactsDoNotNeedFuse2(buildResult) {
const appImageArtifacts = (buildResult.artifactPaths ?? []).filter(
(artifactPath) => path.extname(artifactPath) === '.AppImage',
@@ -1072,6 +1261,7 @@ async function verifyAppImageArtifactsUseSandboxAwareLauncher(buildResult) {
async function verifyLinuxArtifactContracts(buildResult) {
await verifyRpmArtifactsDoNotOwnBuildIds(buildResult);
await verifyLinuxPackagesContainAppArmorProfile(buildResult);
await verifyAppImageArtifactsGlibcCompatibility(buildResult);
await verifyAppImageArtifactsDoNotNeedFuse2(buildResult);
await verifyAppImageArtifactsUseSandboxAwareLauncher(buildResult);
}
@@ -1080,8 +1270,7 @@ module.exports = {
appId,
productName,
copyright: 'Copyright © 2026 Fluxer Platform AB',
// biome-ignore lint/suspicious/noTemplateCurlyInString: electron-builder placeholders, not JS template literals.
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
artifactName: `${artifactProductName}-\${version}-\${os}-\${arch}.\${ext}`,
directories: {
buildResources: 'build_resources',
output: 'dist-electron',
@@ -1134,10 +1323,9 @@ module.exports = {
asarUnpack: [
'**/*.node',
'node_modules/@fluxer/win-process-loopback/*.node',
'node_modules/@fluxer/win-game-capture/*.node',
'node_modules/@fluxer/win-game-capture/*.dll',
'node_modules/@fluxer/win-game-capture/*.exe',
'node_modules/@fluxer/win-game-capture/*.json',
...winGameCaptureTargetArchs.map(
(arch) => `node_modules/@fluxer/win-game-capture/win-game-capture.win32-${arch}-msvc.node`,
),
'node_modules/@fluxer/win-clipboard/*.node',
'node_modules/@fluxer/win-shell/*.node',
'node_modules/@fluxer/win-toast/*.node',
@@ -1160,10 +1348,10 @@ module.exports = {
'node_modules/@fluxer/webauthn/*.node',
'node_modules/@fluxer/webauthn/*.so*',
'node_modules/.pnpm/@fluxer+win-process-loopback@*/node_modules/@fluxer/win-process-loopback/*.node',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.node',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.dll',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.exe',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.json',
...winGameCaptureTargetArchs.map(
(arch) =>
`node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/win-game-capture.win32-${arch}-msvc.node`,
),
'node_modules/.pnpm/@fluxer+win-clipboard@*/node_modules/@fluxer/win-clipboard/*.node',
'node_modules/.pnpm/@fluxer+win-shell@*/node_modules/@fluxer/win-shell/*.node',
'node_modules/.pnpm/@fluxer+win-toast@*/node_modules/@fluxer/win-toast/*.node',
@@ -1252,8 +1440,7 @@ module.exports = {
target: winTargets,
},
portable: {
// biome-ignore lint/suspicious/noTemplateCurlyInString: electron-builder expands these placeholders.
artifactName: '${productName}-${version}-portable-${os}-${arch}.${ext}',
artifactName: `${artifactProductName}-\${version}-portable-\${os}-\${arch}.\${ext}`,
},
linux: {
icon: `build_resources/${iconDir}/1024x1024.png`,
+3 -4
View File
@@ -42,10 +42,9 @@ const BACKEND_REASON: &str = "Windows WebAuthn broker backend (webauthn.dll). \
webauthn.dll is delay-loaded via /DELAYLOAD, so the addon still loads on \
older builds but `isSupported()` returns false there.";
#[cfg(target_os = "linux")]
const BACKEND_REASON: &str = "Linux WebAuthn backend (libfido2). Requires \
libfido2 >= 1.5.0 (HID transport, U2F fallback disabled) and a CTAP \
authenticator on USB-HID; `isSupported()` returns false when no \
authenticator is enumerated.";
const BACKEND_REASON: &str = "Linux WebAuthn backend (bundled libfido2, HID transport, U2F \
fallback disabled). Requires a CTAP authenticator on USB-HID; \
`isSupported()` returns false when no authenticator is enumerated.";
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
const BACKEND_REASON: &str = "no WebAuthn backend is compiled in for this platform";
@@ -8,6 +8,10 @@ publish = false
[workspace]
resolver = "2"
[features]
default = []
game-capture-hook = []
[lib]
crate-type = ["cdylib", "rlib"]
@@ -18,19 +18,7 @@
"index.d.ts",
"loader-diagnostics.cjs",
"win-game-capture.win32-x64-msvc.node",
"win-game-capture.win32-arm64-msvc.node",
"fluxer-game-hook.win32-x64-msvc.dll",
"fluxer-game-hook.win32-ia32-msvc.dll",
"fluxer-game-hook.win32-arm64-msvc.dll",
"fluxer-inject-helper.win32-x64-msvc.exe",
"fluxer-inject-helper.win32-ia32-msvc.exe",
"fluxer-inject-helper.win32-arm64-msvc.exe",
"fluxer-vulkan-layer.win32-x64-msvc.dll",
"fluxer-vulkan-layer.win32-x64-msvc.json",
"fluxer-vulkan-layer.win32-ia32-msvc.dll",
"fluxer-vulkan-layer.win32-ia32-msvc.json",
"fluxer-vulkan-layer.win32-arm64-msvc.dll",
"fluxer-vulkan-layer.win32-arm64-msvc.json"
"win-game-capture.win32-arm64-msvc.node"
],
"scripts": {
"build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon",
@@ -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(feature = "game-capture-hook")]
pub(crate) fn wall_clock_us() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
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::{
CaptureInner,
capture_target::{monitor_rect, resolve_game_capture_target},
compatibility::{InjectionPolicy, injection_policy_for_window},
d3d11_device::create_shared_texture_device,
dxgi_capture::{resolve_output_size, wall_clock_us},
emit_lifecycle, emit_shared_texture_frame,
game_capture_abi::{
@@ -26,26 +28,18 @@ use std::{
sync::{Arc, Mutex, atomic::Ordering},
};
use windows::Win32::{
Foundation::{HANDLE as WinHandle, HMODULE},
Foundation::HANDLE as WinHandle,
Graphics::{
Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN},
Direct3D11::{
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC,
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D,
},
Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIDevice, IDXGIFactory1},
Direct3D11::{D3D11_TEXTURE2D_DESC, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D},
Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1},
},
};
use windows::core::Interface;
use windows_sys::Win32::{
Foundation::{
CloseHandle, FreeLibrary, HANDLE, HINSTANCE, HMODULE as WinSysHmodule, HWND,
INVALID_HANDLE_VALUE, LPARAM, RECT, WAIT_ABANDONED, WAIT_OBJECT_0, WAIT_TIMEOUT,
},
Graphics::Gdi::{
EnumDisplayMonitors, GetMonitorInfoW, HMONITOR, MONITOR_DEFAULTTONEAREST, MONITORINFO,
MonitorFromRect,
},
Graphics::Gdi::{MONITOR_DEFAULTTONEAREST, MonitorFromRect},
System::{
Diagnostics::Debug::WriteProcessMemory,
LibraryLoader::{GetModuleHandleW, GetProcAddress, LoadLibraryW},
@@ -67,10 +61,8 @@ use windows_sys::Win32::{
},
},
UI::WindowsAndMessaging::{
EnumWindows, GW_OWNER, GWL_STYLE, GetClientRect, GetForegroundWindow, GetWindow,
GetWindowLongPtrW, GetWindowRect, GetWindowThreadProcessId, HHOOK, IsWindow,
IsWindowVisible, PostThreadMessageW, SetWindowsHookExW, UnhookWindowsHookEx, WH_GETMESSAGE,
WM_NULL, WS_BORDER, WS_MAXIMIZE,
GetClientRect, GetWindowRect, GetWindowThreadProcessId, HHOOK, IsWindow, IsWindowVisible,
PostThreadMessageW, SetWindowsHookExW, UnhookWindowsHookEx, WH_GETMESSAGE, WM_NULL,
},
};
@@ -812,87 +804,6 @@ fn inject_via_set_windows_hook(
Ok(InstalledWindowsHook { hook, module })
}
fn parse_hwnd_source_id(source_id: &str) -> Option<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> {
let mut pid = 0u32;
unsafe {
@@ -1039,155 +950,6 @@ fn choose_shared_buffer_dimensions(
Some((buffer_width, buffer_height))
}
fn monitor_rect(monitor: HMONITOR) -> Option<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;
fn shared_texture_adapter_candidates() -> Vec<Option<IDXGIAdapter>> {
@@ -3,13 +3,17 @@
#![deny(clippy::all)]
#![allow(unsafe_op_in_unsafe_fn)]
#[cfg(any(target_os = "windows", test))]
#[cfg(target_os = "windows")]
mod capture_target;
#[cfg(any(all(target_os = "windows", feature = "game-capture-hook"), test))]
mod compatibility;
#[cfg(target_os = "windows")]
mod d3d11_device;
#[cfg(any(target_os = "windows", test))]
mod dxgi_capture;
pub mod encoder_attach;
mod fallback;
#[cfg(target_os = "windows")]
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
mod game_capture;
mod game_capture_abi;
mod gpu_priority;
@@ -19,7 +23,7 @@ mod nv12_gpu;
mod sources;
#[cfg(any(target_os = "windows", test))]
mod stall;
#[cfg(target_os = "windows")]
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
mod vulkan_layer_registry;
#[cfg(target_os = "windows")]
mod wgc_capture;
@@ -39,7 +43,7 @@ use dxgi_capture::DxgiCaptureSession;
use fluxer_encoder_ring::EncoderFrameRate;
#[cfg(target_os = "windows")]
use fluxer_screen_frame_bus::EnqueueOutcome;
#[cfg(target_os = "windows")]
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
use game_capture::GameCaptureSession;
#[cfg(target_os = "windows")]
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -48,7 +52,6 @@ use wgc_capture::WgcCaptureSession;
const LIFECYCLE_QUEUE_LIMIT: usize = 8;
const START_OPTION_UNSUPPORTED_LIMIT: usize = 4;
const GAME_CAPTURE_HOOK_FORCE_DISABLED: bool = true;
type LifecycleTsfn = Arc<
ThreadsafeFunction<
@@ -226,7 +229,7 @@ pub struct CaptureInner {
pub session: Mutex<Option<DxgiCaptureSession>>,
#[cfg(target_os = "windows")]
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 running: std::sync::atomic::AtomicBool,
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(all(target_os = "windows", feature = "game-capture-hook"))]
pub(crate) fn note_cpu_fallback_frame_dropped(inner: &CaptureInner, message: &'static str) {
inner
.cpu_fallback_frames_dropped
@@ -470,7 +474,7 @@ impl ScreenCapture {
session: Mutex::new(None),
#[cfg(target_os = "windows")]
wgc_session: Mutex::new(None),
#[cfg(target_os = "windows")]
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
game_session: Mutex::new(None),
running: std::sync::atomic::AtomicBool::new(false),
fallback: Mutex::new(None),
@@ -586,43 +590,48 @@ impl ScreenCapture {
#[cfg(target_os = "windows")]
{
let frame_sink = frame_sink_counter_snapshot(&self.inner);
let guard = self.inner.game_session.lock();
if let Some(session) = guard.as_ref() {
let requested_injection_method = session.requested_injection_method().to_string();
let injection_method = session.used_injection_method().to_string();
if let Some(info) = session.read_shared_info() {
return Some(CaptureDiagnostics {
state: info.state,
api_type: info.api_type,
transport: info.transport,
fallback_reason: info.fallback_reason,
capture_flags: info.capture_flags,
width: info.width,
height: info.height,
dxgi_format: info.dxgi_format,
frame_counter: info.frame_counter as f64,
dropped_frame_counter: info.dropped_frame_counter as f64,
last_present_timestamp_us: info.last_present_timestamp_us as f64,
last_error: info.last_error,
#[cfg(feature = "game-capture-hook")]
{
let guard = self.inner.game_session.lock();
if let Some(session) = guard.as_ref() {
let requested_injection_method =
session.requested_injection_method().to_string();
let injection_method = session.used_injection_method().to_string();
if let Some(info) = session.read_shared_info() {
return Some(CaptureDiagnostics {
state: info.state,
api_type: info.api_type,
transport: info.transport,
fallback_reason: info.fallback_reason,
capture_flags: info.capture_flags,
width: info.width,
height: info.height,
dxgi_format: info.dxgi_format,
frame_counter: info.frame_counter as f64,
dropped_frame_counter: info.dropped_frame_counter as f64,
last_present_timestamp_us: info.last_present_timestamp_us as f64,
last_error: info.last_error,
requested_injection_method,
injection_method,
active_strategy: snapshot.active_strategy,
last_fallback_reason: snapshot.last_fallback_reason,
start_options: current_start_options(&self.inner),
frame_sink_accepted: frame_sink.accepted as f64,
frame_sink_coalesced: frame_sink.coalesced as f64,
frame_sink_rejected: frame_sink.rejected as f64,
media_frames_dropped_without_sink: frame_sink.dropped_without_sink
as f64,
cpu_fallback_frames_dropped: frame_sink.cpu_fallback_dropped as f64,
});
}
return Some(strategy_only_diagnostics(
&snapshot,
requested_injection_method,
injection_method,
active_strategy: snapshot.active_strategy,
last_fallback_reason: snapshot.last_fallback_reason,
start_options: current_start_options(&self.inner),
frame_sink_accepted: frame_sink.accepted as f64,
frame_sink_coalesced: frame_sink.coalesced as f64,
frame_sink_rejected: frame_sink.rejected as f64,
media_frames_dropped_without_sink: frame_sink.dropped_without_sink as f64,
cpu_fallback_frames_dropped: frame_sink.cpu_fallback_dropped as f64,
});
current_start_options(&self.inner),
frame_sink,
));
}
return Some(strategy_only_diagnostics(
&snapshot,
requested_injection_method,
injection_method,
current_start_options(&self.inner),
frame_sink,
));
}
Some(strategy_only_diagnostics(
&snapshot,
@@ -669,7 +678,7 @@ impl ScreenCapture {
#[napi(js_name = "getSharedTextureHandle")]
pub fn get_shared_texture_handle(&self) -> Option<SharedTextureHandleInfo> {
#[cfg(target_os = "windows")]
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
{
let guard = self.inner.game_session.lock();
let session = guard.as_ref()?;
@@ -684,7 +693,7 @@ impl ScreenCapture {
}
None
}
#[cfg(not(target_os = "windows"))]
#[cfg(not(all(target_os = "windows", feature = "game-capture-hook")))]
{
None
}
@@ -706,6 +715,9 @@ impl ScreenCapture {
*guard = None;
let mut wgc_guard = self.inner.wgc_session.lock();
*wgc_guard = None;
}
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
{
let mut game_guard = self.inner.game_session.lock();
*game_guard = None;
}
@@ -955,7 +967,8 @@ impl ScreenCapture {
return Err(napi::Error::from_reason("Capture already running"));
}
if source_kind == "game" && !GAME_CAPTURE_HOOK_FORCE_DISABLED {
#[cfg(feature = "game-capture-hook")]
if source_kind == "game" {
return self.start_windows_game(
source_id,
source_kind,
@@ -991,7 +1004,7 @@ impl ScreenCapture {
}
let hwnd = if source_kind == "game" {
let target = game_capture::resolve_game_capture_target(&source_id, &source_kind)
let target = capture_target::resolve_game_capture_target(&source_id, &source_kind)
.map_err(|e| {
napi::Error::from_reason(format!("Failed to resolve game capture target: {e}"))
})?;
@@ -1115,6 +1128,7 @@ impl ScreenCapture {
})
}
#[cfg(feature = "game-capture-hook")]
#[allow(clippy::too_many_arguments)]
fn start_windows_game(
&self,
@@ -1278,7 +1292,7 @@ pub fn is_supported() -> bool {
#[napi(js_name = "isGameCaptureHookAvailable")]
pub fn is_game_capture_hook_available() -> bool {
cfg!(target_os = "windows") && !GAME_CAPTURE_HOOK_FORCE_DISABLED
cfg!(all(target_os = "windows", feature = "game-capture-hook"))
}
#[napi(js_name = "getAvailability")]
@@ -1314,37 +1328,37 @@ pub fn restore_gpu_scheduling_priority(process_id: Option<u32>) -> Result<()> {
#[napi(js_name = "registerVulkanLayerManifest")]
pub fn register_vulkan_layer_manifest(manifest_path: String) -> Result<()> {
#[cfg(target_os = "windows")]
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
{
vulkan_layer_registry::register_manifest(&manifest_path).map_err(napi::Error::from_reason)
}
#[cfg(not(target_os = "windows"))]
#[cfg(not(all(target_os = "windows", feature = "game-capture-hook")))]
{
let _ = manifest_path;
Err(napi::Error::from_reason(
"Vulkan game capture layer only supported on Windows",
"Vulkan game capture layer is not included in this build",
))
}
}
#[napi(js_name = "unregisterVulkanLayerManifest")]
pub fn unregister_vulkan_layer_manifest(manifest_path: String) -> Result<()> {
#[cfg(target_os = "windows")]
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
{
vulkan_layer_registry::unregister_manifest(&manifest_path).map_err(napi::Error::from_reason)
}
#[cfg(not(target_os = "windows"))]
#[cfg(not(all(target_os = "windows", feature = "game-capture-hook")))]
{
let _ = manifest_path;
Err(napi::Error::from_reason(
"Vulkan game capture layer only supported on Windows",
"Vulkan game capture layer is not included in this build",
))
}
}
#[napi(js_name = "getVulkanLayerRegistrationState")]
pub fn get_vulkan_layer_registration_state(manifest_path: String) -> VulkanLayerRegistrationState {
#[cfg(target_os = "windows")]
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
{
let state = vulkan_layer_registry::registration_state(&manifest_path);
VulkanLayerRegistrationState {
@@ -1354,7 +1368,7 @@ pub fn get_vulkan_layer_registration_state(manifest_path: String) -> VulkanLayer
manifest_path: state.manifest_path,
}
}
#[cfg(not(target_os = "windows"))]
#[cfg(not(all(target_os = "windows", feature = "game-capture-hook")))]
{
VulkanLayerRegistrationState {
registered: false,
@@ -208,7 +208,7 @@ impl WgcCaptureSession {
requested_height: Option<u32>,
) -> Result<Self, String> {
ensure_winrt_initialized();
let (device, context) = crate::game_capture::create_shared_texture_device(None)?;
let (device, context) = crate::d3d11_device::create_shared_texture_device(None)?;
let dxgi_device: IDXGIDevice = device
.cast()
.map_err(|e| format!("IDXGIDevice cast: {e}"))?;
+46 -28
View File
@@ -157,43 +157,58 @@ function collectRuntimeArtifactPaths(packageDir) {
}
function isNativeRuntimeSidecar(fileName) {
return (
fileName.endsWith('.node') ||
/\.so(?:\.|$)/.test(fileName) ||
/\.(?:dll|exe)$/i.test(fileName) ||
isWindowsNativeRuntimeManifest(fileName)
);
return fileName.endsWith('.node') || /\.so(?:\.|$)/.test(fileName) || /\.(?:dll|exe)$/i.test(fileName);
}
function isWindowsNativeRuntimeManifest(fileName) {
return (
fileName === 'compatibility.json' || /^fluxer-vulkan-layer\.win32-(?:x64|ia32|arm64)-msvc\.json$/i.test(fileName)
);
function electronArch() {
return process.env.ELECTRON_ARCH || process.env.npm_config_arch || process.arch;
}
function addWinGameCaptureRuntimeArtifacts(artifacts, tag, arch) {
const add = (relativePath) => {
artifacts.push({
label: '@fluxer/win-game-capture',
relativePath,
runtimeFiles: [],
});
};
function primaryWinGameCaptureNodeFileName() {
const arch = electronArch();
const tag = platformTag(process.platform, arch);
if (!tag || process.platform !== 'win32') {
throw new Error(`Cannot resolve the primary win-game-capture node for ${process.platform}/${arch}`);
}
return `win-game-capture.${tag}.node`;
}
function removeStaleWinGameCaptureArtifacts(packageDir, primaryNodeFileName) {
if (!fs.existsSync(packageDir)) return;
const stale = fs
.readdirSync(packageDir, {withFileTypes: true})
.filter((entry) => {
if (!entry.isFile()) return false;
return (
entry.name.startsWith('fluxer-game-hook.') ||
entry.name.startsWith('fluxer-inject-helper.') ||
entry.name.startsWith('fluxer-vulkan-layer.') ||
(entry.name.startsWith('win-game-capture.') &&
entry.name.endsWith('.node') &&
entry.name !== primaryNodeFileName)
);
})
.map((entry) => entry.name)
.sort();
for (const fileName of stale) {
const artifactPath = path.join(packageDir, fileName);
fs.rmSync(artifactPath);
console.log(` Removed stale @fluxer/win-game-capture artifact ${path.relative(ROOT_DIR, artifactPath)}`);
}
}
function addWinGameCaptureRuntimeArtifacts(artifacts, tag) {
artifacts.push({
label: '@fluxer/win-game-capture',
relativePath: `win-game-capture.${tag}.node`,
});
add(`fluxer-game-hook.${tag}.dll`);
add(`fluxer-inject-helper.${tag}.exe`);
add(`fluxer-vulkan-layer.${tag}.dll`);
add(`fluxer-vulkan-layer.${tag}.json`);
if (arch === 'x64') {
add('fluxer-game-hook.win32-ia32-msvc.dll');
add('fluxer-inject-helper.win32-ia32-msvc.exe');
}
}
function copyRuntimeArtifactsToInstalledPackages({label, packageDir}) {
const primaryNodeFileName = label === '@fluxer/win-game-capture' ? primaryWinGameCaptureNodeFileName() : null;
if (primaryNodeFileName) {
removeStaleWinGameCaptureArtifacts(packageDir, primaryNodeFileName);
}
const artifacts = collectRuntimeArtifactPaths(packageDir);
if (artifacts.length === 0) {
return;
@@ -206,6 +221,9 @@ function copyRuntimeArtifactsToInstalledPackages({label, packageDir}) {
return;
}
for (const installedPackageDir of installedPackageDirs) {
if (primaryNodeFileName) {
removeStaleWinGameCaptureArtifacts(installedPackageDir, primaryNodeFileName);
}
for (const artifact of artifacts) {
const sourcePath = path.join(packageDir, artifact);
const targetPath = path.join(installedPackageDir, artifact);
@@ -225,7 +243,7 @@ function platformTag(platform, arch) {
return null;
}
function expectedNativeRuntimeArtifacts(platform = process.platform, arch = process.env.ELECTRON_ARCH || process.arch) {
function expectedNativeRuntimeArtifacts(platform = process.platform, arch = electronArch()) {
if (platform === 'darwin' && arch === 'universal') {
return [
...expectedNativeRuntimeArtifactsForArch(platform, 'arm64'),
@@ -284,7 +302,7 @@ function expectedNativeRuntimeArtifactsForArch(platform, arch) {
label: '@fluxer/win-process-loopback',
relativePath: `win-process-loopback.${tag}.node`,
});
addWinGameCaptureRuntimeArtifacts(artifacts, tag, arch);
addWinGameCaptureRuntimeArtifacts(artifacts, tag);
artifacts.push({
label: '@fluxer/win-clipboard',
relativePath: `win-clipboard.${tag}.node`,
-1
View File
@@ -688,7 +688,6 @@ export interface ElectronAPI {
pasteFromClipboard: () => Promise<void>;
onDeepLink: (callback: (url: string) => void) => () => void;
getInitialDeepLink: () => Promise<string | null>;
onRpcNavigate: (callback: (path: string) => void) => () => void;
autostartEnable: () => Promise<void>;
autostartDisable: () => Promise<void>;
autostartIsEnabled: () => Promise<boolean>;
-217
View File
@@ -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();
});
});
};
+3 -3
View File
@@ -473,14 +473,14 @@ function buildManualLatestDownloadUrl(format: ManualDesktopFormat): string {
return `${UPDATE_BASE_URL}/latest/${format}`;
}
function getModernProductName(): string {
return BUILD_CHANNEL === 'canary' ? 'Fluxer Canary' : 'Fluxer';
function getArtifactProductName(): string {
return BUILD_CHANNEL === 'canary' ? 'Fluxer-Canary' : 'Fluxer';
}
function getManualUpdateSuggestedName(format: LinuxManualDesktopFormat, version: string): string {
const archToken = LINUX_MANUAL_ARCH_TOKENS[format][DESKTOP_DOWNLOAD_ARCH];
const extension = LINUX_MANUAL_FORMAT_EXTENSIONS[format];
return `${getModernProductName()}-${version}-linux-${archToken}${extension}`;
return `${getArtifactProductName()}-${version}-linux-${archToken}${extension}`;
}
function getManualDownloadOptions(info: ManualLatestInfo): Array<UpdaterDownloadOption> {
+1 -5
View File
@@ -68,7 +68,6 @@ import {
import {runNativeModulePreflight} from '@electron/main/NativeModulePreflight';
import {cleanupNativeScreenCapture, registerNativeScreenCaptureHandlers} from '@electron/main/NativeScreenCapture';
import {appendOpenH264Switches} from '@electron/main/OpenH264Manager';
import {startRpcServer, stopRpcServer} from '@electron/main/RpcServer';
import {cleanupLinuxChromiumSpellcheckDictionaries} from '@electron/main/Spellcheck';
import {registerUpdater} from '@electron/main/Updater';
import {
@@ -430,9 +429,6 @@ if (launchConfigurationError) {
showWindow();
}
});
void startRpcServer().catch((error: unknown) => {
log.error('[RPC] Failed to start RPC server:', error);
});
log.info('App initialized successfully');
})
.catch((error: unknown) => {
@@ -478,7 +474,7 @@ if (launchConfigurationError) {
cleanupNativeHardwareEncoderHandlers();
cleanupVirtmic();
destroyDesktopTray();
const asyncCleanups: Array<Promise<unknown>> = [stopRpcServer()];
const asyncCleanups: Array<Promise<unknown>> = [];
if (netLog.currentlyLogging) {
asyncCleanups.push(
netLog.stopLogging().catch((error) => {
-9
View File
@@ -425,15 +425,6 @@ const api: ElectronAPI = {
};
},
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'),
autostartDisable: (): Promise<void> => ipcRenderer.invoke('autostart-disable'),
autostartIsEnabled: (): Promise<boolean> => ipcRenderer.invoke('autostart-is-enabled'),
+1
View File
@@ -92,6 +92,7 @@ function defaultConfig(): MasterConfig {
api: {
port: 8080,
ip_ban_exempt_ips: [],
desktop_github_redirect_countries: [],
presigned_attachment_uploads_enabled: false,
presigned_downloads_enabled: false,
presigned_harvest_downloads_enabled: true,
+1
View File
@@ -91,6 +91,7 @@ export interface MasterConfig {
api: {
port: number;
ip_ban_exempt_ips: Array<string>;
desktop_github_redirect_countries: Array<string>;
presigned_attachment_uploads_enabled: boolean;
presigned_downloads_enabled: boolean;
presigned_harvest_downloads_enabled: boolean;
@@ -77,6 +77,10 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
FLUXER_NATS_AUTH_TOKEN: {path: ['services', 'nats', 'auth_token']},
FLUXER_API_PORT: {path: ['services', 'api', 'port'], parse: parseEnvValue},
FLUXER_API_IP_BAN_EXEMPT_IPS: {path: ['services', 'api', 'ip_ban_exempt_ips'], parse: parseCsv},
FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES: {
path: ['services', 'api', 'desktop_github_redirect_countries'],
parse: parseCsv,
},
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED: {
path: ['services', 'api', 'presigned_attachment_uploads_enabled'],
parse: parseEnvValue,
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: ar\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: bg\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: cs\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: da\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: el\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: en-GB\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: en-US\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: es-419\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: es-ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: fi\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: he\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: hi\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: hr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: hu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: id\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: it\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: ja\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: ko\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: lt\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: nl\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: no\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: pl\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: pt-BR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: ro\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: sv-SE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: th\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: tr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: uk\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: vi\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: zh-CN\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"POT-Creation-Date: 2026-08-27 00:00+0000\n"
"PO-Revision-Date: 2026-08-27 00:00+0000\n"
"Language: zh-TW\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+1197 -71
View File
File diff suppressed because it is too large Load Diff
+75 -229
View File
@@ -1,8 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::common::{
CommandSpec, command_succeeds, output_text, remove_file_if_exists, run_command,
};
use crate::common::{CommandSpec, command_succeeds, output_text, run_command};
use crate::desktop::MACOS_UNIVERSAL_ARCH;
use anyhow::{Context, Result, anyhow, bail, ensure};
use clap::Args;
@@ -309,7 +307,9 @@ fn build_desktop_native_addon(addon_root: &Path, addon: &DesktopNativeAddon) ->
DesktopNativeSpecialBuild::Webauthn => {
copy_webauthn_linux_shared_libraries(&built.out_file, addon_root)?
}
DesktopNativeSpecialBuild::WinGameCapture => build_win_game_capture_artifacts(addon_root)?,
DesktopNativeSpecialBuild::WinGameCapture => {
remove_stale_win_game_capture_outputs(addon_root, &built.out_file)?
}
}
Ok(())
}
@@ -365,6 +365,9 @@ fn build_rust_node_addon_for_arch(
OsString::from("--manifest-path"),
OsString::from("Cargo.toml"),
];
if addon.special == DesktopNativeSpecialBuild::WinGameCapture {
args.push(OsString::from("--no-default-features"));
}
if !addon.features.is_empty() {
args.push(OsString::from("--features"));
args.push(OsString::from(addon.features.join(",")));
@@ -413,6 +416,7 @@ fn build_rust_node_addon_for_arch(
);
sign_macos_node_addon(&out_file, platform)?;
assert_no_redistributable_runtime_imports(&out_file, platform)?;
assert_no_disabled_win_game_capture_capabilities(&out_file, addon, platform)?;
Ok(BuiltNodeAddon {
out_file,
source,
@@ -668,235 +672,43 @@ fn should_bundle_linux_library(library_name: &str) -> bool {
.any(|prefix| library_name.starts_with(prefix))
}
#[derive(Debug, Clone, Copy)]
struct HookLayerArch {
arch: &'static str,
target: &'static str,
tag: &'static str,
}
const WIN_GAME_CAPTURE_ARCHES: &[HookLayerArch] = &[
HookLayerArch {
arch: "x64",
target: "x86_64-pc-windows-msvc",
tag: "win32-x64-msvc",
},
HookLayerArch {
arch: "ia32",
target: "i686-pc-windows-msvc",
tag: "win32-ia32-msvc",
},
HookLayerArch {
arch: "arm64",
target: "aarch64-pc-windows-msvc",
tag: "win32-arm64-msvc",
},
];
fn build_win_game_capture_artifacts(root: &Path) -> Result<()> {
let primary_arch = electron_arch();
for arch in WIN_GAME_CAPTURE_ARCHES {
let required_for_primary_runtime =
arch.arch == primary_arch || (primary_arch == "x64" && arch.arch == "ia32");
build_win_game_capture_hook(root, arch, required_for_primary_runtime)?;
build_win_game_capture_vulkan_layer(root, arch, arch.arch == primary_arch)?;
build_win_game_capture_inject_helper(root, arch, required_for_primary_runtime)?;
fn remove_stale_win_game_capture_outputs(root: &Path, primary_node: &Path) -> Result<()> {
let primary_node_name = primary_node
.file_name()
.and_then(OsStr::to_str)
.ok_or_else(|| {
anyhow!(
"Invalid win-game-capture output path: {}",
primary_node.display()
)
})?;
let mut stale = fs::read_dir(root)
.with_context(|| format!("Failed to read {}", root.display()))?
.map(|entry| entry.map(|entry| entry.path()))
.collect::<std::result::Result<Vec<_>, _>>()?;
stale.retain(|path| {
if !path.is_file() {
return false;
}
let Some(file_name) = path.file_name().and_then(OsStr::to_str) else {
return false;
};
file_name.starts_with("fluxer-game-hook.")
|| file_name.starts_with("fluxer-inject-helper.")
|| file_name.starts_with("fluxer-vulkan-layer.")
|| (file_name.starts_with("win-game-capture.")
&& file_name.ends_with(".node")
&& file_name != primary_node_name)
});
stale.sort();
for path in stale {
fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?;
println!("[win-game-capture] removed stale output {}", path.display());
}
Ok(())
}
fn build_win_game_capture_hook(root: &Path, arch: &HookLayerArch, required: bool) -> Result<()> {
build_win_game_capture_extra(
"game-capture hook",
root,
&root.join("hook"),
"fluxer_game_hook",
&format!("fluxer-game-hook.{}.dll", arch.tag),
arch,
required,
)
}
fn build_win_game_capture_vulkan_layer(
root: &Path,
arch: &HookLayerArch,
required: bool,
) -> Result<()> {
let layer_dll_name = format!("fluxer-vulkan-layer.{}.dll", arch.tag);
build_win_game_capture_extra(
"Vulkan game-capture layer",
root,
&root.join("vulkan-layer"),
"fluxer_vulkan_layer",
&layer_dll_name,
arch,
required,
)?;
let manifest_path = root.join(format!("fluxer-vulkan-layer.{}.json", arch.tag));
if !root.join(&layer_dll_name).exists() {
remove_file_if_exists(&manifest_path)?;
println!(
"[win-game-capture] no {layer_dll_name}; not emitting {} so packages never ship a manifest pointing at an absent layer",
manifest_path.display()
);
return Ok(());
}
fs::write(&manifest_path, vulkan_layer_manifest(&layer_dll_name))
.with_context(|| format!("Failed to write {}", manifest_path.display()))?;
println!("[win-game-capture] emitted {}", manifest_path.display());
Ok(())
}
fn build_win_game_capture_inject_helper(
root: &Path,
arch: &HookLayerArch,
required: bool,
) -> Result<()> {
let helper_root = root.join("inject-helper");
ensure_rust_target_or_skip(arch, "inject-helper", required)?;
if !try_cargo_build("inject-helper", &helper_root, arch.target) {
if required {
bail!(
"[win-game-capture] required {} inject-helper build failed",
arch.arch
);
}
return Ok(());
}
let helper_source = helper_root
.join("target")
.join(arch.target)
.join("release")
.join("fluxer-inject-helper.exe");
let helper_out = root.join(format!("fluxer-inject-helper.{}.exe", arch.tag));
copy_optional_win_game_capture_artifact(
&helper_source,
&helper_out,
&format!("{} inject-helper", arch.arch),
required,
)
}
fn build_win_game_capture_extra(
label: &str,
root: &Path,
cargo_root: &Path,
crate_name: &str,
output_name: &str,
arch: &HookLayerArch,
required: bool,
) -> Result<()> {
ensure_rust_target_or_skip(arch, label, required)?;
if !try_cargo_build(label, cargo_root, arch.target) {
if required {
bail!(
"[win-game-capture] required {} {} build failed",
arch.arch,
label
);
}
return Ok(());
}
let source = cargo_root
.join("target")
.join(arch.target)
.join("release")
.join(cargo_dynamic_library_file_name(crate_name, "win32")?);
let output = root.join(output_name);
copy_optional_win_game_capture_artifact(
&source,
&output,
&format!("{} {label}", arch.arch),
required,
)
}
fn ensure_rust_target_or_skip(arch: &HookLayerArch, label: &str, required: bool) -> Result<()> {
if rust_target_installed(arch.target) {
return Ok(());
}
let message = format!(
"[win-game-capture] {} {} {}: rust target {} not installed",
if required {
"missing required"
} else {
"skipping"
},
arch.arch,
label,
arch.target
);
if required {
bail!(message);
}
eprintln!("{message}");
Ok(())
}
fn rust_target_installed(target: &str) -> bool {
let rustup = env::var_os("RUSTUP").unwrap_or_else(|| OsString::from("rustup"));
match output_text(CommandSpec::new(rustup).args(["target", "list", "--installed"])) {
Ok(output) => output.lines().any(|line| line.trim() == target),
Err(error) => {
eprintln!(
"[win-game-capture] could not query rustup for target {target}; assuming present: {error:#}"
);
true
}
}
}
fn try_cargo_build(label: &str, cwd: &Path, target: &str) -> bool {
println!("[win-game-capture] building {label} target={target}");
run_command(
CommandSpec::new(resolve_cargo_bin())
.args([
OsString::from("build"),
OsString::from("--release"),
OsString::from("--target"),
OsString::from(target),
OsString::from("--manifest-path"),
cwd.join("Cargo.toml").into_os_string(),
])
.current_dir(cwd),
)
.map(|_| true)
.unwrap_or_else(|error| {
eprintln!(
"[win-game-capture] skipping {label} for {target}: cargo build failed: {error:#}"
);
false
})
}
fn copy_optional_win_game_capture_artifact(
source: &Path,
output: &Path,
label: &str,
required: bool,
) -> Result<()> {
if !source.exists() {
let message = format!(
"[win-game-capture] expected {} after {label} build",
source.display()
);
if required {
bail!(message);
}
eprintln!("{message}; skipping copy");
return Ok(());
}
fs::copy(source, output).with_context(|| {
format!(
"Failed to copy {} to {}",
source.display(),
output.display()
)
})?;
println!("[win-game-capture] emitted {}", output.display());
Ok(())
}
#[cfg(test)]
fn vulkan_layer_manifest(layer_dll_name: &str) -> String {
format!(
"{{\n\
@@ -935,6 +747,40 @@ fn assert_no_redistributable_runtime_imports(node_file_path: &Path, platform: &s
)
}
const DISABLED_WIN_GAME_CAPTURE_BINARY_MARKERS: &[&[u8]] = &[
b"CreateRemoteThread",
b"VirtualAllocEx",
b"VirtualFreeEx",
b"WriteProcessMemory",
b"SetWindowsHookExW",
b"fluxer-inject-helper.",
];
fn assert_no_disabled_win_game_capture_capabilities(
node_file_path: &Path,
addon: &DesktopNativeAddon,
platform: &str,
) -> Result<()> {
if platform != "win32" || addon.special != DesktopNativeSpecialBuild::WinGameCapture {
return Ok(());
}
let binary = fs::read(node_file_path)
.with_context(|| format!("Failed to read {}", node_file_path.display()))?;
let present = DISABLED_WIN_GAME_CAPTURE_BINARY_MARKERS
.iter()
.copied()
.filter(|marker| binary.windows(marker.len()).any(|window| window == *marker))
.map(|marker| String::from_utf8_lossy(marker).into_owned())
.collect::<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> {
read_pe_imports(file_path)
.into_iter()
+676 -196
View File
@@ -4,125 +4,236 @@ use crate::common::{CommandSpec, output_text, parse_version_instant, run_command
use anyhow::{Context, Result, bail, ensure};
use chrono::{DateTime, Utc};
use clap::{Args, Subcommand};
use serde::Deserialize;
use serde_json::Value;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
const RELEASE_REPOSITORY: &str = "fluxerapp/fluxer";
const RELEASE_COMPARE_URL: &str = "https://github.com/fluxerapp/fluxer/compare";
const DESKTOP_DOWNLOAD_URL: &str = "https://api.fluxer.app/dl/desktop";
pub(crate) const DESKTOP_RELEASE_DESCRIPTOR_SCHEMA_VERSION: u8 = 1;
pub(crate) const DESKTOP_RELEASE_ROUTE_COUNT: usize = 28;
pub(crate) const DESKTOP_RELEASE_ASSET_COUNT: usize = 24;
struct DesktopDownload {
arch: &'static str,
label: &'static str,
format: &'static str,
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub(crate) struct DesktopReleaseAsset {
pub(crate) storage_key: String,
pub(crate) release_asset: String,
pub(crate) sha256: String,
pub(crate) size: u64,
}
struct DesktopPlatform {
name: &'static str,
slug: &'static str,
downloads: &'static [DesktopDownload],
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub(crate) struct DesktopReleaseDescriptor {
pub(crate) schema_version: u8,
pub(crate) channel: String,
pub(crate) version: String,
pub(crate) release_tag: String,
pub(crate) source_sha: String,
pub(crate) assets: Vec<DesktopReleaseAsset>,
}
const DESKTOP_PLATFORMS: &[DesktopPlatform] = &[
DesktopPlatform {
name: "Windows",
slug: "win32",
downloads: &[
DesktopDownload {
arch: "x64",
label: "Setup.exe",
format: "setup",
},
DesktopDownload {
arch: "x64",
label: "Portable ZIP",
format: "portable",
},
DesktopDownload {
arch: "arm64",
label: "Setup.exe",
format: "setup",
},
DesktopDownload {
arch: "arm64",
label: "Portable ZIP",
format: "portable",
},
],
},
DesktopPlatform {
name: "macOS",
slug: "darwin",
downloads: &[
DesktopDownload {
arch: "x64",
label: "DMG",
format: "dmg",
},
DesktopDownload {
arch: "x64",
label: "ZIP",
format: "zip",
},
DesktopDownload {
arch: "arm64",
label: "DMG",
format: "dmg",
},
DesktopDownload {
arch: "arm64",
label: "ZIP",
format: "zip",
},
],
},
DesktopPlatform {
name: "Linux",
slug: "linux",
downloads: &[
DesktopDownload {
arch: "x64",
label: "AppImage",
format: "appimage",
},
DesktopDownload {
arch: "x64",
label: "DEB",
format: "deb",
},
DesktopDownload {
arch: "x64",
label: "RPM",
format: "rpm",
},
DesktopDownload {
arch: "x64",
label: "tar.gz",
format: "tar_gz",
},
DesktopDownload {
arch: "arm64",
label: "AppImage",
format: "appimage",
},
DesktopDownload {
arch: "arm64",
label: "DEB",
format: "deb",
},
DesktopDownload {
arch: "arm64",
label: "RPM",
format: "rpm",
},
DesktopDownload {
arch: "arm64",
label: "tar.gz",
format: "tar_gz",
},
],
},
];
pub(crate) fn desktop_release_product(channel: &str) -> Result<&'static str> {
match channel {
"stable" => Ok("Fluxer"),
"canary" => Ok("Fluxer-Canary"),
other => bail!("Unsupported desktop release channel {other:?}"),
}
}
pub(crate) fn desktop_release_descriptor_filename(channel: &str, version: &str) -> Result<String> {
Ok(format!(
"{}-{version}-release-manifest.json",
desktop_release_product(channel)?
))
}
pub(crate) fn desktop_release_asset_name(
channel: &str,
version: &str,
platform: &str,
arch: &str,
storage_filename: &str,
) -> Result<String> {
let release_prefix = format!("{}-{version}-", desktop_release_product(channel)?);
let platform_token = match platform {
"win32" => "win",
"darwin" => "mac",
"linux" => "linux",
other => bail!("Unsupported desktop release platform {other:?}"),
};
ensure!(
matches!(arch, "x64" | "arm64"),
"Unsupported desktop release architecture {arch:?}"
);
if storage_filename.starts_with(&release_prefix) {
return Ok(storage_filename.to_string());
}
let release_filename =
if platform == "darwin" && storage_filename.eq_ignore_ascii_case("releases.json") {
"releases.json"
} else {
storage_filename
};
Ok(format!(
"{release_prefix}{platform_token}-{arch}-{release_filename}"
))
}
pub(crate) fn validate_desktop_release_descriptor(
descriptor: &DesktopReleaseDescriptor,
channel: &str,
version: &str,
source_sha: &str,
) -> Result<()> {
ensure!(
descriptor.schema_version == DESKTOP_RELEASE_DESCRIPTOR_SCHEMA_VERSION,
"Unsupported desktop release descriptor schema version {}",
descriptor.schema_version
);
ensure!(
descriptor.channel == channel,
"Desktop release descriptor channel {:?} does not match {channel:?}",
descriptor.channel
);
ensure!(
descriptor.version == version,
"Desktop release descriptor version {:?} does not match {version:?}",
descriptor.version
);
ensure!(
descriptor.release_tag == format!("fluxer-desktop-{channel}@{version}"),
"Desktop release descriptor tag {:?} is invalid",
descriptor.release_tag
);
ensure!(
descriptor.source_sha == source_sha,
"Desktop release descriptor source SHA {:?} does not match {source_sha:?}",
descriptor.source_sha
);
ensure!(
source_sha.len() == 40
&& source_sha
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),
"Invalid desktop release source SHA {source_sha:?}"
);
parse_version_instant(version)
.with_context(|| format!("Invalid desktop release descriptor version {version:?}"))?;
ensure!(
descriptor.assets.len() == DESKTOP_RELEASE_ROUTE_COUNT,
"Desktop release descriptor must contain {DESKTOP_RELEASE_ROUTE_COUNT} routes, found {}",
descriptor.assets.len()
);
let storage_prefix = format!("desktop/{channel}/");
let release_prefix = format!("{}-{version}-", desktop_release_product(channel)?);
let descriptor_name = desktop_release_descriptor_filename(channel, version)?;
let mut storage_keys = BTreeSet::new();
let mut route_counts = BTreeMap::<String, usize>::new();
let mut release_assets = BTreeMap::<&str, (&str, u64)>::new();
let mut release_asset_names = BTreeMap::from([(
descriptor_name.to_ascii_lowercase(),
descriptor_name.as_str(),
)]);
for asset in &descriptor.assets {
ensure!(
storage_keys.insert(asset.storage_key.as_str()),
"Desktop release descriptor contains duplicate storage key {:?}",
asset.storage_key
);
let key_segments = asset.storage_key.split('/').collect::<Vec<_>>();
ensure!(
key_segments.len() == 5
&& key_segments[0] == "desktop"
&& key_segments[1] == channel
&& matches!(key_segments[2], "win32" | "darwin" | "linux")
&& matches!(key_segments[3], "x64" | "arm64")
&& !key_segments[4].is_empty()
&& key_segments[4].bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')
})
&& asset.storage_key.starts_with(&storage_prefix),
"Desktop release descriptor contains invalid storage key {:?}",
asset.storage_key
);
*route_counts
.entry(format!("{}/{}", key_segments[2], key_segments[3]))
.or_default() += 1;
let expected_release_asset = desktop_release_asset_name(
channel,
version,
key_segments[2],
key_segments[3],
key_segments[4],
)?;
ensure!(
asset.release_asset.starts_with(&release_prefix)
&& asset.release_asset != descriptor_name
&& asset.release_asset == expected_release_asset
&& asset.release_asset.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')
}),
"Desktop release descriptor contains invalid release asset {:?}",
asset.release_asset
);
if let Some(existing) = release_asset_names.insert(
asset.release_asset.to_ascii_lowercase(),
asset.release_asset.as_str(),
) {
ensure!(
existing == asset.release_asset,
"Desktop release asset names differ only by case: {existing:?} and {:?}",
asset.release_asset
);
}
ensure!(
asset.sha256.len() == 64
&& asset
.sha256
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),
"Desktop release descriptor contains invalid SHA-256 for {:?}",
asset.release_asset
);
ensure!(
asset.size > 0,
"Desktop release descriptor contains an empty asset {:?}",
asset.release_asset
);
if let Some((sha256, size)) = release_assets.get(asset.release_asset.as_str()) {
ensure!(
*sha256 == asset.sha256 && *size == asset.size,
"Desktop release descriptor maps conflicting content to {:?}",
asset.release_asset
);
} else {
release_assets.insert(
asset.release_asset.as_str(),
(asset.sha256.as_str(), asset.size),
);
}
}
ensure!(
release_assets.len() == DESKTOP_RELEASE_ASSET_COUNT,
"Desktop release descriptor must contain {DESKTOP_RELEASE_ASSET_COUNT} unique release assets, found {}",
release_assets.len()
);
let expected_route_counts = BTreeMap::from([
("darwin/arm64".to_string(), 4usize),
("darwin/x64".to_string(), 4usize),
("linux/arm64".to_string(), 4usize),
("linux/x64".to_string(), 4usize),
("win32/arm64".to_string(), 6usize),
("win32/x64".to_string(), 6usize),
]);
ensure!(
route_counts == expected_route_counts,
"Desktop release descriptor route inventory mismatch: expected {expected_route_counts:?}, found {route_counts:?}"
);
Ok(())
}
#[derive(Debug, Args, Clone)]
pub struct ReleaseArgs {
@@ -148,6 +259,8 @@ struct PublishArgs {
previous_sha: Option<String>,
#[arg(long)]
prerelease: bool,
#[arg(long)]
asset_dir: Option<PathBuf>,
}
#[derive(Debug, Deserialize)]
@@ -167,15 +280,39 @@ struct QualifiedRelease {
published_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy)]
struct ReleaseHandle<'a> {
id: u64,
tag: &'a str,
}
#[derive(Debug, Deserialize)]
struct ReleaseDetail {
id: u64,
tag_name: String,
target_commitish: String,
name: Option<String>,
body: Option<String>,
draft: 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)]
@@ -207,12 +344,7 @@ fn publish(args: PublishArgs) -> Result<()> {
let summaries = release_summaries()?;
let qualified = qualified_releases(&summaries, &args.component)?;
let existing_release = qualified.iter().find(|release| release.tag == tag);
if let Some(existing) = summaries.iter().find(|release| release.tag_name == tag) {
ensure!(
!existing.is_draft && existing.published_at.is_some(),
"Release {tag} exists but is not a published, non-draft component release"
);
}
let existing_summary = summaries.iter().find(|release| release.tag_name == tag);
if existing_release.is_none()
&& let Some(newer) = qualified
@@ -271,35 +403,85 @@ fn publish(args: PublishArgs) -> Result<()> {
}
};
let body = release_body(
let body = release_body(&previous_sha, &source_sha);
let assets = local_release_assets(
&args.component,
&args.build_version,
&previous_sha,
&source_sha,
);
if summaries.iter().any(|release| release.tag_name == tag) {
verify_existing_release(&tag, &title, &body, &source_sha, args.prerelease)?;
args.asset_dir.as_deref(),
)?;
if let Some(existing) = existing_summary.filter(|release| !release.is_draft) {
ensure!(
existing.published_at.is_some(),
"Published release {tag} is missing its publication timestamp"
);
verify_release(
ReleaseHandle {
id: existing.id,
tag: &tag,
},
&title,
&body,
&source_sha,
args.prerelease,
false,
&assets,
)?;
println!("Release {tag} already exists with the expected state.");
return Ok(());
}
ensure!(
!tag_exists(&tag)?,
"Refusing to publish {tag}: the tag already exists without a matching GitHub Release"
);
let mut command = CommandSpec::new("gh")
.args(["release", "create", &tag])
.args(["--repo", RELEASE_REPOSITORY])
.args(["--title", &title])
.args(["--notes", &body])
.args(["--target", &source_sha])
.args(["--latest=false"]);
if args.prerelease {
command = command.arg("--prerelease");
}
run_command(command)?;
verify_existing_release(&tag, &title, &body, &source_sha, args.prerelease)
let release_id = if let Some(existing) = existing_summary {
ensure!(
existing.published_at.is_none(),
"Draft release {tag} unexpectedly has a publication timestamp"
);
existing.id
} else {
ensure!(
!tag_exists(&tag)?,
"Refusing to publish {tag}: the tag already exists without a matching GitHub Release"
);
create_draft_release(&tag, &title, &body, &source_sha, args.prerelease)?
};
let release = ReleaseHandle {
id: release_id,
tag: &tag,
};
upload_draft_release_assets(
release,
&title,
&body,
&source_sha,
args.prerelease,
&assets,
)?;
verify_release(
release,
&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(
release,
&title,
&body,
&source_sha,
args.prerelease,
false,
&assets,
)
}
fn validate_component(component: &str) -> Result<()> {
@@ -425,20 +607,288 @@ fn tag_exists(tag: &str) -> Result<bool> {
Ok(refs.iter().any(|git_ref| git_ref.name == expected))
}
fn verify_existing_release(
fn local_release_assets(
component: &str,
version: &str,
source_sha: &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 = desktop_release_product(channel)?;
let prefix = format!("{product}-{version}-");
let descriptor_name = desktop_release_descriptor_filename(channel, version)?;
let descriptor_path = asset_dir.join(&descriptor_name);
let descriptor: DesktopReleaseDescriptor = serde_json::from_slice(
&fs::read(&descriptor_path)
.with_context(|| format!("Failed to read {}", descriptor_path.display()))?,
)
.with_context(|| format!("Failed to parse {}", descriptor_path.display()))?;
validate_desktop_release_descriptor(&descriptor, channel, version, source_sha)?;
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());
let mut case_folded_names = BTreeMap::<String, String>::new();
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:?}"
);
ensure!(
name.starts_with(&prefix),
"Release asset {name:?} must start with {prefix:?}"
);
if let Some(existing) = case_folded_names.insert(name.to_ascii_lowercase(), name.clone()) {
ensure!(
existing == name,
"Release asset names differ only by case: {existing:?} and {name:?}"
);
}
assets.push(LocalReleaseAsset {
digest: sha256_file(&path)?,
path,
name,
size: metadata.len(),
});
}
let expected_names = descriptor
.assets
.iter()
.map(|asset| asset.release_asset.clone())
.chain(std::iter::once(descriptor_name))
.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:?}"
);
let local_by_name = assets
.iter()
.map(|asset| (asset.name.as_str(), asset))
.collect::<BTreeMap<_, _>>();
for descriptor_asset in &descriptor.assets {
let local = local_by_name
.get(descriptor_asset.release_asset.as_str())
.with_context(|| {
format!(
"Desktop release descriptor references missing asset {:?}",
descriptor_asset.release_asset
)
})?;
ensure!(
local.digest == descriptor_asset.sha256 && local.size == descriptor_asset.size,
"Desktop release descriptor metadata does not match {:?}",
descriptor_asset.release_asset
);
}
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,
title: &str,
body: &str,
source_sha: &str,
prerelease: bool,
) -> Result<()> {
) -> Result<u64> {
let output = output_text(
CommandSpec::new("gh")
.args(["api", "--method", "POST"])
.arg(format!("repos/{RELEASE_REPOSITORY}/releases"))
.arg("-f")
.arg(format!("tag_name={tag}"))
.arg("-f")
.arg(format!("target_commitish={source_sha}"))
.arg("-f")
.arg(format!("name={title}"))
.arg("-f")
.arg(format!("body={body}"))
.arg("-F")
.arg("draft=true")
.arg("-F")
.arg(format!("prerelease={prerelease}"))
.arg("-f")
.arg("make_latest=false"),
)?;
let release: ReleaseDetail = serde_json::from_str(&output)
.with_context(|| format!("Failed to parse created draft release {tag}"))?;
ensure!(release.id > 0, "Draft release {tag} has an invalid ID");
Ok(release.id)
}
fn release_detail(release_id: u64) -> Result<ReleaseDetail> {
let output = output_text(
CommandSpec::new("gh")
.arg("api")
.arg(format!("repos/{RELEASE_REPOSITORY}/releases/tags/{tag}")),
.arg(format!("repos/{RELEASE_REPOSITORY}/releases/{release_id}")),
)?;
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 ID {release_id}"))
}
fn upload_draft_release_assets(
release: ReleaseHandle<'_>,
title: &str,
body: &str,
source_sha: &str,
prerelease: bool,
expected_assets: &[LocalReleaseAsset],
) -> Result<()> {
let detail = release_detail(release.id)?;
verify_release_metadata(
release.tag,
&detail,
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 &detail.assets {
ensure!(
expected_by_name.contains_key(asset.name.as_str()),
"Draft release {} contains unexpected asset {:?}",
release.tag,
asset.name
);
ensure!(
published_by_name
.insert(asset.name.as_str(), asset)
.is_none(),
"Draft release {} contains duplicate asset name {:?}",
release.tag,
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", release.tag])
.args(["--repo", RELEASE_REPOSITORY])
.arg("--clobber");
for asset in pending {
command = command.arg(&asset.path);
}
run_command(command)
}
fn verify_release(
release: ReleaseHandle<'_>,
title: &str,
body: &str,
source_sha: &str,
prerelease: bool,
draft: bool,
expected_assets: &[LocalReleaseAsset],
) -> Result<()> {
let detail = release_detail(release.id)?;
verify_release_metadata(
release.tag,
&detail,
title,
body,
source_sha,
prerelease,
draft,
)?;
verify_release_assets(release.tag, &detail.assets, expected_assets)?;
if draft {
return Ok(());
}
let tag_sha = resolve_commit_sha(release.tag)?;
ensure!(
tag_sha == source_sha,
"Release tag {} targets {tag_sha}, expected {source_sha}",
release.tag
);
Ok(())
}
fn verify_release_metadata(
tag: &str,
release: &ReleaseDetail,
title: &str,
body: &str,
source_sha: &str,
prerelease: bool,
draft: bool,
) -> Result<()> {
ensure!(
release.tag_name == tag,
"Release {tag} has a mismatched tag"
@@ -451,26 +901,93 @@ fn verify_existing_release(
release.body.as_deref().unwrap_or_default() == body,
"Release {tag} has a mismatched body"
);
ensure!(!release.draft, "Release {tag} is unexpectedly a draft");
ensure!(
release.draft == draft,
"Release {tag} has draft state {}, expected {draft}",
release.draft
);
ensure!(
release.prerelease == prerelease,
"Release {tag} has a mismatched prerelease state"
);
ensure!(
release.assets.is_empty(),
"Release {tag} has assets; asset-free publication is required"
);
let tag_sha = resolve_commit_sha(tag)?;
ensure!(
tag_sha == source_sha,
"Release tag {tag} targets {tag_sha}, expected {source_sha}"
);
let target_sha = resolve_commit_sha(&release.target_commitish)?;
ensure!(
target_sha == source_sha,
"Release {tag} target resolves to {target_sha}, expected {source_sha}"
);
if draft && tag_exists(tag)? {
let tag_sha = resolve_commit_sha(tag)?;
ensure!(
tag_sha == source_sha,
"Draft release tag {tag} targets {tag_sha}, expected {source_sha}"
);
}
Ok(())
}
fn release_asset_matches(published: &PublishedReleaseAsset, expected: &LocalReleaseAsset) -> bool {
let expected_digest = format!("sha256:{}", expected.digest);
published.label.as_deref().unwrap_or_default().is_empty()
&& published.state == "uploaded"
&& published.size == expected.size
&& published.digest.as_deref() == Some(expected_digest.as_str())
}
fn verify_release_assets(
tag: &str,
published_assets: &[PublishedReleaseAsset],
expected_assets: &[LocalReleaseAsset],
) -> Result<()> {
let mut published_by_name = BTreeMap::new();
for asset in published_assets {
ensure!(
published_by_name
.insert(asset.name.as_str(), asset)
.is_none(),
"Release {tag} contains duplicate asset name {:?}",
asset.name
);
}
let expected_names = expected_assets
.iter()
.map(|asset| asset.name.as_str())
.collect::<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(())
}
@@ -482,51 +999,14 @@ fn release_title(component: &str, version: &str) -> String {
format!("{component} {version}")
}
fn release_body(component: &str, version: &str, previous_sha: &str, source_sha: &str) -> String {
let changes = format!(
fn release_body(previous_sha: &str, source_sha: &str) -> String {
format!(
"Changes: [`{}..{}`]({RELEASE_COMPARE_URL}/{previous_sha}..{source_sha})",
&previous_sha[..7],
&source_sha[..7]
);
match desktop_channel(component) {
Some(channel) => format!(
"{changes}\n\n{}",
desktop_download_sections(channel, version)
),
None => changes,
}
)
}
fn desktop_channel(component: &str) -> Option<&str> {
component.strip_prefix("fluxer-desktop-")
}
fn desktop_download_sections(channel: &str, version: &str) -> String {
DESKTOP_PLATFORMS
.iter()
.map(|platform| desktop_download_section(channel, version, platform))
.collect::<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,
)
}
+1 -58
View File
@@ -7,13 +7,11 @@ use anyhow::{Context, Result, bail};
use std::env;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use std::time::Duration;
use url::Url;
const CANARY_APP_NAME: &str = "Fluxer Canary";
const CANARY_BUNDLE_ID: &str = "app.fluxer.canary";
const CANARY_RPC_PORT: u16 = 21864;
const MACOS_DEV_ELECTRON_USAGE_DESCRIPTIONS: &[(&str, &str)] = &[
(
"NSMicrophoneUsageDescription",
@@ -466,7 +464,6 @@ fn stop_running_canary_on_host() -> Result<()> {
],
);
run_best_effort("pkill", &["-TERM", "-x", CANARY_APP_NAME]);
terminate_rpc_port_processes(CANARY_RPC_PORT)?;
Ok(())
}
@@ -479,60 +476,6 @@ fn run_best_effort(program: &str, args: &[&str]) {
.status();
}
fn terminate_rpc_port_processes(port: u16) -> Result<()> {
let mut pids = pids_listening_on_tcp_port(port)?;
if pids.is_empty() {
return Ok(());
}
kill_pids("-TERM", &pids)?;
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
thread::sleep(Duration::from_millis(250));
pids = pids_listening_on_tcp_port(port)?;
if pids.is_empty() {
return Ok(());
}
}
kill_pids("-KILL", &pids)
}
fn pids_listening_on_tcp_port(port: u16) -> Result<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)]
mod tests {
use super::*;