Compare commits

...
Author SHA1 Message Date
HampusandGitHub 8d21c97d08 fix(admin): sort session imports to satisfy rustfmt (#2079) 2026-08-29 15:05:04 +02:00
HampusandGitHub 71a56f590d fix(ci): read dependent load flags at the pe32+ offset (#2078) 2026-08-29 15:00:38 +02:00
HampusandGitHub 38297c4fe7 chore(api): drop knip-unused search and SSO exports (#2077) 2026-08-29 15:00:09 +02:00
HampusandGitHub cf7ec06d85 fix(api): enforce scoped perms, age gates, audit logs (#2076) 2026-08-29 14:55:13 +02:00
HampusandGitHub f2ea10f951 fix(api): harden ratelimit, SSRF, uploads and DM guards (#2074) 2026-08-29 14:55:09 +02:00
HampusandGitHub bbd93df239 fix(api): expire auth tokens and harden login checks (#2073) 2026-08-29 14:55:04 +02:00
HampusandGitHub 9a6ab93e01 fix(media-proxy): bound GIF decode and BMFF box walking (#2075) 2026-08-29 14:55:00 +02:00
HampusandGitHub 38eed7cce6 fix(gateway): restrict /_metrics to loopback callers (#2072) 2026-08-29 14:54:56 +02:00
HampusandGitHub 79064c3399 fix(admin): sign CSRF tokens and escape them in scripts (#2071) 2026-08-29 14:54:51 +02:00
HampusandGitHub 0496b2f530 fix(ci): reject path traversal in S3 prefix downloads (#2070) 2026-08-29 14:54:37 +02:00
HampusandGitHub 9d0be1ebd1 fix(desktop): drop game capture injection and pin dll search (#2069) 2026-08-29 14:26:28 +02:00
HampusandGitHub 9ad026b8ce fix(app): repair theme CSS sync persistence and data loss (#2067) 2026-08-29 03:37:41 +02:00
HampusandGitHub 14de5971d5 chore(api): drop unused ELEVATED_MFA_PERMISSIONS export (#2066) 2026-08-29 03:12:24 +02:00
HampusandGitHub 5474be3efa fix(api): close auth, billing and authorization bypasses (#2065) 2026-08-29 02:59:38 +02:00
fluxer-ci[bot]andGitHub 9a54bbba2d chore(i18n): update public marketing catalogs (#2064) 2026-08-29 01:52:48 +02:00
fluxer-ci[bot]andGitHub 5a0110ccc8 chore(marketing): advance pointer 0c78170 → 5908507 (#2063) 2026-08-29 01:52:44 +02:00
HampusandGitHub d032d577bf fix(app-proxy): drop leaked canary debug cert from assetlinks (#2062) 2026-08-29 01:43:26 +02:00
HampusandGitHub 243954c9c5 test(api): use a future baseline in invoice-skip premium tests (#2061) 2026-08-29 01:22:29 +02:00
146 changed files with 1110 additions and 12431 deletions
+59 -61
View File
@@ -2,24 +2,43 @@
use axum::{
body::{Body, to_bytes},
extract::Request,
extract::{Request, State},
http::{HeaderValue, Method, StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
};
use rand::RngExt;
use crate::middleware::auth::AuthContext;
use crate::session::{create_csrf_token, verify_csrf_token};
use crate::state::AppState;
const CSRF_COOKIE_NAME: &str = "csrf_token";
pub const CSRF_FORM_FIELD: &str = "_csrf";
const CSRF_HEADER_NAME: &str = "x-csrf-token";
const TOKEN_LENGTH: usize = 32;
const HOST_CSRF_COOKIE_NAME: &str = "__Host-csrf_token";
const MAX_CSRF_FORM_BYTES: usize = 8 * 1024 * 1024;
const IGNORED_PATH_SUFFIXES: &[&str] = &["/oauth2_callback", "/auth/start"];
pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
let existing_token = extract_csrf_cookie(&request);
let token = existing_token.unwrap_or_else(generate_csrf_token);
pub async fn csrf_protection(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Response {
let config = state.config();
let secret = config.secret_key_base.clone();
let admin_endpoint = config.admin_endpoint.clone();
let is_production = config.is_production();
let user_id = request
.extensions()
.get::<AuthContext>()
.map(|ctx| ctx.session.user_id.clone())
.unwrap_or_default();
let token = extract_csrf_cookie(&request)
.filter(|cookie| verify_csrf_token(cookie, &user_id, &secret))
.unwrap_or_else(|| create_csrf_token(&user_id, &secret));
request.extensions_mut().insert(CsrfToken(token.clone()));
if matches!(
@@ -31,6 +50,9 @@ pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
.iter()
.any(|suffix| path.ends_with(suffix));
if !is_ignored {
if !is_same_site_request(&request, &admin_endpoint) {
return StatusCode::FORBIDDEN.into_response();
}
let header_token = extract_csrf_header(&request);
let query_token = extract_csrf_from_query(&request);
let mut submitted = query_token.or(header_token);
@@ -43,21 +65,24 @@ pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
request = restored_request;
submitted = body_token;
}
match submitted {
Some(ref submitted_token) if submitted_token == &token => {}
_ => {
return StatusCode::FORBIDDEN.into_response();
}
let accepted = submitted.as_deref().is_some_and(|submitted_token| {
submitted_token == token && verify_csrf_token(submitted_token, &user_id, &secret)
});
if !accepted {
return StatusCode::FORBIDDEN.into_response();
}
}
}
let mut response = next.run(request).await;
let cookie_value = format!(
"{}={}; Path=/; SameSite=Lax; HttpOnly",
CSRF_COOKIE_NAME, token
);
let cookie_name = if is_production {
HOST_CSRF_COOKIE_NAME
} else {
CSRF_COOKIE_NAME
};
let secure = if is_production { "; Secure" } else { "" };
let cookie_value = format!("{cookie_name}={token}; Path=/; SameSite=Lax; HttpOnly{secure}");
if let Ok(value) = HeaderValue::from_str(&cookie_value) {
response.headers_mut().append(header::SET_COOKIE, value);
}
@@ -69,7 +94,10 @@ fn extract_csrf_cookie(request: &Request) -> Option<String> {
let cookie_header = request.headers().get(header::COOKIE)?.to_str().ok()?;
for pair in cookie_header.split(';') {
let pair = pair.trim();
if let Some(value) = pair.strip_prefix("csrf_token=") {
let value = pair
.strip_prefix("__Host-csrf_token=")
.or_else(|| pair.strip_prefix("csrf_token="));
if let Some(value) = value {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_owned());
@@ -127,18 +155,22 @@ async fn extract_csrf_from_form_body(
Ok((request, token))
}
fn generate_csrf_token() -> String {
let mut rng = rand::rng();
let bytes: [u8; TOKEN_LENGTH] = rng.random();
hex_encode(&bytes)
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for byte in bytes {
s.push_str(&format!("{byte:02x}"));
fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
if let Some(site) = request
.headers()
.get("sec-fetch-site")
.and_then(|value| value.to_str().ok())
{
return matches!(site, "same-origin" | "same-site" | "none");
}
match request
.headers()
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
{
Some(origin) => origin == admin_endpoint,
None => true,
}
s
}
#[derive(Clone, Debug)]
@@ -156,40 +188,6 @@ pub fn get_csrf_token(request: &Request) -> String {
mod tests {
use super::*;
#[test]
fn generate_csrf_token_correct_length() {
let token = generate_csrf_token();
assert_eq!(
token.len(),
TOKEN_LENGTH * 2,
"token must be {} hex chars",
TOKEN_LENGTH * 2
);
}
#[test]
fn generate_csrf_token_is_valid_hex() {
let token = generate_csrf_token();
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"token must contain only hex chars: {token}"
);
}
#[test]
fn generate_csrf_token_is_unique() {
let a = generate_csrf_token();
let b = generate_csrf_token();
assert_ne!(a, b, "consecutive tokens must differ");
}
#[test]
fn hex_encode_produces_correct_output() {
assert_eq!(hex_encode(&[0x00, 0xff, 0x0a]), "00ff0a");
assert_eq!(hex_encode(&[]), "");
assert_eq!(hex_encode(&[0xde, 0xad]), "dead");
}
#[test]
fn oauth2_callback_is_exempt() {
let exempt = IGNORED_PATH_SUFFIXES
+1 -1
View File
@@ -348,7 +348,7 @@ fn oauth_callback_page(config: &AdminConfig, code: Option<&str>, state: Option<&
)
}
fn json_string(value: &str) -> String {
pub(crate) fn json_string(value: &str) -> String {
serde_json::to_string(value)
.expect("JSON string serialization cannot fail")
.replace('<', "\\u003c")
+4 -1
View File
@@ -73,7 +73,10 @@ pub fn build_router(config: AdminConfig) -> Router {
.route("/", get(dashboard))
.route("/dashboard", get(dashboard))
.layer(from_fn(middleware::htmx::flash_redirect_to_toast))
.layer(from_fn(middleware::csrf::csrf_protection))
.layer(from_fn_with_state(
state.clone(),
middleware::csrf::csrf_protection,
))
.layer(from_fn(middleware::self_hosted::self_hosted_override))
.layer(from_fn_with_state(
state.clone(),
+31
View File
@@ -2,6 +2,7 @@
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use hmac::{Hmac, KeyInit, Mac};
use rand::RngExt;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -78,6 +79,36 @@ fn verify_signature<'a>(signed_data: &'a str, secret_key: &str) -> Option<&'a st
if diff == 0 { Some(data) } else { None }
}
pub fn create_csrf_token(user_id: &str, secret_key: &str) -> String {
let mut rng = rand::rng();
let nonce: [u8; 16] = rng.random();
let payload = URL_SAFE_NO_PAD.encode(format!(
"{}:{}",
URL_SAFE_NO_PAD.encode(user_id.as_bytes()),
URL_SAFE_NO_PAD.encode(nonce)
));
sign_data(&payload, secret_key)
}
pub fn verify_csrf_token(token: &str, user_id: &str, secret_key: &str) -> bool {
let Some(data) = verify_signature(token, secret_key) else {
return false;
};
let Ok(decoded) = URL_SAFE_NO_PAD.decode(data.as_bytes()) else {
return false;
};
let Ok(payload) = String::from_utf8(decoded) else {
return false;
};
let Some((encoded_uid, _nonce)) = payload.split_once(':') else {
return false;
};
match URL_SAFE_NO_PAD.decode(encoded_uid.as_bytes()) {
Ok(uid_bytes) => uid_bytes == user_id.as_bytes(),
Err(_) => false,
}
}
pub const LEGACY_SESSION_COOKIE_NAME: &str = "session";
pub const SESSION_COOKIE_NAME: &str = "admin_session";
pub const SESSION_MAX_AGE: i64 = MAX_AGE_SECONDS as i64;
@@ -7,6 +7,7 @@ use super::media::user_avatar_url;
use super::nsfw_indicators::{attachment_nsfw_badge, channel_nsfw_state_badge};
use super::user_display::format_user_display;
use crate::config::AdminConfig;
use crate::routes::auth::json_string;
pub struct Attachment {
pub id: String,
@@ -309,7 +310,7 @@ pub fn message_list(
}
pub fn message_deletion_script(csrf_token: &str) -> Markup {
let csrf = serde_json::to_string(csrf_token).unwrap_or_else(|_| "\"\"".into());
let csrf = json_string(csrf_token);
let script = r#"(function() {
var csrf = __CSRF__;
function bp() {
@@ -89,29 +89,77 @@ function isFqdnHostname(hostname: string): boolean {
return !/^\d+$/.test(topLevelDomain);
}
function parseIpv4MappedIpv6Address(ipv6Address: string): string | null {
function expandIpv6ToBytes(ipv6Address: string): Uint8Array | null {
const normalized = stripIpv6Brackets(ipv6Address.trim().toLowerCase());
if (!normalized.startsWith('::ffff:')) {
if (isIP(normalized) !== 6) {
return null;
}
const suffix = normalized.slice('::ffff:'.length);
if (isIP(suffix) === 4) {
return suffix;
let head = normalized;
let embeddedIpv4Octets: Array<number> | null = null;
const lastColonIndex = head.lastIndexOf(':');
const trailing = head.slice(lastColonIndex + 1);
if (trailing.includes('.')) {
if (isIP(trailing) !== 4) {
return null;
}
embeddedIpv4Octets = trailing.split('.').map((part) => Number.parseInt(part, 10));
head = `${head.slice(0, lastColonIndex + 1)}0:0`;
}
const groups = suffix.split(':');
if (groups.length !== 2) {
let groups: Array<string>;
if (head.indexOf('::') === -1) {
groups = head.split(':');
if (groups.length !== 8) {
return null;
}
} else {
const [beforePart, afterPart] = head.split('::');
const before = beforePart.length > 0 ? beforePart.split(':') : [];
const after = afterPart.length > 0 ? afterPart.split(':') : [];
const missing = 8 - before.length - after.length;
if (missing < 1) {
return null;
}
groups = [...before, ...new Array(missing).fill('0'), ...after];
}
const bytes = new Uint8Array(16);
for (let index = 0; index < 8; index += 1) {
const value = parseHexGroup(groups[index]);
if (value === null) {
return null;
}
bytes[index * 2] = (value >> 8) & 0xff;
bytes[index * 2 + 1] = value & 0xff;
}
if (embeddedIpv4Octets) {
bytes[12] = embeddedIpv4Octets[0];
bytes[13] = embeddedIpv4Octets[1];
bytes[14] = embeddedIpv4Octets[2];
bytes[15] = embeddedIpv4Octets[3];
}
return bytes;
}
function parseEmbeddedIpv4Address(ipv6Address: string): string | null {
const bytes = expandIpv6ToBytes(ipv6Address);
if (!bytes) {
return null;
}
const high = parseHexGroup(groups[0]);
const low = parseHexGroup(groups[1]);
if (high === null || low === null) {
return null;
const hasPrefix = (prefix: Array<number>): boolean => prefix.every((byte, index) => bytes[index] === byte);
const dottedQuadAt = (start: number): string =>
`${bytes[start]}.${bytes[start + 1]}.${bytes[start + 2]}.${bytes[start + 3]}`;
if (hasPrefix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff])) {
return dottedQuadAt(12);
}
const octet1 = (high >> 8) & 0xff;
const octet2 = high & 0xff;
const octet3 = (low >> 8) & 0xff;
const octet4 = low & 0xff;
return `${octet1}.${octet2}.${octet3}.${octet4}`;
if (hasPrefix([0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0])) {
return dottedQuadAt(12);
}
if (hasPrefix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])) {
return dottedQuadAt(12);
}
if (hasPrefix([0x20, 0x02])) {
return dottedQuadAt(2);
}
return null;
}
function parseHexGroup(value: string | undefined): number | null {
@@ -128,9 +176,9 @@ function isBlockedIpAddress(address: string): boolean {
return blockedIpv4List.check(normalizedAddress, 'ipv4');
}
if (family === 6) {
const mappedIpv4 = parseIpv4MappedIpv6Address(normalizedAddress);
if (mappedIpv4) {
return blockedIpv4List.check(mappedIpv4, 'ipv4');
const embeddedIpv4 = parseEmbeddedIpv4Address(normalizedAddress);
if (embeddedIpv4) {
return blockedIpv4List.check(embeddedIpv4, 'ipv4');
}
return blockedIpv6List.check(normalizedAddress, 'ipv6');
}
+32 -19
View File
@@ -359,13 +359,36 @@ export async function login(
const MFA_TICKET_MAX_ATTEMPTS = 5;
const MFA_USER_MAX_ATTEMPTS = 10;
const MFA_USER_ATTEMPTS_WINDOW = seconds('15 minutes');
async function consumeMfaAttempt(
ctx: ApiContext,
{userId, ticket, field}: {userId: string; ticket: string; field: string},
): Promise<void> {
const {cache, rateLimit} = ctx.services;
const userLimit = await rateLimit.checkLimit({
identifier: `mfa:user:${userId}`,
maxAttempts: MFA_USER_MAX_ATTEMPTS,
windowMs: ms('15 minutes'),
});
if (!userLimit.allowed) {
throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE);
}
const ticketLimit = await rateLimit.checkLimit({
identifier: `mfa:ticket:${ticket}`,
maxAttempts: MFA_TICKET_MAX_ATTEMPTS,
windowMs: ms('5 minutes'),
});
if (!ticketLimit.allowed) {
await cache.delete(`mfa-ticket:${ticket}`);
throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE);
}
}
export async function loginMfaTotp(
ctx: ApiContext,
{code, ticket, request}: LoginMfaTotpParams,
): Promise<LoginTokenResult> {
const {users, cache} = ctx.services;
const {users, cache, rateLimit} = ctx.services;
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.SESSION_TIMEOUT);
@@ -378,32 +401,19 @@ export async function loginMfaTotp(
if (!user.totpSecret || !user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP)) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.TOTP_NOT_ENABLED);
}
const userAttemptsKey = `mfa-user-attempts:${user.id}`;
const userAttempts = (await cache.get<number>(userAttemptsKey)) ?? 0;
if (userAttempts >= MFA_USER_MAX_ATTEMPTS) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
}
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'code'});
const isValid = await AuthMfa.verifyMfaCode(ctx, {
userId: user.id,
mfaSecret: user.totpSecret,
code,
allowBackup: true,
});
const attemptsKey = `mfa-ticket-attempts:${ticket}`;
if (!isValid) {
await cache.set(userAttemptsKey, userAttempts + 1, MFA_USER_ATTEMPTS_WINDOW);
const attempts = ((await cache.get<number>(attemptsKey)) ?? 0) + 1;
if (attempts >= MFA_TICKET_MAX_ATTEMPTS) {
await cache.delete(`mfa-ticket:${ticket}`);
await cache.delete(attemptsKey);
} else {
await cache.set(attemptsKey, attempts, seconds('5 minutes'));
}
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
}
await cache.delete(`mfa-ticket:${ticket}`);
await cache.delete(attemptsKey);
await cache.delete(userAttemptsKey);
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:user:${user.id}`);
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
@@ -415,7 +425,7 @@ export async function loginMfaWebAuthn(
ctx: ApiContext,
{response, challenge, ticket, request}: LoginMfaWebAuthnParams,
): Promise<LoginTokenResult> {
const {users, cache} = ctx.services;
const {users, cache, rateLimit} = ctx.services;
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
@@ -425,8 +435,11 @@ export async function loginMfaWebAuthn(
throw new UnknownUserError();
}
AuthUtility.assertNonBotUser(ctx, user);
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'ticket'});
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
await cache.delete(`mfa-ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:user:${user.id}`);
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
+19 -1
View File
@@ -21,7 +21,7 @@ import {
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import {seconds} from 'itty-time';
import {ms, seconds} from 'itty-time';
import type {ApiContext} from '../ApiContext';
import {createUserID, type UserID} from '../BrandedTypes';
import {Logger} from '../Logger';
@@ -410,6 +410,20 @@ export async function generateWebAuthnOptionsForSudo(ctx: ApiContext, userId: Us
return options;
}
const SUDO_MFA_USER_MAX_ATTEMPTS = 10;
async function consumeSudoMfaAttempt(ctx: ApiContext, userId: UserID): Promise<void> {
const {rateLimit} = ctx.services;
const userLimit = await rateLimit.checkLimit({
identifier: `sudo-mfa:user:${userId}`,
maxAttempts: SUDO_MFA_USER_MAX_ATTEMPTS,
windowMs: ms('15 minutes'),
});
if (!userLimit.allowed) {
throw InputValidationError.fromCode('mfa_code', ValidationErrorCodes.INVALID_MFA_CODE);
}
}
export async function verifySudoMfa(
ctx: ApiContext,
params: SudoMfaVerificationParams,
@@ -427,7 +441,11 @@ export async function verifySudoMfa(
case 'totp': {
if (!code) return {success: false, error: 'TOTP code is required'};
if (!user.totpSecret) return {success: false, error: 'TOTP is not enabled'};
await consumeSudoMfaAttempt(ctx, userId);
const isValid = await verifyMfaCode(ctx, {userId, mfaSecret: user.totpSecret, code, allowBackup: true});
if (isValid) {
await ctx.services.rateLimit.resetLimit(`sudo-mfa:user:${userId}`);
}
return {success: isValid, error: isValid ? undefined : 'Invalid TOTP code'};
}
case 'webauthn': {
+1 -1
View File
@@ -267,7 +267,7 @@ export async function resetPassword(
},
user.toRow(),
);
await users.deleteAllAuthSessions(user.id);
await AuthSession.terminateAllUserSessions(ctx, user.id);
await users.deletePasswordResetToken(data.token);
const hasMfa =
updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) ||
+1
View File
@@ -91,6 +91,7 @@ export async function createAuthSession(
if (user.isBot) throw new BotUserAuthSessionCreationDeniedError();
if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError();
if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError();
user = await AuthUtility.handleBanStatus(ctx, user);
const now = new Date();
const token = await AuthUtility.generateAuthToken(ctx);
let clientCountry: string | null = null;
@@ -225,7 +225,7 @@ function resolveEmailVerified({
if (values.includes(false)) {
return false;
}
return values.length === 0 || values.includes(true);
return values.length > 0 && values.includes(true);
}
function isJsonWebKeySet(value: unknown): value is JSONWebKeySet {
@@ -233,11 +233,14 @@ export class AttachmentUploadService {
const parts = await Promise.all(
Array.from({length: partCount}, async (_, index) => {
const partNumber = index + 1;
const partContentLength =
partNumber < partCount ? partSize : attachment.file_size - partSize * (partCount - 1);
const presigned_upload_url = await this.storageService.getPresignedUploadPartURL({
bucket,
key: uploadKey,
uploadId,
partNumber,
contentLength: partContentLength,
});
const upload_url = applyUploadRelayDecision({
presignedUrl: presigned_upload_url,
@@ -246,7 +249,7 @@ export class AttachmentUploadService {
relayDecision: uploadRelayDecision,
uploadId,
partNumber,
maxBytes: partSize,
maxBytes: partContentLength,
});
return {part_number: partNumber, upload_url};
}),
@@ -275,7 +278,7 @@ export class AttachmentUploadService {
if (!Config.presignedAttachmentUploadsEnabled) {
throw new FeatureTemporarilyDisabledError();
}
await this.getUploadPermissionAndLimit({userId, channelId});
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId});
const bucket = Config.s3.buckets.uploads;
return Promise.all(
uploads.map(async ({upload_filename, upload_id}, index) => {
@@ -305,6 +308,13 @@ export class AttachmentUploadService {
.catch(() => undefined);
throw InputValidationError.fromCode('parts', ValidationErrorCodes.NO_UPLOADED_PARTS_TO_FINALIZE);
}
const totalUploadedBytes = parts.reduce((sum, part) => sum + (part.size ?? 0), 0);
if (totalUploadedBytes > maxFileSize) {
await this.storageService
.abortMultipartUpload({bucket, key: upload_filename, uploadId: upload_id})
.catch(() => undefined);
throw new FileSizeTooLargeError(maxFileSize);
}
try {
await runAttachmentStorageOperation(() =>
this.storageService.completeMultipartUpload({
@@ -13,6 +13,7 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse
import type {ChannelID, GuildID, UserID} from '../../BrandedTypes';
import {SYSTEM_USER_ID} from '../../constants/Core';
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
import {createGuildMfaEnforcer} from '../../guild/services/GuildMfaEnforcement';
import type {IGatewayService} from '../../infrastructure/IGatewayService';
import type {Channel} from '../../models/Channel';
import type {GuildMember} from '../../models/GuildMember';
@@ -173,8 +174,15 @@ export abstract class BaseChannelAuthService {
userId,
memberData: guildMemberResult.memberData!,
});
const enforceGuildMfa = await createGuildMfaEnforcer({
userRepository: this.userRepository,
guildData: guildDataResult!,
userId,
});
const hasPermission = async (permission: bigint): Promise<boolean> => {
return await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
const allowed = await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
if (allowed) enforceGuildMfa(permission);
return allowed;
};
const checkPermission = async (permission: bigint): Promise<void> => {
const allowed = await hasPermission(permission);
@@ -40,9 +40,8 @@ export class DMPermissionValidator {
if (isBugHunterBotUser(senderUser)) {
return;
}
if (!senderUser.isBot && (senderUser.flags & UserFlags.SPAMMER) === UserFlags.SPAMMER) {
return;
}
const isShadowbannedSpammer =
!senderUser.isBot && (senderUser.flags & UserFlags.SPAMMER) === UserFlags.SPAMMER;
const [senderBlockedTarget, targetBlockedSender, areFriends, targetSettings, senderSettings] = await Promise.all([
this.deps.userRepository.getRelationship(senderId, recipientId, RelationshipTypes.BLOCKED),
this.deps.userRepository.getRelationship(recipientId, senderId, RelationshipTypes.BLOCKED),
@@ -53,6 +52,9 @@ export class DMPermissionValidator {
if (senderBlockedTarget || targetBlockedSender) {
throw new CannotSendMessagesToUserError();
}
if (isShadowbannedSpammer) {
return;
}
if (areFriends) {
return;
}
@@ -17,6 +17,7 @@ import type {IUserRepository} from '../../user/IUserRepository';
import {assertGuildMemberCanCommunicate} from '../../utils/GuildCommunicationUtils';
import type {IChannelRepository} from '../IChannelRepository';
import {MessageInteractionAuthService} from './interaction/MessageInteractionAuthService';
import {MessagePinAuthService} from './interaction/MessagePinAuthService';
import {MessagePinService} from './interaction/MessagePinService';
import {MessageReactionService} from './interaction/MessageReactionService';
import {MessageReadStateService} from './interaction/MessageReadStateService';
@@ -25,6 +26,7 @@ import type {MessagePersistenceService} from './message/MessagePersistenceServic
export class MessageInteractionService {
readonly authService: MessageInteractionAuthService;
private pinAuthService: MessagePinAuthService;
private readStateService: MessageReadStateService;
private pinService: MessagePinService;
private reactionService: MessageReactionService;
@@ -45,6 +47,12 @@ export class MessageInteractionService {
guildRepository,
gatewayService,
);
this.pinAuthService = new MessagePinAuthService(
channelRepository,
userRepository,
guildRepository,
gatewayService,
);
this.readStateService = new MessageReadStateService(gatewayService);
this.pinService = new MessagePinService(
gatewayService,
@@ -85,7 +93,7 @@ export class MessageInteractionService {
items: Array<ChannelPinResponse>;
has_more: boolean;
}> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
const authChannel = await this.pinAuthService.getChannelAuthenticated({userId, channelId});
return this.pinService.getChannelPins({authChannel, userId, requestCache, beforeTimestamp, limit});
}
@@ -186,12 +186,7 @@ export class ChannelOperationsService {
let permissionOverwrites = channel.permissionOverwrites;
if (data.permission_overwrites !== undefined) {
const guildId = createGuildID(BigInt(guild.id));
const canManageRoles = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
await checkPermission(Permissions.MANAGE_ROLES);
const isOwner = guild.owner_id === userId.toString();
const channelPermissions = await this.gatewayService.getUserPermissions({
guildId,
@@ -205,6 +200,17 @@ export class ChannelOperationsService {
throw new MissingPermissionsError();
}
}
const nextDeny = new Map<RoleID | UserID, bigint>();
for (const overwrite of data.permission_overwrites ?? []) {
const targetKey = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
nextDeny.set(targetKey, (overwrite.deny ? BigInt(overwrite.deny) : 0n) & ALL_PERMISSIONS);
}
for (const [targetId, existing] of previousPermissionOverwrites ?? []) {
const removedDeny = existing.deny & ~(nextDeny.get(targetId) ?? 0n);
if ((removedDeny & ~channelPermissions) !== 0n) {
throw new MissingPermissionsError();
}
}
}
permissionOverwrites = new Map();
for (const overwrite of data.permission_overwrites ?? []) {
@@ -590,6 +596,7 @@ export class ChannelOperationsService {
const canManageRoles = await this.gatewayService.checkPermission({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
@@ -615,6 +622,8 @@ export class ChannelOperationsService {
const sanitizedDeny = protectedBits.deny;
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
if (!hasAdministrator && (sanitizedAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const removedDeny = (existing?.deny ?? 0n) & ~sanitizedDeny;
if (!hasAdministrator && (removedDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const previousPermissionOverwrites = channel.permissionOverwrites;
const overwrites = new Map(channel.permissionOverwrites ?? []);
overwrites.set(
@@ -697,6 +706,7 @@ export class ChannelOperationsService {
const canManageRoles = await this.gatewayService.checkPermission({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
@@ -705,6 +715,15 @@ export class ChannelOperationsService {
const removedRole = overwrites.get(createRoleID(params.overwriteId));
const removedUser = overwrites.get(createUserID(params.overwriteId));
const removed = removedRole ?? removedUser;
if (removed) {
const userPermissions = await this.gatewayService.getUserPermissions({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
});
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
if (!hasAdministrator && (removed.deny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
}
overwrites.delete(createRoleID(params.overwriteId));
overwrites.delete(createUserID(params.overwriteId));
const updated = await this.channelRepository.channelData.upsert({
@@ -2,7 +2,6 @@
import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch';
import type {MessageID, UserID} from '../../../BrandedTypes';
import {Config} from '../../../Config';
import type {IPurgeQueue} from '../../../infrastructure/BunnyPurgeQueue';
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import type {IStorageService} from '../../../infrastructure/IStorageService';
@@ -13,7 +12,7 @@ import type {Message} from '../../../models/Message';
import {mapChannelToResponse} from '../../ChannelMappers';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import {dispatchMessageCreateBroadcast} from '../message/MessageGatewayDispatch';
import {makeAttachmentCdnKey, makeAttachmentCdnUrl} from '../message/MessageHelpers';
import {purgeMessageAttachments} from '../message/MessageHelpers';
export class ChannelUtilsService {
constructor(
@@ -44,20 +43,7 @@ export class ChannelUtilsService {
}
private async purgeMessageAttachments(message: Message): Promise<void> {
const cdnUrls: Array<string> = [];
await Promise.all(
message.attachments.map(async (attachment) => {
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
await this.storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
if (Config.bunny.purgeEnabled) {
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
cdnUrls.push(cdnUrl);
}
}),
);
if (Config.bunny.purgeEnabled && cdnUrls.length > 0) {
await this.purgeQueue.addUrls(cdnUrls);
}
await purgeMessageAttachments(message, this.storageService, this.purgeQueue);
}
async dispatchChannelUpdate({channel, requestCache}: {channel: Channel; requestCache: RequestCache}): Promise<void> {
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {BaseChannelAuthService, type ChannelAuthOptions} from '../BaseChannelAuthService';
export class MessagePinAuthService extends BaseChannelAuthService {
protected readonly options: ChannelAuthOptions = {
errorOnMissingGuild: 'unknown_channel',
validateNsfw: true,
};
}
@@ -116,6 +116,16 @@ export class AttachmentProcessingService {
);
const hasVirusDetected = results.some((result) => result.hasVirusDetected);
if (hasVirusDetected) {
await Promise.all(
results.map(async (result) => {
if (result.sourceLocalPath) {
await fs.promises.unlink(result.sourceLocalPath).catch(() => undefined);
}
}),
);
for (const result of results) {
this.deleteUploadObject(result.copyOperation.sourceBucket, result.copyOperation.sourceKey);
}
return {attachments: [], hasVirusDetected: true};
}
const copyResults = await mapWithConcurrency(results, ATTACHMENT_PROCESSING_CONCURRENCY, (result) =>
@@ -25,6 +25,7 @@ import type {LimitConfigService} from '../../../limits/LimitConfigService';
import {resolveLimitSafe} from '../../../limits/LimitConfigUtils';
import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder';
import {Attachment} from '../../../models/Attachment';
import type {Embed} from '../../../models/Embed';
import type {Message} from '../../../models/Message';
import {MessageSnapshot as MessageSnapshotModel} from '../../../models/MessageSnapshot';
import type {User} from '../../../models/User';
@@ -307,22 +308,60 @@ export async function createMessageSnapshotsForForward(
return [new MessageSnapshotModel(snapshotData)];
}
function collectEmbedReferencedAttachmentCdnKeys(message: Message): Array<string> {
const mediaPrefix = `${Config.endpoints.media}/`;
const keys = new Set<string>();
const consider = (url: string | null | undefined): void => {
if (!url || !url.startsWith(mediaPrefix)) {
return;
}
const key = url.slice(mediaPrefix.length);
if (key.startsWith('attachments/')) {
keys.add(key);
}
};
const scanEmbeds = (embeds: Array<Embed>): void => {
for (const embed of embeds) {
consider(embed.image?.url);
consider(embed.thumbnail?.url);
consider(embed.video?.url);
consider(embed.audio?.url);
}
};
scanEmbeds(message.embeds);
for (const snapshot of message.messageSnapshots) {
scanEmbeds(snapshot.embeds);
}
return [...keys];
}
export async function purgeMessageAttachments(
message: Message,
storageService: IStorageService,
purgeQueue: IPurgeQueue,
): Promise<void> {
const cdnKeys = new Set<string>();
const cdnUrls: Array<string> = [];
await Promise.all(
message.attachments.map(async (attachment) => {
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
await storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
if (Config.bunny.purgeEnabled) {
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
cdnUrls.push(cdnUrl);
}
}),
);
for (const attachment of collectMessageAttachments(message)) {
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
if (cdnKeys.has(cdnKey)) {
continue;
}
cdnKeys.add(cdnKey);
if (Config.bunny.purgeEnabled) {
cdnUrls.push(makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename));
}
}
for (const embedKey of collectEmbedReferencedAttachmentCdnKeys(message)) {
if (cdnKeys.has(embedKey)) {
continue;
}
cdnKeys.add(embedKey);
if (Config.bunny.purgeEnabled) {
cdnUrls.push(`${Config.endpoints.media}/${embedKey}`);
}
}
await Promise.all([...cdnKeys].map((cdnKey) => storageService.deleteObject(Config.s3.buckets.cdn, cdnKey)));
if (Config.bunny.purgeEnabled && cdnUrls.length > 0) {
await purgeQueue.addUrls(cdnUrls);
}
@@ -248,9 +248,10 @@ export class MessageValidationService {
}
const isAuthor = message.authorId === userId;
if (!guild) return isAuthor;
if (isAuthor) return true;
const canManageMessages =
(await hasPermission(Permissions.SEND_MESSAGES)) && (await hasPermission(Permissions.MANAGE_MESSAGES));
return isAuthor || canManageMessages;
return canManageMessages;
}
private validateVoiceMessageConstraints(
@@ -17,6 +17,7 @@ export interface GiftCodeRow {
stripe_payment_intent_id: Nullish<string>;
visionary_sequence_number: Nullish<number>;
checkout_session_id: Nullish<string>;
revoked_at?: Nullish<Date>;
version: number;
}
@@ -103,6 +104,7 @@ export const GIFT_CODE_COLUMNS = [
'stripe_payment_intent_id',
'visionary_sequence_number',
'checkout_session_id',
'revoked_at',
'version',
] as const;
export const GIFT_CODE_BY_CREATOR_COLUMNS = ['created_by_user_id', 'code'] as const;
@@ -51,6 +51,7 @@ function isUnsatisfiableRangeError(error: unknown): boolean {
}
const DESKTOP_BUCKET_PREFIX = 'desktop';
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
const DOWNLOAD_KEY_ALLOWED_PREFIXES = [`${DESKTOP_BUCKET_PREFIX}/`, `${DESKTOP_TEST_BUCKET_PREFIX}/`];
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';
@@ -987,7 +988,13 @@ export class DownloadService {
return null;
}
}
return normalized.length > 0 ? normalized : null;
if (normalized.length === 0) {
return null;
}
if (!DOWNLOAD_KEY_ALLOWED_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return null;
}
return normalized;
}
private normalizePlatformArchKey(key: string): string | null {
@@ -14,9 +14,11 @@ import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService';
import type {UserCacheService} from '../../infrastructure/UserCacheService';
import type {LimitConfigService} from '../../limits/LimitConfigService';
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
import type {IUserRepository} from '../../user/IUserRepository';
import type {GuildAuditLogService} from '../GuildAuditLogService';
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
import {ChannelOperationsService} from './channel/ChannelOperationsService';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
export class GuildChannelService {
private readonly channelOps: ChannelOperationsService;
@@ -30,6 +32,7 @@ export class GuildChannelService {
snowflakeService: ISnowflakeService,
guildAuditLogService: GuildAuditLogService,
limitConfigService: LimitConfigService,
private readonly userRepository: IUserRepository,
) {
this.channelOps = new ChannelOperationsService(
channelRepository,
@@ -131,5 +134,12 @@ export class GuildChannelService {
permission: params.permission,
});
if (!hasPermission) throw new MissingPermissionsError();
const guildData = await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId});
const enforceGuildMfa = await createGuildMfaEnforcer({
userRepository: this.userRepository,
guildData,
userId: params.userId,
});
enforceGuildMfa(params.permission);
}
}
@@ -51,7 +51,7 @@ export class GuildDataService {
private readonly guildAuditLogService: GuildAuditLogService,
private readonly limitConfigService: LimitConfigService,
) {
this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService);
this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService, this.userRepository);
this.operationsService = new GuildOperationsService(
this.guildRepository,
this.channelRepository,
@@ -50,7 +50,7 @@ export class GuildMemberService {
ipInfoService: IpInfoService,
) {
this.userRepository = userRepository;
this.authService = new GuildMemberAuthService(gatewayService);
this.authService = new GuildMemberAuthService(gatewayService, userRepository);
this.validationService = new GuildMemberValidationService(guildRepository, userRepository, ipInfoService);
this.auditService = new GuildMemberAuditService(guildAuditLogService);
this.eventService = new GuildMemberEventService(gatewayService, userCacheService);
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {UserID} from '../../BrandedTypes';
import type {IUserRepository} from '../../user/IUserRepository';
const ELEVATED_MFA_PERMISSIONS =
Permissions.KICK_MEMBERS |
Permissions.BAN_MEMBERS |
Permissions.ADMINISTRATOR |
Permissions.MANAGE_CHANNELS |
Permissions.MANAGE_GUILD |
Permissions.MANAGE_MESSAGES |
Permissions.MANAGE_ROLES |
Permissions.MANAGE_WEBHOOKS |
Permissions.MODERATE_MEMBERS;
export async function createGuildMfaEnforcer(params: {
userRepository: IUserRepository;
guildData: Pick<GuildResponse, 'mfa_level' | 'owner_id'>;
userId: UserID;
}): Promise<(permission: bigint) => void> {
const {userRepository, guildData, userId} = params;
const requiresGuildMfa = guildData.mfa_level === GuildMFALevel.ELEVATED && guildData.owner_id !== userId.toString();
let actorLacksMfa = false;
if (requiresGuildMfa) {
const actor = await userRepository.findUnique(userId);
actorLacksMfa = !actor || actor.authenticatorTypes.size === 0;
}
return (permission: bigint) => {
if (requiresGuildMfa && actorLacksMfa && (permission & ELEVATED_MFA_PERMISSIONS) !== 0n) {
throw new MfaNotEnabledError();
}
};
}
@@ -27,6 +27,7 @@ import type {GuildAuditLogService} from '../GuildAuditLogService';
import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
import {mapGuildBansToResponse} from '../GuildModel';
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
import {GuildMemberSearchIndexService} from './member/GuildMemberSearchIndexService';
export class GuildModerationService {
@@ -44,6 +45,19 @@ export class GuildModerationService {
this.searchIndexService = new GuildMemberSearchIndexService();
}
private async checkModerationPermission(params: {
guildId: GuildID;
userId: UserID;
permission: bigint;
}): Promise<void> {
const {guildId, userId, permission} = params;
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
const guildData = await this.gatewayService.getGuildData({guildId, userId});
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
enforceGuildMfa(permission);
}
async banMember(
params: {
userId: UserID;
@@ -57,12 +71,7 @@ export class GuildModerationService {
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, targetId, deleteMessageDays, reason, banDurationSeconds, skipGuildAuditLog} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
if (userId === targetId) throw new UnknownGuildMemberError();
const targetUser = await this.userRepository.findUnique(targetId);
if (!targetUser) {
@@ -145,12 +154,7 @@ export class GuildModerationService {
requestCache: RequestCache;
}): Promise<Array<GuildBanResponse>> {
const {userId, guildId, requestCache} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
const bans = await this.guildRepository.listBans(guildId);
return await mapGuildBansToResponse(bans, this.userCacheService, requestCache);
}
@@ -164,12 +168,7 @@ export class GuildModerationService {
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, targetId} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
const ban = await this.guildRepository.getBan(guildId, targetId);
if (!ban) {
throw InputValidationError.fromCode('user_id', ValidationErrorCodes.USER_IS_NOT_BANNED);
@@ -26,6 +26,7 @@ import type {LimitConfigService} from '../../limits/LimitConfigService';
import {resolveLimitSafe} from '../../limits/LimitConfigUtils';
import {createLimitMatchContext} from '../../limits/LimitMatchContextBuilder';
import {GuildRole} from '../../models/GuildRole';
import type {IUserRepository} from '../../user/IUserRepository';
import {applyProtectedRolePermissions} from '../../utils/featureUtils';
import {computePermissionsDiff} from '../../utils/PermissionUtils';
import type {GuildAuditLogService} from '../GuildAuditLogService';
@@ -33,6 +34,7 @@ import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
import {mapGuildRoleToResponse} from '../GuildModel';
import type {IGuildMemberRepository} from '../repositories/IGuildMemberRepository';
import type {IGuildRoleRepository} from '../repositories/IGuildRoleRepository';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
interface GuildRoleRepository extends IGuildRoleRepository, IGuildMemberRepository {}
@@ -62,6 +64,7 @@ export class GuildRoleService {
private readonly gatewayService: IGatewayService,
private readonly guildAuditLogService: GuildAuditLogService,
private readonly limitConfigService: LimitConfigService,
private readonly userRepository: IUserRepository,
) {}
async systemCreateRole(params: {
@@ -305,7 +308,7 @@ export class GuildRoleService {
position?: number;
}>;
},
_auditLogReason?: string | null,
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, updates} = params;
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
@@ -316,7 +319,7 @@ export class GuildRoleService {
throw new ResourceLockedError();
}
try {
await this.updateRolePositionsByList({userId, guildId, updates});
await this.updateRolePositionsByList({userId, guildId, updates, auditLogReason: auditLogReason ?? null});
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
}
@@ -342,7 +345,7 @@ export class GuildRoleService {
hoistPosition: number;
}>;
},
_auditLogReason?: string | null,
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, updates} = params;
const {checkPermission, guildData} = await this.getGuildAuthenticated({userId, guildId});
@@ -400,6 +403,7 @@ export class GuildRoleService {
}
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
}
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
@@ -411,7 +415,7 @@ export class GuildRoleService {
userId: UserID;
guildId: GuildID;
},
_auditLogReason?: string | null,
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId} = params;
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
@@ -423,6 +427,7 @@ export class GuildRoleService {
}
try {
const allRoles = await this.guildRepository.listRoles(guildId);
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
const changedRoles: Array<GuildRole> = [];
for (const role of allRoles) {
if (role.hoistPosition === null) continue;
@@ -438,6 +443,7 @@ export class GuildRoleService {
}
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
}
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
@@ -446,9 +452,11 @@ export class GuildRoleService {
private async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
enforceGuildMfa(permission);
};
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
return {
@@ -554,8 +562,9 @@ export class GuildRoleService {
roleId: RoleID;
position?: number;
}>;
auditLogReason?: string | null;
}): Promise<void> {
const {userId, guildId, updates} = params;
const {userId, guildId, updates, auditLogReason} = params;
const {guildData} = await this.getGuildAuthenticated({userId, guildId});
const allRoles = await this.guildRepository.listRoles(guildId);
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
@@ -620,6 +629,7 @@ export class GuildRoleService {
});
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
}
}
@@ -694,6 +704,30 @@ export class GuildRoleService {
return newRoles;
}
private async recordRolePositionAuditLogs(params: {
guildId: GuildID;
userId: UserID;
roleMap: Map<RoleID, GuildRole>;
changedRoles: Array<GuildRole>;
auditLogReason?: string | null;
}): Promise<void> {
const {guildId, userId, roleMap, changedRoles, auditLogReason} = params;
for (const role of changedRoles) {
const oldRole = roleMap.get(role.id);
await this.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.ROLE_UPDATE,
targetId: role.id,
auditLogReason: auditLogReason ?? null,
changes: this.guildAuditLogService.computeChanges(
oldRole ? this.serializeRoleForAudit(oldRole) : null,
this.serializeRoleForAudit(role),
),
});
}
}
private serializeRoleForAudit(role: GuildRole): Record<string, unknown> {
return {
role_id: role.id.toString(),
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
@@ -22,6 +22,7 @@ import {buildMessageSearchFilters} from '../../search/BuildMessageSearchFilters'
import {channelNeedsReindexing} from '../../search/ChannelIndexingUtils';
import {MessageSearchResponseMapper} from '../../search/MessageSearchResponseMapper';
import {searchExistingMessages} from '../../search/MessageSearchResultReconciler';
import {channelRequiresAgeVerification} from '../../search/SearchNsfwUtils';
import type {IUserRepository} from '../../user/IUserRepository';
import {canUserAccessNsfwContent} from '../../utils/AgeUtils';
import {mapWithConcurrency} from '../../utils/ConcurrencyUtils';
@@ -84,6 +85,7 @@ export class GuildSearchService {
}
}
const canIncludeNsfw = includeNsfwRequested && canUserAccessNsfw;
const guildNsfw = guildData?.nsfw ?? false;
const channels = await this.channelRepository.listChannels(channelIds);
const channelMap = new Map<string, Channel>();
for (const channel of channels) {
@@ -96,9 +98,10 @@ export class GuildSearchService {
throw InputValidationError.fromCode('channel_ids', ValidationErrorCodes.ALL_CHANNELS_MUST_BELONG_TO_GUILD);
}
}
const categoryLookup = await this.buildParentCategoryLookup(channelMap);
const nsfwFilteredIds = channelIds.filter((id) => {
const channel = channelMap.get(id.toString())!;
return !(channel.isNsfw && !canIncludeNsfw);
return !(channelRequiresAgeVerification(channel, categoryLookup, guildNsfw) && !canIncludeNsfw);
});
const permissionResults = await mapWithConcurrency(nsfwFilteredIds, PERMISSION_CHECK_CONCURRENCY, (channelId) =>
this.gatewayService.checkPermission({
@@ -188,7 +191,7 @@ export class GuildSearchService {
if (!searchService) {
throw new FeatureTemporarilyDisabledError();
}
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels} =
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories} =
await this.collectAccessibleGuildChannels(userId);
if (unindexedChannelIds.size > 0) {
await this.queueIndexingChannels(unindexedChannelIds);
@@ -219,7 +222,7 @@ export class GuildSearchService {
if (guildIsAgeRestricted) {
return canIncludeNsfw;
}
if (channel.isNsfw) {
if (channelRequiresAgeVerification(channel, parentCategories, false)) {
return canIncludeNsfw;
}
return true;
@@ -259,6 +262,24 @@ export class GuildSearchService {
};
}
private async buildParentCategoryLookup(channelMap: Map<string, Channel>): Promise<Map<string, Channel>> {
const lookup = new Map<string, Channel>(channelMap);
const missingParentIds: Array<ChannelID> = [];
for (const channel of channelMap.values()) {
const parentId = channel.parentId;
if (parentId != null && !lookup.has(parentId.toString())) {
missingParentIds.push(parentId);
}
}
if (missingParentIds.length > 0) {
const parents = await this.channelRepository.listChannels(missingParentIds);
for (const parent of parents) {
lookup.set(parent.id.toString(), parent);
}
}
return lookup;
}
private async getCanUserAccessNsfw(userId: UserID): Promise<boolean> {
const user = await this.userRepository.findUnique(userId);
if (!user) {
@@ -284,11 +305,13 @@ export class GuildSearchService {
accessibleChannels: Map<string, Channel>;
unindexedChannelIds: Set<string>;
guildNsfwLevels: Map<string, number>;
parentCategories: Map<string, Channel>;
}> {
const guildIds = await this.userRepository.getUserGuildIds(userId);
const accessibleChannels = new Map<string, Channel>();
const unindexedChannelIds = new Set<string>();
const guildNsfwLevels = new Map<string, number>();
const parentCategories = new Map<string, Channel>();
const permissionChecks: Array<{
channel: Channel;
guildId: GuildID;
@@ -304,6 +327,9 @@ export class GuildSearchService {
}
const viewableChannelIds = new Set(viewableChannels.map((channelId) => channelId.toString()));
for (const channel of guildChannels) {
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
parentCategories.set(channel.id.toString(), channel);
}
if (viewableChannelIds.has(channel.id.toString())) {
permissionChecks.push({channel, guildId});
}
@@ -331,6 +357,6 @@ export class GuildSearchService {
unindexedChannelIds.add(channelIdStr);
}
}
return {accessibleChannels, unindexedChannelIds, guildNsfwLevels};
return {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories};
}
}
@@ -2,9 +2,8 @@
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildFeatures, GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
@@ -44,6 +43,7 @@ import {GuildChannelService} from './GuildChannelService';
import {GuildContentService} from './GuildContentService';
import {GuildDataService} from './GuildDataService';
import {GuildMemberService} from './GuildMemberService';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
import {GuildModerationService} from './GuildModerationService';
import {GuildRoleService} from './GuildRoleService';
import {GuildSearchService} from './GuildSearchService';
@@ -95,17 +95,6 @@ interface GuildAuth {
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
}
const ELEVATED_MFA_PERMISSIONS =
Permissions.KICK_MEMBERS |
Permissions.BAN_MEMBERS |
Permissions.ADMINISTRATOR |
Permissions.MANAGE_CHANNELS |
Permissions.MANAGE_GUILD |
Permissions.MANAGE_MESSAGES |
Permissions.MANAGE_ROLES |
Permissions.MANAGE_WEBHOOKS |
Permissions.MODERATE_MEMBERS;
export class GuildService {
public readonly data: GuildDataService;
public readonly members: GuildMemberService;
@@ -182,6 +171,7 @@ export class GuildService {
gatewayService,
guildAuditLogService,
limitConfigService,
userRepository,
);
this.moderation = new GuildModerationService(
guildRepository,
@@ -211,6 +201,7 @@ export class GuildService {
snowflakeService,
guildAuditLogService,
limitConfigService,
userRepository,
);
this.search = new GuildSearchService(
channelRepository,
@@ -561,17 +552,7 @@ export class GuildService {
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new MissingAccessError();
const requiresGuildMfa = guildData.mfa_level === GuildMFALevel.ELEVATED && guildData.owner_id !== userId.toString();
let actorLacksMfa = false;
if (requiresGuildMfa) {
const actor = await this.userRepository.findUnique(userId);
actorLacksMfa = !actor || actor.authenticatorTypes.size === 0;
}
const enforceGuildMfa = (permission: bigint) => {
if (requiresGuildMfa && actorLacksMfa && (permission & ELEVATED_MFA_PERMISSIONS) !== 0n) {
throw new MfaNotEnabledError();
}
};
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
@@ -355,12 +355,18 @@ export class ChannelOperationsService {
requestCache,
});
if (update.lockPermissions && desiredParent && desiredParent !== (target.parentId ?? null)) {
await this.syncPermissionsWithParent({guildId, channelId: target.id, parentId: desiredParent});
await this.syncPermissionsWithParent({
guildId,
userId: params.userId,
channelId: target.id,
parentId: desiredParent,
});
}
}
private async syncPermissionsWithParent(params: {
guildId: GuildID;
userId: UserID;
channelId: ChannelID;
parentId: ChannelID;
}): Promise<void> {
@@ -368,6 +374,22 @@ export class ChannelOperationsService {
if (!parent || parent.guildId !== params.guildId || parent.type !== ChannelTypes.GUILD_CATEGORY) return;
const child = await this.channelRepository.findUnique(params.channelId);
if (!child || child.guildId !== params.guildId) return;
const userPermissions = await this.gatewayService.getUserPermissions({
guildId: params.guildId,
userId: params.userId,
channelId: child.id,
});
if ((userPermissions & Permissions.MANAGE_ROLES) === 0n) {
throw new MissingPermissionsError();
}
for (const [targetId, existing] of child.permissionOverwrites) {
const incomingDeny = parent.permissionOverwrites.get(targetId)?.deny ?? 0n;
if ((existing.deny & ~incomingDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
}
for (const [targetId, incoming] of parent.permissionOverwrites) {
const existingAllow = child.permissionOverwrites.get(targetId)?.allow ?? 0n;
if ((incoming.allow & ~existingAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
}
await this.channelRepository.upsert({
...child.toRow(),
permission_overwrites: new Map(
@@ -8,12 +8,14 @@ import type {ChannelID, EmojiID, GuildID, RoleID, StickerID, UserID} from '../..
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import {Logger} from '../../../Logger';
import type {Guild} from '../../../models/Guild';
import type {IUserRepository} from '../../../user/IUserRepository';
import {serializeGuildForAudit as serializeGuildForAuditUtil} from '../../../utils/AuditSerializationUtils';
import {requirePermission} from '../../../utils/PermissionUtils';
import type {GuildAuditLogService} from '../../GuildAuditLogService';
import type {GuildAuditLogChange} from '../../GuildAuditLogTypes';
import {mapGuildToGuildResponse} from '../../GuildModel';
import {GuildRepository} from '../../repositories/GuildRepository';
import {createGuildMfaEnforcer} from '../GuildMfaEnforcement';
interface GuildAuth {
guildData: GuildResponse;
@@ -24,6 +26,7 @@ export class GuildDataHelpers {
constructor(
private readonly gatewayService: IGatewayService,
private readonly guildAuditLogService: GuildAuditLogService,
private readonly userRepository: IUserRepository,
) {}
private readonly guildRepository = new GuildRepository();
@@ -33,7 +36,7 @@ export class GuildDataHelpers {
try {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new UnknownGuildError();
return this.createGuildAuth({guildData, guildId, userId});
return await this.createGuildAuth({guildData, guildId, userId});
} catch (error) {
if (error instanceof UnknownGuildError && (await this.guildExists(guildId))) {
throw new AccessDeniedError();
@@ -42,10 +45,16 @@ export class GuildDataHelpers {
}
}
private createGuildAuth(params: {guildData: GuildResponse; guildId: GuildID; userId: UserID}): GuildAuth {
private async createGuildAuth(params: {
guildData: GuildResponse;
guildId: GuildID;
userId: UserID;
}): Promise<GuildAuth> {
const {guildData, guildId, userId} = params;
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
await requirePermission(this.gatewayService, {guildId, userId, permission});
enforceGuildMfa(permission);
};
return {guildData, checkPermission};
}
@@ -57,7 +57,7 @@ import {Guild} from '../../../models/Guild';
import type {User} from '../../../models/User';
import {getGuildSearchService} from '../../../SearchFactory';
import type {GuildDiscoveryContext} from '../../../search/guild/GuildSearchSerializer';
import {deleteGuildMessageSearchDocuments} from '../../../search/MessageSearchIndexCleanup';
import {deleteChannelMessageSearchDocuments} from '../../../search/MessageSearchIndexCleanup';
import {Channels, ChannelsByGuild, GuildMembers, GuildMembersByUserId, GuildRoles, Guilds} from '../../../Tables';
import type {IUserRepository} from '../../../user/IUserRepository';
import {mapUserSettingsToResponse} from '../../../user/UserMappers';
@@ -858,7 +858,11 @@ export class GuildOperationsService {
await Promise.all(webhooks.map((webhook) => this.webhookRepository.delete(webhook.id)));
const channels = await this.channelRepository.listGuildChannels(guildId);
await Promise.all(channels.map((channel) => this.channelRepository.deleteAllChannelMessages(channel.id)));
await deleteGuildMessageSearchDocuments(guildId, {context: {source: 'guild_delete'}});
await Promise.all(
channels.map((channel) =>
deleteChannelMessageSearchDocuments(channel.id, {context: {source: 'guild_delete'}}),
),
);
await Promise.all(channels.map((channel) => this.channelService.attachments.purgeChannelAttachments(channel)));
const discoveryRow = await this.discoveryRepository.findByGuildId(guildId);
if (discoveryRow) {
@@ -5,6 +5,8 @@ import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPe
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {GuildID, RoleID, UserID} from '../../../BrandedTypes';
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import type {IUserRepository} from '../../../user/IUserRepository';
import {createGuildMfaEnforcer} from '../GuildMfaEnforcement';
interface GuildAuth {
guildData: GuildResponse;
@@ -16,22 +18,30 @@ interface GuildAuth {
}
export class GuildMemberAuthService {
constructor(private readonly gatewayService: IGatewayService) {}
constructor(
private readonly gatewayService: IGatewayService,
private readonly userRepository: IUserRepository,
) {}
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new MissingAccessError();
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
enforceGuildMfa(permission);
};
const checkTargetMember = async (targetUserId: UserID) => {
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId});
if (!canManage) throw new MissingPermissionsError();
};
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
const hasPermission = async (permission: bigint) =>
this.gatewayService.checkPermission({guildId, userId, permission});
const hasPermission = async (permission: bigint) => {
const allowed = await this.gatewayService.checkPermission({guildId, userId, permission});
if (allowed) enforceGuildMfa(permission);
return allowed;
};
const canManageRoles = async (targetUserId: UserID, targetRoleId: RoleID) =>
this.gatewayService.canManageRoles({guildId, userId, targetUserId, roleId: targetRoleId});
return {
@@ -91,6 +91,7 @@ export interface IStorageService {
key: string;
uploadId: string;
partNumber: number;
contentLength?: number;
expiresIn?: number;
}): Promise<string>;
purgeBucket(bucket: string): Promise<void>;
@@ -296,12 +296,14 @@ export class StorageService implements IStorageService {
key,
uploadId,
partNumber,
contentLength,
expiresIn = seconds('1 hour'),
}: {
bucket: string;
key: string;
uploadId: string;
partNumber: number;
contentLength?: number;
expiresIn?: number;
}): Promise<string> {
const command = new UploadPartCommand({
@@ -309,6 +311,7 @@ export class StorageService implements IStorageService {
Key: key,
UploadId: uploadId,
PartNumber: partNumber,
ContentLength: contentLength,
});
return getSignedUrl(this.presignClient, command, {expiresIn});
}
@@ -52,7 +52,11 @@ function shouldShowHeadersOnSuccess(accountType: AccountType): boolean {
function getClientIdentifier(ctx: Context<HonoEnv>): string {
const user = ctx.get('user');
if (user?.id) {
return `user:${user.id}`;
const tokenType = ctx.get('authTokenType') ?? 'session';
if (tokenType === 'bearer') {
return `user:${user.id}:bearer:${ctx.get('oauthBearerApplicationId') ?? 'unknown'}`;
}
return `user:${user.id}:${tokenType}`;
}
const ip = extractClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
+3
View File
@@ -113,6 +113,7 @@ export class GiftCode {
readonly stripePaymentIntentId: string | null;
readonly visionarySequenceNumber: number | null;
readonly checkoutSessionId: string | null;
readonly revokedAt: Date | null;
readonly version: number;
constructor(row: GiftCodeRow) {
@@ -128,6 +129,7 @@ export class GiftCode {
this.stripePaymentIntentId = row.stripe_payment_intent_id ?? null;
this.visionarySequenceNumber = row.visionary_sequence_number ?? null;
this.checkoutSessionId = row.checkout_session_id ?? null;
this.revokedAt = row.revoked_at ?? null;
this.version = row.version;
}
@@ -144,6 +146,7 @@ export class GiftCode {
stripe_payment_intent_id: this.stripePaymentIntentId,
visionary_sequence_number: this.visionarySequenceNumber,
checkout_session_id: this.checkoutSessionId,
revoked_at: this.revokedAt,
version: this.version,
};
}
+1 -1
View File
@@ -254,7 +254,7 @@ function normalizeStoredSyncedPreferences(raw: string | null | undefined): strin
try {
decodeSyncedPreferences(raw);
} catch {
return '';
return raw;
}
return raw;
}
@@ -316,7 +316,10 @@ export class OAuth2RequestService {
throw err;
}
}
Logger.info({redirectTo}, 'OAuth2 consent: returning redirect URL');
Logger.info(
{clientId: params.body.client_id.toString(), userId: params.userId.toString()},
'OAuth2 consent: returning redirect URL',
);
return {redirect_to: redirectTo};
}
@@ -3,3 +3,4 @@
import {seconds} from 'itty-time';
export const ACCESS_TOKEN_TTL_SECONDS = seconds('7 days');
export const AUTHORIZATION_CODE_TTL_SECONDS = seconds('10 minutes');
@@ -19,13 +19,17 @@ import {
OAuth2RefreshTokens,
OAuth2RefreshTokensByUser,
} from '../../Tables';
import {ACCESS_TOKEN_TTL_SECONDS} from '../OAuth2TokenConstants';
import {ACCESS_TOKEN_TTL_SECONDS, AUTHORIZATION_CODE_TTL_SECONDS} from '../OAuth2TokenConstants';
import type {IOAuth2TokenRepository} from './IOAuth2TokenRepository';
function isAccessTokenExpired(createdAt: Date): boolean {
return Date.now() - createdAt.getTime() > ACCESS_TOKEN_TTL_SECONDS * 1000;
}
function isAuthorizationCodeExpired(createdAt: Date): boolean {
return Date.now() - createdAt.getTime() > AUTHORIZATION_CODE_TTL_SECONDS * 1000;
}
const SELECT_AUTHORIZATION_CODE = OAuth2AuthorizationCodes.selectCql({
where: OAuth2AuthorizationCodes.where.eq('code'),
});
@@ -46,13 +50,19 @@ const SELECT_REFRESH_TOKENS_BY_USER = OAuth2RefreshTokensByUser.selectCql({
export class OAuth2TokenRepository implements IOAuth2TokenRepository {
async createAuthorizationCode(data: OAuth2AuthorizationCodeRow): Promise<OAuth2AuthorizationCode> {
await upsertOne(OAuth2AuthorizationCodes.insert(data));
await upsertOne(OAuth2AuthorizationCodes.insertWithTtl(data, AUTHORIZATION_CODE_TTL_SECONDS));
return new OAuth2AuthorizationCode(data);
}
async getAuthorizationCode(code: string): Promise<OAuth2AuthorizationCode | null> {
const row = await fetchOne<OAuth2AuthorizationCodeRow>(SELECT_AUTHORIZATION_CODE, {code});
return row ? new OAuth2AuthorizationCode(row) : null;
if (!row) {
return null;
}
if (isAuthorizationCodeExpired(row.created_at)) {
return null;
}
return new OAuth2AuthorizationCode(row);
}
async deleteAuthorizationCode(code: string): Promise<void> {
@@ -30,6 +30,7 @@ import {channelNeedsReindexing} from './ChannelIndexingUtils';
import type {IMessageSearchService} from './IMessageSearchService';
import {MessageSearchResponseMapper} from './MessageSearchResponseMapper';
import {searchExistingMessages} from './MessageSearchResultReconciler';
import {channelRequiresAgeVerification} from './SearchNsfwUtils';
const CHANNEL_INDEX_CHECK_CONCURRENCY = 32;
const CHANNEL_INDEX_JOB_ENQUEUE_CONCURRENCY = 16;
@@ -101,7 +102,7 @@ export class GlobalSearchService {
this.guildService.search.collectAccessibleGuildChannels(params.userId),
this.findDmScopeContextChannel(params.userId, params.includeChannelId),
]);
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels} = guildAccess;
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories} = guildAccess;
if (unindexedChannelIds.size > 0) {
await this.queueIndexingChannels(unindexedChannelIds);
return {indexing: true};
@@ -133,7 +134,7 @@ export class GlobalSearchService {
if (guildIsAgeRestricted) {
return canIncludeNsfw;
}
if (channel.isNsfw) {
if (channelRequiresAgeVerification(channel, parentCategories, false)) {
return canIncludeNsfw;
}
return true;
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {type ChannelID, createMessageID, type GuildID, type MessageID} from '../BrandedTypes';
import {type ChannelID, createMessageID, type MessageID} from '../BrandedTypes';
import {Logger} from '../Logger';
import {getMessageSearchService} from '../SearchFactory';
import type {IMessageSearchService} from './IMessageSearchService';
@@ -59,18 +59,3 @@ export async function deleteChannelMessageSearchDocuments(
);
}
}
export async function deleteGuildMessageSearchDocuments(guildId: GuildID, options: CleanupOptions = {}): Promise<void> {
const searchService = resolveMessageSearchService(options.searchService);
if (!searchService) {
return;
}
try {
await searchService.deleteGuildMessages(guildId);
} catch (error) {
Logger.error(
{...options.context, guildId: guildId.toString(), error},
'Failed to delete guild message documents from search index',
);
}
}
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants';
import {channelToContentWarningView, computeEffectiveChannelNsfw} from '../channel/utils/EffectiveContentWarning';
import type {Channel} from '../models/Channel';
export function channelRequiresAgeVerification(
channel: Channel,
channelsById: ReadonlyMap<string, Channel>,
guildNsfw: boolean,
): boolean {
const parentCategory =
channel.parentId != null ? (channelsById.get(channel.parentId.toString()) ?? null) : null;
return computeEffectiveChannelNsfw(
channelToContentWarningView(channel),
parentCategory ? channelToContentWarningView(parentCategory) : null,
{nsfw: guildNsfw, contentWarningLevel: ContentWarningLevel.INHERIT, contentWarningText: null},
);
}
@@ -123,6 +123,9 @@ export class StripeDisputeWebhookHandler {
reason: 'gift_refund',
chargeId: charge.id,
});
} else if (!giftCode.revokedAt) {
await this.userRepository.revokeGiftCode(giftCode.code);
Logger.debug({giftCode: giftCode.code, chargeId: charge.id}, 'Revoked unredeemed gift code after refund');
}
return;
}
@@ -196,6 +199,12 @@ export class StripeDisputeWebhookHandler {
{giftCode: giftCode.code, redeemerId: giftCode.redeemedByUserId},
'Premium revoked due to gift chargeback',
);
} else if (!giftCode.revokedAt) {
await this.userRepository.revokeGiftCode(giftCode.code);
Logger.debug(
{giftCode: giftCode.code, chargeId: extractId(dispute.charge)},
'Revoked unredeemed gift code after chargeback',
);
}
await this.paymentFraudService.enforceAccountFraudAction({
userId: giftCode.createdByUserId,
@@ -40,7 +40,7 @@ export class StripeGiftService {
async getGiftCode(code: string): Promise<GiftCode> {
const giftCode = await this.userRepository.findGiftCode(code);
if (!giftCode) {
if (!giftCode || giftCode.revokedAt) {
throw new UnknownGiftCodeError();
}
return giftCode;
@@ -62,7 +62,7 @@ export class StripeGiftService {
}
try {
const giftCode = await this.userRepository.findGiftCode(code);
if (!giftCode) {
if (!giftCode || giftCode.revokedAt) {
Logger.debug({userId, giftCode: code}, 'Gift code not found during redemption');
throw new UnknownGiftCodeError();
}
@@ -258,6 +258,7 @@ export class StripeGiftService {
const redeemedGracePeriodMs = 7 * 24 * 60 * 60 * 1000;
const cutoff = Date.now() - redeemedGracePeriodMs;
return gifts
.filter((gift) => gift.revokedAt === null)
.filter((gift) => gift.redeemedAt === null || gift.redeemedAt.getTime() > cutoff)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
@@ -252,7 +252,7 @@ export class StripeRefundService {
return;
}
const user = await this.userRepository.findUnique(userId);
if (!user || user.firstRefundAt) {
if (!user) {
return;
}
const subscriptionId = refund.metadata.subscription_id;
@@ -439,7 +439,7 @@ describe('Stripe Webhook - Invoice Events', () => {
test('skips zero-amount subscription_update invoice without granting an extra monthly cycle', async () => {
const account = await createTestAccount(harness);
const subscriptionId = `sub_test_${Date.now()}`;
const baselinePremiumUntil = new Date('2026-08-25T21:57:05.000Z');
const baselinePremiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({
@@ -481,7 +481,7 @@ describe('Stripe Webhook - Invoice Events', () => {
test('skips paid subscription_update invoices so interval switches do not grant extra time', async () => {
const account = await createTestAccount(harness);
const subscriptionId = `sub_test_${Date.now()}`;
const baselinePremiumUntil = new Date('2026-08-25T21:57:05.000Z');
const baselinePremiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({
@@ -47,6 +47,7 @@ function normaliseGiftCodeRowForWrite(data: GiftCodeRow): GiftCodeRow {
duration_type: durationType,
duration_quantity: durationQuantity,
duration_months: durationMonths,
revoked_at: data.revoked_at ?? null,
};
}
@@ -171,6 +172,10 @@ export class GiftCodeRepository {
await batch.execute();
}
async revokeGiftCode(code: string): Promise<void> {
await upsertOne(GiftCodes.patchByPk({code}, {revoked_at: Db.set(new Date())}));
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
const batch = new BatchBuilder();
const patch: Record<string, DbOp<unknown>> = {};
@@ -37,6 +37,7 @@ export interface IUserContentRepository {
findGiftCodesByRedeemer(userId: UserID): Promise<Array<GiftCode>>;
redeemGiftCode(code: string, userId: UserID): Promise<void>;
unredeemGiftCode(code: string, userId: UserID): Promise<void>;
revokeGiftCode(code: string): Promise<void>;
updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void>;
linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void>;
listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>>;
@@ -62,6 +62,10 @@ export class UserContentRepository implements IUserContentRepository {
return this.giftCodeRepository.unredeemGiftCode(code, userId);
}
async revokeGiftCode(code: string): Promise<void> {
return this.giftCodeRepository.revokeGiftCode(code);
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
return this.giftCodeRepository.updateGiftCode(code, data);
}
@@ -645,6 +645,10 @@ export class UserRepository implements IUserRepositoryAggregate {
return this.contentRepo.unredeemGiftCode(code, userId);
}
async revokeGiftCode(code: string): Promise<void> {
return this.contentRepo.revokeGiftCode(code);
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
return this.contentRepo.updateGiftCode(code, data);
}
@@ -118,7 +118,7 @@ export class TokenRepository {
}
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
await upsertOne(EmailRevertTokens.insert(tokenData));
await upsertOne(EmailRevertTokens.insertWithTtl(tokenData, seconds('24 hours')));
return new EmailRevertToken(tokenData);
}
@@ -89,14 +89,17 @@ export class UserAccountLookupService {
let guildMember: GuildMemberResponse | null = null;
let guildMemberDomain: GuildMember | null = null;
if (guildId != null) {
guildMemberDomain = await this.deps.guildRepository.getMember(guildId, targetId);
if (guildMemberDomain) {
guildMember = await this.deps.guildService.members.getMember({
userId,
targetId,
guildId,
requestCache,
});
const viewerMember = await this.deps.guildRepository.getMember(guildId, userId);
if (viewerMember) {
guildMemberDomain = await this.deps.guildRepository.getMember(guildId, targetId);
if (guildMemberDomain) {
guildMember = await this.deps.guildService.members.getMember({
userId,
targetId,
guildId,
requestCache,
});
}
}
}
let premiumType = user.premiumType ?? undefined;
@@ -118,15 +118,17 @@ export class UserRelationshipService {
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
if (!staffForceAccept && (await getInstanceConfigRepository().getInstancePolicyConfig()).direct_messages_disabled) {
const requesterUser = await this.userRepository.findUnique(userId);
const requesterIsStaff =
requesterUser != null && (requesterUser.flags & UserFlags.STAFF) === UserFlags.STAFF;
if (
!requesterIsStaff &&
(await getInstanceConfigRepository().getInstancePolicyConfig()).direct_messages_disabled
) {
throw new DirectMessagesDisabledError();
}
const requesterUser = await this.userRepository.findUnique(userId);
if (staffForceAccept) {
const requesterIsStaff = requesterUser != null && (requesterUser.flags & UserFlags.STAFF) === UserFlags.STAFF;
if (requesterIsStaff) {
return await this.forceCreateFriendship({userId, targetId, userCacheService, requestCache});
}
if (staffForceAccept && requesterIsStaff) {
return await this.forceCreateFriendship({userId, targetId, userCacheService, requestCache});
}
if (!requesterUser) {
throw new UnknownUserError();
+43 -1
View File
@@ -142,7 +142,15 @@ export class WebhookService {
async getGuildWebhooks({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<Array<Webhook>> {
const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId});
await checkPermission(Permissions.MANAGE_WEBHOOKS);
return await this.repository.listByGuild(guildId);
const webhooks = await this.repository.listByGuild(guildId);
const visibility = await Promise.all(
webhooks.map((webhook) =>
webhook.channelId
? this.canManageChannelWebhooks({userId, guildId, channelId: webhook.channelId})
: Promise.resolve(false),
),
);
return webhooks.filter((_webhook, index) => visibility[index]);
}
async getChannelWebhooks({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<Array<Webhook>> {
@@ -153,6 +161,7 @@ export class WebhookService {
guildId: channel.guildId,
});
await checkPermission(Permissions.MANAGE_WEBHOOKS);
await this.assertChannelWebhookPermission({userId, guildId: channel.guildId, channelId});
return await this.repository.listByChannel(channelId);
}
@@ -172,6 +181,7 @@ export class WebhookService {
guildId: channel.guildId,
});
await checkPermission(Permissions.MANAGE_WEBHOOKS);
await this.assertChannelWebhookPermission({userId, guildId: channel.guildId, channelId});
const guildLimit = this.resolveWebhookLimit(guildData.features, 'max_webhooks_per_guild', MAX_WEBHOOKS_PER_GUILD);
const guildWebhookCount = await this.repository.countByGuild(channel.guildId);
if (guildWebhookCount >= guildLimit) {
@@ -451,9 +461,41 @@ export class WebhookService {
if (!webhook) throw new UnknownWebhookError();
const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId: webhook.guildId!});
await checkPermission(Permissions.MANAGE_WEBHOOKS);
if (webhook.guildId && webhook.channelId) {
await this.assertChannelWebhookPermission({
userId,
guildId: webhook.guildId,
channelId: webhook.channelId,
});
}
return webhook;
}
private async canManageChannelWebhooks({
userId,
guildId,
channelId,
}: {
userId: UserID;
guildId: GuildID;
channelId: ChannelID;
}): Promise<boolean> {
const [canView, canManage] = await Promise.all([
this.gatewayService.checkPermission({guildId, userId, permission: Permissions.VIEW_CHANNEL, channelId}),
this.gatewayService.checkPermission({guildId, userId, permission: Permissions.MANAGE_WEBHOOKS, channelId}),
]);
return canView && canManage;
}
private async assertChannelWebhookPermission(params: {
userId: UserID;
guildId: GuildID;
channelId: ChannelID;
}): Promise<void> {
const allowed = await this.canManageChannelWebhooks(params);
if (!allowed) throw new MissingPermissionsError();
}
private async getTokenAuthenticatedWebhook({webhookId, token}: WebhookTokenParams): Promise<Webhook> {
const webhook = await this.repository.findByToken(webhookId, token);
if (!webhook) throw new UnknownWebhookError();
@@ -33,6 +33,7 @@ export const ZOOM_LEVEL_MARKERS = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] as cons
export const ACCESSIBILITY_STORE_STORAGE_KEY = 'Accessibility';
export const ACCESSIBILITY_ZOOM_STORAGE_KEY = 'Accessibility:zoomLevel';
export const ACCESSIBILITY_CUSTOM_THEME_STORAGE_KEY = 'Accessibility:customThemeCss';
const ACCESSIBILITY_CUSTOM_THEME_SYNC_STORAGE_KEY = 'Accessibility:customThemeCssSyncAcrossDevices';
export const ACCESSIBILITY_MOTION_STORAGE_KEY = 'Accessibility:motion';
export const ACCESSIBILITY_SHOW_NEKO_STORAGE_KEY = 'Accessibility:showNeko';
export const ACCESSIBILITY_KEEP_NEKO_STILL_STORAGE_KEY = 'Accessibility:keepNekoStill';
@@ -492,6 +493,21 @@ function persistLocalCustomThemeCss(css: string | null): void {
} catch {}
}
function persistLocalCustomThemeCssSyncAcrossDevices(value: boolean): void {
try {
AppStorage.setItem(ACCESSIBILITY_CUSTOM_THEME_SYNC_STORAGE_KEY, JSON.stringify(value));
} catch {}
}
function readLocalCustomThemeCssSyncAcrossDevices(): boolean {
try {
const raw = AppStorage.getItem(ACCESSIBILITY_CUSTOM_THEME_SYNC_STORAGE_KEY);
return raw === null ? false : JSON.parse(raw) === true;
} catch {
return false;
}
}
function normalizeCustomThemeCss(css: string | null | undefined): string | null {
if (typeof css !== 'string') {
return null;
@@ -728,6 +744,7 @@ class Accessibility {
if (this.customThemeCss !== null) {
persistLocalCustomThemeCss(this.customThemeCss);
}
this.customThemeCssSyncAcrossDevices = readLocalCustomThemeCssSyncAcrossDevices();
this.showNeko = readAndMigrateLocalShowNeko();
this.keepNekoStill = readAndMigrateLocalKeepNekoStill();
this.showVideoSeekPreviewThumbnails = readLocalVideoSeekPreviewThumbnails();
@@ -851,7 +868,11 @@ class Accessibility {
showStickersInAutocomplete: s.showStickersInExpressionAutocomplete,
showMemesInAutocomplete: s.showMemesInExpressionAutocomplete,
voiceChannelJoinRequiresDoubleClick: s.voiceChannelJoinRequiresDoubleClick,
customThemeCss: s.customThemeCssSyncAcrossDevices ? (s.customThemeCss ?? '') : (s.serverCustomThemeCss ?? ''),
customThemeCss: ((): string | undefined => {
const local = s.customThemeCss;
const server = s.serverCustomThemeCss;
return (s.customThemeCssSyncAcrossDevices ? local : server) ?? undefined;
})(),
showFavorites: s.showFavorites,
dmMessagePreviewMode: DM_PREVIEW_TO_PROTO[s.dmMessagePreviewMode],
enableTtsCommand: s.enableTTSCommand,
@@ -1396,6 +1417,7 @@ class Accessibility {
persistLocalCustomThemeCss(this.customThemeCss);
}
this.customThemeCssSyncAcrossDevices = syncAcrossDevices;
persistLocalCustomThemeCssSyncAcrossDevices(syncAcrossDevices);
}
subscribe(callback: () => void): () => void {
@@ -278,10 +278,6 @@ const DEVELOPER_OPTION_DESCRIPTOR = msg({
message: 'Developer option',
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
});
const GAME_CAPTURE_INJECTION_METHOD_DESCRIPTOR = msg({
message: 'Game capture injection (Windows)',
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
});
const DEVELOPER_OPTION_LABEL_FALLBACKS: Partial<Record<keyof DeveloperOptionsState, MessageDescriptor>> = {
mockAttachmentStates: ATTACHMENT_MOCKS_DESCRIPTOR,
};
@@ -327,7 +323,6 @@ const formatDeveloperOptionValue = <K extends keyof DeveloperOptionsState>(
case 'mockRequiredActionsResendOutcome':
case 'mockTitlebarPlatformOverride':
case 'mockUpdaterState':
case 'gameCaptureInjectionMethod':
return String(value).replace(/_/g, ' ');
case 'premiumSinceOverride':
case 'premiumUntilOverride':
@@ -410,8 +405,6 @@ export const getDeveloperOptionLabel = (key: keyof DeveloperOptionsState): Messa
return VANITY_URL_DISCLAIMER_DESCRIPTOR;
case 'forceShowVoiceConnection':
return VOICE_CONNECTION_DEBUG_DESCRIPTOR;
case 'gameCaptureInjectionMethod':
return GAME_CAPTURE_INJECTION_METHOD_DESCRIPTOR;
case 'premiumTypeOverride':
return PREMIUM_TYPE_DESCRIPTOR;
case 'premiumLifetimeSequenceOverride':
@@ -1,15 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getDeveloperOptionLabel} from '@app/features/channel/components/channel_header_components/developer_tools/DeveloperOptionLabels';
import {DeveloperOptionRadioSubmenu} from '@app/features/channel/components/channel_header_components/developer_tools/DeveloperToolsMenuComponents';
import {translateDescriptor} from '@app/features/channel/components/channel_header_components/developer_tools/DeveloperToolsShared';
import {getGameCaptureInjectionMethodOptions} from '@app/features/channel/components/channel_header_components/developer_tools/OptionPresets';
import {getToggleGroups, type ToggleGroup} from '@app/features/devtools/components/DeveloperOptionsToggleGroups';
import type {DeveloperOptionsState} from '@app/features/devtools/state/DeveloperOptions';
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import {CheckboxItem} from '@app/features/ui/action_menu/ContextMenu';
import {MenuItemSubmenu} from '@app/features/ui/action_menu/MenuItemSubmenu';
import {getNativePlatformSync} from '@app/features/ui/utils/NativeUtils';
import * as UserSettingsCommands from '@app/features/user/commands/UserSettingsCommands';
import UserSettings from '@app/features/user/state/UserSettings';
import Users from '@app/features/user/state/Users';
@@ -71,7 +67,6 @@ export const GeneralDeveloperOptionsMenu: React.FC = observer(() => {
const currentUser = Users.currentUser;
const canConfigureMentionSuppression = currentUser?.isStaff() ?? false;
const suppressUnprivilegedSelfMentions = UserSettings.getSuppressUnprivilegedSelfMentions();
const isWindows = getNativePlatformSync() === 'windows';
return (
<>
{toggleGroups.map((group, index) => (
@@ -81,14 +76,6 @@ export const GeneralDeveloperOptionsMenu: React.FC = observer(() => {
data-flx="channel.channel-header-components.developer-tools-context-menu.general-developer-options-menu.toggle-group-submenu"
/>
))}
{isWindows && (
<DeveloperOptionRadioSubmenu
label={translateDescriptor(i18n, getDeveloperOptionLabel('gameCaptureInjectionMethod'))}
optionKey="gameCaptureInjectionMethod"
options={getGameCaptureInjectionMethodOptions()}
data-flx="channel.channel-header-components.developer-tools-context-menu.general-developer-options-menu.game-capture-injection"
/>
)}
{canConfigureMentionSuppression && (
<MenuItemSubmenu
label={i18n._(MENTION_CONTROLS_DESCRIPTOR)}
@@ -196,14 +196,3 @@ export const getGiftDurationOptions = (): Array<RadioMenuOption<DeveloperOptions
{value: 12, label: MESSAGE_12_MONTHS_1_YEAR_DESCRIPTOR},
{value: 0, label: LIFETIME_DESCRIPTOR},
];
export const INJECT_METHOD_AUTOMATIC_DESCRIPTOR = msg({
message: 'Automatic',
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
});
export const getGameCaptureInjectionMethodOptions = (): Array<
RadioMenuOption<DeveloperOptionsState['gameCaptureInjectionMethod']>
> => [
{value: 'auto', label: INJECT_METHOD_AUTOMATIC_DESCRIPTOR},
{value: 'remote-thread', label: 'CreateRemoteThread'},
{value: 'set-windows-hook', label: 'SetWindowsHookEx'},
];
@@ -69,7 +69,6 @@ export const DEFAULT_DEVELOPER_OPTIONS = {
mockTitlebarPlatformOverride: 'auto',
mockAttachmentStates: {},
noOpInAppReports: false,
gameCaptureInjectionMethod: 'auto',
} satisfies DeveloperOptionsState;
const PREMIUM_SCENARIO_OVERRIDE_KEYS = new Set<keyof DeveloperOptionsState>([
'premiumTypeOverride',
@@ -97,7 +97,6 @@ export type DeveloperOptionsState = Readonly<{
}
>;
noOpInAppReports: boolean;
gameCaptureInjectionMethod: 'auto' | 'remote-thread' | 'set-windows-hook';
}>;
type MutableDeveloperOptionsState = {
-readonly [K in keyof DeveloperOptionsState]: DeveloperOptionsState[K];
@@ -182,7 +181,6 @@ class DeveloperOptions implements DeveloperOptionsState {
mockGiftRedeemed: boolean | null = null;
mockTitlebarPlatformOverride: DeveloperOptionsState['mockTitlebarPlatformOverride'] = 'auto';
noOpInAppReports = false;
gameCaptureInjectionMethod: DeveloperOptionsState['gameCaptureInjectionMethod'] = 'auto';
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
@@ -255,7 +253,6 @@ class DeveloperOptions implements DeveloperOptionsState {
'mockTitlebarPlatformOverride',
'mockAttachmentStates',
'noOpInAppReports',
'gameCaptureInjectionMethod',
]);
}
@@ -125,6 +125,7 @@ class ThemeLibrary {
localFiles: Array<ThemeLibraryLocalFileReference> = [];
enabledThemeIds: Array<string> = [];
isHydrated = false;
loadFailed = false;
isBusy = false;
revision = 0;
private initPromise: Promise<void> | null = null;
@@ -173,12 +174,13 @@ class ThemeLibrary {
this.localFiles = localFiles.sort((a, b) => a.name.localeCompare(b.name));
this.enabledThemeIds = enabledThemeIds.filter((id) => themes.some((theme) => theme.id === id));
this.isHydrated = true;
this.loadFailed = false;
this.revision += 1;
});
} catch (error) {
logger.error('Failed to hydrate theme library', error);
runInAction(() => {
this.isHydrated = true;
this.loadFailed = true;
});
}
}
@@ -94,7 +94,7 @@ function getDatabase(): Promise<IDBDatabase> {
if (openPromise) {
return openPromise;
}
openPromise = new Promise((resolve, reject) => {
const promise = new Promise<IDBDatabase>((resolve, reject) => {
if (!browserIndexedDB) {
reject(new Error('IndexedDB unavailable'));
return;
@@ -126,7 +126,13 @@ function getDatabase(): Promise<IDBDatabase> {
request.onerror = () => reject(request.error ?? new Error('Failed to open theme library database'));
request.onblocked = () => reject(new Error('Theme library database upgrade is blocked by another window'));
});
return openPromise;
promise.catch(() => {
if (openPromise === promise) {
openPromise = null;
}
});
openPromise = promise;
return promise;
}
async function withReadonlyDb<T>(
@@ -188,7 +188,7 @@ function inferWindowsCaptureMethod(
return null;
}
if (diagnostics?.activeStrategy) return diagnostics.activeStrategy;
if (capture.sourceKind === 'game') return 'game-hook';
if (capture.sourceKind === 'game') return 'wgc';
if (capture.sourceKind === 'screen') return 'wgc';
if (capture.sourceKind === 'window') return 'dxgi-duplication';
return 'native-screen-capture';
+1 -6
View File
@@ -695,8 +695,6 @@ export interface NativeScreenCaptureSource {
targetPid?: number;
}
export type GameCaptureInjectionMethod = 'auto' | 'remote-thread' | 'set-windows-hook';
export interface NativeScreenCaptureRect {
x: number;
y: number;
@@ -710,7 +708,6 @@ export interface NativeScreenCaptureStartOptions {
width?: number;
height?: number;
frameRate?: number;
injectionMethod?: GameCaptureInjectionMethod;
captureId?: string;
colorRange?: 'full' | 'limited';
colorSpace?: 'rec709' | 'srgb';
@@ -746,7 +743,7 @@ export interface NativeScreenCaptureLifecycleMessage {
source?: NativeScreenCaptureLifecycleSource;
}
export type NativeScreenCaptureStrategy = 'game-hook' | 'dxgi-duplication' | 'window-gdi' | string;
export type NativeScreenCaptureStrategy = 'wgc' | 'dxgi-duplication' | 'window-gdi' | string;
export interface NativeScreenCaptureDiagnostics {
state?: number;
@@ -761,8 +758,6 @@ export interface NativeScreenCaptureDiagnostics {
droppedFrameCounter?: number;
lastPresentTimestampUs?: number;
lastError?: number;
requestedInjectionMethod?: string;
injectionMethod?: string;
activeStrategy?: NativeScreenCaptureStrategy;
lastFallbackReason?: string;
backend?: string;
@@ -31,8 +31,7 @@ pub async fn assetlinks() -> Response {
"namespace": "android_app",
"package_name": "com.fluxer.canary",
"sha256_cert_fingerprints": [
"91:E4:98:E1:B8:A6:C8:BA:99:41:5E:DB:29:78:29:6B:6C:58:BA:A5:E2:D2:A6:49:CE:C6:2D:A7:A8:29:C7:BC",
"CD:19:82:28:32:A8:DE:E0:97:D8:60:D9:21:28:C9:C7:C4:73:A3:72:7E:63:71:9B:A7:BB:3B:98:06:94:1F:6F"
"91:E4:98:E1:B8:A6:C8:BA:99:41:5E:DB:29:78:29:6B:6C:58:BA:A5:E2:D2:A6:49:CE:C6:2D:A7:A8:29:C7:BC"
]
}
}
@@ -88,8 +87,7 @@ mod tests {
"namespace": "android_app",
"package_name": "com.fluxer.canary",
"sha256_cert_fingerprints": [
"91:E4:98:E1:B8:A6:C8:BA:99:41:5E:DB:29:78:29:6B:6C:58:BA:A5:E2:D2:A6:49:CE:C6:2D:A7:A8:29:C7:BC",
"CD:19:82:28:32:A8:DE:E0:97:D8:60:D9:21:28:C9:C7:C4:73:A3:72:7E:63:71:9B:A7:BB:3B:98:06:94:1F:6F"
"91:E4:98:E1:B8:A6:C8:BA:99:41:5E:DB:29:78:29:6B:6C:58:BA:A5:E2:D2:A6:49:CE:C6:2D:A7:A8:29:C7:BC"
]
}
}
@@ -359,10 +359,6 @@ function windowsGameCaptureArtifactExcludes(arch) {
];
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`),
]);
}
+2 -9
View File
@@ -17,14 +17,7 @@
# own `[workspace]`.
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=/DEPENDENTLOADFLAG:0x800"]
[target.aarch64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
# 32-bit injected game-capture hook/layer (for capturing 32-bit games from the
# 64-bit app). Same rationale: the DLL is LoadLibrary'd into an arbitrary game
# process that may not have the 32-bit VC++ redistributable, so the runtime
# must be static.
[target.i686-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=/DEPENDENTLOADFLAG:0x800"]
@@ -8,10 +8,6 @@ publish = false
[workspace]
resolver = "2"
[features]
default = []
game-capture-hook = []
[lib]
crate-type = ["cdylib", "rlib"]
@@ -1,6 +0,0 @@
{
"$comment": "Optional per-process game-capture injection policy override. Placed next to the native addon, it augments the deny/force-cpu lists compiled into the addon. Entries are matched case-insensitively by executable file name (e.g. game.exe); paths are reduced to their final component. 'deny' refuses injection; 'allow' opts a process back in past the built-in deny list; 'forceCpu' keeps injection but prefers CPU readback over the shared-texture fast path. A missing or malformed file falls back to the embedded defaults.",
"deny": [],
"allow": [],
"forceCpu": []
}
-375
View File
@@ -1,375 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "fluxer_game_hook"
version = "0.0.0"
dependencies = [
"retour",
"windows",
"windows-sys 0.61.2",
]
[[package]]
name = "generic-array"
version = "0.14.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "iced-x86"
version = "1.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c447cff8c7f384a7d4f741cfcff32f75f3ad02b406432e8d6c878d56b1edf6b"
dependencies = [
"lazy_static",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "mmap-fixed-fixed"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0681853891801e4763dc252e843672faf32bcfee27a0aa3b19733902af450acc"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "region"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7"
dependencies = [
"bitflags",
"libc",
"mach2",
"windows-sys 0.52.0",
]
[[package]]
name = "retour"
version = "0.4.0-alpha.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ead4bc8e12d553ff70769c5f5c21f5f4f0e73c0018068a6bb5a3d7d3b9e57ec7"
dependencies = [
"cfg-if",
"generic-array",
"iced-x86",
"libc",
"mmap-fixed-fixed",
"once_cell",
"region",
"slice-pool2",
]
[[package]]
name = "slice-pool2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a3d689654af89bdfeba29a914ab6ac0236d382eb3b764f7454dde052f2821f8"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "typenum"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
@@ -1,46 +0,0 @@
[package]
name = "fluxer_game_hook"
version = "0.0.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[workspace]
resolver = "2"
[lib]
crate-type = ["cdylib"]
# `retour` only ships x86/x86_64 trampoline + patcher backends (its `arch`
# module has no aarch64 variant), so it does not even compile for
# aarch64-pc-windows-msvc. Gate it to the architectures it supports; the
# aarch64 hook uses the in-crate `inline_hook::aarch64` backend instead.
[target.'cfg(all(target_os = "windows", any(target_arch = "x86", target_arch = "x86_64")))'.dependencies]
retour = "0.4.0-alpha.4"
[target.'cfg(target_os = "windows")'.dependencies]
windows = {version = "0.62.2", features = [
"Win32_Foundation",
"Win32_Graphics_Direct3D",
"Win32_Graphics_Direct3D9",
"Win32_Graphics_Direct3D10",
"Win32_Graphics_Direct3D11",
"Win32_Graphics_Direct3D11on12",
"Win32_Graphics_Direct3D12",
"Win32_Graphics_Dxgi",
"Win32_Graphics_Dxgi_Common",
"Win32_Graphics_Gdi",
]}
windows-sys = {version = "0.61.2", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_Graphics_OpenGL",
"Win32_Security",
"Win32_System_Diagnostics_Debug",
"Win32_System_LibraryLoader",
"Win32_System_Memory",
"Win32_System_Performance",
"Win32_System_SystemServices",
"Win32_System_Threading",
"Win32_UI_WindowsAndMessaging",
]}
@@ -1,248 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
pub const STOLEN_BYTES: usize = 16;
pub const NOP: u32 = 0xD503_201F;
pub const LDR_X16_PC8: u32 = 0x5800_0050;
pub const BR_X16: u32 = 0xD61F_0200;
pub const BLR_X16: u32 = 0xD63F_0200;
pub fn is_b(insn: u32) -> bool {
(insn & 0xFC00_0000) == 0x1400_0000
}
pub fn is_bl(insn: u32) -> bool {
(insn & 0xFC00_0000) == 0x9400_0000
}
pub fn needs_absolute_island(insn: u32) -> bool {
is_b(insn) || is_bl(insn)
}
pub fn branch_target(insn: u32, src_pc: u64) -> Option<u64> {
if !is_b(insn) && !is_bl(insn) {
return None;
}
let imm26 = (insn & 0x03FF_FFFF) as i32;
let off = ((imm26 << 6) >> 6) as i64 * 4;
Some((src_pc as i64 + off) as u64)
}
pub fn encode_imm26(byte_off: i64) -> Option<u32> {
if byte_off & 0b11 != 0 {
return None;
}
let words = byte_off >> 2;
if !(-(1 << 25)..(1 << 25)).contains(&words) {
return None;
}
Some((words as u32) & 0x03FF_FFFF)
}
pub fn append_abs_branch(out: &mut Vec<u8>, addr: u64, link: bool) {
let branch = if link { BLR_X16 } else { BR_X16 };
out.extend_from_slice(&LDR_X16_PC8.to_le_bytes());
out.extend_from_slice(&branch.to_le_bytes());
out.extend_from_slice(&addr.to_le_bytes());
}
pub fn adrp_target(insn: u32, src_pc: u64) -> Option<u64> {
if (insn & 0x9F00_0000) != 0x9000_0000 {
return None;
}
let immlo = ((insn >> 29) & 0x3) as i64;
let immhi = ((insn >> 5) & 0x7FFFF) as i64;
let raw = (immhi << 2) | immlo;
let imm21 = (raw << 43) >> 43;
let page = (src_pc & !0xFFF) as i64 + imm21 * 4096;
Some(page as u64)
}
fn ldr_unsigned_64(insn: u32) -> Option<(u32, u32, u64)> {
if (insn & 0xFFC0_0000) != 0xF940_0000 {
return None;
}
let imm12 = ((insn >> 10) & 0xFFF) as u64;
let rn = (insn >> 5) & 0x1F;
let rt = insn & 0x1F;
Some((rt, rn, imm12 * 8))
}
fn br_register(insn: u32) -> Option<u32> {
if (insn & 0xFFFF_FC1F) != 0xD61F_0000 {
return None;
}
Some((insn >> 5) & 0x1F)
}
pub unsafe fn import_thunk_target(prologue: &[u8], src_base: u64) -> Option<u64> {
if prologue.len() < 12 {
return None;
}
let adrp = u32::from_le_bytes(prologue[0..4].try_into().ok()?);
let ldr = u32::from_le_bytes(prologue[4..8].try_into().ok()?);
let br = u32::from_le_bytes(prologue[8..12].try_into().ok()?);
let adrp_reg = adrp & 0x1F;
let page = adrp_target(adrp, src_base)?;
let (ldr_rt, ldr_rn, offset) = ldr_unsigned_64(ldr)?;
let br_rn = br_register(br)?;
if adrp_reg != ldr_rn || ldr_rt != br_rn {
return None;
}
let pointer_addr = page.checked_add(offset)?;
let target = unsafe { core::ptr::read_unaligned(pointer_addr as *const u64) };
(target != 0).then_some(target)
}
pub fn emit_branch_to_island(insn: u32, dst_pc: u64, island_addr: u64) -> Option<u32> {
let link = is_bl(insn);
let off = island_addr as i64 - dst_pc as i64;
let imm = encode_imm26(off)?;
let opc = if link { 0x9400_0000 } else { 0x1400_0000 };
Some(opc | imm)
}
pub fn island_for_branch(insn: u32, src_pc: u64) -> Option<Vec<u8>> {
let target = branch_target(insn, src_pc)?;
let mut bytes = Vec::new();
append_abs_branch(&mut bytes, target, is_bl(insn));
Some(bytes)
}
pub fn relocate_instruction(insn: u32, src_pc: u64, dst_pc: u64) -> Option<u32> {
if (insn & 0x9F00_0000) == 0x9000_0000 {
return relocate_adr(insn, src_pc, dst_pc, true);
}
if (insn & 0x9F00_0000) == 0x1000_0000 {
return relocate_adr(insn, src_pc, dst_pc, false);
}
if is_b(insn) || is_bl(insn) {
let target = branch_target(insn, src_pc)?;
let off = target as i64 - dst_pc as i64;
let imm = encode_imm26(off)?;
return Some((insn & 0xFC00_0000) | imm);
}
if (insn & 0xFF00_0010) == 0x5400_0000 {
return relocate_imm19_at5(insn, src_pc, dst_pc);
}
if (insn & 0x7F00_0000) == 0x3400_0000 {
return relocate_imm19_at5(insn, src_pc, dst_pc);
}
if (insn & 0x7F00_0000) == 0x3600_0000 {
return relocate_tbz(insn, src_pc, dst_pc);
}
if (insn & 0x3B00_0000) == 0x1800_0000 {
return relocate_imm19_at5(insn, src_pc, dst_pc);
}
Some(insn)
}
fn relocate_adr(insn: u32, src_pc: u64, dst_pc: u64, page: bool) -> Option<u32> {
let immlo = ((insn >> 29) & 0x3) as i64;
let immhi = ((insn >> 5) & 0x7FFFF) as i64;
let raw = (immhi << 2) | immlo;
let imm21 = (raw << 43) >> 43;
let (src_ref, dst_ref, scale) = if page {
(src_pc & !0xFFF, dst_pc & !0xFFF, 4096i64)
} else {
(src_pc, dst_pc, 1i64)
};
let target = src_ref as i64 + imm21 * scale;
let new_off = target - dst_ref as i64;
if scale != 1 && new_off & 0xFFF != 0 {
return None;
}
let scaled = new_off / scale;
if !(-(1 << 20)..(1 << 20)).contains(&scaled) {
return None;
}
let new_raw = (scaled as u32) & 0x1F_FFFF;
let new_immlo = (new_raw & 0x3) << 29;
let new_immhi = ((new_raw >> 2) & 0x7FFFF) << 5;
Some((insn & 0x9F00_001F) | new_immlo | new_immhi)
}
fn relocate_imm19_at5(insn: u32, src_pc: u64, dst_pc: u64) -> Option<u32> {
let imm19 = ((insn >> 5) & 0x7FFFF) as i64;
let off = ((imm19 << 45) >> 45) * 4;
let target = src_pc as i64 + off;
let new_off = target - dst_pc as i64;
if new_off & 0b11 != 0 {
return None;
}
let words = new_off >> 2;
if !(-(1 << 18)..(1 << 18)).contains(&words) {
return None;
}
let new_imm19 = ((words as u32) & 0x7FFFF) << 5;
Some((insn & !(0x7FFFF << 5)) | new_imm19)
}
fn relocate_tbz(insn: u32, src_pc: u64, dst_pc: u64) -> Option<u32> {
let imm14 = ((insn >> 5) & 0x3FFF) as i64;
let off = ((imm14 << 50) >> 50) * 4;
let target = src_pc as i64 + off;
let new_off = target - dst_pc as i64;
if new_off & 0b11 != 0 {
return None;
}
let words = new_off >> 2;
if !(-(1 << 13)..(1 << 13)).contains(&words) {
return None;
}
let new_imm14 = ((words as u32) & 0x3FFF) << 5;
Some((insn & !(0x3FFF << 5)) | new_imm14)
}
pub fn assemble_trampoline(
prologue: &[u8],
src_base: u64,
dst_base: u64,
resume: u64,
) -> Option<Vec<u8>> {
if !prologue.len().is_multiple_of(4) {
return None;
}
let count = prologue.len() / 4;
const RETURN_BRANCH_BYTES: usize = 16;
const ISLAND_BYTES: usize = 16;
let islands_base = dst_base + (count * 4) as u64 + RETURN_BRANCH_BYTES as u64;
let mut prologue_out: Vec<u8> = Vec::with_capacity(count * 4);
let mut islands_out: Vec<u8> = Vec::new();
let mut next_island = islands_base;
for i in 0..count {
let insn = u32::from_le_bytes(prologue[i * 4..i * 4 + 4].try_into().ok()?);
let src_pc = src_base + (i * 4) as u64;
let dst_pc = dst_base + (i * 4) as u64;
if needs_absolute_island(insn) {
let island_addr = next_island;
next_island += ISLAND_BYTES as u64;
let relocated = emit_branch_to_island(insn, dst_pc, island_addr)?;
prologue_out.extend_from_slice(&relocated.to_le_bytes());
let island = island_for_branch(insn, src_pc)?;
debug_assert_eq!(island.len(), ISLAND_BYTES);
islands_out.extend_from_slice(&island);
} else {
let relocated = relocate_instruction(insn, src_pc, dst_pc)?;
prologue_out.extend_from_slice(&relocated.to_le_bytes());
}
}
let mut out = prologue_out;
append_abs_branch(&mut out, resume, false);
out.extend_from_slice(&islands_out);
Some(out)
}
pub fn relocated_prologue(prologue: &[u8], src_base: u64, dst_base: u64) -> Option<Vec<u8>> {
let count = prologue.len() / 4;
let body = assemble_trampoline(prologue, src_base, dst_base, src_base + STOLEN_BYTES as u64)?;
Some(body[..count * 4].to_vec())
}
File diff suppressed because it is too large Load Diff
@@ -1,584 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
GAME_CAPTURE_API_OPENGL, GAME_CAPTURE_FALLBACK_NONE,
GAME_CAPTURE_FALLBACK_SHARED_TEXTURE_UNSUPPORTED, HookState, mark_present,
publish_shared_texture_frame, set_capture_flags, set_fallback_reason, verbose_log,
};
use std::{
ffi::c_void,
ptr::null_mut,
sync::atomic::{AtomicBool, Ordering},
};
use windows::{
Win32::{
Foundation::{HMODULE as WinHmodule, HWND as WinHwnd},
Graphics::{
Direct3D::D3D_DRIVER_TYPE_HARDWARE,
Direct3D11::{
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE,
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_RESOURCE_MISC_SHARED, D3D11_SDK_VERSION,
D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, D3D11CreateDeviceAndSwapChain,
ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D,
},
Dxgi::{
Common::{
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_MODE_DESC,
DXGI_MODE_SCALING_UNSPECIFIED, DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED,
DXGI_RATIONAL, DXGI_SAMPLE_DESC,
},
DXGI_PRESENT, DXGI_SWAP_CHAIN_DESC, DXGI_SWAP_EFFECT_DISCARD,
DXGI_USAGE_RENDER_TARGET_OUTPUT, IDXGIResource, IDXGISwapChain,
},
},
},
core::{BOOL as WinBool, Interface},
};
use windows_sys::Win32::{
Foundation::HWND as SysHwnd,
Graphics::OpenGL::{
GL_COLOR_BUFFER_BIT, GL_LINEAR, GL_NEAREST, GL_NO_ERROR, GL_TEXTURE_2D,
GL_TEXTURE_BINDING_2D, glBindTexture, glDeleteTextures, glFinish, glGenTextures,
glGetError, glGetIntegerv, wglGetCurrentContext, wglGetProcAddress,
},
UI::WindowsAndMessaging::DestroyWindow,
};
const WGL_ACCESS_READ_ONLY_NV: u32 = 0x0000;
const WGL_ACCESS_READ_WRITE_NV: u32 = 0x0001;
const WGL_ACCESS_WRITE_DISCARD_NV: u32 = 0x0002;
const GL_READ_FRAMEBUFFER: u32 = 0x8CA8;
const GL_DRAW_FRAMEBUFFER: u32 = 0x8CA9;
const GL_FRAMEBUFFER: u32 = 0x8D40;
const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
const GL_READ_FRAMEBUFFER_BINDING: u32 = 0x8CAA;
const GL_DRAW_FRAMEBUFFER_BINDING: u32 = 0x8CA6;
type DxOpenDeviceNvFn = unsafe extern "system" fn(dx_device: *mut c_void) -> *mut c_void;
type DxCloseDeviceNvFn = unsafe extern "system" fn(device: *mut c_void) -> i32;
type DxRegisterObjectNvFn = unsafe extern "system" fn(
device: *mut c_void,
dx_object: *mut c_void,
name: u32,
object_type: u32,
access: u32,
) -> *mut c_void;
type DxUnregisterObjectNvFn =
unsafe extern "system" fn(device: *mut c_void, object: *mut c_void) -> i32;
type DxLockObjectsNvFn =
unsafe extern "system" fn(device: *mut c_void, count: i32, objects: *const *mut c_void) -> i32;
type DxUnlockObjectsNvFn =
unsafe extern "system" fn(device: *mut c_void, count: i32, objects: *const *mut c_void) -> i32;
type GlGenFramebuffersFn = unsafe extern "system" fn(n: i32, framebuffers: *mut u32);
type GlDeleteFramebuffersFn = unsafe extern "system" fn(n: i32, framebuffers: *const u32);
type GlBindFramebufferFn = unsafe extern "system" fn(target: u32, framebuffer: u32);
type GlFramebufferTexture2DFn = unsafe extern "system" fn(
target: u32,
attachment: u32,
textarget: u32,
texture: u32,
level: i32,
);
type GlCheckFramebufferStatusFn = unsafe extern "system" fn(target: u32) -> u32;
type GlBlitFramebufferFn = unsafe extern "system" fn(
src_x0: i32,
src_y0: i32,
src_x1: i32,
src_y1: i32,
dst_x0: i32,
dst_y0: i32,
dst_x1: i32,
dst_y1: i32,
mask: u32,
filter: u32,
);
struct InteropProcs {
open_device: DxOpenDeviceNvFn,
close_device: DxCloseDeviceNvFn,
register_object: DxRegisterObjectNvFn,
unregister_object: DxUnregisterObjectNvFn,
lock_objects: DxLockObjectsNvFn,
unlock_objects: DxUnlockObjectsNvFn,
gen_framebuffers: GlGenFramebuffersFn,
delete_framebuffers: GlDeleteFramebuffersFn,
bind_framebuffer: GlBindFramebufferFn,
framebuffer_texture_2d: GlFramebufferTexture2DFn,
check_framebuffer_status: GlCheckFramebufferStatusFn,
blit_framebuffer: GlBlitFramebufferFn,
}
pub(crate) struct GlInteropState {
procs: InteropProcs,
_device: ID3D11Device,
_context: ID3D11DeviceContext,
swap_chain: IDXGISwapChain,
_texture: ID3D11Texture2D,
dummy_hwnd: SysHwnd,
shared_handle: u64,
dx_device: *mut c_void,
dx_object: *mut c_void,
gl_texture: u32,
draw_fbo: u32,
width: u32,
height: u32,
}
unsafe impl Send for GlInteropState {}
static GL_GPU_DISABLED: AtomicBool = AtomicBool::new(false);
static GL_GPU_UNAVAILABLE_LOGGED: AtomicBool = AtomicBool::new(false);
static GL_DUMMY_PRESENT_ACTIVE: AtomicBool = AtomicBool::new(false);
struct DummyPresentGuard;
impl DummyPresentGuard {
fn enter() -> Self {
GL_DUMMY_PRESENT_ACTIVE.store(true, Ordering::Release);
Self
}
}
impl Drop for DummyPresentGuard {
fn drop(&mut self) {
GL_DUMMY_PRESENT_ACTIVE.store(false, Ordering::Release);
}
}
fn latch_disable(reason: &str) {
if !GL_GPU_DISABLED.swap(true, Ordering::AcqRel) {
verbose_log(&format!(
"opengl interop: latch-disabling GPU path, falling back to glReadPixels CPU path ({reason})"
));
}
}
pub(crate) fn gpu_path_disabled() -> bool {
GL_GPU_DISABLED.load(Ordering::Acquire)
}
pub(crate) fn dummy_present_active() -> bool {
GL_DUMMY_PRESENT_ACTIVE.load(Ordering::Acquire)
}
unsafe fn load_proc<T>(name: &[u8]) -> Option<T> {
debug_assert_eq!(
name.last(),
Some(&0),
"wglGetProcAddress name must be NUL-terminated"
);
let proc = wglGetProcAddress(name.as_ptr());
match proc {
Some(proc) => Some(std::mem::transmute_copy::<_, T>(&proc)),
None => None,
}
}
impl InteropProcs {
unsafe fn load() -> Option<Self> {
if wglGetCurrentContext().is_null() {
return None;
}
let open_device = load_proc::<DxOpenDeviceNvFn>(b"wglDXOpenDeviceNV\0")?;
let close_device = load_proc::<DxCloseDeviceNvFn>(b"wglDXCloseDeviceNV\0")?;
let register_object = load_proc::<DxRegisterObjectNvFn>(b"wglDXRegisterObjectNV\0")?;
let unregister_object = load_proc::<DxUnregisterObjectNvFn>(b"wglDXUnregisterObjectNV\0")?;
let lock_objects = load_proc::<DxLockObjectsNvFn>(b"wglDXLockObjectsNV\0")?;
let unlock_objects = load_proc::<DxUnlockObjectsNvFn>(b"wglDXUnlockObjectsNV\0")?;
let gen_framebuffers = load_proc::<GlGenFramebuffersFn>(b"glGenFramebuffers\0")?;
let delete_framebuffers = load_proc::<GlDeleteFramebuffersFn>(b"glDeleteFramebuffers\0")?;
let bind_framebuffer = load_proc::<GlBindFramebufferFn>(b"glBindFramebuffer\0")?;
let framebuffer_texture_2d =
load_proc::<GlFramebufferTexture2DFn>(b"glFramebufferTexture2D\0")?;
let check_framebuffer_status =
load_proc::<GlCheckFramebufferStatusFn>(b"glCheckFramebufferStatus\0")?;
let blit_framebuffer = load_proc::<GlBlitFramebufferFn>(b"glBlitFramebuffer\0")?;
Some(Self {
open_device,
close_device,
register_object,
unregister_object,
lock_objects,
unlock_objects,
gen_framebuffers,
delete_framebuffers,
bind_framebuffer,
framebuffer_texture_2d,
check_framebuffer_status,
blit_framebuffer,
})
}
}
unsafe fn create_interop_d3d11_device()
-> Option<(ID3D11Device, ID3D11DeviceContext, IDXGISwapChain, SysHwnd)> {
let dummy_hwnd = crate::create_dummy_window();
if dummy_hwnd.is_null() {
verbose_log("opengl interop: failed to create dummy D3D11 flush window");
return None;
}
let desc = DXGI_SWAP_CHAIN_DESC {
BufferDesc: DXGI_MODE_DESC {
Width: 2,
Height: 2,
RefreshRate: DXGI_RATIONAL {
Numerator: 60,
Denominator: 1,
},
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
ScanlineOrdering: DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED,
Scaling: DXGI_MODE_SCALING_UNSPECIFIED,
},
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT,
BufferCount: 2,
OutputWindow: WinHwnd(dummy_hwnd),
Windowed: WinBool(1),
SwapEffect: DXGI_SWAP_EFFECT_DISCARD,
Flags: 0,
};
let mut swap_chain = None;
let mut device = None;
let mut context = None;
let result = D3D11CreateDeviceAndSwapChain(
None,
D3D_DRIVER_TYPE_HARDWARE,
WinHmodule(null_mut()),
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
None,
D3D11_SDK_VERSION,
Some(&desc),
Some(&mut swap_chain),
Some(&mut device),
None,
Some(&mut context),
);
if result.is_err() {
let _ = DestroyWindow(dummy_hwnd);
return None;
}
match (device, context, swap_chain) {
(Some(device), Some(context), Some(swap_chain)) => {
Some((device, context, swap_chain, dummy_hwnd))
}
_ => {
let _ = DestroyWindow(dummy_hwnd);
None
}
}
}
unsafe fn create_shared_texture(
device: &ID3D11Device,
width: u32,
height: u32,
) -> Option<(ID3D11Texture2D, u64)> {
let desc = D3D11_TEXTURE2D_DESC {
Width: width,
Height: height,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
CPUAccessFlags: 0,
MiscFlags: D3D11_RESOURCE_MISC_SHARED.0 as u32,
};
let mut texture = None;
if device
.CreateTexture2D(&desc, None, Some(&mut texture))
.is_err()
{
return None;
}
let texture = texture?;
let handle = texture
.cast::<IDXGIResource>()
.and_then(|resource| resource.GetSharedHandle())
.ok()?;
Some((texture, handle.0 as usize as u64))
}
impl GlInteropState {
unsafe fn create(width: u32, height: u32) -> Option<Self> {
let procs = InteropProcs::load()?;
let (device, context, swap_chain, dummy_hwnd) = create_interop_d3d11_device()?;
let (texture, shared_handle) = match create_shared_texture(&device, width, height) {
Some(texture) => texture,
None => {
let _ = DestroyWindow(dummy_hwnd);
return None;
}
};
if shared_handle == 0 {
let _ = DestroyWindow(dummy_hwnd);
return None;
}
let dx_device = (procs.open_device)(device.as_raw());
if dx_device.is_null() {
verbose_log("opengl interop: wglDXOpenDeviceNV returned NULL");
let _ = DestroyWindow(dummy_hwnd);
return None;
}
verbose_log("opengl interop: wglDXOpenDeviceNV opened private D3D11 device");
let mut gl_texture = 0u32;
glGenTextures(1, &mut gl_texture);
if gl_texture == 0 {
(procs.close_device)(dx_device);
let _ = DestroyWindow(dummy_hwnd);
return None;
}
let dx_object = (procs.register_object)(
dx_device,
texture.as_raw(),
gl_texture,
GL_TEXTURE_2D,
WGL_ACCESS_WRITE_DISCARD_NV,
);
if dx_object.is_null() {
verbose_log("opengl interop: wglDXRegisterObjectNV returned NULL");
glDeleteTextures(1, &gl_texture);
(procs.close_device)(dx_device);
let _ = DestroyWindow(dummy_hwnd);
return None;
}
verbose_log(&format!(
"opengl interop: registered D3D11 texture <-> GL texture {gl_texture} ({width}x{height} BGRA)"
));
let mut draw_fbo = 0u32;
(procs.gen_framebuffers)(1, &mut draw_fbo);
if draw_fbo == 0 {
(procs.unregister_object)(dx_device, dx_object);
glDeleteTextures(1, &gl_texture);
(procs.close_device)(dx_device);
let _ = DestroyWindow(dummy_hwnd);
return None;
}
Some(Self {
procs,
_device: device,
_context: context,
swap_chain,
_texture: texture,
dummy_hwnd,
shared_handle,
dx_device,
dx_object,
gl_texture,
draw_fbo,
width,
height,
})
}
fn matches(&self, width: u32, height: u32) -> bool {
self.width == width && self.height == height
}
unsafe fn blit_default_framebuffer(&self) -> bool {
let objects = [self.dx_object];
let mut prev_read_fbo = 0i32;
let mut prev_draw_fbo = 0i32;
glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &mut prev_read_fbo);
glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &mut prev_draw_fbo);
let mut prev_tex = 0i32;
glGetIntegerv(GL_TEXTURE_BINDING_2D, &mut prev_tex);
if (self.procs.lock_objects)(self.dx_device, 1, objects.as_ptr()) == 0 {
verbose_log("opengl interop: wglDXLockObjectsNV FAILED");
return false;
}
(self.procs.bind_framebuffer)(GL_DRAW_FRAMEBUFFER, self.draw_fbo);
(self.procs.framebuffer_texture_2d)(
GL_DRAW_FRAMEBUFFER,
GL_COLOR_ATTACHMENT0,
GL_TEXTURE_2D,
self.gl_texture,
0,
);
let status = (self.procs.check_framebuffer_status)(GL_DRAW_FRAMEBUFFER);
if status != GL_FRAMEBUFFER_COMPLETE {
verbose_log(&format!(
"opengl interop: draw FBO incomplete (status 0x{status:04X}); unlocking and falling back"
));
(self.procs.framebuffer_texture_2d)(
GL_DRAW_FRAMEBUFFER,
GL_COLOR_ATTACHMENT0,
GL_TEXTURE_2D,
0,
0,
);
(self.procs.bind_framebuffer)(GL_DRAW_FRAMEBUFFER, prev_draw_fbo as u32);
(self.procs.bind_framebuffer)(GL_READ_FRAMEBUFFER, prev_read_fbo as u32);
let _ = (self.procs.unlock_objects)(self.dx_device, 1, objects.as_ptr());
return false;
}
(self.procs.bind_framebuffer)(GL_READ_FRAMEBUFFER, 0);
let w = self.width as i32;
let h = self.height as i32;
(self.procs.blit_framebuffer)(
0,
0,
w,
h,
0,
h,
w,
0,
GL_COLOR_BUFFER_BIT,
if w == self.width as i32 && h == self.height as i32 {
GL_NEAREST
} else {
GL_LINEAR
},
);
let blit_err = glGetError();
(self.procs.framebuffer_texture_2d)(
GL_DRAW_FRAMEBUFFER,
GL_COLOR_ATTACHMENT0,
GL_TEXTURE_2D,
0,
0,
);
(self.procs.bind_framebuffer)(GL_DRAW_FRAMEBUFFER, prev_draw_fbo as u32);
(self.procs.bind_framebuffer)(GL_READ_FRAMEBUFFER, prev_read_fbo as u32);
glBindTexture(GL_TEXTURE_2D, prev_tex as u32);
glFinish();
if (self.procs.unlock_objects)(self.dx_device, 1, objects.as_ptr()) == 0 {
verbose_log("opengl interop: wglDXUnlockObjectsNV FAILED");
return false;
}
self._context.Flush();
let present_result = {
let _guard = DummyPresentGuard::enter();
self.swap_chain.Present(0, DXGI_PRESENT(0))
};
if present_result.is_err() {
verbose_log(&format!(
"opengl interop: dummy D3D11 Present flush failed hr={:#010x}",
present_result.0 as u32
));
return false;
}
if blit_err != GL_NO_ERROR {
verbose_log(&format!(
"opengl interop: glBlitFramebuffer raised GL error 0x{blit_err:04X}"
));
return false;
}
true
}
}
impl Drop for GlInteropState {
fn drop(&mut self) {
unsafe {
if !self.dx_object.is_null() {
let _ = (self.procs.unregister_object)(self.dx_device, self.dx_object);
}
if self.draw_fbo != 0 {
(self.procs.delete_framebuffers)(1, &self.draw_fbo);
}
if self.gl_texture != 0 {
glDeleteTextures(1, &self.gl_texture);
}
if !self.dx_device.is_null() {
let _ = (self.procs.close_device)(self.dx_device);
}
if !self.dummy_hwnd.is_null() {
let _ = DestroyWindow(self.dummy_hwnd);
}
}
}
}
unsafe fn interop_state_for_frame(
state: &mut HookState,
width: u32,
height: u32,
) -> Option<&mut GlInteropState> {
let recreate = state
.gl_interop
.as_ref()
.map(|interop| !interop.matches(width, height))
.unwrap_or(true);
if recreate {
state.gl_interop = None;
match GlInteropState::create(width, height) {
Some(interop) => state.gl_interop = Some(interop),
None => return None,
}
}
state.gl_interop.as_mut()
}
pub(crate) unsafe fn capture_opengl_frame_gpu(
state: &mut HookState,
hwnd: SysHwnd,
width: u32,
height: u32,
) -> bool {
if gpu_path_disabled() {
return false;
}
let Some(interop) = interop_state_for_frame(state, width, height) else {
if !GL_GPU_UNAVAILABLE_LOGGED.swap(true, Ordering::AcqRel) {
verbose_log(
"opengl interop: WGL_NV_DX_interop2 unavailable or pipeline creation failed",
);
}
latch_disable("interop pipeline creation failed");
set_fallback_reason(state, GAME_CAPTURE_FALLBACK_SHARED_TEXTURE_UNSUPPORTED);
return false;
};
let shared_handle = interop.shared_handle;
let blitted = interop.blit_default_framebuffer();
if !blitted {
latch_disable("lock/blit failed after successful registration");
set_fallback_reason(state, GAME_CAPTURE_FALLBACK_SHARED_TEXTURE_UNSUPPORTED);
return false;
}
mark_present(state, GAME_CAPTURE_API_OPENGL);
set_capture_flags(state, 0);
set_fallback_reason(state, GAME_CAPTURE_FALLBACK_NONE);
let published = publish_shared_texture_frame(
state,
hwnd,
width,
height,
DXGI_FORMAT(DXGI_FORMAT_B8G8R8A8_UNORM.0),
shared_handle,
);
if published {
verbose_log(&format!(
"opengl interop: published shared-texture frame {width}x{height} (handle 0x{shared_handle:X})"
));
}
published
}
@@ -1,262 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
#[cfg(not(target_arch = "aarch64"))]
use retour::Function;
#[cfg(target_arch = "aarch64")]
pub(crate) use aarch64_function::Function;
#[cfg(target_arch = "aarch64")]
mod aarch64_function {
pub(crate) trait Function: Copy + Sync + 'static {
unsafe fn from_ptr(ptr: *const ()) -> Self;
fn to_ptr(&self) -> *const ();
}
macro_rules! impl_function {
($($arg:ident),*) => {
impl<Ret: 'static, $($arg: 'static),*> Function
for unsafe extern "system" fn($($arg),*) -> Ret
{
unsafe fn from_ptr(ptr: *const ()) -> Self {
core::mem::transmute(ptr)
}
fn to_ptr(&self) -> *const () {
*self as *const ()
}
}
};
}
impl_function!();
impl_function!(A);
impl_function!(A, B);
impl_function!(A, B, C);
impl_function!(A, B, C, D);
impl_function!(A, B, C, D, E);
impl_function!(A, B, C, D, E, F);
}
pub(crate) struct Detour<T: Function> {
inner: Inner<T>,
}
#[cfg(not(target_arch = "aarch64"))]
enum Inner<T: Function> {
Retour(retour::GenericDetour<T>),
}
#[cfg(target_arch = "aarch64")]
enum Inner<T: Function> {
Aarch64(aarch64::Aarch64Detour<T>),
}
impl<T: Function> Detour<T> {
pub(crate) unsafe fn new(target: T, detour: T) -> Result<Self, ()> {
#[cfg(not(target_arch = "aarch64"))]
{
match retour::GenericDetour::<T>::new(target, detour) {
Ok(detour) => Ok(Self {
inner: Inner::Retour(detour),
}),
Err(_) => Err(()),
}
}
#[cfg(target_arch = "aarch64")]
{
aarch64::Aarch64Detour::<T>::new(target, detour).map(|detour| Self {
inner: Inner::Aarch64(detour),
})
}
}
pub(crate) unsafe fn enable(&self) -> Result<(), ()> {
match &self.inner {
#[cfg(not(target_arch = "aarch64"))]
Inner::Retour(detour) => detour.enable().map_err(|_| ()),
#[cfg(target_arch = "aarch64")]
Inner::Aarch64(detour) => detour.enable(),
}
}
pub(crate) fn trampoline_fn(&self) -> T {
match &self.inner {
#[cfg(not(target_arch = "aarch64"))]
Inner::Retour(detour) => unsafe {
T::from_ptr(detour.trampoline() as *const () as *const ())
},
#[cfg(target_arch = "aarch64")]
Inner::Aarch64(detour) => detour.trampoline_fn(),
}
}
}
#[cfg(target_arch = "aarch64")]
mod aarch64 {
use super::Function;
use crate::arm64_reloc::{
NOP, STOLEN_BYTES, append_abs_branch, assemble_trampoline, import_thunk_target,
};
use core::marker::PhantomData;
use std::ptr;
use windows_sys::Win32::System::{
Diagnostics::Debug::FlushInstructionCache,
Memory::{
MEM_COMMIT, MEM_RELEASE, MEM_RESERVE, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE,
PAGE_PROTECTION_FLAGS, VirtualAlloc, VirtualFree, VirtualProtect,
},
Threading::GetCurrentProcess,
};
const TRAMPOLINE_CAP: usize = 256;
pub(super) struct Aarch64Detour<T: Function> {
target: *mut u8,
detour: *const u8,
trampoline: *mut u8,
original_prologue: [u8; STOLEN_BYTES],
enabled: std::cell::Cell<bool>,
_marker: PhantomData<T>,
}
unsafe impl<T: Function> Send for Aarch64Detour<T> {}
unsafe impl<T: Function> Sync for Aarch64Detour<T> {}
impl<T: Function> Aarch64Detour<T> {
pub(super) unsafe fn new(target: T, detour: T) -> Result<Self, ()> {
let target_ptr = target.to_ptr() as *mut u8;
let detour_ptr = detour.to_ptr() as *const u8;
if target_ptr.is_null() || detour_ptr.is_null() {
return Err(());
}
let mut original = [0u8; STOLEN_BYTES];
ptr::copy_nonoverlapping(target_ptr, original.as_mut_ptr(), STOLEN_BYTES);
let trampoline = VirtualAlloc(
ptr::null(),
TRAMPOLINE_CAP,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE,
) as *mut u8;
if trampoline.is_null() {
return Err(());
}
let trampoline_addr = trampoline as u64;
let resume = target_ptr as u64 + STOLEN_BYTES as u64;
let body =
match assemble_trampoline(&original, target_ptr as u64, trampoline_addr, resume) {
Some(body) => body,
None => match import_thunk_target(&original, target_ptr as u64) {
Some(target) => {
let mut body = Vec::new();
append_abs_branch(&mut body, target, false);
body
}
None => {
VirtualFree(trampoline.cast(), 0, MEM_RELEASE);
return Err(());
}
},
};
if body.len() > TRAMPOLINE_CAP {
VirtualFree(trampoline.cast(), 0, MEM_RELEASE);
return Err(());
}
ptr::copy_nonoverlapping(body.as_ptr(), trampoline, body.len());
let mut old = 0 as PAGE_PROTECTION_FLAGS;
VirtualProtect(
trampoline.cast(),
TRAMPOLINE_CAP,
PAGE_EXECUTE_READ,
&mut old,
);
FlushInstructionCache(GetCurrentProcess(), trampoline.cast(), TRAMPOLINE_CAP);
Ok(Self {
target: target_ptr,
detour: detour_ptr,
trampoline,
original_prologue: original,
enabled: std::cell::Cell::new(false),
_marker: PhantomData,
})
}
pub(super) unsafe fn enable(&self) -> Result<(), ()> {
if self.enabled.get() {
return Ok(());
}
let mut patch = Vec::new();
append_abs_branch(&mut patch, self.detour as u64, false);
if patch.len() > STOLEN_BYTES {
return Err(());
}
while patch.len() < STOLEN_BYTES {
patch.extend_from_slice(&NOP.to_le_bytes());
}
let mut old = 0 as PAGE_PROTECTION_FLAGS;
if VirtualProtect(
self.target.cast(),
STOLEN_BYTES,
PAGE_EXECUTE_READWRITE,
&mut old,
) == 0
{
return Err(());
}
ptr::copy_nonoverlapping(patch.as_ptr(), self.target, STOLEN_BYTES);
let mut restore = 0 as PAGE_PROTECTION_FLAGS;
VirtualProtect(self.target.cast(), STOLEN_BYTES, old, &mut restore);
FlushInstructionCache(GetCurrentProcess(), self.target.cast(), STOLEN_BYTES);
self.enabled.set(true);
Ok(())
}
unsafe fn disable(&self) {
if !self.enabled.get() {
return;
}
let mut old = 0 as PAGE_PROTECTION_FLAGS;
if VirtualProtect(
self.target.cast(),
STOLEN_BYTES,
PAGE_EXECUTE_READWRITE,
&mut old,
) != 0
{
ptr::copy_nonoverlapping(
self.original_prologue.as_ptr(),
self.target,
STOLEN_BYTES,
);
let mut restore = 0 as PAGE_PROTECTION_FLAGS;
VirtualProtect(self.target.cast(), STOLEN_BYTES, old, &mut restore);
FlushInstructionCache(GetCurrentProcess(), self.target.cast(), STOLEN_BYTES);
}
self.enabled.set(false);
}
pub(super) fn trampoline_fn(&self) -> T {
unsafe { T::from_ptr(self.trampoline as *const ()) }
}
}
impl<T: Function> Drop for Aarch64Detour<T> {
fn drop(&mut self) {
unsafe {
self.disable();
if !self.trampoline.is_null() {
VirtualFree(self.trampoline.cast(), 0, MEM_RELEASE);
self.trampoline = ptr::null_mut();
}
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,412 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[path = "../src/arm64_reloc.rs"]
mod arm64_reloc;
use arm64_reloc::*;
fn adr(rd: u32, imm21: i32) -> u32 {
let raw = (imm21 as u32) & 0x1F_FFFF;
let immlo = (raw & 0x3) << 29;
let immhi = ((raw >> 2) & 0x7FFFF) << 5;
0x1000_0000 | immlo | immhi | (rd & 0x1F)
}
fn adrp(rd: u32, imm21: i32) -> u32 {
let raw = (imm21 as u32) & 0x1F_FFFF;
let immlo = (raw & 0x3) << 29;
let immhi = ((raw >> 2) & 0x7FFFF) << 5;
0x9000_0000 | immlo | immhi | (rd & 0x1F)
}
fn b(off_words: i32) -> u32 {
0x1400_0000 | ((off_words as u32) & 0x03FF_FFFF)
}
fn bl(off_words: i32) -> u32 {
0x9400_0000 | ((off_words as u32) & 0x03FF_FFFF)
}
fn bcond(cond: u32, off_words: i32) -> u32 {
0x5400_0000 | (((off_words as u32) & 0x7FFFF) << 5) | (cond & 0xF)
}
fn cbz(rt: u32, off_words: i32) -> u32 {
0xB400_0000 | (((off_words as u32) & 0x7FFFF) << 5) | (rt & 0x1F)
}
fn tbz(rt: u32, bit: u32, off_words: i32) -> u32 {
let b5 = (bit & 0x20) << (31 - 5);
let b40 = (bit & 0x1F) << 19;
0x3600_0000 | b5 | b40 | (((off_words as u32) & 0x3FFF) << 5) | (rt & 0x1F)
}
fn ldr_lit(rt: u32, off_words: i32) -> u32 {
0x5800_0000 | (((off_words as u32) & 0x7FFFF) << 5) | (rt & 0x1F)
}
fn adr_target(insn: u32, pc: u64, page: bool) -> u64 {
let immlo = ((insn >> 29) & 0x3) as i64;
let immhi = ((insn >> 5) & 0x7FFFF) as i64;
let raw = (immhi << 2) | immlo;
let imm21 = (raw << 43) >> 43;
if page {
((pc & !0xFFF) as i64 + imm21 * 4096) as u64
} else {
(pc as i64 + imm21) as u64
}
}
fn imm19_target(insn: u32, pc: u64) -> u64 {
let imm19 = ((insn >> 5) & 0x7FFFF) as i64;
let off = ((imm19 << 45) >> 45) * 4;
(pc as i64 + off) as u64
}
fn imm14_target(insn: u32, pc: u64) -> u64 {
let imm14 = ((insn >> 5) & 0x3FFF) as i64;
let off = ((imm14 << 50) >> 50) * 4;
(pc as i64 + off) as u64
}
#[test]
fn non_pc_relative_copied_verbatim() {
let stp = 0xA9BF_7BFD;
assert_eq!(relocate_instruction(stp, 0x1000, 0x9000), Some(stp));
let mov = 0xAA01_03E0;
assert_eq!(relocate_instruction(mov, 0x1000, 0x9000), Some(mov));
let sub = 0xD100_83FF;
assert_eq!(relocate_instruction(sub, 0x1000, 0x9000), Some(sub));
let mov_fp_sp = 0x9100_03FD;
assert_eq!(
relocate_instruction(mov_fp_sp, 0x1000, 0x9000),
Some(mov_fp_sp)
);
}
#[test]
fn adr_relocates_to_same_target() {
let src_pc = 0x140_0010_0000u64;
let dst_pc = 0x140_0010_8000u64;
let insn = adr(0, 0x4000);
let original = adr_target(insn, src_pc, false);
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
assert_eq!(adr_target(reloc, dst_pc, false), original);
assert_eq!(reloc & 0x1F, 0);
}
#[test]
fn adr_negative_offset() {
let src_pc = 0x140_0010_0000u64;
let dst_pc = 0x140_0010_0010u64;
let insn = adr(5, -0x100);
let original = adr_target(insn, src_pc, false);
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
assert_eq!(adr_target(reloc, dst_pc, false), original);
assert_eq!(reloc & 0x1F, 5);
}
#[test]
fn adr_out_of_range_refused() {
let src_pc = 0x0000_0000_0000u64;
let dst_pc = 0x0000_0080_0000u64;
let insn = adr(0, 0x1000);
assert_eq!(relocate_instruction(insn, src_pc, dst_pc), None);
}
#[test]
fn adrp_relocates_to_same_page() {
let src_pc = 0x140_0010_0000u64;
let dst_pc = 0x140_0030_0000u64;
let insn = adrp(9, 0x10);
let original = adr_target(insn, src_pc, true);
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
assert_eq!(adr_target(reloc, dst_pc, true), original);
assert_eq!(reloc & 0x1F, 9);
assert_eq!(reloc & 0x9F00_0000, 0x9000_0000);
}
#[test]
fn adrp_negative() {
let src_pc = 0x140_0090_0000u64;
let dst_pc = 0x140_0050_0000u64;
let insn = adrp(1, -0x20);
let original = adr_target(insn, src_pc, true);
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
assert_eq!(adr_target(reloc, dst_pc, true), original);
}
#[test]
fn bcond_relocates() {
let src_pc = 0x10_0000u64;
let dst_pc = 0x12_0000u64;
let insn = bcond(0x0, 0x40);
let original = imm19_target(insn, src_pc);
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
assert_eq!(imm19_target(reloc, dst_pc), original);
assert_eq!(reloc & 0xF, 0x0);
}
#[test]
fn bcond_out_of_range_refused() {
let src_pc = 0x0u64;
let dst_pc = 0x20_0000u64;
let insn = bcond(0x1, 0x10);
assert_eq!(relocate_instruction(insn, src_pc, dst_pc), None);
}
#[test]
fn cbz_relocates() {
let src_pc = 0x10_0000u64;
let dst_pc = 0x10_8000u64;
let insn = cbz(3, -0x20);
let original = imm19_target(insn, src_pc);
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
assert_eq!(imm19_target(reloc, dst_pc), original);
assert_eq!(reloc & 0x1F, 3);
assert_eq!(reloc & 0x8000_0000, 0x8000_0000);
}
#[test]
fn tbz_relocates() {
let src_pc = 0x10_0000u64;
let dst_pc = 0x10_1000u64;
let insn = tbz(7, 5, 0x10);
let original = imm14_target(insn, src_pc);
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
assert_eq!(imm14_target(reloc, dst_pc), original);
assert_eq!(reloc & 0x1F, 7);
}
#[test]
fn tbz_out_of_range_refused() {
let src_pc = 0x0u64;
let dst_pc = 0x1_0000u64;
let insn = tbz(0, 1, 0x8);
assert_eq!(relocate_instruction(insn, src_pc, dst_pc), None);
}
#[test]
fn ldr_literal_relocates() {
let src_pc = 0x20_0000u64;
let dst_pc = 0x20_4000u64;
let insn = ldr_lit(2, 0x100);
let original = imm19_target(insn, src_pc);
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
assert_eq!(imm19_target(reloc, dst_pc), original);
assert_eq!(reloc & 0x1F, 2);
}
#[test]
fn direct_b_relocation_in_range() {
let src_pc = 0x10_0000u64;
let dst_pc = 0x14_0000u64;
let insn = b(0x100);
let target = branch_target(insn, src_pc).unwrap();
let reloc = relocate_instruction(insn, src_pc, dst_pc).expect("in range");
assert_eq!(branch_target(reloc, dst_pc).unwrap(), target);
}
#[test]
fn branch_target_decode() {
let pc = 0x10_0000u64;
assert_eq!(branch_target(b(4), pc), Some(pc + 16));
assert_eq!(branch_target(b(-4), pc), Some(pc - 16));
assert_eq!(branch_target(bl(1), pc), Some(pc + 4));
}
#[test]
fn abs_branch_encoding() {
let mut bytes = Vec::new();
append_abs_branch(&mut bytes, 0x1234_5678_9ABC_DEF0, false);
assert_eq!(bytes.len(), 16);
assert_eq!(
u32::from_le_bytes(bytes[0..4].try_into().unwrap()),
LDR_X16_PC8
);
assert_eq!(u32::from_le_bytes(bytes[4..8].try_into().unwrap()), BR_X16);
assert_eq!(
u64::from_le_bytes(bytes[8..16].try_into().unwrap()),
0x1234_5678_9ABC_DEF0
);
let mut linked = Vec::new();
append_abs_branch(&mut linked, 0xDEAD_BEEF, true);
assert_eq!(
u32::from_le_bytes(linked[4..8].try_into().unwrap()),
BLR_X16
);
}
#[test]
fn classify_branches() {
assert!(is_b(b(1)));
assert!(!is_bl(b(1)));
assert!(is_bl(bl(1)));
assert!(!is_b(bl(1)));
assert!(needs_absolute_island(b(1)));
assert!(needs_absolute_island(bl(1)));
assert!(!needs_absolute_island(adr(0, 1)));
assert!(!needs_absolute_island(NOP));
}
#[test]
fn assemble_trampoline_relocates_prologue() {
let stp = 0xA9BF_7BFDu32;
let mov = 0x9100_03FDu32;
let adrp_insn = adrp(8, 0x20);
let mut prologue = Vec::new();
for insn in [stp, mov, adrp_insn, NOP] {
prologue.extend_from_slice(&insn.to_le_bytes());
}
let src_base = 0x140_0010_0000u64;
let dst_base = 0x140_0030_0000u64;
let resume = src_base + STOLEN_BYTES as u64;
let body = assemble_trampoline(&prologue, src_base, dst_base, resume).expect("relocatable");
assert_eq!(body.len(), 32);
assert_eq!(u32::from_le_bytes(body[0..4].try_into().unwrap()), stp);
assert_eq!(u32::from_le_bytes(body[4..8].try_into().unwrap()), mov);
assert_eq!(u32::from_le_bytes(body[12..16].try_into().unwrap()), NOP);
let orig_target = adr_target(adrp_insn, src_base + 8, true);
let reloc_adrp = u32::from_le_bytes(body[8..12].try_into().unwrap());
assert_eq!(adr_target(reloc_adrp, dst_base + 8, true), orig_target);
assert_eq!(
u32::from_le_bytes(body[16..20].try_into().unwrap()),
LDR_X16_PC8
);
assert_eq!(u32::from_le_bytes(body[20..24].try_into().unwrap()), BR_X16);
assert_eq!(u64::from_le_bytes(body[24..32].try_into().unwrap()), resume);
}
#[test]
fn assemble_trampoline_promotes_leading_branch() {
let lead_b = b(0x4000);
let mut prologue = Vec::new();
for insn in [lead_b, NOP, NOP, NOP] {
prologue.extend_from_slice(&insn.to_le_bytes());
}
let src_base = 0x140_0010_0000u64;
let dst_base = 0x0000_7000_0000u64;
let resume = src_base + STOLEN_BYTES as u64;
let body = assemble_trampoline(&prologue, src_base, dst_base, resume).expect("island path");
assert_eq!(body.len(), 48);
let first = u32::from_le_bytes(body[0..4].try_into().unwrap());
assert!(is_b(first));
let island_addr = branch_target(first, dst_base).unwrap();
assert_eq!(island_addr, dst_base + 16 + 16);
let original_b_target = branch_target(lead_b, src_base).unwrap();
assert_eq!(
u32::from_le_bytes(body[32..36].try_into().unwrap()),
LDR_X16_PC8
);
assert_eq!(u32::from_le_bytes(body[36..40].try_into().unwrap()), BR_X16);
assert_eq!(
u64::from_le_bytes(body[40..48].try_into().unwrap()),
original_b_target
);
}
#[test]
fn assemble_trampoline_promotes_leading_bl_with_blr() {
let lead_bl = bl(0x100);
let mut prologue = Vec::new();
for insn in [lead_bl, NOP, NOP, NOP] {
prologue.extend_from_slice(&insn.to_le_bytes());
}
let src_base = 0x140_0010_0000u64;
let dst_base = 0x0000_7000_0000u64;
let resume = src_base + STOLEN_BYTES as u64;
let body = assemble_trampoline(&prologue, src_base, dst_base, resume).expect("island path");
assert_eq!(body.len(), 48);
let first = u32::from_le_bytes(body[0..4].try_into().unwrap());
assert!(is_bl(first));
let original = branch_target(lead_bl, src_base).unwrap();
assert_eq!(
u32::from_le_bytes(body[36..40].try_into().unwrap()),
BLR_X16
);
assert_eq!(
u64::from_le_bytes(body[40..48].try_into().unwrap()),
original
);
}
#[test]
fn assemble_trampoline_two_islands() {
let b0 = b(0x10);
let bl1 = bl(0x20);
let mut prologue = Vec::new();
for insn in [b0, NOP, bl1, NOP] {
prologue.extend_from_slice(&insn.to_le_bytes());
}
let src_base = 0x140_0010_0000u64;
let dst_base = 0x0000_7000_0000u64;
let resume = src_base + STOLEN_BYTES as u64;
let body = assemble_trampoline(&prologue, src_base, dst_base, resume).expect("islands");
assert_eq!(body.len(), 64);
let first = u32::from_le_bytes(body[0..4].try_into().unwrap());
let third = u32::from_le_bytes(body[8..12].try_into().unwrap());
let island0 = branch_target(first, dst_base).unwrap();
let island1 = branch_target(third, dst_base + 8).unwrap();
assert_eq!(island0, dst_base + 32);
assert_eq!(island1, dst_base + 48);
assert_eq!(
u64::from_le_bytes(body[40..48].try_into().unwrap()),
branch_target(b0, src_base).unwrap()
);
assert_eq!(
u64::from_le_bytes(body[56..64].try_into().unwrap()),
branch_target(bl1, src_base + 8).unwrap()
);
}
#[test]
fn assemble_trampoline_refuses_unrelocatable_narrow_branch() {
let cond = bcond(0x2, 0x10);
let mut prologue = Vec::new();
for insn in [NOP, cond, NOP, NOP] {
prologue.extend_from_slice(&insn.to_le_bytes());
}
let src_base = 0x0u64;
let dst_base = 0x0000_0080_0000u64;
let resume = src_base + STOLEN_BYTES as u64;
assert_eq!(
assemble_trampoline(&prologue, src_base, dst_base, resume),
None
);
}
#[test]
fn relocated_prologue_matches_assemble_prefix() {
let stp = 0xA9BF_7BFDu32;
let mut prologue = Vec::new();
for insn in [stp, NOP, NOP, NOP] {
prologue.extend_from_slice(&insn.to_le_bytes());
}
let src_base = 0x140_0010_0000u64;
let dst_base = 0x140_0030_0000u64;
let pro = relocated_prologue(&prologue, src_base, dst_base).expect("ok");
assert_eq!(pro.len(), 16);
assert_eq!(u32::from_le_bytes(pro[0..4].try_into().unwrap()), stp);
}
#[test]
fn island_holds_absolute_branch_to_original_target() {
let lead_bl = bl(0x100);
let src_base = 0x140_0010_0000u64;
let original_target = branch_target(lead_bl, src_base).unwrap();
let island = island_for_branch(lead_bl, src_base).expect("island");
assert_eq!(island.len(), 16);
assert_eq!(
u32::from_le_bytes(island[4..8].try_into().unwrap()),
BLR_X16
);
assert_eq!(
u64::from_le_bytes(island[8..16].try_into().unwrap()),
original_target
);
}
#[test]
fn stolen_bytes_is_four_instructions() {
assert_eq!(STOLEN_BYTES, 16);
assert_eq!(STOLEN_BYTES % 4, 0);
}
#[test]
fn encode_imm26_rejects_unaligned_and_overflow() {
assert_eq!(encode_imm26(3), None);
assert_eq!(encode_imm26(4), Some(1));
assert_eq!(encode_imm26(-4), Some((-1i32 as u32) & 0x03FF_FFFF));
assert_eq!(encode_imm26(1 << 27), None);
}
+1 -31
View File
@@ -2,9 +2,7 @@
import {EventEmitter} from 'node:events';
export type GameCaptureInjectionMethod = 'auto' | 'remote-thread' | 'set-windows-hook';
export type CaptureStrategyName = 'game-hook' | 'wgc' | 'dxgi-duplication' | 'window-gdi';
export type CaptureStrategyName = 'wgc' | 'dxgi-duplication' | 'window-gdi';
export interface ScreenCaptureRect {
x: number;
@@ -19,9 +17,6 @@ export interface ScreenCaptureOptions {
width?: number;
height?: number;
frameRate?: number;
hookDllPath?: string;
hookDllPathX86?: string;
injectionMethod?: GameCaptureInjectionMethod;
captureId?: string;
colorRange?: 'full' | 'limited';
colorSpace?: 'rec709' | 'srgb';
@@ -66,8 +61,6 @@ export interface CaptureDiagnostics {
droppedFrameCounter: number;
lastPresentTimestampUs: number;
lastError: number;
requestedInjectionMethod: GameCaptureInjectionMethod;
injectionMethod: 'remote-thread' | 'set-windows-hook';
activeStrategy: CaptureStrategyName;
lastFallbackReason: string;
startOptions: ScreenCaptureStartOptionsDiagnostics;
@@ -86,14 +79,6 @@ export interface ScreenCaptureStartOptionsDiagnostics {
unsupportedOptions: Array<'showCursorClicks' | 'captureRect' | 'colorRange' | 'colorSpace'>;
}
export interface SharedTextureHandleInfo {
handle: bigint;
width: number;
height: number;
dxgiFormat: number;
timestampUs: number;
}
export interface EncoderAttachDiagnostics {
attached: boolean;
width: number;
@@ -113,13 +98,6 @@ export interface FrameSinkDiagnostics {
cpuFallbackFramesDropped: number;
}
export interface VulkanLayerRegistrationState {
registered: boolean;
manifestExists: boolean;
dllExists: boolean;
manifestPath: string | null;
}
export declare interface ScreenCapture {
on(event: 'error', listener: (err: Error) => void): this;
on(event: 'closed', listener: () => void): this;
@@ -144,7 +122,6 @@ export declare class ScreenCapture extends EventEmitter {
start(): Promise<ScreenCaptureStartResult | undefined>;
stop(): Promise<void>;
getDiagnostics(): CaptureDiagnostics | null;
getSharedTextureHandle(): SharedTextureHandleInfo | null;
attachEncoder(width: number, height: number, frameRate?: number): void;
detachEncoder(): void;
isEncoderAttached(): boolean;
@@ -156,13 +133,6 @@ export declare class ScreenCapture extends EventEmitter {
export declare function isSupported(): boolean;
export declare function getAvailability(): AvailabilityInfo;
export declare function listSources(): Promise<Array<ScreenCaptureSourceDescriptor>>;
export declare function resolveGameHookPath(): string | null;
export declare function resolveGameHookPathX86(): string | null;
export declare function isGameCaptureHookAvailable(): boolean;
export declare function resolveVulkanLayerManifestPath(): string | null;
export declare function registerVulkanLayerManifest(): boolean;
export declare function unregisterVulkanLayerManifest(): boolean;
export declare function getVulkanLayerRegistrationState(): VulkanLayerRegistrationState;
export declare function parseFallbackRecommendation(message: string | undefined): CaptureStrategyName | 'none' | null;
export declare function elevateGpuSchedulingPriority(processId?: number, priorityClass?: 'high' | 'realtime'): boolean;
export declare function restoreGpuSchedulingPriority(processId?: number): boolean;
+1 -115
View File
@@ -59,97 +59,6 @@ if (process.platform === 'win32') {
});
}
function gameHookFileName(arch) {
switch (arch) {
case 'x64':
return 'fluxer-game-hook.win32-x64-msvc.dll';
case 'ia32':
return 'fluxer-game-hook.win32-ia32-msvc.dll';
case 'arm64':
return 'fluxer-game-hook.win32-arm64-msvc.dll';
default:
return null;
}
}
function resolveGameHookPathForArch(arch, root = nativeRoot) {
if (process.platform !== 'win32') return null;
const fileName = gameHookFileName(arch);
if (!fileName) return null;
const hookPath = join(root, fileName);
return existsSync(hookPath) ? hookPath : null;
}
function resolveGameHookPath(root = nativeRoot) {
return resolveGameHookPathForArch(process.arch, root);
}
function resolveGameHookPathX86(root = nativeRoot) {
return resolveGameHookPathForArch('ia32', root);
}
function vulkanLayerManifestFileName(arch) {
switch (arch) {
case 'x64':
return 'fluxer-vulkan-layer.win32-x64-msvc.json';
case 'ia32':
return 'fluxer-vulkan-layer.win32-ia32-msvc.json';
case 'arm64':
return 'fluxer-vulkan-layer.win32-arm64-msvc.json';
default:
return null;
}
}
function resolveVulkanLayerManifestPath(root = nativeRoot) {
if (process.platform !== 'win32') return null;
const fileName = vulkanLayerManifestFileName(process.arch);
if (!fileName) return null;
const manifestPath = join(root, fileName);
return existsSync(manifestPath) ? manifestPath : null;
}
function isGameCaptureHookAvailable(root = nativeRoot) {
if (typeof binding?.isGameCaptureHookAvailable !== 'function') return false;
if (binding.isGameCaptureHookAvailable() !== true) return false;
return resolveGameHookPath(root) !== null;
}
function registerVulkanLayerManifest(root = nativeRoot) {
if (!binding?.registerVulkanLayerManifest) return false;
if (!isGameCaptureHookAvailable(root)) return false;
const manifestPath = resolveVulkanLayerManifestPath(root);
if (!manifestPath) return false;
binding.registerVulkanLayerManifest(manifestPath);
return true;
}
function unregisterVulkanLayerManifest(root = nativeRoot) {
if (!binding?.unregisterVulkanLayerManifest) return false;
const manifestPath = resolveVulkanLayerManifestPath(root);
if (!manifestPath) return false;
try {
binding.unregisterVulkanLayerManifest(manifestPath);
return true;
} catch (error) {
console.warn('[win-game-capture] unregisterVulkanLayerManifest failed:', error?.message || error);
return false;
}
}
function getVulkanLayerRegistrationState(root = nativeRoot) {
const manifestPath = resolveVulkanLayerManifestPath(root);
if (!binding?.getVulkanLayerRegistrationState) {
return {registered: false, manifestExists: Boolean(manifestPath), dllExists: false, manifestPath};
}
try {
return binding.getVulkanLayerRegistrationState(manifestPath ?? '');
} catch (error) {
console.warn('[win-game-capture] getVulkanLayerRegistrationState failed:', error?.message || error);
return {registered: false, manifestExists: Boolean(manifestPath), dllExists: false, manifestPath};
}
}
class ScreenCapture extends EventEmitter {
constructor(options = {}) {
super();
@@ -161,9 +70,6 @@ class ScreenCapture extends EventEmitter {
this.width = options.width ?? 0;
this.height = options.height ?? 0;
this.frameRate = options.frameRate ?? 30;
this.hookDllPath = options.hookDllPath ?? resolveGameHookPath();
this.hookDllPathX86 = options.hookDllPathX86 ?? resolveGameHookPathX86();
this.injectionMethod = options.injectionMethod ?? undefined;
this.captureId = typeof options.captureId === 'string' ? options.captureId : undefined;
this.colorRange = options.colorRange;
this.colorSpace = options.colorSpace;
@@ -226,9 +132,6 @@ class ScreenCapture extends EventEmitter {
this.width || undefined,
this.height || undefined,
this.frameRate || undefined,
this.sourceKind === 'game' ? this.hookDllPath : undefined,
this.sourceKind === 'game' ? (this.hookDllPathX86 ?? undefined) : undefined,
this.sourceKind === 'game' ? (this.injectionMethod ?? undefined) : undefined,
this.captureId,
{
colorRange: this.colorRange,
@@ -272,16 +175,6 @@ class ScreenCapture extends EventEmitter {
}
}
getSharedTextureHandle() {
if (!this.native || typeof this.native.getSharedTextureHandle !== 'function') return null;
try {
return this.native.getSharedTextureHandle() ?? null;
} catch (error) {
console.warn('[win-game-capture] getSharedTextureHandle failed:', error?.message || error);
return null;
}
}
attachEncoder(width, height) {
if (!this.native || typeof this.native.attachEncoder !== 'function') {
throw new Error(`${MODULE_NAME} native binding does not support encoder attachment`);
@@ -346,7 +239,7 @@ class ScreenCapture extends EventEmitter {
}
}
const FALLBACK_STRATEGY_NAMES = new Set(['game-hook', 'wgc', 'dxgi-duplication', 'window-gdi', 'none']);
const FALLBACK_STRATEGY_NAMES = new Set(['wgc', 'dxgi-duplication', 'window-gdi', 'none']);
function parseFallbackRecommendation(message) {
if (typeof message !== 'string') return null;
const match = message.match(/\[next-strategy=([a-z-]+)\]/);
@@ -439,13 +332,6 @@ function __setBindingForTests(nextBinding) {
module.exports = {
isSupported,
getAvailability,
resolveGameHookPath,
resolveGameHookPathX86,
isGameCaptureHookAvailable,
resolveVulkanLayerManifestPath,
registerVulkanLayerManifest,
unregisterVulkanLayerManifest,
getVulkanLayerRegistrationState,
listSources,
ScreenCapture,
parseFallbackRecommendation,
@@ -22,8 +22,6 @@ function makeFakeBinding() {
const calls = [];
const frameSinkHandleCalls = [];
const priorityCalls = [];
const vulkanCalls = [];
const hookAvailable = {value: false};
const natives = [];
const diagnostics = {
state: 1,
@@ -38,9 +36,7 @@ function makeFakeBinding() {
droppedFrameCounter: 0,
lastPresentTimestampUs: 123456,
lastError: 0,
requestedInjectionMethod: 'auto',
injectionMethod: 'remote-thread',
activeStrategy: 'game-hook',
activeStrategy: 'wgc',
lastFallbackReason: '',
startOptions: {
colorRange: 'full',
@@ -88,27 +84,13 @@ function makeFakeBinding() {
frameSinkHandleCalls.push(handle);
}
start(
sourceId,
sourceKind,
width,
height,
frameRate,
hookDllPath,
hookDllPathX86,
injectionMethod,
captureId,
captureOptions,
) {
start(sourceId, sourceKind, width, height, frameRate, captureId, captureOptions) {
calls.push({
sourceId,
sourceKind,
width,
height,
frameRate,
hookDllPath,
hookDllPathX86,
injectionMethod,
captureId,
captureOptions,
});
@@ -190,19 +172,10 @@ function makeFakeBinding() {
restoreGpuSchedulingPriority: (processId) => {
priorityCalls.push({type: 'restore', processId});
},
isGameCaptureHookAvailable: () => hookAvailable.value,
registerVulkanLayerManifest: (manifestPath) => {
vulkanCalls.push({type: 'register', manifestPath});
},
unregisterVulkanLayerManifest: (manifestPath) => {
vulkanCalls.push({type: 'unregister', manifestPath});
},
},
calls,
frameSinkHandleCalls,
priorityCalls,
vulkanCalls,
hookAvailable,
natives,
diagnostics,
encoderDiagnostics,
@@ -258,74 +231,7 @@ describe('win-game-capture loader wrapper -- binding-absent fallback path', () =
);
});
describe('win-game-capture loader wrapper -- arch path resolvers (platform-portable)', () => {
test('resolveGameHookPath() is null or the host-arch hook path', () => {
const r = winGameCapture.resolveGameHookPath();
assert.ok(
r === null || (typeof r === 'string' && /fluxer-game-hook\.win32-(x64|ia32|arm64)-msvc\.dll$/.test(r)),
`unexpected resolveGameHookPath(): ${r}`,
);
});
test('resolveGameHookPathX86() is null or the ia32 hook path', () => {
const r = winGameCapture.resolveGameHookPathX86();
assert.ok(
r === null || (typeof r === 'string' && r.endsWith('fluxer-game-hook.win32-ia32-msvc.dll')),
`unexpected resolveGameHookPathX86(): ${r}`,
);
});
test('resolveVulkanLayerManifestPath() is null or the host-arch layer manifest path', () => {
const r = winGameCapture.resolveVulkanLayerManifestPath();
assert.ok(
r === null || (typeof r === 'string' && /fluxer-vulkan-layer\.win32-(x64|ia32|arm64)-msvc\.json$/.test(r)),
`unexpected resolveVulkanLayerManifestPath(): ${r}`,
);
});
});
describe('win-game-capture loader wrapper -- injected fake binding', () => {
test('isGameCaptureHookAvailable() follows the native hook flag', {skip: injectionSkip}, () => {
const {binding} = makeFakeBinding();
winGameCapture.__setBindingForTests(binding);
assert.equal(winGameCapture.isGameCaptureHookAvailable(), false);
});
test(
'isGameCaptureHookAvailable() is false when the native binding predates the hook flag',
{skip: injectionSkip},
() => {
const {binding} = makeFakeBinding();
binding.isGameCaptureHookAvailable = undefined;
winGameCapture.__setBindingForTests(binding);
assert.equal(winGameCapture.isGameCaptureHookAvailable(), false);
},
);
test(
'registerVulkanLayerManifest() never touches the registry while hook capture is unavailable',
{skip: injectionSkip},
() => {
const {binding, vulkanCalls} = makeFakeBinding();
winGameCapture.__setBindingForTests(binding);
assert.equal(winGameCapture.registerVulkanLayerManifest(), false);
assert.deepEqual(vulkanCalls, [], 'the native registration entry point must not be called');
},
);
test(
'registerVulkanLayerManifest() still refuses when the hook DLL is missing for this host',
{skip: injectionSkip || (winGameCapture.resolveGameHookPath() !== null && 'host ships a game capture hook DLL')},
() => {
const {binding, hookAvailable, vulkanCalls} = makeFakeBinding();
hookAvailable.value = true;
winGameCapture.__setBindingForTests(binding);
assert.equal(winGameCapture.isGameCaptureHookAvailable(), false);
assert.equal(winGameCapture.registerVulkanLayerManifest(), false);
assert.deepEqual(vulkanCalls, []);
},
);
test(
'listSources() forwards sanitized screen/window sources from the native binding',
{skip: injectionSkip},
@@ -355,7 +261,7 @@ describe('win-game-capture loader wrapper -- injected fake binding', () => {
);
test(
'start() forwards sourceId/kind/dims/frameRate and BOTH hook paths (6th + 7th args)',
'start() forwards sourceId/kind/dims/frameRate/captureId and the capture options',
{skip: injectionSkip},
async () => {
const {binding, calls} = makeFakeBinding();
@@ -366,8 +272,6 @@ describe('win-game-capture loader wrapper -- injected fake binding', () => {
width: 1600,
height: 900,
frameRate: 60,
hookDllPath: 'C:/hooks/fluxer-game-hook.win32-x64-msvc.dll',
hookDllPathX86: 'C:/hooks/fluxer-game-hook.win32-ia32-msvc.dll',
captureId: 'capture-1',
colorRange: 'full',
colorSpace: 'rec709',
@@ -383,9 +287,6 @@ describe('win-game-capture loader wrapper -- injected fake binding', () => {
width: 1600,
height: 900,
frameRate: 60,
hookDllPath: 'C:/hooks/fluxer-game-hook.win32-x64-msvc.dll',
hookDllPathX86: 'C:/hooks/fluxer-game-hook.win32-ia32-msvc.dll',
injectionMethod: undefined,
captureId: 'capture-1',
captureOptions: {
colorRange: 'full',
@@ -401,40 +302,7 @@ describe('win-game-capture loader wrapper -- injected fake binding', () => {
},
);
test(
'start() forwards the injectionMethod option as the 8th arg for game capture',
{skip: injectionSkip},
async () => {
const {binding, calls} = makeFakeBinding();
winGameCapture.__setBindingForTests(binding);
const capture = new winGameCapture.ScreenCapture({
sourceId: '555',
sourceKind: 'game',
hookDllPath: 'C:/hooks/fluxer-game-hook.win32-x64-msvc.dll',
hookDllPathX86: 'C:/hooks/fluxer-game-hook.win32-ia32-msvc.dll',
injectionMethod: 'set-windows-hook',
});
capture.on('error', () => {});
await capture.start();
assert.equal(calls.length, 1);
assert.equal(calls[0].injectionMethod, 'set-windows-hook');
},
);
test('window sourceKind does not forward the injectionMethod', {skip: injectionSkip}, async () => {
const {binding, calls} = makeFakeBinding();
winGameCapture.__setBindingForTests(binding);
const capture = new winGameCapture.ScreenCapture({
sourceId: '42',
sourceKind: 'window',
injectionMethod: 'set-windows-hook',
});
capture.on('error', () => {});
await capture.start();
assert.equal(calls[0].injectionMethod, undefined);
});
test('window sourceKind does not forward hook paths', {skip: injectionSkip}, async () => {
test('native start() takes no hook or injection arguments', {skip: injectionSkip}, async () => {
const {binding, calls} = makeFakeBinding();
winGameCapture.__setBindingForTests(binding);
const capture = new winGameCapture.ScreenCapture({
@@ -445,27 +313,17 @@ describe('win-game-capture loader wrapper -- injected fake binding', () => {
});
capture.on('error', () => {});
await capture.start();
assert.equal(calls.length, 1);
assert.deepEqual(Object.keys(calls[0]), [
'sourceId',
'sourceKind',
'width',
'height',
'frameRate',
'captureId',
'captureOptions',
]);
assert.equal(calls[0].sourceKind, 'window');
assert.equal(calls[0].hookDllPath, undefined);
assert.equal(calls[0].hookDllPathX86, undefined);
});
test('screen sourceKind does not forward hook paths or the injectionMethod', {skip: injectionSkip}, async () => {
const {binding, calls} = makeFakeBinding();
winGameCapture.__setBindingForTests(binding);
const capture = new winGameCapture.ScreenCapture({
sourceId: 'screen:0:0',
sourceKind: 'screen',
hookDllPath: 'C:/hooks/primary.dll',
hookDllPathX86: 'C:/hooks/x86.dll',
injectionMethod: 'set-windows-hook',
});
capture.on('error', () => {});
await capture.start();
assert.equal(calls[0].sourceKind, 'screen');
assert.equal(calls[0].hookDllPath, undefined);
assert.equal(calls[0].hookDllPathX86, undefined);
assert.equal(calls[0].injectionMethod, undefined);
});
test('getDiagnostics() exposes native start option state', {skip: injectionSkip}, () => {
@@ -580,8 +438,6 @@ describe('win-game-capture loader wrapper -- injected fake binding', () => {
const capture = new winGameCapture.ScreenCapture({
sourceId: '7',
sourceKind: 'game',
hookDllPath: '',
hookDllPathX86: '',
});
capture.on('error', () => {});
await capture.start();
@@ -739,8 +595,7 @@ describe('win-game-capture loader wrapper -- injected fake binding', () => {
test('getDiagnostics() surfaces the activeStrategy + lastFallbackReason fields', {skip: injectionSkip}, () => {
const {binding, diagnostics} = makeFakeBinding();
diagnostics.activeStrategy = 'dxgi-duplication';
diagnostics.lastFallbackReason =
'game-hook capture could not inject its capture hook; switching to dxgi-duplication capture';
diagnostics.lastFallbackReason = 'wgc capture stopped delivering frames; switching to dxgi-duplication capture';
winGameCapture.__setBindingForTests(binding);
const capture = new winGameCapture.ScreenCapture({sourceId: '1'});
const snapshot = capture.getDiagnostics();
@@ -788,7 +643,7 @@ describe('win-game-capture loader wrapper -- injected fake binding', () => {
capture.on('error', (err) => errors.push(err));
natives[0].lifecycleCallback([
'error',
'fallback: game-hook -> dxgi-duplication (game-hook capture could not inject its capture hook) [next-strategy=dxgi-duplication]',
'fallback: wgc -> dxgi-duplication (wgc capture stopped delivering frames) [next-strategy=dxgi-duplication]',
]);
assert.equal(errors.length, 1);
assert.ok(errors[0] instanceof Error);
@@ -824,7 +679,7 @@ describe('win-game-capture loader wrapper -- parseFallbackRecommendation', () =>
test('extracts the recommended strategy from a transition error message', () => {
assert.equal(
winGameCapture.parseFallbackRecommendation(
'fallback: game-hook -> window-gdi (reason) [next-strategy=window-gdi]',
'fallback: dxgi-duplication -> window-gdi (reason) [next-strategy=window-gdi]',
),
'window-gdi',
);
@@ -853,7 +708,7 @@ describe('win-game-capture loader wrapper -- parseFallbackRecommendation', () =>
assert.equal(winGameCapture.parseFallbackRecommendation('[next-strategy=wgc]'), 'wgc');
assert.equal(
winGameCapture.parseFallbackRecommendation(
'fallback: game-hook -> wgc (game-hook capture could not inject its capture hook) [next-strategy=wgc]',
'fallback: dxgi-duplication -> wgc (dxgi-duplication capture stopped delivering frames) [next-strategy=wgc]',
),
'wgc',
);
@@ -1,25 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "fluxer_inject_helper"
version = "0.0.0"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
@@ -1,32 +0,0 @@
[package]
name = "fluxer_inject_helper"
version = "0.0.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[workspace]
resolver = "2"
[[bin]]
name = "fluxer-inject-helper"
path = "src/main.rs"
[profile.release]
# Keep the helper tiny: it is a one-shot injector exe shipped alongside the
# hook DLLs, so optimise hard for size and strip symbols.
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = {version = "0.61.2", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_System_Diagnostics_Debug",
"Win32_System_LibraryLoader",
"Win32_System_Memory",
"Win32_System_Threading",
]}
@@ -1,242 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![deny(clippy::all)]
#[cfg(not(target_os = "windows"))]
fn main() {
eprintln!("fluxer-inject-helper is only supported on Windows");
std::process::exit(Stage::Unsupported as i32);
}
#[repr(i32)]
#[derive(Clone, Copy)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
enum Stage {
Success = 0,
BadArgs = 2,
HookMissing = 3,
OpenProcess = 4,
Alloc = 5,
Write = 6,
Kernel32 = 7,
LoadLibraryAddr = 8,
CreateThread = 9,
WaitTimeout = 10,
LoadLibraryFailed = 11,
#[cfg_attr(target_os = "windows", allow(dead_code))]
Unsupported = 64,
}
#[cfg(target_os = "windows")]
fn main() {
let code = win::run();
std::process::exit(code as i32);
}
#[cfg(target_os = "windows")]
mod win {
use super::Stage;
use core::ffi::c_void;
use std::ptr::{null, null_mut};
use windows_sys::Win32::Foundation::{
CloseHandle, GetLastError, HANDLE, INVALID_HANDLE_VALUE, WAIT_ABANDONED, WAIT_OBJECT_0,
};
use windows_sys::Win32::System::Diagnostics::Debug::{OutputDebugStringW, WriteProcessMemory};
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress};
use windows_sys::Win32::System::Memory::{
MEM_COMMIT, MEM_RELEASE, MEM_RESERVE, PAGE_READWRITE, VirtualAllocEx, VirtualFreeEx,
};
use windows_sys::Win32::System::Threading::{
CreateRemoteThread, GetExitCodeThread, INFINITE, OpenProcess, PROCESS_CREATE_THREAD,
PROCESS_QUERY_INFORMATION, PROCESS_VM_OPERATION, PROCESS_VM_READ, PROCESS_VM_WRITE,
WaitForSingleObject,
};
const DEFAULT_TIMEOUT_MS: u32 = 10_000;
struct OwnedHandle(HANDLE);
impl OwnedHandle {
fn raw(&self) -> HANDLE {
self.0
}
}
impl Drop for OwnedHandle {
fn drop(&mut self) {
if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE {
unsafe {
CloseHandle(self.0);
}
}
}
}
fn to_wide(value: &str) -> Vec<u16> {
value.encode_utf16().chain(std::iter::once(0)).collect()
}
fn log(message: &str) {
let text = format!("[fluxer-inject-helper] {message}");
let wide = to_wide(&text);
unsafe {
OutputDebugStringW(wide.as_ptr());
}
eprintln!("{text}");
}
fn fail(stage: Stage, context: &str) -> Stage {
let err = unsafe { GetLastError() };
log(&format!(
"FAILED stage={} ({context}); GetLastError={err}",
stage as i32
));
stage
}
pub(super) fn run() -> Stage {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.len() < 2 || args.len() > 3 {
log(&format!(
"bad args: expected <pid> <hook-dll-path> [timeout-ms], got {} arg(s)",
args.len()
));
return Stage::BadArgs;
}
let Ok(target_pid) = args[0].parse::<u32>() else {
log(&format!("bad args: unparseable pid {:?}", args[0]));
return Stage::BadArgs;
};
if target_pid == 0 {
log("bad args: pid must be non-zero");
return Stage::BadArgs;
}
let hook_path = args[1].as_str();
let timeout_ms = match args.get(2) {
None => DEFAULT_TIMEOUT_MS,
Some(raw) => match raw.parse::<u32>() {
Ok(0) => INFINITE,
Ok(value) => value,
Err(_) => {
log(&format!("bad args: unparseable timeout {raw:?}"));
return Stage::BadArgs;
}
},
};
if !std::path::Path::new(hook_path).exists() {
log(&format!("hook DLL missing: {hook_path}"));
return Stage::HookMissing;
}
log(&format!(
"injecting (pid={target_pid}, hook={hook_path}, timeout_ms={timeout_ms}, \
helper_bits={})",
usize::BITS
));
inject(target_pid, hook_path, timeout_ms)
}
fn inject(target_pid: u32, hook_path: &str, timeout_ms: u32) -> Stage {
let wide_path = to_wide(hook_path);
let path_bytes = wide_path.len() * std::mem::size_of::<u16>();
unsafe {
let process = OpenProcess(
PROCESS_CREATE_THREAD
| PROCESS_VM_OPERATION
| PROCESS_VM_WRITE
| PROCESS_VM_READ
| PROCESS_QUERY_INFORMATION,
0,
target_pid,
);
if process.is_null() {
return fail(Stage::OpenProcess, "OpenProcess returned null");
}
let process = OwnedHandle(process);
let remote_path = VirtualAllocEx(
process.raw(),
null(),
path_bytes,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE,
);
if remote_path.is_null() {
return fail(Stage::Alloc, "VirtualAllocEx returned null");
}
let mut written: usize = 0;
let write_ok = WriteProcessMemory(
process.raw(),
remote_path,
wide_path.as_ptr().cast(),
path_bytes,
&mut written,
) != 0;
if !write_ok || written != path_bytes {
let stage = fail(Stage::Write, "WriteProcessMemory failed/short");
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
return stage;
}
let kernel32_name = to_wide("kernel32.dll");
let kernel32 = GetModuleHandleW(kernel32_name.as_ptr());
if kernel32.is_null() {
let stage = fail(Stage::Kernel32, "GetModuleHandleW(kernel32.dll)");
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
return stage;
}
let load_library = GetProcAddress(kernel32, c"LoadLibraryW".as_ptr().cast());
let Some(load_library) = load_library else {
let stage = fail(Stage::LoadLibraryAddr, "GetProcAddress(LoadLibraryW)");
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
return stage;
};
let start_routine: unsafe extern "system" fn(*mut c_void) -> u32 =
std::mem::transmute(load_library);
let thread = CreateRemoteThread(
process.raw(),
null(),
0,
Some(start_routine),
remote_path,
0,
null_mut(),
);
if thread.is_null() {
let stage = fail(Stage::CreateThread, "CreateRemoteThread returned null");
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
return stage;
}
let thread = OwnedHandle(thread);
let wait = WaitForSingleObject(thread.raw(), timeout_ms);
if wait != WAIT_OBJECT_0 && wait != WAIT_ABANDONED {
let stage = fail(Stage::WaitTimeout, "WaitForSingleObject did not signal");
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
return stage;
}
let mut exit_code: u32 = 0;
let got_exit = GetExitCodeThread(thread.raw(), &mut exit_code) != 0;
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
if !got_exit {
return fail(Stage::LoadLibraryFailed, "GetExitCodeThread failed");
}
if exit_code == 0 {
log("remote LoadLibraryW returned NULL -- DLL failed to load in target");
return Stage::LoadLibraryFailed;
}
log(&format!(
"injection succeeded (remote LoadLibraryW HMODULE low bits={exit_code:#010x})"
));
Stage::Success
}
}
}
@@ -1,610 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![allow(dead_code)]
#[cfg(target_os = "windows")]
use std::path::Path;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InjectionPolicy {
Allow,
ForceCpuReadback,
Deny(String),
}
const HARD_DENY_PROCESS_NAMES: &[(&str, &str)] = &[
("easyanticheat.exe", "EasyAntiCheat"),
("easyanticheat_eos.exe", "EasyAntiCheat"),
("easyanticheat_launcher.exe", "EasyAntiCheat"),
("eac.exe", "EasyAntiCheat"),
("eac_launcher.exe", "EasyAntiCheat"),
("beservice.exe", "BattlEye"),
("beservice_x64.exe", "BattlEye"),
("bedaisy.exe", "BattlEye"),
("be_service.exe", "BattlEye"),
("vgc.exe", "Riot Vanguard"),
("vgk.exe", "Riot Vanguard"),
("vgtray.exe", "Riot Vanguard"),
("vanguard.exe", "Riot Vanguard"),
("destiny2.exe", "Destiny 2 anti-cheat policy"),
("equ8.exe", "EQU8 anti-cheat"),
("equ8_service.exe", "EQU8 anti-cheat"),
("gameguard.des", "nProtect GameGuard"),
("gamemon.des", "nProtect GameGuard"),
("gamemon64.des", "nProtect GameGuard"),
("npggnt.des", "nProtect GameGuard"),
("xigncode.exe", "XIGNCODE"),
("xigncode3.exe", "XIGNCODE3"),
("mhyprot.exe", "mhyprot anti-cheat"),
("mhyprot2.exe", "mhyprot anti-cheat"),
("anticheatexpert.exe", "Anti-Cheat Expert"),
("ace-base.exe", "Anti-Cheat Expert"),
("faceit.exe", "FACEIT Anti-cheat"),
("faceitclient.exe", "FACEIT Anti-cheat"),
("faceitservice.exe", "FACEIT Anti-cheat"),
("esea.exe", "ESEA Anti-cheat"),
("eseaclient.exe", "ESEA Anti-cheat"),
("eseaservice.exe", "ESEA Anti-cheat"),
("punkbuster.exe", "PunkBuster"),
("pnkbstra.exe", "PunkBuster"),
("pnkbstrb.exe", "PunkBuster"),
("system", "Windows kernel process"),
("csrss.exe", "Windows system process"),
("smss.exe", "Windows system process"),
("wininit.exe", "Windows system process"),
("winlogon.exe", "Windows system process"),
("services.exe", "Windows system process"),
("svchost.exe", "Windows service host"),
("dwm.exe", "Windows compositor"),
("fontdrvhost.exe", "Windows font driver host"),
("logonui.exe", "Windows secure desktop"),
("consent.exe", "Windows secure desktop"),
("secureuxhost.exe", "Windows secure desktop"),
("lsass.exe", "Windows security process"),
("lsaiso.exe", "Windows security process"),
("msmpeng.exe", "Microsoft Defender"),
("securityhealthservice.exe", "Windows Security"),
("securityhealthsystray.exe", "Windows Security"),
("audiodg.exe", "Windows protected audio graph"),
("wudfhost.exe", "Windows driver host"),
("taskhostw.exe", "Windows task host"),
("dllhost.exe", "Windows COM surrogate"),
("runtimebroker.exe", "Windows runtime broker"),
("applicationframehost.exe", "Windows application frame host"),
("lockapp.exe", "Windows lock screen"),
("sihost.exe", "Windows shell infrastructure"),
("startmenuexperiencehost.exe", "Windows shell"),
("searchhost.exe", "Windows shell"),
("searchapp.exe", "Windows shell"),
("textinputhost.exe", "Windows shell"),
("explorer.exe", "Windows shell"),
("taskmgr.exe", "Windows administrative tool"),
("regedit.exe", "Windows administrative tool"),
("mmc.exe", "Windows administrative tool"),
("obs32.exe", "capture application"),
("obs64.exe", "capture application"),
("fluxer.exe", "Fluxer application"),
("fluxer-desktop.exe", "Fluxer application"),
("fluxer_desktop.exe", "Fluxer application"),
];
const COMPATIBILITY_DENY_PROCESS_NAMES: &[(&str, &str)] = &[
("gta-sa.exe", "legacy D3D8/RenderWare compatibility"),
("samp.exe", "legacy D3D8/RenderWare compatibility"),
("leagueclientux.exe", "League of Legends launcher"),
("steamwebhelper.exe", "Chromium-based launcher"),
("epicgameslauncher.exe", "Chromium-based launcher"),
("riotclientux.exe", "Riot client"),
("riotclientservices.exe", "Riot client"),
("battle.net.exe", "Chromium-based launcher"),
("gamingservices.exe", "Xbox Gaming Services"),
("gamingservicesnet.exe", "Xbox Gaming Services"),
];
const COMPATIBILITY_DENY_WINDOW_CLASSES: &[(&str, &str)] = &[
("chrome_widgetwin_0", "Chromium-based game window"),
("chrome_widgetwin_1", "Chromium-based game window"),
(
"gamingservicesui_hosting_window_class",
"Xbox Gaming Services",
),
];
const FORCE_CPU_PROCESS_NAMES: &[&str] = &["terraria.exe"];
const OVERRIDE_FILE_NAME: &str = "compatibility.json";
pub fn injection_policy(target_pid: u32) -> InjectionPolicy {
let exe_name = match target_process_exe_name(target_pid) {
Some(name) => name,
None => {
return InjectionPolicy::Allow;
}
};
evaluate_policy(&exe_name, None, load_override())
}
#[cfg(target_os = "windows")]
pub fn injection_policy_for_window(
target_pid: u32,
hwnd: windows_sys::Win32::Foundation::HWND,
) -> InjectionPolicy {
let exe_name = match target_process_exe_name(target_pid) {
Some(name) => name,
None => {
return InjectionPolicy::Allow;
}
};
evaluate_policy(
&exe_name,
target_window_class_name(hwnd).as_deref(),
load_override(),
)
}
fn evaluate_policy(
exe_name: &str,
window_class: Option<&str>,
override_lists: Option<OverrideLists>,
) -> InjectionPolicy {
let exe_name_lower = file_name_lower(exe_name);
let exe_name_lower = exe_name_lower.as_str();
let window_class_lower = window_class.map(|name| name.trim().to_ascii_lowercase());
let window_class_lower = window_class_lower.as_deref();
if let Some(lists) = override_lists.as_ref() {
if let Some(reason) = embedded_hard_deny_reason(exe_name_lower) {
return reason;
}
if lists.deny.iter().any(|name| name == exe_name_lower) {
return InjectionPolicy::Deny(format!(
"{exe_name_lower} is on the local compatibility deny list; Fluxer will not inject \
its game-capture hook"
));
}
let allowed_by_override = lists.allow.iter().any(|name| name == exe_name_lower);
if !allowed_by_override
&& let Some(reason) = embedded_compatibility_deny_reason(exe_name_lower)
{
return reason;
}
if !allowed_by_override
&& let Some(reason) = embedded_window_class_deny_reason(window_class_lower)
{
return reason;
}
if lists.force_cpu.iter().any(|name| name == exe_name_lower) {
return InjectionPolicy::ForceCpuReadback;
}
if allowed_by_override {
return InjectionPolicy::Allow;
}
} else {
if let Some(reason) = embedded_hard_deny_reason(exe_name_lower) {
return reason;
}
if let Some(reason) = embedded_compatibility_deny_reason(exe_name_lower) {
return reason;
}
if let Some(reason) = embedded_window_class_deny_reason(window_class_lower) {
return reason;
}
}
if FORCE_CPU_PROCESS_NAMES.contains(&exe_name_lower) {
return InjectionPolicy::ForceCpuReadback;
}
InjectionPolicy::Allow
}
fn embedded_hard_deny_reason(exe_name_lower: &str) -> Option<InjectionPolicy> {
HARD_DENY_PROCESS_NAMES
.iter()
.find(|(name, _)| *name == exe_name_lower)
.map(|(_, label)| {
InjectionPolicy::Deny(format!(
"{exe_name_lower} is protected by {label}; Fluxer will not inject its game-capture \
hook into anti-cheat or security-sensitive processes"
))
})
}
fn embedded_compatibility_deny_reason(exe_name_lower: &str) -> Option<InjectionPolicy> {
COMPATIBILITY_DENY_PROCESS_NAMES
.iter()
.find(|(name, _)| *name == exe_name_lower)
.map(|(_, label)| {
InjectionPolicy::Deny(format!(
"{exe_name_lower} has known game-capture compatibility issues ({label}); Fluxer \
will not inject its game-capture hook by default"
))
})
}
fn embedded_window_class_deny_reason(window_class_lower: Option<&str>) -> Option<InjectionPolicy> {
let window_class_lower = window_class_lower?;
COMPATIBILITY_DENY_WINDOW_CLASSES
.iter()
.find(|(name, _)| *name == window_class_lower)
.map(|(_, label)| {
InjectionPolicy::Deny(format!(
"window class {window_class_lower} has known OBS game-capture compatibility issues \
({label}); Fluxer will not inject its game-capture hook by default"
))
})
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct OverrideLists {
deny: Vec<String>,
allow: Vec<String>,
force_cpu: Vec<String>,
}
impl OverrideLists {
fn is_empty(&self) -> bool {
self.deny.is_empty() && self.allow.is_empty() && self.force_cpu.is_empty()
}
}
fn load_override() -> Option<OverrideLists> {
let path = override_file_path()?;
let contents = std::fs::read_to_string(&path).ok()?;
let lists = parse_override_json(&contents);
if lists.is_empty() { None } else { Some(lists) }
}
fn override_file_path() -> Option<PathBuf> {
if let Some(dir) = addon_directory() {
let candidate = dir.join(OVERRIDE_FILE_NAME);
if candidate.is_file() {
return Some(candidate);
}
}
let exe_dir = std::env::current_exe().ok()?.parent()?.to_path_buf();
let candidate = exe_dir.join(OVERRIDE_FILE_NAME);
if candidate.is_file() {
Some(candidate)
} else {
None
}
}
#[cfg(target_os = "windows")]
fn addon_directory() -> Option<PathBuf> {
use std::os::windows::ffi::OsStringExt;
use windows_sys::Win32::Foundation::HMODULE;
use windows_sys::Win32::System::LibraryLoader::{
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
GetModuleFileNameW, GetModuleHandleExW,
};
let mut module: HMODULE = std::ptr::null_mut();
let ok = unsafe {
GetModuleHandleExW(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
addon_directory as *const u16,
&mut module,
)
};
if ok == 0 || module.is_null() {
return None;
}
let mut buffer = vec![0u16; 1024];
let len = unsafe { GetModuleFileNameW(module, buffer.as_mut_ptr(), buffer.len() as u32) };
if len == 0 || len as usize >= buffer.len() {
return None;
}
buffer.truncate(len as usize);
let module_path = PathBuf::from(std::ffi::OsString::from_wide(&buffer));
module_path.parent().map(Path::to_path_buf)
}
#[cfg(not(target_os = "windows"))]
fn addon_directory() -> Option<PathBuf> {
None
}
#[cfg(target_os = "windows")]
fn target_process_exe_name(pid: u32) -> Option<String> {
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION,
QueryFullProcessImageNameW,
};
if pid == 0 {
return None;
}
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
return None;
}
let mut buffer = vec![0u16; 1024];
let mut size = buffer.len() as u32;
let ok = unsafe {
QueryFullProcessImageNameW(handle, PROCESS_NAME_WIN32, buffer.as_mut_ptr(), &mut size)
};
unsafe {
CloseHandle(handle);
}
if ok == 0 || size == 0 || size as usize > buffer.len() {
return None;
}
let full_path: String = String::from_utf16_lossy(&buffer[..size as usize]);
Some(file_name_lower(&full_path))
}
#[cfg(target_os = "windows")]
fn target_window_class_name(hwnd: windows_sys::Win32::Foundation::HWND) -> Option<String> {
use windows_sys::Win32::UI::WindowsAndMessaging::GetClassNameW;
if hwnd.is_null() {
return None;
}
let mut buffer = vec![0u16; 256];
let len = unsafe { GetClassNameW(hwnd, buffer.as_mut_ptr(), buffer.len() as i32) };
if len <= 0 {
return None;
}
buffer.truncate(len as usize);
Some(
String::from_utf16_lossy(&buffer)
.trim()
.to_ascii_lowercase(),
)
}
#[cfg(not(target_os = "windows"))]
fn target_process_exe_name(_pid: u32) -> Option<String> {
None
}
fn file_name_lower(path: &str) -> String {
path.rsplit(['\\', '/'])
.next()
.unwrap_or(path)
.trim()
.to_ascii_lowercase()
}
fn parse_override_json(text: &str) -> OverrideLists {
let mut force_cpu = extract_string_array(text, "forceCpu");
force_cpu.extend(extract_string_array(text, "force_cpu"));
OverrideLists {
deny: extract_string_array(text, "deny"),
allow: extract_string_array(text, "allow"),
force_cpu,
}
}
fn extract_string_array(text: &str, key: &str) -> Vec<String> {
let needle = format!("\"{key}\"");
let mut search_from = 0usize;
while let Some(rel) = text[search_from..].find(&needle) {
let key_pos = search_from + rel;
let after_key = key_pos + needle.len();
let rest = text[after_key..].trim_start();
if let Some(rest) = rest.strip_prefix(':') {
let rest = rest.trim_start();
if let Some(array_body) = rest.strip_prefix('[')
&& let Some(end) = array_body.find(']')
{
return parse_json_string_list(&array_body[..end]);
}
}
search_from = after_key;
}
Vec::new()
}
fn parse_json_string_list(body: &str) -> Vec<String> {
let mut out = Vec::new();
let mut chars = body.char_indices().peekable();
while let Some((_, ch)) = chars.next() {
if ch != '"' {
continue;
}
let mut value = String::new();
let mut closed = false;
while let Some((_, c)) = chars.next() {
match c {
'\\' => {
if let Some((_, escaped)) = chars.next() {
match escaped {
'n' => value.push('\n'),
't' => value.push('\t'),
'r' => value.push('\r'),
other => value.push(other),
}
}
}
'"' => {
closed = true;
break;
}
other => value.push(other),
}
}
if closed {
let normalised = file_name_lower(&value);
if !normalised.is_empty() {
out.push(normalised);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_deny_matches_anticheat() {
match evaluate_policy("easyanticheat.exe", None, None) {
InjectionPolicy::Deny(reason) => assert!(reason.contains("EasyAntiCheat")),
other => panic!("expected Deny, got {other:?}"),
}
match evaluate_policy("beservice.exe", None, None) {
InjectionPolicy::Deny(reason) => assert!(reason.contains("BattlEye")),
other => panic!("expected Deny, got {other:?}"),
}
match evaluate_policy("vgc.exe", None, None) {
InjectionPolicy::Deny(reason) => assert!(reason.contains("Vanguard")),
other => panic!("expected Deny, got {other:?}"),
}
match evaluate_policy("destiny2.exe", None, None) {
InjectionPolicy::Deny(reason) => assert!(reason.contains("Destiny 2")),
other => panic!("expected Deny, got {other:?}"),
}
}
#[test]
fn embedded_deny_matches_security_sensitive_windows_processes() {
for name in [
"lsass.exe",
"dwm.exe",
"explorer.exe",
"applicationframehost.exe",
"obs64.exe",
"fluxer-desktop.exe",
] {
match evaluate_policy(name, None, None) {
InjectionPolicy::Deny(reason) => {
assert!(reason.contains("security-sensitive") || reason.contains("protected"))
}
other => panic!("expected Deny for {name}, got {other:?}"),
}
}
}
#[test]
fn unknown_process_is_allowed() {
assert_eq!(
evaluate_policy("mygame.exe", None, None),
InjectionPolicy::Allow
);
}
#[test]
fn override_deny_wins() {
let lists = parse_override_json(r#"{ "deny": ["MyGame.exe"] }"#);
match evaluate_policy("mygame.exe", None, Some(lists)) {
InjectionPolicy::Deny(_) => {}
other => panic!("expected Deny, got {other:?}"),
}
}
#[test]
fn override_allow_cannot_unblock_hard_deny() {
let lists = parse_override_json(r#"{ "allow": ["easyanticheat.exe"] }"#);
match evaluate_policy("easyanticheat.exe", None, Some(lists)) {
InjectionPolicy::Deny(reason) => assert!(reason.contains("EasyAntiCheat")),
other => panic!("expected hard Deny, got {other:?}"),
}
}
#[test]
fn override_allow_unblocks_soft_compatibility_deny() {
let lists = parse_override_json(r#"{ "allow": ["LeagueClientUx.exe"] }"#);
assert_eq!(
evaluate_policy("LeagueClientUx.exe", None, Some(lists)),
InjectionPolicy::Allow
);
}
#[test]
fn override_allow_plus_force_cpu_unblocks_soft_deny_with_cpu_readback() {
let lists = parse_override_json(
r#"{ "allow": ["LeagueClientUx.exe"], "forceCpu": ["LeagueClientUx.exe"] }"#,
);
assert_eq!(
evaluate_policy("LeagueClientUx.exe", None, Some(lists)),
InjectionPolicy::ForceCpuReadback
);
}
#[test]
fn override_force_cpu_applies() {
let lists = parse_override_json(r#"{ "forceCpu": ["weird.exe"] }"#);
assert_eq!(
evaluate_policy("weird.exe", None, Some(lists)),
InjectionPolicy::ForceCpuReadback
);
}
#[test]
fn embedded_force_cpu_applies_for_known_cross_adapter_case() {
assert_eq!(
evaluate_policy("Terraria.exe", None, None),
InjectionPolicy::ForceCpuReadback
);
}
#[test]
fn local_force_cpu_cannot_override_hard_deny() {
let lists = parse_override_json(r#"{ "forceCpu": ["lsass.exe"] }"#);
match evaluate_policy("lsass.exe", None, Some(lists)) {
InjectionPolicy::Deny(reason) => assert!(reason.contains("security-sensitive")),
other => panic!("expected hard Deny, got {other:?}"),
}
}
#[test]
fn force_cpu_snake_case_alias_parses() {
let lists = parse_override_json(r#"{ "force_cpu": ["weird.exe"] }"#);
assert!(lists.force_cpu.contains(&"weird.exe".to_string()));
}
#[test]
fn embedded_window_class_deny_matches_obs_chromium_game_windows() {
match evaluate_policy("game.exe", Some("Chrome_WidgetWin_1"), None) {
InjectionPolicy::Deny(reason) => assert!(reason.contains("Chromium")),
other => panic!("expected Deny, got {other:?}"),
}
}
#[test]
fn embedded_window_class_deny_matches_xbox_gaming_services() {
match evaluate_policy(
"game.exe",
Some("GamingServicesUI_Hosting_Window_Class"),
None,
) {
InjectionPolicy::Deny(reason) => assert!(reason.contains("Xbox Gaming Services")),
other => panic!("expected Deny, got {other:?}"),
}
}
#[test]
fn override_allow_unblocks_soft_window_class_deny() {
let lists = parse_override_json(r#"{ "allow": ["game.exe"] }"#);
assert_eq!(
evaluate_policy("game.exe", Some("Chrome_WidgetWin_0"), Some(lists)),
InjectionPolicy::Allow
);
}
#[test]
fn parser_normalises_paths_and_ignores_garbage() {
let lists = parse_override_json(
r#"{ "deny": ["C:\\Games\\Foo\\Foo.exe", "/opt/bar/Bar.EXE", 123, null] }"#,
);
assert_eq!(lists.deny, vec!["foo.exe", "bar.exe"]);
}
#[test]
fn malformed_json_yields_empty() {
let lists = parse_override_json("not json at all");
assert!(lists.is_empty());
}
#[test]
fn file_name_lower_handles_both_separators() {
assert_eq!(file_name_lower("C:\\A\\B\\Game.EXE"), "game.exe");
assert_eq!(file_name_lower("/a/b/Game.EXE"), "game.exe");
assert_eq!(file_name_lower("bare.exe"), "bare.exe");
}
}
@@ -393,16 +393,6 @@ 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()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros() as i64)
.unwrap_or(0)
}
#[cfg(target_os = "windows")]
pub(crate) fn capture_timestamp_us(capture_start: std::time::Instant) -> i64 {
let elapsed_us = capture_start.elapsed().as_micros();
File diff suppressed because it is too large Load Diff
@@ -5,16 +5,12 @@
#[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(all(target_os = "windows", feature = "game-capture-hook"))]
mod game_capture;
mod game_capture_abi;
mod gpu_priority;
mod hdr;
@@ -23,8 +19,6 @@ mod nv12_gpu;
mod sources;
#[cfg(any(target_os = "windows", test))]
mod stall;
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
mod vulkan_layer_registry;
#[cfg(target_os = "windows")]
mod wgc_capture;
@@ -43,8 +37,6 @@ use dxgi_capture::DxgiCaptureSession;
use fluxer_encoder_ring::EncoderFrameRate;
#[cfg(target_os = "windows")]
use fluxer_screen_frame_bus::EnqueueOutcome;
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
use game_capture::GameCaptureSession;
#[cfg(target_os = "windows")]
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
#[cfg(target_os = "windows")]
@@ -152,10 +144,6 @@ pub struct CaptureDiagnostics {
pub last_present_timestamp_us: f64,
#[napi(js_name = "lastError")]
pub last_error: u32,
#[napi(js_name = "requestedInjectionMethod")]
pub requested_injection_method: String,
#[napi(js_name = "injectionMethod")]
pub injection_method: String,
#[napi(js_name = "activeStrategy")]
pub active_strategy: String,
#[napi(js_name = "lastFallbackReason")]
@@ -201,36 +189,12 @@ pub struct FrameSinkDiagnostics {
pub cpu_fallback_frames_dropped: f64,
}
#[napi(object)]
pub struct SharedTextureHandleInfo {
pub handle: BigInt,
pub width: u32,
pub height: u32,
#[napi(js_name = "dxgiFormat")]
pub dxgi_format: u32,
#[napi(js_name = "timestampUs")]
pub timestamp_us: f64,
}
#[napi(object)]
pub struct VulkanLayerRegistrationState {
pub registered: bool,
#[napi(js_name = "manifestExists")]
pub manifest_exists: bool,
#[napi(js_name = "dllExists")]
pub dll_exists: bool,
#[napi(js_name = "manifestPath")]
pub manifest_path: String,
}
pub struct CaptureInner {
pub lifecycle_tsfn: Mutex<Option<LifecycleTsfn>>,
#[cfg(target_os = "windows")]
pub session: Mutex<Option<DxgiCaptureSession>>,
#[cfg(target_os = "windows")]
pub(crate) wgc_session: Mutex<Option<WgcCaptureSession>>,
#[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>>,
pub capture_id: Mutex<Option<String>>,
@@ -423,18 +387,6 @@ pub(crate) fn note_media_frame_without_sink(inner: &CaptureInner, message: &'sta
emit_lifecycle(inner, "diagnostic", message);
}
#[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
.fetch_add(1, Ordering::AcqRel);
if inner.cpu_fallback_emitted.swap(true, Ordering::AcqRel) {
return;
}
emit_lifecycle(inner, "diagnostic", message);
}
#[cfg(target_os = "windows")]
fn native_frame_sink_for(
inner: &CaptureInner,
@@ -474,8 +426,6 @@ impl ScreenCapture {
session: Mutex::new(None),
#[cfg(target_os = "windows")]
wgc_session: Mutex::new(None),
#[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),
capture_id: Mutex::new(None),
@@ -533,9 +483,6 @@ impl ScreenCapture {
width: Option<u32>,
height: Option<u32>,
frame_rate: Option<u32>,
hook_path: Option<String>,
hook_path_x86: Option<String>,
injection_method: Option<String>,
capture_id: Option<String>,
start_options: Option<ScreenCaptureStartOptions>,
) -> Result<CaptureStartResult> {
@@ -555,9 +502,6 @@ impl ScreenCapture {
width,
height,
frame_rate,
hook_path,
hook_path_x86,
injection_method,
start_options,
)
}
@@ -569,9 +513,6 @@ impl ScreenCapture {
width,
height,
frame_rate,
hook_path,
hook_path_x86,
injection_method,
start_options,
);
Err(napi::Error::from_reason(
@@ -590,53 +531,8 @@ impl ScreenCapture {
#[cfg(target_os = "windows")]
{
let frame_sink = frame_sink_counter_snapshot(&self.inner);
#[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,
current_start_options(&self.inner),
frame_sink,
));
}
}
Some(strategy_only_diagnostics(
&snapshot,
String::new(),
String::new(),
current_start_options(&self.inner),
frame_sink,
))
@@ -645,8 +541,6 @@ impl ScreenCapture {
#[cfg(not(target_os = "windows"))]
Some(strategy_only_diagnostics(
&snapshot,
String::new(),
String::new(),
current_start_options(&self.inner),
FrameSinkCounterSnapshot {
accepted: 0,
@@ -676,29 +570,6 @@ impl ScreenCapture {
}
}
#[napi(js_name = "getSharedTextureHandle")]
pub fn get_shared_texture_handle(&self) -> Option<SharedTextureHandleInfo> {
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
{
let guard = self.inner.game_session.lock();
let session = guard.as_ref()?;
if let Some(native_texture) = session.read_native_texture_info() {
return Some(SharedTextureHandleInfo {
handle: BigInt::from(native_texture.handle),
width: native_texture.width,
height: native_texture.height,
dxgi_format: native_texture.dxgi_format,
timestamp_us: native_texture.timestamp_us as f64,
});
}
None
}
#[cfg(not(all(target_os = "windows", feature = "game-capture-hook")))]
{
None
}
}
#[napi]
pub fn stop(&self) -> Result<()> {
self.inner
@@ -716,11 +587,6 @@ impl ScreenCapture {
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;
}
{
let mut fallback_guard = self.inner.fallback.lock();
*fallback_guard = None;
@@ -956,9 +822,6 @@ impl ScreenCapture {
width: Option<u32>,
height: Option<u32>,
frame_rate: Option<u32>,
hook_path: Option<String>,
hook_path_x86: Option<String>,
injection_method: Option<String>,
_start_options: CaptureStartOptionsDiagnostics,
) -> Result<CaptureStartResult> {
use std::sync::atomic::Ordering;
@@ -967,21 +830,6 @@ impl ScreenCapture {
return Err(napi::Error::from_reason("Capture already running"));
}
#[cfg(feature = "game-capture-hook")]
if source_kind == "game" {
return self.start_windows_game(
source_id,
source_kind,
width,
height,
frame_rate,
hook_path,
hook_path_x86,
injection_method,
_start_options,
);
}
let _ = (hook_path, hook_path_x86, injection_method);
let target_frame_rate = frame_rate.unwrap_or(30).clamp(1, 144);
let frame_interval =
@@ -1127,86 +975,10 @@ impl ScreenCapture {
pixel_format: "bgra".to_string(),
})
}
#[cfg(feature = "game-capture-hook")]
#[allow(clippy::too_many_arguments)]
fn start_windows_game(
&self,
source_id: String,
source_kind: String,
width: Option<u32>,
height: Option<u32>,
frame_rate: Option<u32>,
hook_path: Option<String>,
hook_path_x86: Option<String>,
injection_method: Option<String>,
_start_options: CaptureStartOptionsDiagnostics,
) -> Result<CaptureStartResult> {
use std::sync::atomic::Ordering;
if game_capture_abi::env_flag_enabled(game_capture_abi::ENV_DISABLE_HOOK) {
return Err(napi::Error::from_reason(
"game capture hook disabled via FLUXER_GAME_CAPTURE_DISABLE_HOOK",
));
}
let hook_path = hook_path
.ok_or_else(|| napi::Error::from_reason("missing game capture hook DLL path"))?;
let target_frame_rate = frame_rate.unwrap_or(30).clamp(1, 144);
let session = GameCaptureSession::new(
&source_id,
&source_kind,
width,
height,
target_frame_rate,
&hook_path,
hook_path_x86.as_deref(),
injection_method.as_deref(),
)
.map_err(|e| napi::Error::from_reason(format!("Failed to create game capture: {e}")))?;
let capture_width = session.capture_width();
let capture_height = session.capture_height();
let session = Arc::new(session);
{
let mut guard = self.inner.game_session.lock();
*guard = Some(session);
}
{
let mut guard = self.inner.fallback.lock();
*guard = Some(fallback::FallbackTracker::new(
fallback::CaptureStrategy::GameHook,
));
}
self.inner.running.store(true, Ordering::Release);
let inner = Arc::clone(&self.inner);
let frame_interval =
std::time::Duration::from_nanos(1_000_000_000 / target_frame_rate as u64);
std::thread::Builder::new()
.name("game-capture".into())
.spawn(move || {
game_capture::capture_loop(&inner, frame_interval);
})
.map_err(|e| {
napi::Error::from_reason(format!("Failed to spawn game capture thread: {e}"))
})?;
Ok(CaptureStartResult {
width: capture_width,
height: capture_height,
frame_rate: target_frame_rate,
pixel_format: "bgra".to_string(),
})
}
}
fn strategy_only_diagnostics(
snapshot: &fallback::FallbackSnapshot,
requested_injection_method: String,
injection_method: String,
start_options: CaptureStartOptionsDiagnostics,
frame_sink: FrameSinkCounterSnapshot,
) -> CaptureDiagnostics {
@@ -1223,8 +995,6 @@ fn strategy_only_diagnostics(
dropped_frame_counter: 0.0,
last_present_timestamp_us: 0.0,
last_error: 0,
requested_injection_method,
injection_method,
active_strategy: snapshot.active_strategy.clone(),
last_fallback_reason: snapshot.last_fallback_reason.clone(),
start_options,
@@ -1290,11 +1060,6 @@ pub fn is_supported() -> bool {
cfg!(target_os = "windows")
}
#[napi(js_name = "isGameCaptureHookAvailable")]
pub fn is_game_capture_hook_available() -> bool {
cfg!(all(target_os = "windows", feature = "game-capture-hook"))
}
#[napi(js_name = "getAvailability")]
pub fn get_availability() -> AvailabilityInfo {
AvailabilityInfo {
@@ -1325,56 +1090,3 @@ pub fn elevate_gpu_scheduling_priority(
pub fn restore_gpu_scheduling_priority(process_id: Option<u32>) -> Result<()> {
gpu_priority::restore(process_id).map_err(napi::Error::from_reason)
}
#[napi(js_name = "registerVulkanLayerManifest")]
pub fn register_vulkan_layer_manifest(manifest_path: String) -> Result<()> {
#[cfg(all(target_os = "windows", feature = "game-capture-hook"))]
{
vulkan_layer_registry::register_manifest(&manifest_path).map_err(napi::Error::from_reason)
}
#[cfg(not(all(target_os = "windows", feature = "game-capture-hook")))]
{
let _ = manifest_path;
Err(napi::Error::from_reason(
"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(all(target_os = "windows", feature = "game-capture-hook"))]
{
vulkan_layer_registry::unregister_manifest(&manifest_path).map_err(napi::Error::from_reason)
}
#[cfg(not(all(target_os = "windows", feature = "game-capture-hook")))]
{
let _ = manifest_path;
Err(napi::Error::from_reason(
"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(all(target_os = "windows", feature = "game-capture-hook"))]
{
let state = vulkan_layer_registry::registration_state(&manifest_path);
VulkanLayerRegistrationState {
registered: state.registered,
manifest_exists: state.manifest_exists,
dll_exists: state.dll_exists,
manifest_path: state.manifest_path,
}
}
#[cfg(not(all(target_os = "windows", feature = "game-capture-hook")))]
{
VulkanLayerRegistrationState {
registered: false,
manifest_exists: false,
dll_exists: false,
manifest_path,
}
}
}
@@ -1,205 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::path::{Path, PathBuf};
use std::ptr::{null, null_mut};
use windows_sys::Win32::{
Foundation::{ERROR_FILE_NOT_FOUND, ERROR_SUCCESS},
System::Registry::{
HKEY, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_QUERY_VALUE, KEY_SET_VALUE, REG_DWORD,
REG_OPTION_NON_VOLATILE, REG_VALUE_TYPE, RegCloseKey, RegCreateKeyExW, RegDeleteValueW,
RegOpenKeyExW, RegQueryValueExW, RegSetValueExW,
},
};
const VULKAN_IMPLICIT_LAYERS_KEY: &str = "Software\\Khronos\\Vulkan\\ImplicitLayers";
const HKCU: HKEY = HKEY_CURRENT_USER;
const HKLM: HKEY = HKEY_LOCAL_MACHINE;
pub struct RegistrationState {
pub registered: bool,
pub manifest_exists: bool,
pub dll_exists: bool,
pub manifest_path: String,
}
fn wide(value: &str) -> Vec<u16> {
value.encode_utf16().chain(std::iter::once(0)).collect()
}
fn manifest_dll_path(manifest_path: &Path) -> Option<PathBuf> {
let dir = manifest_path.parent()?;
if let Ok(contents) = std::fs::read_to_string(manifest_path)
&& let Some(library) = extract_library_path(&contents)
{
let candidate = Path::new(&library);
if candidate.is_absolute() {
return Some(candidate.to_path_buf());
}
return Some(dir.join(candidate));
}
None
}
fn extract_library_path(contents: &str) -> Option<String> {
let key = "\"library_path\"";
let key_pos = contents.find(key)?;
let rest = contents[key_pos + key.len()..].trim_start();
let rest = rest.strip_prefix(':')?.trim_start();
let rest = rest.strip_prefix('"')?;
let end = rest.find('"')?;
let raw = &rest[..end];
Some(raw.replace("\\\\", "\\"))
}
pub fn register_manifest(manifest_path: &str) -> Result<(), String> {
if manifest_path.trim().is_empty() {
return Err("Vulkan layer manifest path is empty".into());
}
let manifest = Path::new(manifest_path);
if !manifest.is_file() {
return Err(format!(
"Vulkan layer manifest does not exist: {}",
manifest.display()
));
}
if let Some(dll) = manifest_dll_path(manifest)
&& !dll.is_file()
{
return Err(format!(
"Vulkan layer DLL referenced by manifest does not exist: {}",
dll.display()
));
}
set_value_under(HKCU, manifest_path)?;
let _ = set_value_under(HKLM, manifest_path);
Ok(())
}
fn set_value_under(root: HKEY, manifest_path: &str) -> Result<(), String> {
let subkey = wide(VULKAN_IMPLICIT_LAYERS_KEY);
let value_name = wide(manifest_path);
let enabled: u32 = 0;
let mut key: HKEY = null_mut();
let create_status = unsafe {
RegCreateKeyExW(
root,
subkey.as_ptr(),
0,
null(),
REG_OPTION_NON_VOLATILE,
KEY_SET_VALUE,
null(),
&mut key,
null_mut(),
)
};
if create_status != ERROR_SUCCESS {
return Err(format!(
"RegCreateKeyExW Vulkan implicit layers failed: {create_status}"
));
}
let set_status = unsafe {
RegSetValueExW(
key,
value_name.as_ptr(),
0,
REG_DWORD,
(&enabled as *const u32).cast(),
std::mem::size_of::<u32>() as u32,
)
};
unsafe {
RegCloseKey(key);
}
if set_status != ERROR_SUCCESS {
return Err(format!(
"RegSetValueExW Vulkan implicit layer manifest failed: {set_status}"
));
}
Ok(())
}
pub fn unregister_manifest(manifest_path: &str) -> Result<(), String> {
if manifest_path.trim().is_empty() {
return Err("Vulkan layer manifest path is empty".into());
}
let hkcu = delete_value_under(HKCU, manifest_path);
let _ = delete_value_under(HKLM, manifest_path);
hkcu
}
fn delete_value_under(root: HKEY, manifest_path: &str) -> Result<(), String> {
let subkey = wide(VULKAN_IMPLICIT_LAYERS_KEY);
let value_name = wide(manifest_path);
let mut key: HKEY = null_mut();
let open_status = unsafe { RegOpenKeyExW(root, subkey.as_ptr(), 0, KEY_SET_VALUE, &mut key) };
if open_status == ERROR_FILE_NOT_FOUND {
return Ok(());
}
if open_status != ERROR_SUCCESS {
return Err(format!(
"RegOpenKeyExW Vulkan implicit layers failed: {open_status}"
));
}
let delete_status = unsafe { RegDeleteValueW(key, value_name.as_ptr()) };
unsafe {
RegCloseKey(key);
}
if delete_status == ERROR_SUCCESS || delete_status == ERROR_FILE_NOT_FOUND {
Ok(())
} else {
Err(format!(
"RegDeleteValueW Vulkan implicit layer manifest failed: {delete_status}"
))
}
}
pub fn registration_state(manifest_path: &str) -> RegistrationState {
let manifest = Path::new(manifest_path);
let manifest_exists = manifest.is_file();
let dll_exists = manifest_dll_path(manifest)
.map(|dll| dll.is_file())
.unwrap_or(false);
let registered = registry_value_present(manifest_path);
RegistrationState {
registered,
manifest_exists,
dll_exists,
manifest_path: manifest_path.to_string(),
}
}
fn registry_value_present(manifest_path: &str) -> bool {
if manifest_path.trim().is_empty() {
return false;
}
registry_value_present_under(HKCU, manifest_path)
|| registry_value_present_under(HKLM, manifest_path)
}
fn registry_value_present_under(root: HKEY, manifest_path: &str) -> bool {
let subkey = wide(VULKAN_IMPLICIT_LAYERS_KEY);
let value_name = wide(manifest_path);
let mut key: HKEY = null_mut();
let open_status = unsafe { RegOpenKeyExW(root, subkey.as_ptr(), 0, KEY_QUERY_VALUE, &mut key) };
if open_status != ERROR_SUCCESS {
return false;
}
let mut value_type: REG_VALUE_TYPE = 0;
let query_status = unsafe {
RegQueryValueExW(
key,
value_name.as_ptr(),
null(),
&mut value_type,
null_mut(),
null_mut(),
)
};
unsafe {
RegCloseKey(key);
}
query_status == ERROR_SUCCESS
}
@@ -1,277 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "ash"
version = "0.38.0+1.3.281"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
[[package]]
name = "ash-layer"
version = "0.0.2+v0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedca11308198ffa300ad1a981c10ee176e44c8ff6ee7c6883a062608b9779a5"
dependencies = [
"ash",
]
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "dashmap"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "fluxer_vulkan_layer"
version = "0.0.0"
dependencies = [
"ash",
"ash-layer",
"dashmap",
"once_cell",
"windows",
"windows-sys",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
@@ -1,31 +0,0 @@
[package]
name = "fluxer_vulkan_layer"
version = "0.0.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[workspace]
resolver = "2"
[lib]
crate-type = ["cdylib"]
[target.'cfg(target_os = "windows")'.dependencies]
ash = {version = "0.38.0", default-features = false, features = ["std", "debug"]}
ash-layer = "0.0.2"
dashmap = "6.2.1"
once_cell = "1.21.4"
windows = {version = "0.62.2", features = [
"Win32_Foundation",
"Win32_Graphics_Direct3D",
"Win32_Graphics_Direct3D11",
"Win32_Graphics_Dxgi",
"Win32_Graphics_Dxgi_Common",
]}
windows-sys = {version = "0.61.2", features = [
"Win32_Foundation",
"Win32_System_Memory",
"Win32_System_Performance",
"Win32_System_Threading",
]}
@@ -1,138 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use ash::vk;
use windows::Win32::Graphics::{
Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN, D3D_DRIVER_TYPE_WARP},
Direct3D11::{
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_CREATE_DEVICE_BGRA_SUPPORT,
D3D11_RESOURCE_MISC_SHARED, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D,
},
Dxgi::{
Common::{
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_B8G8R8A8_UNORM_SRGB,
DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_FORMAT_R8G8B8A8_UNORM_SRGB,
DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC,
},
IDXGIResource,
},
};
use windows::core::Interface;
use crate::game_capture_abi::{GAME_CAPTURE_FLAG_HDR, GAME_CAPTURE_FLAG_TEN_BIT};
#[derive(Clone, Copy)]
pub struct InteropFormat {
pub vk_format: vk::Format,
pub dxgi_format: DXGI_FORMAT,
pub capture_flags: u32,
}
pub fn interop_format(format: vk::Format) -> Option<InteropFormat> {
let (dxgi_format, capture_flags) = match format {
vk::Format::B8G8R8A8_UNORM => (DXGI_FORMAT_B8G8R8A8_UNORM, 0),
vk::Format::B8G8R8A8_SRGB => (DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, 0),
vk::Format::R8G8B8A8_UNORM => (DXGI_FORMAT_R8G8B8A8_UNORM, 0),
vk::Format::R8G8B8A8_SRGB => (DXGI_FORMAT_R8G8B8A8_UNORM_SRGB, 0),
vk::Format::A2B10G10R10_UNORM_PACK32 => (
DXGI_FORMAT_R10G10B10A2_UNORM,
GAME_CAPTURE_FLAG_TEN_BIT | GAME_CAPTURE_FLAG_HDR,
),
vk::Format::R16G16B16A16_SFLOAT => (DXGI_FORMAT_R16G16B16A16_FLOAT, GAME_CAPTURE_FLAG_HDR),
_ => return None,
};
Some(InteropFormat {
vk_format: format,
dxgi_format,
capture_flags,
})
}
pub struct D3d11Device {
device: ID3D11Device,
_context: ID3D11DeviceContext,
}
unsafe impl Send for D3d11Device {}
unsafe impl Sync for D3d11Device {}
pub struct SharedTexture {
_texture: ID3D11Texture2D,
pub handle: u64,
}
unsafe impl Send for SharedTexture {}
unsafe impl Sync for SharedTexture {}
impl D3d11Device {
pub fn create() -> Option<Self> {
for driver in [D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_WARP] {
let mut device: Option<ID3D11Device> = None;
let mut context: Option<ID3D11DeviceContext> = None;
let result = unsafe {
D3D11CreateDevice(
None,
driver,
Default::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
None,
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)
};
if result.is_ok()
&& let (Some(device), Some(context)) = (device, context)
{
return Some(Self {
device,
_context: context,
});
}
}
let _ = D3D_DRIVER_TYPE_UNKNOWN;
None
}
pub fn create_shared_texture(
&self,
width: u32,
height: u32,
format: InteropFormat,
) -> Option<SharedTexture> {
if width == 0 || height == 0 {
return None;
}
let desc = D3D11_TEXTURE2D_DESC {
Width: width,
Height: height,
MipLevels: 1,
ArraySize: 1,
Format: format.dxgi_format,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: (D3D11_BIND_SHADER_RESOURCE.0 | D3D11_BIND_RENDER_TARGET.0) as u32,
CPUAccessFlags: 0,
MiscFlags: D3D11_RESOURCE_MISC_SHARED.0 as u32,
};
let mut texture: Option<ID3D11Texture2D> = None;
let result = unsafe { self.device.CreateTexture2D(&desc, None, Some(&mut texture)) };
if result.is_err() {
return None;
}
let texture = texture?;
let resource: IDXGIResource = texture.cast().ok()?;
let handle = unsafe { resource.GetSharedHandle() }.ok()?;
if handle.is_invalid() {
return None;
}
Some(SharedTexture {
_texture: texture,
handle: handle.0 as usize as u64,
})
}
}
File diff suppressed because it is too large Load Diff
+1 -6
View File
@@ -472,8 +472,6 @@ export interface NativeScreenCaptureSource {
targetPid?: number;
}
export type GameCaptureInjectionMethod = 'auto' | 'remote-thread' | 'set-windows-hook';
export interface NativeScreenCaptureRect {
x: number;
y: number;
@@ -487,7 +485,6 @@ export interface NativeScreenCaptureStartOptions {
width?: number;
height?: number;
frameRate?: number;
injectionMethod?: GameCaptureInjectionMethod;
captureId?: string;
colorRange?: 'full' | 'limited';
colorSpace?: 'rec709' | 'srgb';
@@ -523,7 +520,7 @@ export interface NativeScreenCaptureLifecycleMessage {
source?: NativeScreenCaptureLifecycleSource;
}
export type NativeScreenCaptureStrategy = 'game-hook' | 'wgc' | 'dxgi-duplication' | 'window-gdi' | string;
export type NativeScreenCaptureStrategy = 'wgc' | 'dxgi-duplication' | 'window-gdi' | string;
export interface NativeScreenCaptureDiagnostics {
state?: number;
@@ -538,8 +535,6 @@ export interface NativeScreenCaptureDiagnostics {
droppedFrameCounter?: number;
lastPresentTimestampUs?: number;
lastError?: number;
requestedInjectionMethod?: string;
injectionMethod?: string;
activeStrategy?: NativeScreenCaptureStrategy;
lastFallbackReason?: string;
backend?: string;
@@ -273,7 +273,6 @@ describe('NativeScreenCapture source identity and capability reporting', () => {
width: 2560,
height: 1440,
frameRate: 60,
injectionMethod: undefined,
captureId: 'capture-1',
colorRange: 'full',
colorSpace: 'rec709',
@@ -287,7 +286,6 @@ describe('NativeScreenCapture source identity and capability reporting', () => {
width: 1280,
height: 720,
frameRate: 30,
injectionMethod: undefined,
captureId: 'capture-2',
colorRange: undefined,
colorSpace: undefined,
@@ -352,7 +350,6 @@ describe('NativeScreenCapture source identity and capability reporting', () => {
width: 1280,
height: 720,
frameRate: 30,
injectionMethod: undefined,
captureId: 'preselected-capture-id',
colorRange: undefined,
colorSpace: undefined,
@@ -485,14 +482,12 @@ describe('NativeScreenCapture source identity and capability reporting', () => {
width: 2560,
height: 1440,
frameRate: 60,
injectionMethod: 'set-windows-hook',
nativeFrameSinkRequired: true,
},
);
assert.equal(captures.length, 1);
assert.equal(captures[0].options.sourceKind, 'screen');
assert.equal(captures[0].options.injectionMethod, undefined);
const diagnostics = await harness.handlers.get('native-screen-capture:get-diagnostics')({sender}, result.captureId);
assert.equal(diagnostics.sourceKind, 'screen');
@@ -827,7 +827,6 @@ async function startNativeScreenCapture(
width: requestedWidth,
height: requestedHeight,
frameRate: options.frameRate ?? 30,
injectionMethod: options.sourceKind === 'game' ? options.injectionMethod : undefined,
captureId,
colorRange: options.colorRange,
colorSpace: options.colorSpace,
@@ -86,14 +86,6 @@ export function isValidStartOptions(options: unknown): options is NativeScreenCa
) {
return false;
}
if (
options.injectionMethod !== undefined &&
options.injectionMethod !== 'auto' &&
options.injectionMethod !== 'remote-thread' &&
options.injectionMethod !== 'set-windows-hook'
) {
return false;
}
if (
options.captureId !== undefined &&
(typeof options.captureId !== 'string' ||

Some files were not shown because too many files have changed in this diff Show More