mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-10 04:32:34 +09:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d21c97d08 | ||
|
|
71a56f590d | ||
|
|
38297c4fe7 | ||
|
|
cf7ec06d85 | ||
|
|
f2ea10f951 | ||
|
|
bbd93df239 | ||
|
|
9a6ab93e01 | ||
|
|
38eed7cce6 | ||
|
|
79064c3399 | ||
|
|
0496b2f530 | ||
|
|
9d0be1ebd1 | ||
|
|
9ad026b8ce | ||
|
|
14de5971d5 | ||
|
|
5474be3efa | ||
|
|
9a54bbba2d | ||
|
|
5a0110ccc8 | ||
|
|
d032d577bf | ||
|
|
243954c9c5 |
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
-7
@@ -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':
|
||||
|
||||
-13
@@ -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)}
|
||||
|
||||
-11
@@ -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'},
|
||||
];
|
||||
|
||||
-1
@@ -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';
|
||||
|
||||
Vendored
+1
-6
@@ -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`),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user