mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 04:02:41 +09:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53ddca725e | ||
|
|
094fb0d1c8 | ||
|
|
dc230926a4 | ||
|
|
026ace6747 | ||
|
|
374db9ed2b | ||
|
|
5ee59c4675 | ||
|
|
33605171a8 | ||
|
|
89fac5b088 | ||
|
|
4a34b942b7 | ||
|
|
fc3065ebe4 | ||
|
|
23493b4ac2 | ||
|
|
13344096b7 | ||
|
|
a800430997 | ||
|
|
509562e6da | ||
|
|
88d85919f1 | ||
|
|
154e223284 |
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
Vendored
-1
@@ -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;
|
||||
|
||||
@@ -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:"];
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
-1
Submodule fluxer_marketing updated: 530c44ec38...0c78170d8d
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+75
-229
@@ -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
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user