Compare commits

...
Author SHA1 Message Date
HampusandGitHub 00bf74cef5 fix(app): handle swapped overwrites when comparing channels (#2949) 2026-09-24 23:38:04 +02:00
HampusandGitHub c1c45d835f fix(app): only parse markdown in rich embeds (#2948) 2026-09-24 22:50:34 +02:00
HampusandGitHub bbfe809bef fix(app): crop animated images on web with libwebp (#2947) 2026-09-24 22:45:53 +02:00
HampusandGitHub e0843ac4f5 fix(app): keep guild folder expansion state local (#2944) 2026-09-24 17:52:33 +02:00
HampusandGitHub 43741cdad8 fix(gateway): always trim the connect snapshot for guild connects (#2943) 2026-09-24 17:09:48 +02:00
HampusandGitHub b8e3807262 Revert "fix(push): deliver direct messages without holding them" (#2942) 2026-09-24 17:09:44 +02:00
HampusandGitHub 3304f01a84 chore(i18n): recompile uk error catalog (#2941) 2026-09-24 17:09:36 +02:00
fluxer-weblate[bot]andGitHub 2ba463235b chore(i18n): update translations from Weblate (#2909) 2026-09-24 16:25:26 +02:00
fluxer-weblate[bot]andGitHub 15136fed59 chore(i18n): update translations from Weblate (#2923) 2026-09-24 16:25:05 +02:00
HampusandGitHub 6013581dd9 fix(push): deliver direct messages without holding them (#2938) 2026-09-24 16:21:42 +02:00
HampusandGitHub 7a91f128e9 fix(app-proxy): drop link preview metadata on self-hosted (#2936) 2026-09-24 16:07:00 +02:00
HampusandGitHub 963ffc5550 feat(push): scope read clears to the enrolled cohort (#2935) 2026-09-24 15:15:45 +02:00
HampusandGitHub a90991612c fix(gateway): truncate reads on an expired outbox entry (#2934) 2026-09-24 15:04:24 +02:00
HampusandGitHub 50ad23b760 fix(api): run the notification extension on every iOS alert (#2933) 2026-09-24 15:04:01 +02:00
HampusandGitHub 425dab983b fix(push): restore iOS avatars and stop misrouting relay endpoints (#2932) 2026-09-24 15:03:32 +02:00
HampusandGitHub a0825e77c4 feat(voice): ship the screen share delivery rework to everyone (#2931) 2026-09-24 14:57:40 +02:00
HampusandGitHub 88038a1d5b fix(voice): stop direct input capturing microphones in stereo (#2929) 2026-09-24 14:51:05 +02:00
HampusandGitHub c2c0fdb445 fix(app): make corner volume control the focused stream (#2928) 2026-09-24 14:04:05 +02:00
HampusandGitHub dcd5f09d6a feat(api): add env toggles for automatic phone flagging (#2927) 2026-09-24 03:36:35 +02:00
HampusandGitHub 590b1f36fd docs(downloads): document the canary apt and dnf repositories (#2926) 2026-09-24 03:29:52 +02:00
HampusandGitHub 168ac727f1 fix(desktop): set the deb package synopsis (#2925) 2026-09-24 03:29:33 +02:00
HampusandGitHub deb86dd92e fix(admin): format users list search hint (#2924) 2026-09-24 02:12:37 +02:00
omsterandGitHub 7ccec4d3b8 feat(admin): hint text for * search in user page (#2922) 2026-09-24 01:56:17 +02:00
omsterandGitHub 2f38bcdf26 fix(admin): ordering fixes for admin user search and meilisearch (#2920) 2026-09-24 01:45:56 +02:00
HampusandGitHub f2785941aa fix(app): point self-hosted users at their instance admins (#2921) 2026-09-24 01:42:33 +02:00
HampusandGitHub ea9f83a443 fix(push): keep read-state clears alive as long as the alert (#2919) 2026-09-24 01:26:18 +02:00
HampusandGitHub bd6ca7290e fix(api): allow deleting messages without send permission (#2918) 2026-09-24 01:14:01 +02:00
HampusandGitHub b85e975fb5 feat(push): deliver our own relay endpoints in process (#2917) 2026-09-24 01:07:09 +02:00
HampusandGitHub b6e504f68c fix(push): keep device tokens out of logs (#2916) 2026-09-24 00:33:50 +02:00
HampusandGitHub 5fde6eb484 feat(push): ring Android calls and harden the relay (#2915) 2026-09-24 00:07:15 +02:00
242 changed files with 18537 additions and 13486 deletions
+1
View File
@@ -32,6 +32,7 @@
/fluxer_docs/.astro/
/fluxer_app/.devserver-cache.json
/fluxer_app/pkgs/libfluxcore/
/fluxer_app/pkgs/libfluxwebp/
/fluxer_app/src/features/i18n/locales/*/messages.mjs
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
/fluxer_app/src/features/theme/styles/generated/
+22
View File
@@ -123,12 +123,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -142,12 +146,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -190,6 +198,9 @@ jobs:
- name: Check Rust dependencies
run: cargo deny --locked check -D warnings
- name: Check libfluxwebp dependencies
run: cargo deny --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml --config deny.toml --locked check licenses bans sources
- name: Check desktop native dependencies
run: tools/ci/check-desktop-native-workspaces.sh dependencies
@@ -242,6 +253,9 @@ jobs:
- name: Check formatting
run: cargo fmt --all -- --check
- name: Check formatting (libfluxwebp)
run: cargo fmt --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml -- --check
- name: Check formatting (desktop native workspaces)
run: tools/ci/check-desktop-native-workspaces.sh fmt
@@ -398,12 +412,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -417,12 +435,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
+1
View File
@@ -26,6 +26,7 @@
/fluxer_app/.devserver-cache.json
/fluxer_app/pkgs/libfluxcore/
/fluxer_app/pkgs/libfluxwebp/
/fluxer_app/src/features/i18n/locales/*/messages.mjs
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
/fluxer_app/src/features/theme/styles/generated/
Generated
+1
View File
@@ -1894,6 +1894,7 @@ dependencies = [
"futures",
"hmac 0.13.0",
"p256",
"percent-encoding",
"rand 0.10.2",
"reqwest",
"ring",
+19 -2
View File
@@ -49,7 +49,7 @@ On Linux, prefer a repository over a single file so Fluxer updates with the rest
## Linux package repositories
Every repository serves both channels. The package is `fluxer` for stable, `fluxer-canary` for canary.
The package is `fluxer` for stable and `fluxer-canary` for canary. apt and dnf subscribe to one channel per entry file. pacman and Flatpak serve both from one repository.
### Flatpak
@@ -59,7 +59,7 @@ Stable is on [Flathub][flathub], the easiest route on most desktops:
flatpak install flathub app.fluxer.Fluxer
```
Flathub has stable only. For canary, or to use Fluxer's own repository, open [this reference file][flatpak-ref] and your software manager takes over. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` in the address bar.
Flathub has stable only. To use Fluxer's own repository, open [the stable][flatpak-ref] or [the canary][flatpak-canary-ref] reference file and your software manager takes over. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` in the address bar.
From a terminal:
@@ -76,6 +76,15 @@ sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer.sources https://pkgs.fluxer.co
sudo apt update && sudo apt install fluxer
```
For canary, use the canary entry file and package.
```sh
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer-canary.sources https://pkgs.fluxer.com/deb/fluxer-canary.sources
sudo apt update && sudo apt install fluxer-canary
```
A `.deb` installed from a download only updates once its channel's entry is added.
### Fedora and RHEL
```sh
@@ -83,6 +92,13 @@ sudo curl -fsSL -o /etc/yum.repos.d/fluxer.repo https://pkgs.fluxer.com/rpm/flux
sudo dnf install fluxer
```
For canary, use the canary entry file and package.
```sh
sudo curl -fsSL -o /etc/yum.repos.d/fluxer-canary.repo https://pkgs.fluxer.com/rpm/fluxer-canary.repo
sudo dnf install fluxer-canary
```
RHEL, Rocky, Alma and CentOS Stream need `sudo dnf install epel-release` first, because their base repositories lack `libXScrnSaver`. Fedora does not.
### Arch Linux
@@ -150,6 +166,7 @@ endorsement rights.
[linux-targz-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/tar_gz
[linux-targz-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/tar_gz
[flatpak-ref]: https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
[flatpak-canary-ref]: https://pkgs.fluxer.com/flatpak/fluxer-canary.flatpakref
[flathub]: https://flathub.org/apps/app.fluxer.Fluxer
[android-apk]: https://github.com/fluxerapp/flutter_client/releases
[obtainium]: https://obtainium.imranr.dev/
-54
View File
@@ -10524,7 +10524,6 @@
},
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
"screen_share_delivery": {"$ref": "#/components/schemas/ScreenShareDeliveryConfigResponse"},
"push_service_delivery": {"$ref": "#/components/schemas/PushServiceDeliveryConfigResponse"},
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
"registration": {
@@ -10953,7 +10952,6 @@
"sso",
"gateway_rollout",
"voice_noise_suppression",
"screen_share_delivery",
"push_service_delivery",
"experiment_delivery",
"registration",
@@ -11089,10 +11087,6 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
},
"screen_share_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ScreenShareDeliveryConfigUpdateRequest"}]
},
"push_service_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/PushServiceDeliveryConfigUpdateRequest"}]
@@ -15208,24 +15202,6 @@
}
}
},
"ScreenShareDeliveryConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
}
},
"VoiceNoiseSuppressionConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15327,36 +15303,6 @@
],
"additionalProperties": false
},
"ScreenShareDeliveryConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"default": "screen-share-delivery-v1", "type": "string", "minLength": 1, "maxLength": 64},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"excluded_user_ids"
],
"additionalProperties": false
},
"VoiceNoiseSuppressionConfigResponse": {
"type": "object",
"properties": {
@@ -23,8 +23,6 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
#[serde(default)]
pub screen_share_delivery: ScreenShareDeliveryConfigResponse,
#[serde(default)]
pub push_service_delivery: PushServiceDeliveryConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
@@ -452,7 +450,6 @@ impl VoiceE2eeScope {
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const PUSH_SERVICE_DELIVERY_DEFAULT_SALT: &str = "push-service-delivery-v1";
pub const SCREEN_SHARE_DELIVERY_DEFAULT_SALT: &str = "screen-share-delivery-v1";
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
impl NoiseSuppressionBackend {
@@ -543,44 +540,6 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
pub suppression_strength: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ScreenShareDeliveryConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
}
impl Default for ScreenShareDeliveryConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: SCREEN_SHARE_DELIVERY_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ScreenShareDeliveryConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct PushServiceDeliveryConfigResponse {
@@ -735,8 +694,6 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screen_share_delivery: Option<ScreenShareDeliveryConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub push_service_delivery: Option<PushServiceDeliveryConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
@@ -1076,30 +1033,18 @@ mod tests {
.expect("admin schema");
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
.expect("default noise config");
let screen_share = serde_json::from_value::<ScreenShareDeliveryConfigResponse>(json!({}))
.expect("default screen share config");
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let noise = serde_json::to_value(noise).expect("serializable noise config");
let screen_share =
serde_json::to_value(screen_share).expect("serializable screen share config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
serde_json::from_value(noise.clone()).expect("generated noise config contract");
let generated_screen_share: generated_types::ScreenShareDeliveryConfigResponse =
serde_json::from_value(screen_share.clone())
.expect("generated screen share config contract");
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
assert_eq!(
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
noise
);
assert_eq!(
serde_json::to_value(generated_screen_share)
.expect("serializable generated screen share config"),
screen_share
);
assert_eq!(
serde_json::to_value(generated_delivery)
.expect("serializable generated delivery config"),
@@ -1107,7 +1052,6 @@ mod tests {
);
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("ScreenShareDeliveryConfigResponse", screen_share),
("ExperimentDeliveryConfigResponse", delivery),
] {
for (field, value) in value.as_object().expect("config object") {
@@ -1119,29 +1063,6 @@ mod tests {
}
}
#[test]
fn screen_share_delivery_update_preserves_empty_lists_and_omitted_fields() {
let update = ScreenShareDeliveryConfigUpdateRequest {
included_user_ids: Some(Vec::new()),
excluded_user_ids: Some(Vec::new()),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::ScreenShareDeliveryConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(ScreenShareDeliveryConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
let update = VoiceNoiseSuppressionConfigUpdateRequest {
+2 -116
View File
@@ -19,9 +19,8 @@ use crate::{
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
PremiumMode, PushServiceDeliveryConfigUpdateRequest, RegistrationMode,
ScreenShareDeliveryConfigUpdateRequest, SsoConfigUpdateRequest,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
VoiceNoiseSuppressionGuildOverride,
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
},
},
config::AdminConfig,
@@ -208,10 +207,6 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_screen_share_delivery" => match build_screen_share_delivery_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_push_service_delivery" => match build_push_service_delivery_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
@@ -653,38 +648,6 @@ fn build_voice_noise_suppression_update(
})
}
fn build_screen_share_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
screen_share_delivery: Some(ScreenShareDeliveryConfigUpdateRequest {
enabled: Some(form.bool_value("screen_share_delivery_enabled")),
rollout_basis_points: parse_form_number(
form,
"screen_share_delivery_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(
form,
"screen_share_delivery_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("screen_share_delivery_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("screen_share_delivery_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_push_service_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
@@ -1638,83 +1601,6 @@ mod tests {
}
}
#[test]
fn build_screen_share_delivery_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
b"screen_share_delivery_enabled=true&screen_share_delivery_rollout_basis_points=%20250%20&screen_share_delivery_rollout_salt=%20screen-share-delivery-v2%20&screen_share_delivery_included_user_ids=1500000000000000001%0A1500000000000000002&screen_share_delivery_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
);
let update = build_screen_share_delivery_update(&form)
.expect("valid form")
.screen_share_delivery
.expect("screen share delivery update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(250));
assert_eq!(
update.rollout_salt,
Some("screen-share-delivery-v2".to_owned())
);
assert_eq!(
update.included_user_ids,
Some(vec![
"1500000000000000001".to_owned(),
"1500000000000000002".to_owned()
])
);
assert_eq!(
update.excluded_user_ids,
Some(vec![
"1500000000000000003".to_owned(),
"1500000000000000004".to_owned()
])
);
}
#[test]
fn build_screen_share_delivery_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_screen_share_delivery_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"screen_share_delivery": {
"enabled": false,
"included_user_ids": [],
"excluded_user_ids": [],
}})
);
}
#[test]
fn build_screen_share_delivery_update_rejects_invalid_rollout_fields() {
for (form, message) in [
(
"screen_share_delivery_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"screen_share_delivery_rollout_basis_points=abc",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"screen_share_delivery_rollout_salt=%20%20",
"Rollout salt must be between 1 and 64 characters",
),
(
"screen_share_delivery_included_user_ids=123%2Cinvalid",
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"screen_share_delivery_excluded_user_ids=123%2Cinvalid",
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_screen_share_delivery_update(&form).expect_err("invalid rollout field"),
message
);
}
}
#[test]
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");
@@ -7,8 +7,7 @@ use crate::{
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, NoiseSuppressionBackend, PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
PendingRegistrationResponse, PushServiceDeliveryConfigResponse, RegistrationUrlResponse,
SCREEN_SHARE_DELIVERY_DEFAULT_SALT, ScreenShareDeliveryConfigResponse, SsoConfigResponse,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -149,7 +148,6 @@ pub fn instance_config_page(
html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
(screen_share_delivery_section(base, csrf_token, &instance_config.screen_share_delivery))
(push_service_delivery_section(base, csrf_token, &instance_config.push_service_delivery))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
@@ -1179,117 +1177,6 @@ fn voice_noise_suppression_section(
)
}
fn screen_share_delivery_section(
base: &str,
csrf_token: &str,
screen_share_delivery: &ScreenShareDeliveryConfigResponse,
) -> Markup {
let status = if screen_share_delivery.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = screen_share_delivery.included_user_ids.join("\n");
let excluded_user_ids = screen_share_delivery.excluded_user_ids.join("\n");
section_card_with_description(
"Screen Share Delivery",
"Pick how many clients publish screen shares through the reworked delivery path. While \
the master switch below is off nothing on this form reaches any client: every user \
keeps the screen share pipeline they have today, whatever the rest of these fields say. \
A client that is already sharing keeps the path it started on until the share ends.",
html! {
form method="post" action={(base) "/instance-config?action=update_screen_share_delivery"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (screen_share_delivery.config_version)
}
}
(checkbox(
"screen_share_delivery_enabled",
"true",
"Serve screen share delivery assignments to clients",
screen_share_delivery.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every client is told the \
feature is inert and keeps its current behavior, so the rollout and \
targeting fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"screen_share_delivery_rollout_basis_points",
"Rollout (basis points)",
&screen_share_delivery.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"screen_share_delivery_rollout_salt",
"Rollout Salt",
&screen_share_delivery.rollout_salt,
SCREEN_SHARE_DELIVERY_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"screen_share_delivery_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
screen_share_delivery.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"screen_share_delivery_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
screen_share_delivery.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage. This is the per-user kill switch."
}
}
(form_actions(html! {
(submit_button("Save Screen Share Delivery Configuration"))
}))
}
}
},
)
}
fn push_service_delivery_section(
base: &str,
csrf_token: &str,
@@ -2042,25 +1929,6 @@ mod tests {
assert!(!markup.contains("at the cap"));
}
#[test]
fn screen_share_delivery_section_shows_list_counts_and_the_master_switch() {
let screen_share_delivery = ScreenShareDeliveryConfigResponse {
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
..ScreenShareDeliveryConfigResponse::default()
};
let markup =
screen_share_delivery_section("/admin", "csrf", &screen_share_delivery).into_string();
assert!(markup.contains("action=update_screen_share_delivery"));
assert!(markup.contains("screen_share_delivery_enabled"));
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(!markup.contains("at the cap"));
}
#[test]
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
@@ -114,7 +114,10 @@ pub fn users_list_page(
let content = html! {
div class="space-y-6" {
(page_header("Users", None))
div class="rounded-lg bg-white transition-all border border-neutral-200 p-4" {
div class="rounded-lg bg-white transition-all border border-neutral-200 p-3" {
p class="mb-1 text-xs text-neutral-500" {
"For example, type " span class="font-mono" { "*" } " in to search for all users."
}
(search_form(base, params))
}
(results_markup)
-18
View File
@@ -409,15 +409,6 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"future_object_knob": {"nested": true},
"future_list_knob": ["a", "b"]
},
"screen_share_delivery": {
"enabled": true,
"config_version": 2,
"rollout_basis_points": 2500,
"rollout_salt": "screen-share-delivery-v1",
"included_user_ids": ["1500000000000000001"],
"future_delivery_knob": 9,
"excluded_user_ids": []
},
"push_service_delivery": {
"enabled": true,
"config_version": 3,
@@ -555,14 +546,6 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert_eq!(resp.voice_noise_suppression.rollout_basis_points, 10000);
assert_eq!(*resp.voice_noise_suppression.rollout_salt, "voice-ns-v1");
assert_eq!(resp.voice_noise_suppression.enabled_backends.len(), 3);
assert!(resp.screen_share_delivery.enabled);
assert_eq!(resp.screen_share_delivery.config_version, 2);
assert_eq!(resp.screen_share_delivery.rollout_basis_points, 2500);
assert_eq!(
*resp.screen_share_delivery.rollout_salt,
"screen-share-delivery-v1"
);
assert_eq!(resp.screen_share_delivery.included_user_ids.len(), 1);
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
assert!(resp.policy.single_community_guild_id.is_none());
assert_eq!(resp.policy.services.gif_enabled, Some(true));
@@ -573,7 +556,6 @@ fn deserialize_instance_config_response_with_unknown_keys() {
.replace("\"future_rollout_knob\": 3,", "")
.replace("\"future_presentation_knob\": \"verbose\",", "")
.replace("\"future_knob\": 7,", "")
.replace("\"future_delivery_knob\": 9,", "")
.replace("\"future_object_knob\": {\"nested\": true},", "")
.replace("\"future_list_knob\": [\"a\", \"b\"],", "")
.replace(
-9
View File
@@ -465,7 +465,6 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
"/instance-config?action=update_gateway_rollout",
"/instance-config?action=update_sso",
"/instance-config?action=update_voice_noise_suppression",
"/instance-config?action=update_screen_share_delivery",
"/instance-config?action=update_experiment_delivery",
][..],
),
@@ -1200,14 +1199,6 @@ fn instance_config() -> Value {
"guild_overrides": [],
"suppression_strength": 80
},
"screen_share_delivery": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "screen-share-delivery-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"experiment_delivery": {
"poll_interval_seconds": 300,
"poll_jitter_percent": 15
+4
View File
@@ -476,6 +476,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
},
abusePolicy: {
inboundPhoneCountryCodes: master.instance.abuse_policy.inbound_phone_country_codes,
phoneFlagging: {
enabled: master.instance.abuse_policy.phone_flagging.enabled,
exemptCountryCodes: master.instance.abuse_policy.phone_flagging.exempt_country_codes,
},
phoneVerification: {
inboundRequiredPrefixes: master.instance.abuse_policy.phone_verification.inbound_required_prefixes,
},
@@ -36,7 +36,6 @@ import {
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {PushServiceDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import {ScreenShareDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
@@ -65,7 +64,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
ssoConfig,
gatewayRollout,
voiceNoiseSuppression,
screenShareDelivery,
pushServiceDelivery,
experimentDelivery,
registrationConfig,
@@ -75,7 +73,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getSsoConfig(),
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getScreenShareDeliveryConfig(),
instanceConfigRepository.getPushServiceDeliveryConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
@@ -108,7 +105,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
},
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
screen_share_delivery: screenShareDelivery,
push_service_delivery: pushServiceDelivery,
experiment_delivery: experimentDelivery,
registration: {
@@ -273,18 +269,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
}
if (data.screen_share_delivery) {
const patch = omitUndefinedFields(data.screen_share_delivery);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateScreenShareDeliveryConfig((current) =>
ScreenShareDeliveryConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.push_service_delivery) {
const patch = omitUndefinedFields(data.push_service_delivery);
if (Object.keys(patch).length > 0) {
@@ -11,6 +11,7 @@ import {Logger} from '@app/api/Logger';
import {getGuildSearchService, getUserSearchService} from '@app/api/SearchFactory';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {UserSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
interface RefreshSearchIndexJobPayload extends WorkerJobPayload {
@@ -130,16 +131,28 @@ export class AdminSearchService {
throw new FeatureTemporarilyDisabledError();
}
const query = data.query?.trim() || '';
const isBrowseAll = query === '' || query === '*';
const searchFilters: UserSearchFilters = isBrowseAll
? {sortBy: 'createdAt', sortOrder: 'asc'}
: {sortBy: 'relevance'};
const directUserId = /^\d+$/.test(query) ? createUserID(BigInt(query)) : null;
const canResolveDirectUser = directUserId !== null && !isSyntheticUserId(directUserId) && data.offset === 0;
const [searchResult, directUser] = await Promise.all([
userSearchService.search(query, {}, {limit: data.limit, offset: data.offset}),
userSearchService.search(query, searchFilters, {limit: data.limit, offset: data.offset}),
canResolveDirectUser ? userRepository.findUnique(directUserId).catch(() => null) : Promise.resolve(null),
]);
const {hits, total} = searchResult;
const userIds = hits.map((hit) => createUserID(BigInt(hit.id)));
const users = await userRepository.listUsers(userIds);
const response = await Promise.all(users.map((user) => mapUserToAdminResponse(user, cacheService, acls)));
const usersById = new Map(users.map((user) => [user.id.toString(), user]));
const orderedUsers = [];
for (const userId of userIds) {
const user = usersById.get(userId.toString());
if (user) {
orderedUsers.push(user);
}
}
const response = await Promise.all(orderedUsers.map((user) => mapUserToAdminResponse(user, cacheService, acls)));
if (directUser && data.offset === 0) {
const directId = directUser.id.toString();
if (!response.some((u) => u.id === directId)) {
+4 -2
View File
@@ -23,7 +23,7 @@ import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstri
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import {UserSettings} from '@app/api/models/UserSettings';
import {countryRequiresInboundPhoneVerification} from '@app/api/risk/AbusePolicy';
import {countryRequiresInboundPhoneVerification, stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy';
import {
type IAccountPolicyEvaluator,
isAssessmentThresholdAuditEvent,
@@ -362,7 +362,9 @@ export async function register(
action: riskResult.recommendedAction,
},
});
const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(policyDecision.flagBits);
const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(
await stripDisallowedPhoneFlags(policyDecision.flagBits, async () => countryCode),
);
const createdAt = new Date();
const riskContext = deriveLatestRiskContext({
userId: userId.toString(),
@@ -9,6 +9,7 @@ import {
loginAccount,
registerUser,
} from '@app/api/auth/tests/AuthTestUtils';
import {Config} from '@app/api/Config';
import {setInjectedRegistrationRiskEvaluator} from '@app/api/middleware/ServiceMiddleware';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {
@@ -33,7 +34,7 @@ import {
SuspiciousActivityFlags,
} from '@fluxer/constants/src/UserConstants';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
function phoneRiskEvaluator(level: RiskLevelType, riskScore: number): IRegistrationRiskEvaluator {
return {
@@ -241,6 +242,59 @@ describe('Deferred phone verification gate', () => {
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
});
describe('with phone flagging disabled', () => {
const originalPhoneFlagging = {...Config.abusePolicy.phoneFlagging};
afterEach(() => {
Config.abusePolicy.phoneFlagging = originalPhoneFlagging;
});
it('sets no phone requirement and no deferral at registration', async () => {
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
const registration = await registerUser(harness, {
email: createUniqueEmail('flagging-off'),
username: createUniqueUsername('flagging_off'),
global_name: 'Flagging Off',
password: 'StrongPassword!123',
date_of_birth: '2000-01-01',
consent: true,
});
const flags = await readFlags(registration.user_id);
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).toBe(0);
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
});
it('keeps an existing deferral dormant on a qualifying join', async () => {
await getInstanceConfigRepository().setInstancePolicyConfig({
deferred_phone_gate_enabled: true,
deferred_phone_gate_member_threshold: 1,
deferred_phone_gate_window_hours: 24,
});
const {inviteCode} = await createGuildWithInvite(harness);
const filler = await createTestAccount(harness);
await createBuilder(harness, filler.token).post(`/invites/${inviteCode}`).expect(200).execute();
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
const registration = await registerUser(harness, {
email: createUniqueEmail('flagging-off-join'),
username: createUniqueUsername('flagging_off_join'),
global_name: 'Flagging Off Join',
password: 'StrongPassword!123',
date_of_birth: '2000-01-01',
consent: true,
});
setInjectedRegistrationRiskEvaluator(undefined);
expect((await readFlags(registration.user_id)) & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(200).execute();
const flags = await readFlags(registration.user_id);
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
expect(flags & PHONE_GATE_PROMOTED_FROM_DEFERRAL).toBe(0);
});
});
describe('phone gate escape', () => {
async function configurePhoneGate(
overrides: {
@@ -0,0 +1,210 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createChannelID, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import {CallService} from '@app/api/channel/services/CallService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {CallCaller, CallData, IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
import type {IVoiceRoomStore} from '@app/api/infrastructure/IVoiceRoomStore';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {Channel} from '@app/api/models/Channel';
import type {User} from '@app/api/models/User';
import type {ReadStateService} from '@app/api/read_state/ReadStateService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {beforeEach, describe, expect, it} from 'vitest';
const CALLER_ID = createUserID(1n);
const RECIPIENT_ID = createUserID(2n);
const CHANNEL_ID = createChannelID(12n);
interface CallerOverrides {
username?: string;
globalName?: string | null;
avatar?: string | null;
nickname?: string;
userRowMissing?: boolean;
}
interface Harness {
service: CallService;
created: Array<CallCaller | undefined>;
rung: Array<CallCaller | undefined>;
}
const EXISTING_CALL: CallData = {
channel_id: CHANNEL_ID.toString(),
message_id: '99',
region: 'automatic',
ringing: [],
recipients: [CALLER_ID.toString(), RECIPIENT_ID.toString()],
voice_states: [],
};
function harness(overrides: CallerOverrides, existingCall: CallData | null): Harness {
const username = overrides.username ?? 'elias';
const globalName = overrides.globalName === undefined ? 'Elias' : overrides.globalName;
const avatar = overrides.avatar === undefined ? 'a1b2c3d4' : overrides.avatar;
const nicknames = new Map<string, string>();
if (overrides.nickname !== undefined) {
nicknames.set(CALLER_ID.toString(), overrides.nickname);
}
const channel = {
id: CHANNEL_ID,
type: ChannelTypes.GROUP_DM,
recipientIds: new Set<UserID>([CALLER_ID, RECIPIENT_ID]),
nicknames,
} as unknown as Channel;
const created: Array<CallCaller | undefined> = [];
const rung: Array<CallCaller | undefined> = [];
const channelRepository = {
findUnique: async () => channel,
upsertMessage: async () => {},
getMessage: async () => null,
} as unknown as IChannelRepository;
const userRepository = {
findUnique: async () => (overrides.userRowMissing ? null : ({...callerUser(username, globalName, avatar)} as User)),
listUsers: async () => [],
findSettings: async () => null,
isDmChannelOpen: async () => true,
} as unknown as IUserRepository;
const gatewayService = {
getCall: async () => existingCall,
createCall: async (
_channelId: unknown,
_messageId: string,
_region: string,
_ringing: Array<string>,
_recipients: Array<string>,
caller?: CallCaller,
) => {
created.push(caller);
return EXISTING_CALL;
},
ringCallRecipients: async (_channelId: unknown, _recipients: Array<string>, caller?: CallCaller) => {
rung.push(caller);
return true;
},
} as unknown as IGatewayService;
const userCacheService = {
getUserPartialResponse: async (): Promise<UserPartialResponse> =>
({
id: CALLER_ID.toString(),
username,
discriminator: '0001',
global_name: globalName,
avatar,
avatar_color: null,
flags: 0,
}) as unknown as UserPartialResponse,
} as unknown as UserCacheService;
const snowflakeService = {
generateForChannel: async () => 7777n,
} as unknown as ISnowflakeService;
const readStateService = {
ackMessage: async () => {},
bulkIncrementMentionCounts: async () => {},
} as unknown as ReadStateService;
const service = new CallService(
channelRepository,
userRepository,
{} as unknown as IGuildRepositoryAggregate,
gatewayService,
userCacheService,
snowflakeService,
readStateService,
null,
{} as unknown as IVoiceRoomStore,
);
return {service, created, rung};
}
function callerUser(username: string, globalName: string | null, avatar: string | null): Partial<User> {
return {
id: CALLER_ID,
username,
globalName,
avatarHash: avatar,
isBot: false,
};
}
const requestCache = {
userPartials: new Map(),
} as unknown as RequestCache;
describe('CallService caller identity', () => {
let harnessState: Harness;
const createCall = (overrides: CallerOverrides = {}) => {
harnessState = harness(overrides, null);
return harnessState.service.createOrGetCall({
userId: CALLER_ID,
channelId: CHANNEL_ID,
ringing: [RECIPIENT_ID],
requestCache,
});
};
const ringExistingCall = (overrides: CallerOverrides = {}) => {
harnessState = harness(overrides, EXISTING_CALL);
return harnessState.service.ringCallRecipients({
userId: CALLER_ID,
channelId: CHANNEL_ID,
requestCache,
});
};
beforeEach(() => {
requestCache.userPartials.clear();
});
it('sends the caller id, display name and avatar hash to createCall', async () => {
await createCall();
expect(harnessState.created).toEqual([{id: '1', name: 'Elias', avatar: 'a1b2c3d4'}]);
});
it('prefers the group dm nickname over the global name on createCall', async () => {
await createCall({nickname: 'Eli'});
expect(harnessState.created[0]?.name).toBe('Eli');
});
it('falls back to the username when the caller has no nickname and no global name', async () => {
await createCall({globalName: null});
expect(harnessState.created[0]?.name).toBe('elias');
});
it('sends a null avatar when the caller has no custom avatar', async () => {
await createCall({avatar: null});
expect(harnessState.created[0]).toEqual({id: '1', name: 'Elias', avatar: null});
});
it('sends no caller at all when the caller user row is gone', async () => {
await createCall({userRowMissing: true});
expect(harnessState.created).toEqual([undefined]);
});
it('sends the caller id, display name and avatar hash to ringCallRecipients', async () => {
await ringExistingCall();
expect(harnessState.rung).toEqual([{id: '1', name: 'Elias', avatar: 'a1b2c3d4'}]);
});
it('prefers the group dm nickname over the global name on ringCallRecipients', async () => {
await ringExistingCall({nickname: 'Eli'});
expect(harnessState.rung[0]?.name).toBe('Eli');
});
it('falls back to the username on ringCallRecipients', async () => {
await ringExistingCall({globalName: null});
expect(harnessState.rung[0]?.name).toBe('elias');
});
it('resolves the caller on the ring branch and not on the create branch', async () => {
await ringExistingCall();
expect(harnessState.created).toEqual([]);
expect(harnessState.rung).toHaveLength(1);
});
});
@@ -12,6 +12,7 @@ import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'
import type {IVoiceRoomStore} from '@app/api/infrastructure/IVoiceRoomStore';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {Channel} from '@app/api/models/Channel';
import type {ReadStateService} from '@app/api/read_state/ReadStateService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {VoiceAccessContext, VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService';
@@ -208,14 +209,26 @@ export class CallService {
has_reaction: false,
version: 1,
});
const author = await this.userRepository.findUnique(userId);
const call = await this.gatewayService.createCall(
channelId,
messageId.toString(),
selectedRegion,
ringing.map((id) => id.toString()),
allRecipients.map((id) => id.toString()),
author
? {
id: userId.toString(),
name: this.resolveCallerName({
channel,
userId,
globalName: author.globalName,
username: author.username,
}),
avatar: author.avatarHash,
}
: undefined,
);
const author = await this.userRepository.findUnique(userId);
await incrementDmMentionCounts({
readStateService: this.readStateService,
userRepository: this.userRepository,
@@ -390,13 +403,45 @@ export class CallService {
longitude,
});
} else {
const caller = await this.userCacheService.getUserPartialResponse(userId, requestCache);
await this.gatewayService.ringCallRecipients(
channelId,
recipientsToRing.map((id) => id.toString()),
{
id: userId.toString(),
name: this.resolveCallerName({
channel,
userId,
globalName: caller.global_name,
username: caller.username,
}),
avatar: caller.avatar,
},
);
}
}
private resolveCallerName({
channel,
userId,
globalName,
username,
}: {
channel: Channel;
userId: UserID;
globalName: string | null;
username: string;
}): string {
const nickname = channel.nicknames.get(userId.toString());
if (nickname) {
return nickname;
}
if (globalName) {
return globalName;
}
return username;
}
async stopRingingCallRecipients({
userId,
channelId,
@@ -252,9 +252,7 @@ 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 canManageMessages;
return hasPermission(Permissions.MANAGE_MESSAGES);
}
private validateVoiceMessageConstraints(
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
createPermissionOverwrite,
sendChannelMessage,
setupTestGuildWithMembers,
} from '@app/api/channel/tests/ChannelTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
describe('Message delete permissions', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('lets a member with MANAGE_MESSAGES but without SEND_MESSAGES delete another member message', async () => {
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 2);
const [author, moderator] = members as [TestAccount, TestAccount];
const message = await sendChannelMessage(harness, author.token, systemChannel.id, 'delete me');
await createPermissionOverwrite(harness, owner.token, systemChannel.id, moderator.userId, {
type: 1,
allow: Permissions.MANAGE_MESSAGES.toString(),
deny: Permissions.SEND_MESSAGES.toString(),
});
await createBuilder(harness, moderator.token)
.delete(`/channels/${systemChannel.id}/messages/${message.id}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, author.token)
.get(`/channels/${systemChannel.id}/messages/${message.id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('refuses a member without MANAGE_MESSAGES deleting another member message', async () => {
const {members, systemChannel} = await setupTestGuildWithMembers(harness, 2);
const [author, other] = members as [TestAccount, TestAccount];
const message = await sendChannelMessage(harness, author.token, systemChannel.id, 'keep me');
await createBuilder(harness, other.token)
.delete(`/channels/${systemChannel.id}/messages/${message.id}`)
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
.execute();
});
});
+4
View File
@@ -336,6 +336,10 @@ export interface APIConfig {
};
abusePolicy: {
inboundPhoneCountryCodes: Array<string>;
phoneFlagging: {
enabled: boolean;
exemptCountryCodes: Array<string>;
};
phoneVerification: {
inboundRequiredPrefixes: Array<string>;
};
@@ -1,18 +1,18 @@
{
"auth.unknown_location": "Ubicación desconocida",
"billing.donation_description_monthly": "Donación mensual para apoyar a {product_name}",
"billing.donation_description_one_time": "Donación única para apoyar a {product_name}",
"billing.donation_description_yearly": "Donación anual para apoyar a {product_name}",
"billing.donation_name_one_time": "Donación a {product_name}",
"billing.donation_name_recurring": "Donación recurrente a {product_name}",
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se otorgue el acceso. Esto no afecta otros derechos de consumo obligatorios. Consulta los [Términos de servicio]({terms_url}).",
"bulk_message_deletion.complete": "Terminamos de eliminar tus mensajes. Eliminamos {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
"content.virus_detected": "Ese archivo fue marcado como potencialmente inseguro y se ha eliminado.",
"guild.bulk_create.emoji_limit": "Se alcanzó el límite máximo de emojis ({limit}).",
"guild.bulk_create.sticker_limit": "Se alcanzó el límite máximo de stickers ({limit}).",
"guild.bulk_create.unknown_error": "Error desconocido.",
"guild.default_category_text": "Canales de texto",
"guild.default_category_voice": "Canales de voz",
"guild.default_channel_text": "general",
"guild.default_channel_voice": "General"
"auth.unknown_location": "Ubicación desconocida",
"billing.donation_description_monthly": "Donación mensual para apoyar a {product_name}",
"billing.donation_description_one_time": "Donación única para apoyar a {product_name}",
"billing.donation_description_yearly": "Donación anual para apoyar a {product_name}",
"billing.donation_name_one_time": "Donación a {product_name}",
"billing.donation_name_recurring": "Donación recurrente a {product_name}",
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se otorgue el acceso. Esto no afecta otros derechos de consumo obligatorios. Consulta los [Términos de servicio]({terms_url}).",
"bulk_message_deletion.complete": "Terminamos de eliminar tus mensajes. Eliminamos {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
"content.virus_detected": "Ese archivo fue marcado como potencialmente inseguro y se ha eliminado.",
"guild.bulk_create.emoji_limit": "Se alcanzó el límite máximo de emojis ({limit}).",
"guild.bulk_create.sticker_limit": "Se alcanzó el límite máximo de stickers ({limit}).",
"guild.bulk_create.unknown_error": "Error desconocido.",
"guild.default_category_text": "Canales de texto",
"guild.default_category_voice": "Canales de voz",
"guild.default_channel_text": "general",
"guild.default_channel_voice": "General"
}
@@ -8,7 +8,6 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {entityTagMatches} from '@app/api/utils/EntityTag';
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
import {resolveScreenShareDeliveryAssignment} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
import {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
@@ -29,10 +28,9 @@ export function ExperimentController(app: HonoApp) {
}),
async (ctx) => {
const instanceConfigRepository = ctx.get('instanceConfigRepository');
const [delivery, voiceConfig, screenShareConfig] = await Promise.all([
const [delivery, voiceConfig] = await Promise.all([
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getScreenShareDeliveryConfig(),
]);
const userId = ctx.get('user').id.toString();
const body: ExperimentAssignmentsResponse = {
@@ -40,7 +38,6 @@ export function ExperimentController(app: HonoApp) {
poll_jitter_percent: delivery.poll_jitter_percent,
assignments: {
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
screen_share_delivery: resolveScreenShareDeliveryAssignment(screenShareConfig, userId),
},
};
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
@@ -6,10 +6,6 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {
DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
INERT_SCREEN_SHARE_DELIVERY_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
import {
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
@@ -19,7 +15,6 @@ import {
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
type ExperimentAssignmentsResponse,
type ExperimentDeliveryConfigResponse,
readScreenShareDeliveryAssignment,
readVoiceNoiseSuppressionAssignment,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
@@ -56,7 +51,6 @@ describe('GET /experiments', () => {
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
assignments: {
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
screen_share_delivery: INERT_SCREEN_SHARE_DELIVERY_ASSIGNMENT,
},
});
});
@@ -88,52 +82,6 @@ describe('GET /experiments', () => {
expect(readVoiceNoiseSuppressionAssignment(body).enabled).toBe(false);
});
it('populates the screen share assignment key even when the rollout is disabled', async () => {
const account = await createTestAccount(harness);
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(Object.hasOwn(body.assignments, 'screen_share_delivery')).toBe(true);
expect(readScreenShareDeliveryAssignment(body).enabled).toBe(false);
});
it('resolves the screen share caller through the allowlist', async () => {
const targeted = await createTestAccount(harness);
const untargeted = await createTestAccount(harness);
await getInstanceConfigRepository().setScreenShareDeliveryConfig({
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
enabled: true,
config_version: 4,
rollout_basis_points: 0,
included_user_ids: [targeted.userId],
});
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
.get(ENDPOINT)
.execute();
expect(targetedBody.assignments.screen_share_delivery).toEqual({enabled: true});
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
.get(ENDPOINT)
.execute();
expect(untargetedBody.assignments.screen_share_delivery).toEqual({enabled: false});
});
it('keeps the screen share exclusion ahead of a full rollout', async () => {
const excluded = await createTestAccount(harness);
await getInstanceConfigRepository().setScreenShareDeliveryConfig({
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
enabled: true,
rollout_basis_points: 10000,
included_user_ids: [excluded.userId],
excluded_user_ids: [excluded.userId],
});
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token).get(ENDPOINT).execute();
expect(body.assignments.screen_share_delivery).toEqual({enabled: false});
});
it('serves the delivery cadence from the delivery config and not from the voice config', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().setExperimentDeliveryConfig({
@@ -248,30 +196,6 @@ describe('GET /experiments', () => {
});
});
it('serves a fresh body once the screen share config changes', async () => {
const account = await createTestAccount(harness);
const first = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
.get(ENDPOINT)
.executeWithResponse();
const staleEtag = first.response.headers.get('etag') as string;
await getInstanceConfigRepository().setScreenShareDeliveryConfig({
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
enabled: true,
config_version: 1,
rollout_basis_points: 10000,
});
const refreshed = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
.get(ENDPOINT)
.header('If-None-Match', staleEtag)
.executeWithResponse();
expect(refreshed.response.status).toBe(HTTP_STATUS.OK);
expect(refreshed.response.headers.get('etag')).not.toBe(staleEtag);
expect(refreshed.json?.assignments.screen_share_delivery).toEqual({enabled: true});
});
it('serves a fresh body once the delivery config changes', async () => {
const account = await createTestAccount(harness);
@@ -328,44 +252,6 @@ describe('GET /experiments', () => {
});
});
it('bumps the screen share config version on every admin update without the client sending one', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const afterFirst = await createBuilder<{screen_share_delivery: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
.patch('/admin/instance/config')
.body({screen_share_delivery: {enabled: true, rollout_basis_points: 10000}})
.execute();
expect(afterFirst.screen_share_delivery).toMatchObject({config_version: 1, enabled: true});
const afterSecond = await createBuilder<{screen_share_delivery: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
.patch('/admin/instance/config')
.body({screen_share_delivery: {rollout_salt: 'screen-share-delivery-v2'}})
.execute();
expect(afterSecond.screen_share_delivery).toMatchObject({config_version: 2, enabled: true});
const afterEmpty = await createBuilder<{screen_share_delivery: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
.patch('/admin/instance/config')
.body({screen_share_delivery: {}})
.execute();
expect(afterEmpty.screen_share_delivery).toMatchObject({config_version: 2, enabled: true});
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
expect(body.assignments.screen_share_delivery).toEqual({enabled: true});
});
it('leaves the config version alone for an admin update that sets no field', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
@@ -3,6 +3,7 @@
import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils';
import type {GuildID, InviteCode, RoleID, UserID} from '@app/api/BrandedTypes';
import {createChannelID, createRoleID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {ChannelService} from '@app/api/channel/services/ChannelService';
import {assertMutableUserId} from '@app/api/constants/Core';
import type {GuildMemberRow} from '@app/api/database/types/GuildTypes';
@@ -421,6 +422,13 @@ export class GuildMemberOperationsService {
memberCount: guild.memberCount,
accountAgeMs: Date.now() - snowflakeToDate(BigInt(user.id)).getTime(),
};
if (
!Config.abusePolicy.phoneFlagging.enabled &&
(getEffectiveSuspiciousFlags(user) & PHONE_REQUIREMENT_FLAGS) === 0
) {
Logger.info(logContext, 'deferred_phone_gate.skipped_phone_flagging_disabled');
return;
}
if (status !== 'ok') {
const undeferredFlags = getEffectiveSuspiciousFlags({
...user,
@@ -3,7 +3,7 @@
import {Config} from '@app/api/Config';
import {GatewayRpcMethodError, GatewayRpcMethodErrorCodes} from '@app/api/infrastructure/GatewayRpcError';
import type {IGatewayRpcTransport} from '@app/api/infrastructure/IGatewayRpcTransport';
import type {CallData} from '@app/api/infrastructure/IGatewayService';
import {type CallCaller, type CallData, callCallerRpcParams} from '@app/api/infrastructure/IGatewayService';
import {NatsGatewayRpcTransport} from '@app/api/infrastructure/NatsGatewayRpcTransport';
import {Logger} from '@app/api/Logger';
import {NatsConnectionManager} from '@pkgs/nats/src/NatsConnectionManager';
@@ -128,6 +128,7 @@ export class GatewayRpcClient {
region: string,
ringing: Array<string>,
recipients: Array<string>,
caller?: CallCaller,
): Promise<CallData> {
return this.call<CallData>('call.create', {
channel_id: channelId,
@@ -135,6 +136,7 @@ export class GatewayRpcClient {
region,
ringing,
recipients,
...callCallerRpcParams(caller),
});
}
@@ -142,8 +144,8 @@ export class GatewayRpcClient {
return this.call('call.update_region', {channel_id: channelId, region});
}
async ringCallRecipients(channelId: string, recipients: Array<string>): Promise<boolean> {
return this.call('call.ring', {channel_id: channelId, recipients});
async ringCallRecipients(channelId: string, recipients: Array<string>, caller?: CallCaller): Promise<boolean> {
return this.call('call.ring', {channel_id: channelId, recipients, ...callCallerRpcParams(caller)});
}
async stopRingingCallRecipients(channelId: string, recipients: Array<string>): Promise<boolean> {
@@ -6,16 +6,18 @@ import {SYSTEM_USER_ID} from '@app/api/constants/Core';
import type {GatewayDispatchEvent} from '@app/api/constants/Gateway';
import {GatewayRpcClient} from '@app/api/infrastructure/GatewayRpcClient';
import {GatewayRpcMethodError, GatewayRpcMethodErrorCodes} from '@app/api/infrastructure/GatewayRpcError';
import type {
CallData,
GatewayChannelMention,
GatewayGuildMemoryStats,
GatewayMentionSources,
GatewayMentionSourcesPage,
GatewayNodeStats,
GatewayVoiceStateCounts,
GatewayVoiceStateEntry,
GuildChannelAuthContext,
import {
type CallCaller,
type CallData,
callCallerRpcParams,
type GatewayChannelMention,
type GatewayGuildMemoryStats,
type GatewayMentionSources,
type GatewayMentionSourcesPage,
type GatewayNodeStats,
type GatewayVoiceStateCounts,
type GatewayVoiceStateEntry,
type GuildChannelAuthContext,
} from '@app/api/infrastructure/IGatewayService';
import {Logger} from '@app/api/Logger';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
@@ -1695,6 +1697,7 @@ export class GatewayService {
region: string,
ringing: Array<string>,
recipients: Array<string>,
caller?: CallCaller,
): Promise<CallData> {
return this.call<CallData>('call.create', {
channel_id: channelId.toString(),
@@ -1702,6 +1705,7 @@ export class GatewayService {
region,
ringing,
recipients,
...callCallerRpcParams(caller),
});
}
@@ -1709,8 +1713,12 @@ export class GatewayService {
return this.call<boolean>('call.update_region', {channel_id: channelId.toString(), region});
}
async ringCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean> {
return this.call<boolean>('call.ring', {channel_id: channelId.toString(), recipients});
async ringCallRecipients(channelId: ChannelID, recipients: Array<string>, caller?: CallCaller): Promise<boolean> {
return this.call<boolean>('call.ring', {
channel_id: channelId.toString(),
recipients,
...callCallerRpcParams(caller),
});
}
async stopRingingCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean> {
@@ -24,6 +24,19 @@ export interface CallData {
voice_states: Array<VoiceState>;
}
export interface CallCaller {
id: string;
name: string;
avatar: string | null;
}
export function callCallerRpcParams(caller: CallCaller | undefined): Record<string, unknown> {
if (!caller) {
return {};
}
return {caller_id: caller.id, caller_name: caller.name, caller_avatar: caller.avatar};
}
export interface GatewayGuildMemoryStatsEntry {
node_id: string;
guild_id: string | null;
@@ -381,11 +394,12 @@ export abstract class IGatewayService {
region: string,
ringing: Array<string>,
recipients: Array<string>,
caller?: CallCaller,
): Promise<CallData>;
abstract updateCallRegion(channelId: ChannelID, region: string | null): Promise<boolean>;
abstract ringCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean>;
abstract ringCallRecipients(channelId: ChannelID, recipients: Array<string>, caller?: CallCaller): Promise<boolean>;
abstract stopRingingCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean>;
@@ -84,6 +84,41 @@ describe('stripNonJpegImageMetadataForUpload', () => {
});
});
function riffChunk(type: string, data: Uint8Array): Uint8Array {
const out = new Uint8Array(8 + data.length + (data.length & 1));
out.set(textBytes(type), 0);
new DataView(out.buffer).setUint32(4, data.length, true);
out.set(data, 8);
return out;
}
function webp(chunks: ReadonlyArray<Uint8Array>): Uint8Array {
const body = concatBytes(chunks);
const header = concatBytes([textBytes('RIFF'), new Uint8Array(4), textBytes('WEBP')]);
new DataView(header.buffer).setUint32(4, 4 + body.length, true);
return concatBytes([header, body]);
}
describe('stripNonJpegImageMetadataForUpload for WebP', () => {
it('drops EXIF and XMP chunks without re-encoding frames', async () => {
const vp8x = new Uint8Array(10);
vp8x[0] = 0x02 | 0x08 | 0x04;
const anmf = riffChunk('ANMF', new Uint8Array([9, 8, 7]));
const input = webp([
riffChunk('VP8X', vp8x),
riffChunk('ANIM', new Uint8Array(6)),
anmf,
riffChunk('EXIF', textBytes('GPS=1,2')),
riffChunk('XMP ', textBytes('private metadata')),
]);
const stripped = await stripNonJpegImageMetadataForUpload(input, 'image/webp');
const expectedVp8x = new Uint8Array(10);
expectedVp8x[0] = 0x02;
expect(stripped.contentType).toBe('image/webp');
expect(stripped.body).toEqual(webp([riffChunk('VP8X', expectedVp8x), riffChunk('ANIM', new Uint8Array(6)), anmf]));
});
});
describe('buildProcessedMediaObject', () => {
it('leaves non-media objects for plain copy', async () => {
await expect(buildProcessedMediaObject(textBytes('plain text'), 'text/plain')).resolves.toBeNull();
@@ -162,6 +162,8 @@ export async function stripNonJpegImageMetadataForUpload(
contentType: normalizedContentType === 'image/apng' ? 'image/apng' : 'image/png',
};
}
const strippedWebp = isWebp(data) ? stripWebpMetadataChunks(data) : null;
if (strippedWebp) return {body: strippedWebp, contentType: 'image/webp'};
const image = sharp(data, {animated: true});
const metadata = await image.metadata();
switch (metadata.format) {
@@ -242,6 +244,50 @@ function stripPngMetadataChunks(data: Uint8Array): Uint8Array {
return output;
}
const WEBP_CHUNKS_TO_KEEP = new Set(['VP8 ', 'VP8L', 'VP8X', 'ALPH', 'ANIM', 'ANMF', 'ICCP']);
const WEBP_VP8X_EXIF_FLAG = 0x08;
const WEBP_VP8X_XMP_FLAG = 0x04;
function readFourCc(data: Uint8Array, offset: number): string {
return String.fromCharCode(data[offset]!, data[offset + 1]!, data[offset + 2]!, data[offset + 3]!);
}
function readU32LE(data: Uint8Array, offset: number): number {
return (data[offset]! | (data[offset + 1]! << 8) | (data[offset + 2]! << 16) | (data[offset + 3]! << 24)) >>> 0;
}
function isWebp(data: Uint8Array): boolean {
return data.length >= 12 && readFourCc(data, 0) === 'RIFF' && readFourCc(data, 8) === 'WEBP';
}
function stripWebpMetadataChunks(data: Uint8Array): Uint8Array | null {
const riffEnd = Math.min(data.length, 8 + readU32LE(data, 4));
const chunks: Array<Uint8Array> = [];
let offset = 12;
while (offset + 8 <= riffEnd) {
const length = readU32LE(data, offset + 4);
const chunkEnd = offset + 8 + length + (length & 1);
if (offset + 8 + length > riffEnd) return null;
const type = readFourCc(data, offset);
if (WEBP_CHUNKS_TO_KEEP.has(type)) {
const chunk = data.slice(offset, Math.min(chunkEnd, riffEnd));
if (type === 'VP8X' && length > 0) chunk[8] = (chunk[8] ?? 0) & ~(WEBP_VP8X_EXIF_FLAG | WEBP_VP8X_XMP_FLAG);
chunks.push(chunk);
}
offset = chunkEnd;
}
const bodyLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const output = new Uint8Array(12 + bodyLength);
output.set(data.subarray(0, 12));
new DataView(output.buffer).setUint32(4, 4 + bodyLength, true);
let cursor = 12;
for (const chunk of chunks) {
output.set(chunk, cursor);
cursor += chunk.length;
}
return output;
}
function imageExtensionForContentType(contentType: string): string {
if (contentType.includes('svg')) return 'svg';
if (contentType.includes('tiff')) return 'tiff';
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createChannelID} from '@app/api/BrandedTypes';
import {GatewayRpcClient} from '@app/api/infrastructure/GatewayRpcClient';
import {GatewayService} from '@app/api/infrastructure/GatewayService';
import type {IGatewayRpcTransport} from '@app/api/infrastructure/IGatewayRpcTransport';
import {afterEach, describe, expect, it} from 'vitest';
const CHANNEL_ID = createChannelID(12n);
interface RecordedCall {
method: string;
params: Record<string, unknown>;
}
function recordingService(recorded: Array<RecordedCall>): GatewayService {
const transport: IGatewayRpcTransport = {
async call(method: string, params: Record<string, unknown>): Promise<unknown> {
recorded.push({method, params});
return null;
},
async destroy(): Promise<void> {},
};
GatewayRpcClient.createForTests(transport);
return new GatewayService();
}
describe('call rpc caller params', () => {
afterEach(async () => {
await GatewayRpcClient.resetForTests();
});
it('sends the caller to call.create as caller_id, caller_name and caller_avatar', async () => {
const recorded: Array<RecordedCall> = [];
const service = recordingService(recorded);
await service.createCall(CHANNEL_ID, '99', 'automatic', ['2'], ['1', '2'], {
id: '1',
name: 'Elias',
avatar: 'a1b2c3d4',
});
expect(recorded).toHaveLength(1);
expect(recorded[0].method).toBe('call.create');
expect(recorded[0].params.caller_id).toBe('1');
expect(recorded[0].params.caller_name).toBe('Elias');
expect(recorded[0].params.caller_avatar).toBe('a1b2c3d4');
});
it('sends the caller to call.ring as caller_id, caller_name and caller_avatar', async () => {
const recorded: Array<RecordedCall> = [];
const service = recordingService(recorded);
await service.ringCallRecipients(CHANNEL_ID, ['2'], {id: '1', name: 'Elias', avatar: 'a1b2c3d4'});
expect(recorded).toHaveLength(1);
expect(recorded[0].method).toBe('call.ring');
expect(recorded[0].params.caller_id).toBe('1');
expect(recorded[0].params.caller_name).toBe('Elias');
expect(recorded[0].params.caller_avatar).toBe('a1b2c3d4');
});
it('sends caller_avatar as null when the caller has no avatar', async () => {
const recorded: Array<RecordedCall> = [];
const service = recordingService(recorded);
await service.ringCallRecipients(CHANNEL_ID, ['2'], {id: '1', name: 'Elias', avatar: null});
expect(recorded[0].params.caller_avatar).toBeNull();
expect(Object.hasOwn(recorded[0].params, 'caller_avatar')).toBe(true);
});
it('omits every caller key from call.create when no caller was resolved', async () => {
const recorded: Array<RecordedCall> = [];
const service = recordingService(recorded);
await service.createCall(CHANNEL_ID, '99', 'automatic', ['2'], ['1', '2']);
expect(recorded[0].params).toEqual({
channel_id: '12',
message_id: '99',
region: 'automatic',
ringing: ['2'],
recipients: ['1', '2'],
});
});
it('omits every caller key from call.ring when no caller was resolved', async () => {
const recorded: Array<RecordedCall> = [];
const service = recordingService(recorded);
await service.ringCallRecipients(CHANNEL_ID, ['2']);
expect(recorded[0].params).toEqual({channel_id: '12', recipients: ['2']});
});
});
@@ -18,10 +18,6 @@ import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceC
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {
DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
type ScreenShareDeliveryConfig,
} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
import {
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
type VoiceNoiseSuppressionConfig,
@@ -39,7 +35,6 @@ import {
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
const SCREEN_SHARE_DELIVERY_CONFIG_KEY = 'screen_share_delivery_config';
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
@@ -356,70 +351,6 @@ describe('InstanceConfigRepository', () => {
});
});
it('returns the default screen share delivery config when the key is absent', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
await expect(repository.getScreenShareDeliveryConfig()).resolves.toEqual(DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG);
});
it.each([
{name: 'unparseable text', stored: 'not-json'},
{name: 'a json array', stored: '[]'},
{name: 'out-of-range values', stored: '{"rollout_basis_points":99999}'},
{name: 'a non-boolean enabled flag', stored: '{"enabled":"yes"}'},
])('falls back to the default screen share delivery config for $name', async ({stored}) => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
await repository.setConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY, stored);
await expect(repository.getScreenShareDeliveryConfig()).resolves.toEqual(DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG);
});
it('round-trips a stored screen share delivery config', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
const config: ScreenShareDeliveryConfig = {
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
enabled: true,
config_version: 5,
rollout_basis_points: 2500,
rollout_salt: 'screen-share-delivery-v2',
included_user_ids: ['1400000000000000001'],
excluded_user_ids: ['1400000000000000002'],
};
await repository.setScreenShareDeliveryConfig(config);
await expect(repository.getScreenShareDeliveryConfig()).resolves.toEqual(config);
});
it('fills newly added screen share delivery fields from the schema defaults', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
await repository.setConfig(
SCREEN_SHARE_DELIVERY_CONFIG_KEY,
JSON.stringify({enabled: true, config_version: 2, rollout_basis_points: 1000}),
);
await expect(repository.getScreenShareDeliveryConfig()).resolves.toEqual({
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
enabled: true,
config_version: 2,
rollout_basis_points: 1000,
});
});
it('returns the default experiment delivery config when the key is absent', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
@@ -492,26 +423,6 @@ describe('InstanceConfigRepository', () => {
});
});
it('publishes a refresh so another repository observes the screen share delivery config', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const reader = createRepository(kvProvider);
const writer = createRepository(kvProvider);
await expect(reader.getScreenShareDeliveryConfig()).resolves.toEqual(DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG);
await writer.setScreenShareDeliveryConfig({
...DEFAULT_SCREEN_SHARE_DELIVERY_CONFIG,
enabled: true,
config_version: 1,
});
await vi.waitFor(async () => {
expect(await reader.getScreenShareDeliveryConfig()).toMatchObject({enabled: true, config_version: 1});
});
});
it('uses the registration URL id as the admin-visible registration code', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
@@ -36,10 +36,6 @@ import {
type PushServiceDeliveryConfig,
PushServiceDeliveryConfigSchema,
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
import {
type ScreenShareDeliveryConfig,
ScreenShareDeliveryConfigSchema,
} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
import {
type VoiceNoiseSuppressionConfig,
VoiceNoiseSuppressionConfigSchema,
@@ -66,7 +62,6 @@ import {z} from 'zod';
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
const SCREEN_SHARE_DELIVERY_CONFIG_KEY = 'screen_share_delivery_config';
const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_config';
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const REGISTRATION_CONFIG_KEY = 'registration_config';
@@ -374,7 +369,6 @@ type StoredConfigSection =
| 'app public'
| 'gateway rollout'
| 'voice noise suppression'
| 'screen share delivery'
| 'push service delivery'
| 'experiment delivery'
| 'instance policy'
@@ -514,10 +508,6 @@ function parseStoredVoiceNoiseSuppressionConfig(raw: string | null): VoiceNoiseS
return parseStoredConfigOrDefault(VoiceNoiseSuppressionConfigSchema, raw, 'voice noise suppression');
}
function parseStoredScreenShareDeliveryConfig(raw: string | null): ScreenShareDeliveryConfig {
return parseStoredConfigOrDefault(ScreenShareDeliveryConfigSchema, raw, 'screen share delivery');
}
function parseStoredPushServiceDeliveryConfig(raw: string | null): PushServiceDeliveryConfig {
return parseStoredConfigOrDefault(PushServiceDeliveryConfigSchema, raw, 'push service delivery');
}
@@ -1169,7 +1159,6 @@ export class InstanceConfigRepository {
parseStoredGatewayRolloutConfig(snapshot.get(GATEWAY_ROLLOUT_CONFIG_KEY) ?? null),
);
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
parseStoredScreenShareDeliveryConfig(snapshot.get(SCREEN_SHARE_DELIVERY_CONFIG_KEY) ?? null);
parseStoredPushServiceDeliveryConfig(snapshot.get(PUSH_SERVICE_DELIVERY_CONFIG_KEY) ?? null);
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
@@ -1267,27 +1256,6 @@ export class InstanceConfigRepository {
);
}
async getScreenShareDeliveryConfig(): Promise<ScreenShareDeliveryConfig> {
const raw = await this.getConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY);
return parseStoredScreenShareDeliveryConfig(raw);
}
async setScreenShareDeliveryConfig(config: ScreenShareDeliveryConfig): Promise<void> {
await this.updateScreenShareDeliveryConfig(() => config);
}
updateScreenShareDeliveryConfig(
update: (current: ScreenShareDeliveryConfig) => ScreenShareDeliveryConfig,
): Promise<ScreenShareDeliveryConfig> {
return this.updateStoredConfig(SCREEN_SHARE_DELIVERY_CONFIG_KEY, (raw) =>
validateStoredConfig(
ScreenShareDeliveryConfigSchema,
update(parseStoredScreenShareDeliveryConfig(raw)),
'screen share delivery',
),
);
}
async getPushServiceDeliveryConfig(): Promise<PushServiceDeliveryConfig> {
const raw = await this.getConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
return parseStoredPushServiceDeliveryConfig(raw);
+1 -8
View File
@@ -27190,8 +27190,7 @@
"assignments": {
"type": "object",
"properties": {
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"},
"screen_share_delivery": {"$ref": "#/components/schemas/ScreenShareDeliveryAssignmentResponse"}
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"}
},
"additionalProperties": false
}
@@ -30695,12 +30694,6 @@
"additionalProperties": false
},
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
"ScreenShareDeliveryAssignmentResponse": {
"type": "object",
"properties": {"enabled": {"type": "boolean"}},
"required": ["enabled"],
"additionalProperties": false
},
"VoiceNoiseSuppressionAssignmentResponse": {
"type": "object",
"properties": {
+1 -3
View File
@@ -204,9 +204,7 @@ function buildApnsPayload(payload: Record<string, unknown>): Record<string, unkn
if (badge !== undefined) {
aps.badge = badge;
}
if (imageUrl) {
aps['mutable-content'] = 1;
}
aps['mutable-content'] = 1;
return {
...data,
title,
@@ -139,7 +139,7 @@ describe('ApnsPushService', () => {
notification: {title: 'Alice', body: 'Hello', icon: 'https://cdn.example/avatar.png'},
});
expect(payload.image_url).toBeUndefined();
expect(payload.aps).not.toHaveProperty('mutable-content');
expect(payload.aps).toHaveProperty('mutable-content', 1);
expect(payload.author_avatar_url).toBe('https://cdn.example/avatar.png');
});
it('imports the APNs signing key once per PEM and rejects a truncated one every time', async () => {
+42
View File
@@ -1,6 +1,29 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {PHONE_REQUIREMENT_FLAGS, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
const EMAIL_ONLY_EQUIVALENTS: ReadonlyArray<readonly [number, number]> = [
[SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE, SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL],
[SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE, SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL],
[
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE,
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL,
],
[
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE,
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL,
],
];
const PHONE_OFFERING_FLAGS = EMAIL_ONLY_EQUIVALENTS.reduce((mask, [either]) => mask | either, PHONE_REQUIREMENT_FLAGS);
function withoutPhoneOfferingFlags(flagBits: number): number {
return EMAIL_ONLY_EQUIVALENTS.reduce(
(next, [either, emailOnly]) => ((flagBits & either) !== 0 ? next | emailOnly : next),
flagBits & ~PHONE_OFFERING_FLAGS,
);
}
function normalizeCountryCode(countryCode: string | null | undefined): string | null {
const trimmed = countryCode?.trim();
@@ -17,6 +40,25 @@ export function countryRequiresInboundPhoneVerification(countryCode: string | nu
return configuredCountrySet(Config.abusePolicy.inboundPhoneCountryCodes).has(normalized);
}
export function phoneFlaggingAllowedForCountry(countryCode: string | null | undefined): boolean {
const {enabled, exemptCountryCodes} = Config.abusePolicy.phoneFlagging;
if (!enabled) return false;
const normalized = normalizeCountryCode(countryCode);
if (!normalized) return true;
return !configuredCountrySet(exemptCountryCodes).has(normalized);
}
export async function stripDisallowedPhoneFlags(
flagBits: number,
resolveCountryCode: () => Promise<string | null>,
): Promise<number> {
if ((flagBits & PHONE_OFFERING_FLAGS) === 0) return flagBits;
const {enabled, exemptCountryCodes} = Config.abusePolicy.phoneFlagging;
if (enabled && exemptCountryCodes.length === 0) return flagBits;
if (enabled && phoneFlaggingAllowedForCountry(await resolveCountryCode())) return flagBits;
return withoutPhoneOfferingFlags(flagBits);
}
export function phoneRequiresInboundVerification(
phone: string,
prefixes: ReadonlyArray<string> = Config.abusePolicy.phoneVerification.inboundRequiredPrefixes,
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {phoneFlaggingAllowedForCountry, stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy';
import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const PHONE_AND_EMAIL =
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL |
SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION;
describe('phone flagging policy', () => {
const original = {...Config.abusePolicy.phoneFlagging};
beforeEach(() => {
Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: []};
});
afterEach(() => {
Config.abusePolicy.phoneFlagging = original;
});
it('keeps phone flags by default without resolving the country', async () => {
const resolveCountryCode = vi.fn(async () => 'NG');
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, resolveCountryCode)).toBe(PHONE_AND_EMAIL);
expect(resolveCountryCode).not.toHaveBeenCalled();
expect(phoneFlaggingAllowedForCountry('NG')).toBe(true);
});
it('strips only phone flags when disabled', async () => {
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
const resolveCountryCode = vi.fn(async () => 'NG');
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, resolveCountryCode)).toBe(
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL,
);
expect(resolveCountryCode).not.toHaveBeenCalled();
expect(phoneFlaggingAllowedForCountry('NG')).toBe(false);
expect(phoneFlaggingAllowedForCountry(null)).toBe(false);
});
it('strips phone flags for exempt countries only', async () => {
Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: [' br', 'PT']};
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => 'BR')).toBe(
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL,
);
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => 'ng')).toBe(PHONE_AND_EMAIL);
expect(await stripDisallowedPhoneFlags(PHONE_AND_EMAIL, async () => null)).toBe(PHONE_AND_EMAIL);
expect(phoneFlaggingAllowedForCountry('pt')).toBe(false);
expect(phoneFlaggingAllowedForCountry('NG')).toBe(true);
});
it('replaces email or phone flags with their email only equivalent', async () => {
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
expect(
await stripDisallowedPhoneFlags(
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE,
async () => null,
),
).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL);
expect(
await stripDisallowedPhoneFlags(
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION,
async () => null,
),
).toBe(SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL);
Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: ['BR']};
expect(
await stripDisallowedPhoneFlags(
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE,
async () => 'BR',
),
).toBe(SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL);
});
it('skips the country lookup when no phone flags are present', async () => {
Config.abusePolicy.phoneFlagging = {enabled: true, exemptCountryCodes: ['BR']};
const resolveCountryCode = vi.fn(async () => 'BR');
expect(await stripDisallowedPhoneFlags(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL, resolveCountryCode)).toBe(
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL,
);
expect(resolveCountryCode).not.toHaveBeenCalled();
});
});
@@ -10,7 +10,7 @@ import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {Logger} from '@app/api/Logger';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import {countryRequiresInboundPhoneVerification} from '@app/api/risk/AbusePolicy';
import {countryRequiresInboundPhoneVerification, phoneFlaggingAllowedForCountry} from '@app/api/risk/AbusePolicy';
import {
createRpcTimingNode,
RpcTimingRecorder,
@@ -311,6 +311,17 @@ export class RpcSessionStartService {
) {
return null;
}
if (
!timeRpcStepSync(timingSteps, 'check_phone_flagging_allowed', () =>
phoneFlaggingAllowedForCountry(geoipCountryIso),
)
) {
Logger.info(
{userId: user.id.toString(), countryIso: geoipCountryIso},
'Skipping configured-country inbound phone requirement: phone flagging disabled for this country',
);
return null;
}
if (
timeRpcStepSync(timingSteps, 'check_not_suspicious_flag', () => (user.flags & UserFlags.NOT_SUSPICIOUS) !== 0n)
) {
@@ -52,7 +52,8 @@ function snowflakeSeconds(snowflake: string): number {
function buildSort(sortBy: string, sortOrder: 'asc' | 'desc' | undefined): Array<string> | undefined {
if (sortBy === 'relevance') return undefined;
return [`${sortBy}:${sortOrder ?? 'desc'}`, 'id:desc'];
const direction = sortOrder ?? 'desc';
return [`${sortBy}:${direction}`, `id:${direction}`];
}
function buildTimestampSort(filters: MessageSearchFilters | AuditLogSearchFilters): Array<string> | undefined {
@@ -117,7 +117,7 @@ describe('MeilisearchMessageAdapter', () => {
'(guildId = "guild-1") AND ((channelId = "channel-\\"quoted\\"" OR channelId = "channel-2")) AND (mentionedUserIds = "user-1")',
limit: 10,
offset: 20,
sort: ['createdAt:asc', 'id:desc'],
sort: ['createdAt:asc', 'id:asc'],
attributesToSearchOn: ['content', 'embedContent'],
showRankingScore: false,
},
@@ -19,6 +19,7 @@ import {GuildMemberRepository} from '@app/api/guild/repositories/GuildMemberRepo
import {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import {GuildRoleRepository} from '@app/api/guild/repositories/GuildRoleRepository';
import {
type CallCaller,
type CallData,
type GatewayChannelMention,
type GatewayGuildMemoryStats,
@@ -925,6 +926,7 @@ export class NoopGatewayService extends IGatewayService {
_region: string,
_ringing: Array<string>,
_recipients: Array<string>,
_caller?: CallCaller,
): Promise<CallData> {
return {
channel_id: _channelId.toString(),
@@ -940,7 +942,7 @@ export class NoopGatewayService extends IGatewayService {
return true;
}
async ringCallRecipients(_channelId: ChannelID, _recipients: Array<string>): Promise<boolean> {
async ringCallRecipients(_channelId: ChannelID, _recipients: Array<string>, _caller?: CallCaller): Promise<boolean> {
return true;
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import type {User} from '@app/api/models/User';
import {setInjectedAccountPolicyEvaluator} from '@app/api/risk/AccountPolicyService';
import {setCachedDeferredPhoneGateEnabled} from '@app/api/risk/DeferredPhoneGateCache';
@@ -52,6 +53,20 @@ describe('deferred phone gate marker', () => {
});
expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE']);
});
it('keeps a deferral suppressed when the gate reads off but phone flagging is disabled', () => {
setCachedDeferredPhoneGateEnabled(false);
const original = {...Config.abusePolicy.phoneFlagging};
Config.abusePolicy.phoneFlagging = {enabled: false, exemptCountryCodes: []};
try {
const user = createUser({
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
});
expect(getRequiredActions(user)).toEqual([]);
expect(getEffectiveSuspiciousFlags(user)).toBe(0);
} finally {
Config.abusePolicy.phoneFlagging = original;
}
});
it('suppresses a deferred phone requirement so the account is not locked out', () => {
const user = createUser({
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
+1 -1
View File
@@ -134,7 +134,7 @@ function suppressDeferredPhoneFlags(rawFlags: number): number {
if ((rawFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0) {
return rawFlags;
}
if (getCachedDeferredPhoneGateEnabled() === false) {
if (getCachedDeferredPhoneGateEnabled() === false && Config.abusePolicy.phoneFlagging.enabled) {
return rawFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN;
}
return rawFlags & ~DEFERRABLE_PHONE_FLAGS;
@@ -12,6 +12,7 @@ import {Logger} from '@app/api/Logger';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {AuthSession} from '@app/api/models/AuthSession';
import type {User} from '@app/api/models/User';
import {stripDisallowedPhoneFlags} from '@app/api/risk/AbusePolicy';
import {createAccountPolicyContactContext, type IAccountPolicyEvaluator} from '@app/api/risk/AccountPolicyEvaluator';
import type {IRegistrationEventsRepository} from '@app/api/risk/adapters/VelocityAdapter';
import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository';
@@ -42,6 +43,7 @@ import {
mapUserToPrivateResponse,
mapUserToProfileResponse,
} from '@app/api/user/UserMappers';
import {lookupGeoip} from '@app/api/utils/IpUtils';
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, imposePhoneRequirements} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {getCurrentTimeZoneOffsetMinutes} from '@fluxer/date_utils/src/TimeZoneUtils';
@@ -302,7 +304,11 @@ export class UserAccountRequestService {
action: emailSetRecommendedAction,
},
});
nextSuspiciousFlags = imposePhoneRequirements(nextSuspiciousFlags, policyDecision.flagBits);
const policyFlagBits = await stripDisallowedPhoneFlags(
policyDecision.flagBits,
async () => (await lookupGeoip(request)).countryCode,
);
nextSuspiciousFlags = imposePhoneRequirements(nextSuspiciousFlags, policyFlagBits);
if (nextSuspiciousFlags !== currentSuspiciousFlags) {
user = await this.userRepository.patchUpsert(
user.id,
@@ -52,7 +52,6 @@ export interface InternalRoomOptions {
singlePeerConnection: boolean;
subscriberVideoCodecExclusions?: Array<VideoCodec>;
screenShareDelivery?: boolean;
h264HardwareProfiles?: ReadonlySet<string>;
dataStream?: RoomDataStreamOptions;
}
@@ -73,66 +73,34 @@ describe('applyVideoStartBitrate', () => {
}
it('adds a start bitrate to a non-SVC codec section', () => {
for (const screenShareDelivery of [false, true]) {
const media = videoMedia('camera-track', [
{payload: 96, config: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f'},
]);
expect(applyVideoStartBitrate(media, 'camera-track', 'H264', 1000, false, screenShareDelivery)).toBe(96);
expect(media.fmtp[0]?.config).toBe(
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f;x-google-start-bitrate=900',
);
}
});
it('caps camera start bitrates but not screen share start bitrates while screen share delivery is off', () => {
const camera = videoMedia('camera-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
applyVideoStartBitrate(camera, 'camera-track', 'H264', 3000);
expect(camera.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=1000');
const screen = videoMedia('screen-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
applyVideoStartBitrate(screen, 'screen-track', 'H264', 6000, true);
expect(screen.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=5400');
const media = videoMedia('camera-track', [
{payload: 96, config: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f'},
]);
expect(applyVideoStartBitrate(media, 'camera-track', 'H264', 1000, false)).toBe(96);
expect(media.fmtp[0]?.config).toBe(
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f;x-google-start-bitrate=900',
);
});
it('caps camera and screen share start bitrates at their own ceilings', () => {
const camera = videoMedia('camera-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
applyVideoStartBitrate(camera, 'camera-track', 'H264', 3000, false, true);
applyVideoStartBitrate(camera, 'camera-track', 'H264', 3000, false);
expect(camera.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=1000');
const screen = videoMedia('screen-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
applyVideoStartBitrate(screen, 'screen-track', 'H264', 6000, true, true);
applyVideoStartBitrate(screen, 'screen-track', 'H264', 6000, true);
expect(screen.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=1500');
});
it('leaves a small screen share start bitrate on the floor while screen share delivery is off', () => {
const screen = videoMedia('screen-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
applyVideoStartBitrate(screen, 'screen-track', 'H264', 300, true);
expect(screen.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=270');
});
it('keeps a screen share start bitrate above the frame dropper cliff', () => {
const screen = videoMedia('screen-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
applyVideoStartBitrate(screen, 'screen-track', 'H264', 300, true, true);
applyVideoStartBitrate(screen, 'screen-track', 'H264', 300, true);
expect(screen.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=600');
});
it('stamps only the lead payload type while screen share delivery is off', () => {
const media = multiPayloadScreenMedia();
expect(applyVideoStartBitrate(media, 'screen-track', 'H264', 6000, true)).toBe(116);
expect(media.fmtp.find((fmtp) => fmtp.payload === 116)?.config).toBe(
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f;x-google-start-bitrate=5400',
);
expect(media.fmtp.find((fmtp) => fmtp.payload === 102)?.config).toBe(
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f',
);
expect(media.fmtp.find((fmtp) => fmtp.payload === 108)?.config).toBe(
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
);
});
it('stamps every payload type the codec is offered under, not only the lead one', () => {
const media = multiPayloadScreenMedia();
expect(applyVideoStartBitrate(media, 'screen-track', 'H264', 6000, true, true)).toBe(116);
expect(applyVideoStartBitrate(media, 'screen-track', 'H264', 6000, true)).toBe(116);
for (const fmtp of media.fmtp) {
expect(fmtp.config).toContain('x-google-start-bitrate=1500');
}
@@ -140,47 +108,35 @@ describe('applyVideoStartBitrate', () => {
});
it('only touches the fmtp line for the matching payload', () => {
for (const screenShareDelivery of [false, true]) {
const media = videoMedia(
'screen-track',
[
{payload: 96, config: 'profile-level-id=42e01f'},
{payload: 98, config: 'profile-id=0'},
],
[
{payload: 96, codec: 'H264'},
{payload: 98, codec: 'VP9'},
],
);
applyVideoStartBitrate(media, 'screen-track', 'VP9', 1500, true, screenShareDelivery);
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f');
expect(media.fmtp[1]?.config).toBe('profile-id=0;x-google-start-bitrate=1350');
}
});
it('never appends a second start bitrate while screen share delivery is off', () => {
const media = videoMedia('camera-track', [
{payload: 96, config: 'profile-level-id=42e01f;x-google-start-bitrate=900'},
]);
applyVideoStartBitrate(media, 'camera-track', 'H264', 2000);
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=900');
const media = videoMedia(
'screen-track',
[
{payload: 96, config: 'profile-level-id=42e01f'},
{payload: 98, config: 'profile-id=0'},
],
[
{payload: 96, codec: 'H264'},
{payload: 98, codec: 'VP9'},
],
);
applyVideoStartBitrate(media, 'screen-track', 'VP9', 1500, true);
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f');
expect(media.fmtp[1]?.config).toBe('profile-id=0;x-google-start-bitrate=1350');
});
it('replaces a start bitrate an earlier offer wrote instead of keeping it', () => {
const media = videoMedia('camera-track', [
{payload: 96, config: 'profile-level-id=42e01f;x-google-start-bitrate=900'},
]);
applyVideoStartBitrate(media, 'camera-track', 'H264', 2000, false, true);
applyVideoStartBitrate(media, 'camera-track', 'H264', 2000, false);
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f;x-google-start-bitrate=1000');
});
it('leaves other tracks and missing codecs alone', () => {
for (const screenShareDelivery of [false, true]) {
const media = videoMedia('camera-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
expect(applyVideoStartBitrate(media, 'other-track', 'H264', 2000, false, screenShareDelivery)).toBeUndefined();
expect(applyVideoStartBitrate(media, 'camera-track', 'AV1', 2000, false, screenShareDelivery)).toBe(0);
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f');
}
const media = videoMedia('camera-track', [{payload: 96, config: 'profile-level-id=42e01f'}]);
expect(applyVideoStartBitrate(media, 'other-track', 'H264', 2000, false)).toBeUndefined();
expect(applyVideoStartBitrate(media, 'camera-track', 'AV1', 2000, false)).toBe(0);
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f');
});
});
@@ -49,7 +49,6 @@ export function applyVideoStartBitrate(
codec: string,
maxbr: number,
isScreenShare = false,
screenShareDelivery = false,
): number | undefined {
if (!media.msid?.includes(cid)) {
return undefined;
@@ -65,25 +64,10 @@ export function applyVideoStartBitrate(
const calculatedStartBitrate = Math.round(maxbr * startBitrateMultiplier);
let startBitrate = Math.min(calculatedStartBitrate, maxStartBitrateKbps);
if (isScreenShare) {
startBitrate = screenShareDelivery
? Math.max(minScreenShareStartBitrateKbps, Math.min(calculatedStartBitrate, maxScreenShareStartBitrateKbps))
: calculatedStartBitrate;
}
if (!screenShareDelivery) {
const codecPayload = codecPayloads[0];
const fmtp = media.fmtp.find((entry) => entry.payload === codecPayload);
if (fmtp) {
if (!fmtp.config.includes(startBitrateParameter)) {
fmtp.config += `;${startBitrateParameter}=${startBitrate}`;
}
} else {
media.fmtp.push({
payload: codecPayload,
config: `${startBitrateParameter}=${startBitrate}`,
});
}
return codecPayload;
startBitrate = Math.max(
minScreenShareStartBitrateKbps,
Math.min(calculatedStartBitrate, maxScreenShareStartBitrateKbps),
);
}
for (const payload of codecPayloads) {
@@ -143,8 +127,6 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
excludedVideoDecoderMimeTypes: Set<string> = new Set();
private screenShareDelivery: boolean;
onOffer?: (offer: RTCSessionDescriptionInit, offerId: number) => void;
onIceCandidate?: (candidate: RTCIceCandidate) => void;
@@ -161,10 +143,9 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
onTrack?: (ev: RTCTrackEvent) => void;
constructor(config?: RTCConfiguration, loggerOptions: LoggerOptions = {}, screenShareDelivery: boolean = false) {
constructor(config?: RTCConfiguration, loggerOptions: LoggerOptions = {}) {
super();
this.loggerOptions = loggerOptions;
this.screenShareDelivery = screenShareDelivery;
this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCTransport, () => this.logContext);
this.iceLog = getLogger(LoggerNames.ICE, () => this.logContext);
this.config = config;
@@ -412,7 +393,6 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
trackbr.codec,
trackbr.maxbr,
trackbr.isScreenShare,
this.screenShareDelivery,
);
if (codecPayload === undefined) {
return false;
@@ -471,10 +451,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
for (const transceiver of this.getTransceivers()) {
if (transceiver.receiver.track?.kind !== 'video') continue;
if ((transceiver as {stopped?: boolean}).stopped) continue;
const receives = this.screenShareDelivery
? transceiver.direction === 'recvonly'
: transceiver.direction === 'recvonly' || transceiver.direction === 'sendrecv';
if (!receives) continue;
if (transceiver.direction !== 'recvonly') continue;
if (typeof transceiver.setCodecPreferences !== 'function') continue;
try {
transceiver.setCodecPreferences(allowed);
@@ -98,7 +98,6 @@ export class PCTransportManager {
loggerOptions: LoggerOptions,
rtcConfig?: RTCConfiguration,
subscriberVideoCodecExclusions?: Array<VideoCodec>,
screenShareDelivery: boolean = false,
) {
this.loggerOptions = loggerOptions;
this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCManager, () => this.logContext);
@@ -106,10 +105,10 @@ export class PCTransportManager {
this.isPublisherConnectionRequired = mode !== 'subscriber-primary';
this.isSubscriberConnectionRequired = mode === 'subscriber-primary';
this.publisher = new PCTransport(rtcConfig, loggerOptions, screenShareDelivery);
this.publisher = new PCTransport(rtcConfig, loggerOptions);
this._mode = mode;
if (mode !== 'publisher-only') {
this.subscriber = new PCTransport(rtcConfig, loggerOptions, screenShareDelivery);
this.subscriber = new PCTransport(rtcConfig, loggerOptions);
this.subscriber.onConnectionStateChange = this.updateState;
this.subscriber.onIceConnectionStateChange = this.updateState;
this.subscriber.onSignalingStatechange = this.updateState;
@@ -127,7 +126,7 @@ export class PCTransportManager {
};
}
const receivingTransport = screenShareDelivery ? (this.subscriber ?? this.publisher) : this.subscriber;
const receivingTransport = this.subscriber ?? this.publisher;
if (receivingTransport) {
for (const codec of subscriberVideoCodecExclusions ?? []) {
receivingTransport.excludedVideoDecoderMimeTypes.add(`video/${codec}`);
@@ -25,41 +25,20 @@ describe('selectPublisherCodecPreferences', () => {
const mainLine = 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f';
const highLine = 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f';
it('keeps Main and Baseline ahead of Constrained Baseline while screen share delivery is off', () => {
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
const baseline = codec('video/H264', baselineLine);
const highProfile = codec('video/H264', highLine);
const rtx = codec('video/rtx');
const preferences = selectPublisherCodecPreferences('h264', [constrainedBaseline, rtx, baseline, highProfile]);
expect(preferences).toEqual([highProfile, baseline, constrainedBaseline, rtx]);
});
it('offers High first, then the one profile this server always registers, then the ones it registers nowhere', () => {
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
const baseline = codec('video/H264', baselineLine);
const highProfile = codec('video/H264', highLine);
const rtx = codec('video/rtx');
const preferences = selectPublisherCodecPreferences(
'h264',
[constrainedBaseline, rtx, baseline, highProfile],
true,
);
const preferences = selectPublisherCodecPreferences('h264', [constrainedBaseline, rtx, baseline, highProfile]);
expect(preferences).toEqual([highProfile, constrainedBaseline, baseline, rtx]);
});
it('leads with Main and then Baseline while screen share delivery is off', () => {
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
const mainProfile = codec('video/H264', mainLine);
const baseline = codec('video/H264', baselineLine);
const preferences = selectPublisherCodecPreferences('h264', [mainProfile, baseline, constrainedBaseline]);
expect(preferences).toEqual([mainProfile, baseline, constrainedBaseline]);
});
it('never leads with Main or Baseline, which this server registers nowhere and deletes from the answer', () => {
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
const mainProfile = codec('video/H264', mainLine);
const baseline = codec('video/H264', baselineLine);
const preferences = selectPublisherCodecPreferences('h264', [mainProfile, baseline, constrainedBaseline], true);
const preferences = selectPublisherCodecPreferences('h264', [mainProfile, baseline, constrainedBaseline]);
expect(preferences).toEqual([constrainedBaseline, mainProfile, baseline]);
});
@@ -75,21 +54,8 @@ describe('selectPublisherCodecPreferences', () => {
];
}
it('sorts the capabilities Chromium reports by the old table while screen share delivery is off', () => {
const preferences = selectPublisherCodecPreferences('h264', chromiumCapabilities());
expect(preferences.map((entry) => entry.sdpFmtpLine)).toEqual([
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640034',
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f',
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f',
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f',
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f',
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f',
]);
});
it('offers High first out of the capabilities Chromium reports, so the only hardware profile this server registers wins', () => {
const preferences = selectPublisherCodecPreferences('h264', chromiumCapabilities(), true);
const preferences = selectPublisherCodecPreferences('h264', chromiumCapabilities());
expect(preferences.map((entry) => entry.sdpFmtpLine)).toEqual([
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640034',
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
@@ -116,7 +82,7 @@ describe('selectPublisherCodecPreferences', () => {
return {constrainedBaseline, mainProfile, highProfileLevel31, highProfileLevel51, constrainedHigh};
}
it('ranks High, Constrained High, Main and Baseline above Constrained Baseline while screen share delivery is off', () => {
it('ranks High and Constrained High above Constrained Baseline, whatever level each one reports', () => {
const {constrainedBaseline, mainProfile, highProfileLevel31, highProfileLevel51, constrainedHigh} = levelSpread();
const preferences = selectPublisherCodecPreferences('h264', [
mainProfile,
@@ -125,22 +91,6 @@ describe('selectPublisherCodecPreferences', () => {
constrainedHigh,
constrainedBaseline,
]);
expect(preferences).toEqual([
highProfileLevel31,
highProfileLevel51,
constrainedHigh,
mainProfile,
constrainedBaseline,
]);
});
it('ranks High and Constrained High above Constrained Baseline, whatever level each one reports', () => {
const {constrainedBaseline, mainProfile, highProfileLevel31, highProfileLevel51, constrainedHigh} = levelSpread();
const preferences = selectPublisherCodecPreferences(
'h264',
[mainProfile, highProfileLevel31, highProfileLevel51, constrainedHigh, constrainedBaseline],
true,
);
expect(preferences).toEqual([
highProfileLevel31,
highProfileLevel51,
@@ -150,7 +100,7 @@ describe('selectPublisherCodecPreferences', () => {
]);
});
it('ranks packetization-mode=1 above packetization-mode=0 in both arms, which no hardware encoder takes', () => {
it('ranks packetization-mode=1 above packetization-mode=0, which no hardware encoder takes', () => {
const constrainedBaselineMode0 = codec(
'video/H264',
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f',
@@ -167,7 +117,6 @@ describe('selectPublisherCodecPreferences', () => {
const capabilities = [highProfileMode0, constrainedBaselineMode0, constrainedBaselineMode1, highProfileMode1];
const expected = [highProfileMode1, constrainedBaselineMode1, highProfileMode0, constrainedBaselineMode0];
expect(selectPublisherCodecPreferences('h264', capabilities)).toEqual(expected);
expect(selectPublisherCodecPreferences('h264', capabilities, true)).toEqual(expected);
});
it('puts the chosen codec first and keeps every other codec in browser capability order', () => {
@@ -175,7 +124,6 @@ describe('selectPublisherCodecPreferences', () => {
const vp8 = codec('video/VP8');
const rtx = codec('video/rtx');
expect(selectPublisherCodecPreferences('vp9', [vp8, rtx, vp9])).toEqual([vp9, vp8, rtx]);
expect(selectPublisherCodecPreferences('vp9', [vp8, rtx, vp9], true)).toEqual([vp9, vp8, rtx]);
});
it('keeps the other codecs so a later publication on the same connection can negotiate them', () => {
@@ -192,7 +140,7 @@ describe('selectPublisherCodecPreferences', () => {
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
const capabilities = [vp8, highProfile, constrainedBaseline];
expect(selectPublisherCodecPreferences('vp8', capabilities)).toEqual([vp8, highProfile, constrainedBaseline]);
expect(selectPublisherCodecPreferences('vp8', capabilities, true, new Set(['42e0']))).toEqual([
expect(selectPublisherCodecPreferences('vp8', capabilities, new Set(['42e0']))).toEqual([
vp8,
constrainedBaseline,
highProfile,
@@ -203,25 +151,15 @@ describe('selectPublisherCodecPreferences', () => {
expect(selectPublisherCodecPreferences('av1', [codec('video/VP8'), codec('video/rtx')])).toEqual([]);
});
it('ignores the profiles this host measured while screen share delivery is off', () => {
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
const highProfile = codec('video/H264', highLine);
const capabilities = [highProfile, constrainedBaseline];
expect(selectPublisherCodecPreferences('h264', capabilities, false, new Set(['42e0']))).toEqual([
highProfile,
constrainedBaseline,
]);
});
it('only lets a profile this host encodes in hardware outrank Constrained Baseline', () => {
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
const highProfile = codec('video/H264', highLine);
const capabilities = [highProfile, constrainedBaseline];
expect(selectPublisherCodecPreferences('h264', capabilities, true, new Set(['42e0']))).toEqual([
expect(selectPublisherCodecPreferences('h264', capabilities, new Set(['42e0']))).toEqual([
constrainedBaseline,
highProfile,
]);
expect(selectPublisherCodecPreferences('h264', capabilities, true, new Set(['6400', '42e0']))).toEqual([
expect(selectPublisherCodecPreferences('h264', capabilities, new Set(['6400', '42e0']))).toEqual([
highProfile,
constrainedBaseline,
]);
@@ -231,8 +169,8 @@ describe('selectPublisherCodecPreferences', () => {
const constrainedBaseline = codec('video/H264', constrainedBaselineLine);
const highProfile = codec('video/H264', highLine);
const capabilities = [constrainedBaseline, highProfile];
expect(selectPublisherCodecPreferences('h264', capabilities, true)).toEqual([highProfile, constrainedBaseline]);
expect(selectPublisherCodecPreferences('h264', capabilities, true, new Set())).toEqual([
expect(selectPublisherCodecPreferences('h264', capabilities)).toEqual([highProfile, constrainedBaseline]);
expect(selectPublisherCodecPreferences('h264', capabilities, new Set())).toEqual([
highProfile,
constrainedBaseline,
]);
@@ -245,7 +183,7 @@ describe('selectPublisherCodecPreferences', () => {
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=64001f',
);
expect(
selectPublisherCodecPreferences('h264', [highProfileMode0, constrainedBaselineMode1], true, new Set(['6400'])),
selectPublisherCodecPreferences('h264', [highProfileMode0, constrainedBaselineMode1], new Set(['6400'])),
).toEqual([constrainedBaselineMode1, highProfileMode0]);
});
});
@@ -123,13 +123,6 @@ const videoCodecMimeTypes: Record<VideoCodec, Array<string>> = {
vp8: ['video/vp8'],
};
const h264ProfileRanks = new Map([
['6400', 0],
['640c', 1],
['4d00', 2],
['4200', 3],
['42e0', 4],
]);
const h264DeliveryProfileRanks = new Map([
['6400', 0],
['640c', 1],
['42e0', 2],
@@ -139,8 +132,7 @@ const h264DeliveryProfileRanks = new Map([
const h264UnrankedProfileScore = 5;
const h264MissingProfileScore = 6;
const h264NonHardwareProfilePenalty = 8;
const h264PacketizationMode0Score = 10;
const h264DeliveryPacketizationMode0Score = 20;
const h264PacketizationMode0Score = 20;
type RtpCodecCapability = RTCRtpCapabilities['codecs'][number] & {sdpFmtpLine?: string};
enum PCState {
@@ -523,7 +515,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
this.loggerOptions,
rtcConfig,
this.options.subscriberVideoCodecExclusions,
this.options.screenShareDelivery ?? false,
);
} else {
this.participantSid = joinResponse.participant?.sid;
@@ -537,7 +528,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
this.loggerOptions,
rtcConfig,
this.options.subscriberVideoCodecExclusions,
this.options.screenShareDelivery ?? false,
);
}
@@ -1062,12 +1052,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
if (typeof RTCRtpSender === 'undefined' || typeof RTCRtpSender.getCapabilities !== 'function') return;
const capabilities = RTCRtpSender.getCapabilities('video');
if (!capabilities) return;
const preferences = selectPublisherCodecPreferences(
codec,
capabilities.codecs,
this.options.screenShareDelivery ?? false,
this.options.h264HardwareProfiles,
);
const preferences = selectPublisherCodecPreferences(codec, capabilities.codecs, this.options.h264HardwareProfiles);
if (preferences.length === 0) {
this.log.warn('sender cannot encode the requested codec, leaving the browser order in place', {
...this.logContext,
@@ -1843,33 +1828,27 @@ function getFmtpParameter(sdpFmtpLine: string | undefined, key: string): string
function getH264PublisherCodecScore(
codec: RtpCodecCapability,
screenShareDelivery: boolean,
hardwareProfiles: ReadonlySet<string> | undefined,
): number {
const profileLevelId = getFmtpParameter(codec.sdpFmtpLine, 'profile-level-id');
const packetizationMode = getFmtpParameter(codec.sdpFmtpLine, 'packetization-mode');
const mode0Score = screenShareDelivery ? h264DeliveryPacketizationMode0Score : h264PacketizationMode0Score;
const packetizationScore = packetizationMode === '1' ? 0 : mode0Score;
const packetizationScore = packetizationMode === '1' ? 0 : h264PacketizationMode0Score;
if (!profileLevelId) return packetizationScore + h264MissingProfileScore;
const profile = profileLevelId.slice(0, 4);
if (!screenShareDelivery) {
return packetizationScore + (h264ProfileRanks.get(profile) ?? h264UnrankedProfileScore);
}
const isSoftwareOnly = hardwareProfiles !== undefined && hardwareProfiles.size > 0 && !hardwareProfiles.has(profile);
const hardwareScore = isSoftwareOnly ? h264NonHardwareProfilePenalty : 0;
return packetizationScore + hardwareScore + (h264DeliveryProfileRanks.get(profile) ?? h264UnrankedProfileScore);
return packetizationScore + hardwareScore + (h264ProfileRanks.get(profile) ?? h264UnrankedProfileScore);
}
function preferHardwareH264Codecs(
codecs: ReadonlyArray<RtpCodecCapability>,
screenShareDelivery: boolean,
hardwareProfiles: ReadonlySet<string> | undefined,
): Array<RtpCodecCapability> {
return codecs
.map((codec, index) => ({
codec,
index,
score: getH264PublisherCodecScore(codec, screenShareDelivery, hardwareProfiles),
score: getH264PublisherCodecScore(codec, hardwareProfiles),
}))
.sort((a, b) => a.score - b.score || a.index - b.index)
.map((entry) => entry.codec);
@@ -1878,17 +1857,15 @@ function preferHardwareH264Codecs(
export function selectPublisherCodecPreferences(
codec: VideoCodec,
codecs: ReadonlyArray<RtpCodecCapability>,
screenShareDelivery: boolean = false,
h264HardwareProfiles?: ReadonlySet<string>,
): Array<RtpCodecCapability> {
const mimeTypes = new Set(videoCodecMimeTypes[codec]);
const selected = codecs.filter((entry) => mimeTypes.has(entry.mimeType.toLowerCase()));
if (selected.length === 0) return [];
const preferred =
codec === 'h264' ? preferHardwareH264Codecs(selected, screenShareDelivery, h264HardwareProfiles) : selected;
const preferred = codec === 'h264' ? preferHardwareH264Codecs(selected, h264HardwareProfiles) : selected;
const isH264 = (entry: RtpCodecCapability): boolean => entry.mimeType.toLowerCase() === 'video/h264';
const remaining = codecs.filter((entry) => !mimeTypes.has(entry.mimeType.toLowerCase()));
const rankedH264 = preferHardwareH264Codecs(remaining.filter(isH264), screenShareDelivery, h264HardwareProfiles);
const rankedH264 = preferHardwareH264Codecs(remaining.filter(isH264), h264HardwareProfiles);
let nextH264 = 0;
const rest = remaining.map((entry) => (isH264(entry) ? rankedH264[nextH264++] : entry));
return [...preferred, ...rest];
@@ -63,7 +63,6 @@ import {
publishDefaults,
roomConnectOptionDefaults,
roomOptionDefaults,
screenShareDeliveryPublishDefaults,
videoDefaults,
} from './defaults.ts';
import {ConnectionError, ConnectionErrorReason, UnexpectedConnectionState, UnsupportedServer} from './errors.ts';
@@ -228,7 +227,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
...options?.videoCaptureDefaults,
};
this.options.publishDefaults = {
...(this.options.screenShareDelivery ? screenShareDeliveryPublishDefaults : publishDefaults),
...publishDefaults,
...options?.publishDefaults,
};
@@ -8,7 +8,7 @@ import {AudioPresets, BackupCodecPolicy, ScreenSharePresets, VideoPresets} from
export const defaultVideoCodec = 'h264';
export const screenShareDeliveryPublishDefaults: TrackPublishDefaults = {
export const publishDefaults: TrackPublishDefaults = {
audioPreset: AudioPresets.music,
dtx: false,
red: true,
@@ -21,11 +21,6 @@ export const screenShareDeliveryPublishDefaults: TrackPublishDefaults = {
preConnectBuffer: false,
} as const;
export const publishDefaults: TrackPublishDefaults = {
...screenShareDeliveryPublishDefaults,
degradationPreference: 'maintain-resolution',
};
export const audioDefaults: AudioCaptureOptions = {
deviceId: {ideal: 'default'},
autoGainControl: true,
@@ -814,7 +814,6 @@ export default class LocalParticipant extends Participant {
...this.roomOptions.publishDefaults,
...options,
};
track.screenShareDelivery = this.roomOptions.screenShareDelivery ?? false;
const isStereoInput =
('channelCount' in track.mediaStreamTrack.getSettings() &&
track.mediaStreamTrack.getSettings().channelCount === 2) ||
@@ -1788,7 +1787,7 @@ export default class LocalParticipant extends Participant {
return;
}
let subscribedCodecs = update.subscribedCodecs;
if (this.roomOptions.screenShareDelivery && hasSingleRidlessEncoding(pub.videoTrack)) {
if (hasSingleRidlessEncoding(pub.videoTrack)) {
subscribedCodecs = subscribedCodecs.filter((codec) => codec.qualities.some((quality) => quality.enabled));
if (subscribedCodecs.length === 0) {
return;
@@ -41,8 +41,6 @@ export default abstract class LocalTrack<TrackKind extends Track.Kind = Track.Ki
codec?: VideoCodec;
screenShareDelivery: boolean = false;
get constraints() {
return this._constraints;
}
@@ -563,7 +561,7 @@ export default abstract class LocalTrack<TrackKind extends Track.Kind = Track.Ki
);
private debouncedTrackMuteHandler = debounce(async () => {
if (this.screenShareDelivery && this.source === Track.Source.ScreenShare) {
if (this.source === Track.Source.ScreenShare) {
this.log.debug('screen share capture went idle, keeping upstream published', this.logContext);
return;
}
+1
View File
@@ -433,6 +433,7 @@ export default () => {
staticFilesPlugin({
staticCdnEndpoint: normalizedStaticCdnEndpoint,
fontsDir: path.join(MONOREPO_ROOT, 'packages', 'fonts'),
wasmCratesDir: path.join(ROOT_DIR, 'rust'),
}),
new DefinePlugin({
__FLUXER_PRECACHE_MANIFEST__: JSON.stringify([]),
@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2026, Alexandre Bury
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,32 @@
The auto-generated bindings are under the 3-clause BSD license:
BSD License
For Zstandard software
Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name Facebook, nor Meta, nor the names of its contributors may
be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,30 @@
BSD License
For Zstandard software
Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name Facebook, nor Meta, nor the names of its contributors may
be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+12
View File
@@ -0,0 +1,12 @@
# libfluxcore licenses
`libfluxcore_bg.wasm` is built from this crate and bundled into the app. It
contains third-party code that keeps its upstream license.
| Component | Source | License |
| --- | --- | --- |
| Zstandard 1.5.7 | Vendored by the `zstd-sys` 2.1.0 crate | BSD-3-Clause, see `LICENSE-ZSTD.txt` (Zstandard is dual licensed, Fluxer uses it under BSD-3-Clause) |
| `zstd-sys` 2.1.0 | Rust bindings, build script and WebAssembly libc shim | BSD-3-Clause, see `LICENSE-ZSTD-SYS.txt` |
| `zstd` 0.14.0 | Rust wrapper | BSD-3-Clause, see `LICENSE-ZSTD-RS.txt` |
No Fluxer license notice grants rights to third-party trademarks or brand names.
+68 -3
View File
@@ -16,6 +16,22 @@ pub fn is_animated_image_bytes(input: &[u8]) -> bool {
false
}
pub fn sniff_image_format_bytes(input: &[u8]) -> u8 {
if is_png(input) {
1
} else if is_gif(input) {
2
} else if is_webp(input) {
3
} else if is_avif_file(input) {
4
} else if input.starts_with(&[0xff, 0xd8, 0xff]) {
5
} else {
0
}
}
fn is_gif(input: &[u8]) -> bool {
input.starts_with(b"GIF89a") || input.starts_with(b"GIF87a")
}
@@ -35,7 +51,16 @@ fn is_avif_file(input: &[u8]) -> bool {
}
fn has_avif_anim(input: &[u8]) -> bool {
is_avif_file(input) && &input[8..12] == b"avis"
if !is_avif_file(input) {
return false;
}
if &input[8..12] == b"avis" {
return true;
}
let box_end = read_u32_be(input, 0).map_or(0, |size| (size as usize).min(input.len()));
input
.get(16..box_end)
.is_some_and(|brands| brands.as_chunks::<4>().0.contains(b"avis"))
}
fn has_apng_actl(input: &[u8]) -> bool {
@@ -243,13 +268,53 @@ mod tests {
#[test]
fn detects_avif_sequence_brand() {
let avif = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avis";
assert!(!is_animated_image_bytes(avif));
let still = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00mif1miaf";
assert!(!is_animated_image_bytes(still));
let avis = b"\x00\x00\x00\x18ftypavis\x00\x00\x00\x00avif";
assert!(is_animated_image_bytes(avis));
}
#[test]
fn detects_avis_among_compatible_brands() {
let compat = b"\x00\x00\x00\x1cftypavif\x00\x00\x00\x00mif1avismsf1";
assert!(is_animated_image_bytes(compat));
let outside_ftyp = b"\x00\x00\x00\x14ftypavif\x00\x00\x00\x00mif1avis";
assert!(!is_animated_image_bytes(outside_ftyp));
let truncated = b"\x00\x00\x00\x40ftypavif\x00\x00\x00\x00mif1av";
assert!(!is_animated_image_bytes(truncated));
}
#[test]
fn sniffs_image_formats_from_magic_bytes() {
assert_eq!(sniff_image_format_bytes(b""), 0);
assert_eq!(sniff_image_format_bytes(b"<svg xmlns"), 0);
assert_eq!(
sniff_image_format_bytes(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"),
1
);
assert_eq!(sniff_image_format_bytes(b"GIF89a\x01\x00"), 2);
assert_eq!(sniff_image_format_bytes(b"GIF87a\x01\x00"), 2);
assert_eq!(sniff_image_format_bytes(b"RIFF\x04\0\0\0WEBPVP8 "), 3);
assert_eq!(sniff_image_format_bytes(b"RIFF\x04\0\0\0WAVEfmt "), 0);
assert_eq!(
sniff_image_format_bytes(b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00"),
4
);
assert_eq!(
sniff_image_format_bytes(b"\x00\x00\x00\x18ftypavis\x00\x00\x00\x00"),
4
);
assert_eq!(
sniff_image_format_bytes(b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00"),
0
);
assert_eq!(sniff_image_format_bytes(b"\xff\xd8\xff\xe0\x00\x10JFIF"), 5);
assert_eq!(sniff_image_format_bytes(b"\xff\xd8"), 0);
}
#[test]
fn rejects_truncated_chunks_without_panicking() {
assert!(!is_animated_image_bytes(
+40 -2
View File
@@ -5,8 +5,8 @@ mod rgba;
mod zstd_frame;
mod zstd_stream;
use formats::is_animated_image_bytes;
use rgba::{TransformRequest, crop_rotate_rgba_alloc};
use formats::{is_animated_image_bytes, sniff_image_format_bytes};
use rgba::{TransformRequest, crop_rotate_rgba_alloc, crop_rotate_rgba_into};
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
@@ -40,6 +40,39 @@ pub fn crop_rotate_rgba_raw(
.map_err(|error| JsValue::from_str(error.message()))
}
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn crop_rotate_rgba_into_buffer(
input: &[u8],
output: &mut [u8],
src_width: u32,
src_height: u32,
x: u32,
y: u32,
width: u32,
height: u32,
rotation_deg: u32,
output_width: u32,
output_height: u32,
) -> Result<(), JsValue> {
crop_rotate_rgba_into(
input,
output,
TransformRequest {
src_width,
src_height,
x,
y,
width,
height,
rotation_deg,
resize_width: output_width,
resize_height: output_height,
},
)
.map_err(|error| JsValue::from_str(error.message()))
}
#[wasm_bindgen]
pub fn decompress_zstd_frame(input: &[u8]) -> Result<Vec<u8>, JsValue> {
zstd_frame::decompress(input).map_err(zstd_error_to_js)
@@ -106,6 +139,11 @@ pub fn is_animated_image(input: &[u8]) -> bool {
is_animated_image_bytes(input)
}
#[wasm_bindgen]
pub fn sniff_image_format(input: &[u8]) -> u8 {
sniff_image_format_bytes(input)
}
fn optional_dimension_to_abi(value: Option<u32>) -> u32 {
value.filter(|dimension| *dimension > 0).unwrap_or(u32::MAX)
}
+65 -9
View File
@@ -22,6 +22,7 @@ pub struct TransformRequest {
pub enum TransformError {
InvalidDimensions,
InvalidRgbaLength,
InvalidOutputLength,
EmptyCrop,
EmptyTarget,
ImageTooLarge,
@@ -33,6 +34,7 @@ impl TransformError {
match self {
Self::InvalidDimensions => "invalid RGBA dimensions",
Self::InvalidRgbaLength => "RGBA input length does not match dimensions",
Self::InvalidOutputLength => "RGBA output length does not match target dimensions",
Self::EmptyCrop => "Crop area is empty",
Self::EmptyTarget => "Target dimensions are empty",
Self::ImageTooLarge => "Image is too large to crop",
@@ -75,12 +77,7 @@ pub fn crop_rotate_rgba_alloc(
input: &[u8],
request: TransformRequest,
) -> Result<Vec<u8>, TransformError> {
let geometry = output_geometry(request)?;
let expected_len = rgba_byte_len(request.src_width, request.src_height, 0)?;
if input.len() != expected_len {
return Err(TransformError::InvalidRgbaLength);
}
let geometry = checked_geometry(input, request)?;
let output_len = rgba_byte_len(
geometry.target_width,
geometry.target_height,
@@ -89,8 +86,45 @@ pub fn crop_rotate_rgba_alloc(
let mut output = try_zeroed_vec(output_len)?;
write_u32_le(&mut output, 0, geometry.target_width);
write_u32_le(&mut output, 4, geometry.target_height);
write_transformed(
input,
&mut output[RGBA_RESULT_HEADER_BYTES..],
request,
geometry,
);
Ok(output)
}
let dst = &mut output[RGBA_RESULT_HEADER_BYTES..];
pub fn crop_rotate_rgba_into(
input: &[u8],
output: &mut [u8],
request: TransformRequest,
) -> Result<(), TransformError> {
let geometry = checked_geometry(input, request)?;
if output.len() != rgba_byte_len(geometry.target_width, geometry.target_height, 0)? {
return Err(TransformError::InvalidOutputLength);
}
write_transformed(input, output, request, geometry);
Ok(())
}
fn checked_geometry(
input: &[u8],
request: TransformRequest,
) -> Result<OutputGeometry, TransformError> {
let geometry = output_geometry(request)?;
if input.len() != rgba_byte_len(request.src_width, request.src_height, 0)? {
return Err(TransformError::InvalidRgbaLength);
}
Ok(geometry)
}
fn write_transformed(
input: &[u8],
dst: &mut [u8],
request: TransformRequest,
geometry: OutputGeometry,
) {
if geometry.target_width == geometry.base_width
&& geometry.target_height == geometry.base_height
{
@@ -98,8 +132,6 @@ pub fn crop_rotate_rgba_alloc(
} else {
copy_rotated_with_nearest_resize(input, dst, request, geometry);
}
Ok(output)
}
fn output_geometry(request: TransformRequest) -> Result<OutputGeometry, TransformError> {
@@ -463,6 +495,30 @@ mod tests {
assert!(crop_rotate_rgba_alloc(&rgba(&[1, 2, 3, 4]), empty_target).is_ok());
}
#[test]
fn into_writes_the_same_pixels_as_alloc() {
let input = rgba(&[1, 2, 3, 4, 5, 6]);
for rotation_deg in [0, 90, 180, 270] {
let mut transform = request(2, 3);
transform.rotation_deg = rotation_deg;
transform.resize_width = 5;
transform.resize_height = 4;
let expected = crop_rotate_rgba_alloc(&input, transform).unwrap();
let mut output = vec![0xaa; 5 * 4 * RGBA_BYTES_PER_PIXEL];
crop_rotate_rgba_into(&input, &mut output, transform).unwrap();
assert_eq!(output, payload(&expected));
}
}
#[test]
fn into_rejects_an_output_of_the_wrong_length() {
let mut output = vec![0; 3 * RGBA_BYTES_PER_PIXEL];
assert_eq!(
crop_rotate_rgba_into(&rgba(&[1, 2, 3, 4]), &mut output, request(2, 2)).unwrap_err(),
TransformError::InvalidOutputLength
);
}
proptest! {
#[test]
fn identity_transform_preserves_pixels(width in 1u32..16, height in 1u32..16) {
+160
View File
@@ -0,0 +1,160 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "find-msvc-tools"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
[[package]]
name = "glob"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "libfluxwebp"
version = "0.1.0"
dependencies = [
"libwebp-sys",
"wasm-bindgen",
]
[[package]]
name = "libwebp-sys"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b3a87b44e34d17161e4f17d92a463d596cb13825dcd1758ed18fd3a721e189c"
dependencies = [
"cc",
"glob",
"pkg-config",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "wasm-bindgen"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
dependencies = [
"unicode-ident",
]
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "libfluxwebp"
version = "0.1.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "=0.2.128"
libwebp-sys = {version = "=0.14.4", default-features = false}
[profile.release]
codegen-units = 1
lto = true
opt-level = "z"
panic = "abort"
strip = true
[workspace]
@@ -0,0 +1,21 @@
MIT License
Copyright (c) the libwebp-sys authors (XianYou, Kornel Lesiński and contributors)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,30 @@
Copyright (c) 2010, Google Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of Google nor the names of its contributors may
be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+16
View File
@@ -0,0 +1,16 @@
# libfluxwebp licenses
`libfluxwebp_bg.wasm` and `libfluxwebp_simd_bg.wasm` are built from this crate
and bundled into the app. They contain third-party code that keeps its upstream
license.
| Component | Source | License |
| --- | --- | --- |
| libwebp 1.6.0 | Vendored by the `libwebp-sys` 0.14.4 crate | BSD-3-Clause, see `LICENSE-LIBWEBP.txt`, with the additional patent grant in `PATENTS-LIBWEBP.txt` |
| `libwebp-sys` 0.14.4 | Rust bindings and build script | MIT, see `LICENSE-LIBWEBP-SYS.txt` |
| `simd/xmmintrin.h`, `simd/emmintrin.h` | Emscripten 4.0.15 SSE compatibility headers | MIT or University of Illinois/NCSA, see `simd/LICENSE` |
The SIMD build compiles libwebp's SSE2 code paths through the Emscripten
headers, unmodified. The headers in `shim/` and `src/shim.rs` are Fluxer code.
No Fluxer license notice grants rights to third-party trademarks or brand names.
@@ -0,0 +1,23 @@
Additional IP Rights Grant (Patents)
------------------------------------
"These implementations" means the copyrightable works that implement the WebM
codecs distributed by Google as part of the WebM Project.
Google hereby grants to you a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable (except as stated in this section) patent license to
make, have made, use, offer to sell, sell, import, transfer, and otherwise
run, modify and propagate the contents of these implementations of WebM, where
such license applies only to those patent claims, both currently owned by
Google and acquired in the future, licensable by Google that are necessarily
infringed by these implementations of WebM. This grant does not include claims
that would be infringed only as a consequence of further modification of these
implementations. If you or your agent or exclusive licensee institute or order
or agree to the institution of patent litigation or any other patent
enforcement activity against any entity (including a cross-claim or
counterclaim in a lawsuit) alleging that any of these implementations of WebM
or any code incorporated within any of these implementations of WebM
constitute direct or contributory patent infringement, or inducement of
patent infringement, then any patent rights granted to you under this License
for these implementations of WebM shall terminate as of the date such
litigation is filed.
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#ifndef FLUXWEBP_ASSERT_H
#define FLUXWEBP_ASSERT_H
#define assert(expr) ((void)0)
#endif
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#ifndef FLUXWEBP_INTTYPES_H
#define FLUXWEBP_INTTYPES_H
#include <stdint.h>
#endif
+33
View File
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#ifndef FLUXWEBP_MATH_H
#define FLUXWEBP_MATH_H
double fluxwebp_shim_pow(double x, double y);
double fluxwebp_shim_log(double x);
float fluxwebp_shim_expf(float x);
double fluxwebp_shim_log10(double x);
float fluxwebp_shim_logf(float x);
double fluxwebp_shim_round(double x);
#define pow(x, y) fluxwebp_shim_pow(x, y)
#define log(x) fluxwebp_shim_log(x)
#define expf(x) fluxwebp_shim_expf(x)
#define log10(x) fluxwebp_shim_log10(x)
#define logf(x) fluxwebp_shim_logf(x)
#define round(x) fluxwebp_shim_round(x)
#define fabs(x) __builtin_fabs(x)
#define floor(x) __builtin_floor(x)
#define ceil(x) __builtin_ceil(x)
#define sqrt(x) __builtin_sqrt(x)
#define sqrtf(x) __builtin_sqrtf(x)
#define rint(x) __builtin_rint(x)
#define rintf(x) __builtin_rintf(x)
#define fabsf(x) __builtin_fabsf(x)
#define floorf(x) __builtin_floorf(x)
#define ceilf(x) __builtin_ceilf(x)
#define isnan(x) __builtin_isnan(x)
#define isinf(x) __builtin_isinf(x)
#define lrint(x) ((long)__builtin_rint(x))
#define llrint(x) ((long long)__builtin_rint(x))
#define lrintf(x) ((long)__builtin_rintf(x))
#define llrintf(x) ((long long)__builtin_rintf(x))
#endif
+50
View File
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#ifndef FLUXWEBP_STDIO_H
#define FLUXWEBP_STDIO_H
#include <stddef.h>
#include <stdarg.h>
typedef struct FluxWebpFile FILE;
#define stderr ((FILE*)0)
#define stdout ((FILE*)0)
#define fprintf(f, ...) ((void)0)
#define printf(...) ((void)0)
#define fflush(f) ((void)0)
static inline void fluxwebp_shim_put(char* buf, size_t cap, size_t* n, char c) {
if (*n + 1 < cap) buf[*n] = c;
(*n)++;
}
static inline int snprintf(char* buf, size_t cap, const char* fmt, ...) {
va_list ap;
size_t n = 0;
va_start(ap, fmt);
for (; *fmt; fmt++) {
if (*fmt != '%') {
fluxwebp_shim_put(buf, cap, &n, *fmt);
continue;
}
fmt++;
if (*fmt == 's') {
const char* s = va_arg(ap, const char*);
while (s && *s) fluxwebp_shim_put(buf, cap, &n, *s++);
} else if (*fmt == 'd') {
int v = va_arg(ap, int);
char tmp[12];
int i = 0;
unsigned int u = v < 0 ? 0u - (unsigned int)v : (unsigned int)v;
if (v < 0) fluxwebp_shim_put(buf, cap, &n, '-');
do { tmp[i++] = (char)('0' + u % 10); u /= 10; } while (u);
while (i) fluxwebp_shim_put(buf, cap, &n, tmp[--i]);
} else if (*fmt == '%') {
fluxwebp_shim_put(buf, cap, &n, '%');
} else {
break;
}
}
va_end(ap);
if (cap) buf[n < cap ? n : cap - 1] = '\0';
return (int)n;
}
#endif
+16
View File
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#ifndef FLUXWEBP_STDLIB_H
#define FLUXWEBP_STDLIB_H
#include <stddef.h>
void* fluxwebp_shim_malloc(size_t size);
void* fluxwebp_shim_calloc(size_t nmemb, size_t size);
void fluxwebp_shim_free(void* ptr);
void fluxwebp_shim_qsort(void* base, size_t nitems, size_t size, int (*compar)(const void*, const void*));
#define malloc(size) fluxwebp_shim_malloc(size)
#define calloc(nmemb, size) fluxwebp_shim_calloc(nmemb, size)
#define free(ptr) fluxwebp_shim_free(ptr)
#define qsort(base, nitems, size, compar) fluxwebp_shim_qsort(base, nitems, size, compar)
#define abort() __builtin_trap()
static inline int abs(int x) { return x < 0 ? -x : x; }
#endif
+11
View File
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#ifndef FLUXWEBP_STRING_H
#define FLUXWEBP_STRING_H
#include <stddef.h>
#define memcpy(d, s, n) __builtin_memcpy(d, s, n)
#define memmove(d, s, n) __builtin_memmove(d, s, n)
#define memset(d, c, n) __builtin_memset(d, c, n)
#define memcmp(a, b, n) __builtin_memcmp(a, b, n)
#define strlen(s) __builtin_strlen(s)
#endif
+102
View File
@@ -0,0 +1,102 @@
Emscripten is available under 2 licenses, the MIT license and the
University of Illinois/NCSA Open Source License.
Both are permissive open source licenses, with little if any
practical difference between them.
The reason for offering both is that (1) the MIT license is
well-known, while (2) the University of Illinois/NCSA Open Source
License allows Emscripten's code to be integrated upstream into
LLVM, which uses that license, should the opportunity arise.
The full text of both licenses follows.
==============================================================================
Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
==============================================================================
Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file.
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal with the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimers.
Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimers
in the documentation and/or other materials provided with the
distribution.
Neither the names of Mozilla,
nor the names of its contributors may be used to endorse
or promote products derived from this Software without specific prior
written permission.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR
ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE SOFTWARE.
==============================================================================
This program uses portions of Node.js source code located in src/library_path.js,
in accordance with the terms of the MIT license. Node's license follows:
"""
Copyright Joyent, Inc. and other Node contributors. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
"""
The musl libc project is bundled in this repo, and it has the MIT license, see
system/lib/libc/musl/COPYRIGHT
The third_party/ subdirectory contains code with other licenses. None of it is
used by default, but certain options use it (e.g., the optional closure compiler
flag will run closure compiler from third_party/).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,714 @@
/*
* Copyright 2020 The Emscripten Authors. All rights reserved.
* Emscripten is available under two separate licenses, the MIT license and the
* University of Illinois/NCSA Open Source License. Both these licenses can be
* found in the LICENSE file.
*/
#ifndef __emscripten_xmmintrin_h__
#define __emscripten_xmmintrin_h__
#include <wasm_simd128.h>
#include <limits.h>
#include <math.h>
#include <string.h>
#ifndef __SSE__
#error "SSE instruction set not enabled"
#endif
#ifdef WASM_SIMD_COMPAT_SLOW
#define DIAGNOSE_SLOW diagnose_if(1, "Instruction emulated via slow path.", "warning")
#else
#define DIAGNOSE_SLOW
#endif
// Emscripten SIMD support doesn't support MMX/float32x2/__m64.
// However, we support loading and storing 2-vectors, so
// recognize the type at least.
typedef float __m64 __attribute__((__vector_size__(8), __aligned__(8)));
typedef __f32x4 __m128;
typedef v128_t __m128i;
#define __f32x4_shuffle(__a, __b, __c0, __c1, __c2, __c3) \
((v128_t)(__builtin_shufflevector((__f32x4)(__a), (__f32x4)(__b), __c0, \
__c1, __c2, __c3)))
// This is defined as a macro because __builtin_shufflevector requires its
// mask argument to be a compile-time constant.
#define _mm_shuffle_ps(__a, __b, __mask) __extension__ ({ \
((__m128)__f32x4_shuffle(__a, __b, \
(((__mask) >> 0) & 0x3) + 0, \
(((__mask) >> 2) & 0x3) + 0, \
(((__mask) >> 4) & 0x3) + 4, \
(((__mask) >> 6) & 0x3) + 4)); })
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_set_ps(float __z, float __y, float __x, float __w)
{
return (__m128)wasm_f32x4_make(__w, __x, __y, __z);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_setr_ps(float __z, float __y, float __x, float __w)
{
return (__m128)wasm_f32x4_make(__z, __y, __x, __w);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_set_ss(float __w)
{
return (__m128)wasm_f32x4_make(__w, 0, 0, 0);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_set_ps1(float __w)
{
return (__m128)wasm_f32x4_splat(__w);
}
#define _mm_set1_ps _mm_set_ps1
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_setzero_ps(void)
{
return (__m128)wasm_f32x4_const(0.f, 0.f, 0.f, 0.f);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_load_ps(const float *__p)
{
return *(__m128*)__p;
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_loadl_pi(__m128 __a, const void /*__m64*/ *__p)
{
return (__m128)wasm_v128_load64_lane(__p, (v128_t)__a, 0);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_loadh_pi(__m128 __a, const void /*__m64*/ *__p)
{
return (__m128)wasm_v128_load64_lane(__p, (v128_t)__a, 1);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_loadr_ps(const float *__p)
{
__m128 __v = _mm_load_ps(__p);
return (__m128)__f32x4_shuffle(__v, __v, 3, 2, 1, 0);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_loadu_ps(const float *__p)
{
return (__m128)wasm_v128_load(__p);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_load_ps1(const float *__p)
{
return (__m128)wasm_v32x4_load_splat(__p);
}
#define _mm_load1_ps _mm_load_ps1
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_load_ss(const float *__p)
{
return (__m128)wasm_v128_load32_zero(__p);
}
static __inline__ void __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_storel_pi(__m64 *__p, __m128 __a)
{
wasm_v128_store64_lane((void*)__p, (v128_t)__a, 0);
}
static __inline__ void __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_storeh_pi(__m64 *__p, __m128 __a)
{
wasm_v128_store64_lane((void*)__p, (v128_t)__a, 1);
}
static __inline__ void __attribute__((__always_inline__, __nodebug__))
_mm_store_ps(float *__p, __m128 __a)
{
*(__m128 *)__p = __a;
}
// No NTA cache hint available.
#define _mm_stream_ps _mm_store_ps
#define _MM_HINT_T0 3
#define _MM_HINT_T1 2
#define _MM_HINT_T2 1
#define _MM_HINT_NTA 0
// No prefetch available, dummy it out.
static __inline__ void __attribute__((__always_inline__, __nodebug__))
_mm_prefetch(const void *__p, int __i)
{
((void)__p);
((void)__i);
}
static __inline__ void __attribute__((__always_inline__, __nodebug__))
_mm_sfence(void)
{
// Wasm/SharedArrayBuffer memory model is sequentially consistent.
// Perhaps a future version of the spec can provide a related fence.
__sync_synchronize();
}
#define _MM_SHUFFLE(w, z, y, x) (((w) << 6) | ((z) << 4) | ((y) << 2) | (x))
static __inline__ void __attribute__((__always_inline__, __nodebug__))
_mm_storer_ps(float *__p, __m128 __a)
{
_mm_store_ps(__p, _mm_shuffle_ps(__a, __a, _MM_SHUFFLE(0, 1, 2, 3)));
}
static __inline__ void __attribute__((__always_inline__, __nodebug__))
_mm_store_ps1(float *__p, __m128 __a)
{
_mm_store_ps(__p, _mm_shuffle_ps(__a, __a, _MM_SHUFFLE(0, 0, 0, 0)));
}
#define _mm_store1_ps _mm_store_ps1
static __inline__ void __attribute__((__always_inline__, __nodebug__))
_mm_store_ss(float *__p, __m128 __a)
{
wasm_v128_store32_lane((void*)__p, (v128_t)__a, 0);
}
static __inline__ void __attribute__((__always_inline__, __nodebug__))
_mm_storeu_ps(float *__p, __m128 __a)
{
struct __unaligned {
__m128 __v;
} __attribute__((__packed__, __may_alias__));
((struct __unaligned *)__p)->__v = __a;
}
static __inline__ int __attribute__((__always_inline__, __nodebug__))
_mm_movemask_ps(__m128 __a)
{
return (int)wasm_i32x4_bitmask((v128_t)__a);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_move_ss(__m128 __a, __m128 __b)
{
return (__m128)__f32x4_shuffle(__a, __b, 4, 1, 2, 3);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_add_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_add((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_add_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_add_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_sub_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_sub((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_sub_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_sub_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_mul_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_mul((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_mul_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_mul_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_div_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_div((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_div_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_div_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_min_ps(__m128 __a, __m128 __b)
{
// return (__m128)wasm_f32x4_pmin((v128_t)__a, (v128_t)__b); // TODO: Migrate to this, once it works in VMs
return (__m128)wasm_v128_bitselect((v128_t)__a, (v128_t)__b, (v128_t)wasm_f32x4_lt((v128_t)__a, (v128_t)__b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_min_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_min_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_max_ps(__m128 __a, __m128 __b)
{
// return (__m128)wasm_f32x4_pmax((v128_t)__a, (v128_t)__b); // TODO: Migrate to this, once it works in VMs
return (__m128)wasm_v128_bitselect((v128_t)__a, (v128_t)__b, (v128_t)wasm_f32x4_gt((v128_t)__a, (v128_t)__b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_max_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_max_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_rcp_ps(__m128 __a)
{
return (__m128)wasm_f32x4_div((v128_t)_mm_set1_ps(1.0f), (v128_t)__a);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_rcp_ss(__m128 __a)
{
return _mm_move_ss(__a, _mm_rcp_ps(__a));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_sqrt_ps(__m128 __a)
{
return (__m128)wasm_f32x4_sqrt((v128_t)__a);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_sqrt_ss(__m128 __a)
{
return _mm_move_ss(__a, _mm_sqrt_ps(__a));
}
#define _mm_rsqrt_ps(__a) _mm_rcp_ps(_mm_sqrt_ps((__a)))
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_rsqrt_ss(__m128 __a)
{
return _mm_move_ss(__a, _mm_rsqrt_ps(__a));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_unpackhi_ps(__m128 __a, __m128 __b)
{
return (__m128)__f32x4_shuffle(__a, __b, 2, 6, 3, 7);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_unpacklo_ps(__m128 __a, __m128 __b)
{
return (__m128)__f32x4_shuffle(__a, __b, 0, 4, 1, 5);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_movehl_ps(__m128 __a, __m128 __b)
{
return (__m128)__f32x4_shuffle(__a, __b, 6, 7, 2, 3);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_movelh_ps(__m128 __a, __m128 __b)
{
return (__m128)__f32x4_shuffle(__a, __b, 0, 1, 4, 5);
}
#define _MM_TRANSPOSE4_PS(row0, row1, row2, row3) \
do { \
__m128 __row0 = (row0); \
__m128 __row1 = (row1); \
__m128 __row2 = (row2); \
__m128 __row3 = (row3); \
__m128 __tmp0 = _mm_unpacklo_ps(__row0, __row1); \
__m128 __tmp1 = _mm_unpackhi_ps(__row0, __row1); \
__m128 __tmp2 = _mm_unpacklo_ps(__row2, __row3); \
__m128 __tmp3 = _mm_unpackhi_ps(__row2, __row3); \
(row0) = _mm_movelh_ps(__tmp0, __tmp2); \
(row1) = _mm_movehl_ps(__tmp2, __tmp0); \
(row2) = _mm_movelh_ps(__tmp1, __tmp3); \
(row3) = _mm_movehl_ps(__tmp3, __tmp1); \
} while (0)
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmplt_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_lt((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmplt_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmplt_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmple_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_le((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmple_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmple_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpeq_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_eq((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpeq_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpeq_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpge_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_ge((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpge_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpge_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpgt_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_gt((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpgt_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpgt_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cmpord_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_and(wasm_f32x4_eq((v128_t)__a, (v128_t)__a),
wasm_f32x4_eq((v128_t)__b, (v128_t)__b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cmpord_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpord_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cmpunord_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_or(wasm_f32x4_ne((v128_t)__a, (v128_t)__a),
wasm_f32x4_ne((v128_t)__b, (v128_t)__b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cmpunord_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpunord_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_and_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_and((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_andnot_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_andnot((v128_t)__b, (v128_t)__a);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_or_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_or((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_xor_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_xor((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpneq_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_f32x4_ne((v128_t)__a, (v128_t)__b);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpneq_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpneq_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpnge_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_not((v128_t)_mm_cmpge_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpnge_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpnge_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpngt_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_not((v128_t)_mm_cmpgt_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpngt_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpngt_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpnle_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_not((v128_t)_mm_cmple_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpnle_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpnle_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpnlt_ps(__m128 __a, __m128 __b)
{
return (__m128)wasm_v128_not((v128_t)_mm_cmplt_ps(__a, __b));
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_cmpnlt_ss(__m128 __a, __m128 __b)
{
return _mm_move_ss(__a, _mm_cmpnlt_ps(__a, __b));
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_comieq_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) == wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_comige_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) >= wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_comigt_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) > wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_comile_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) <= wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_comilt_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) < wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_comineq_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) != wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_ucomieq_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) == wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_ucomige_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) >= wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_ucomigt_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) > wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_ucomile_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) <= wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_ucomilt_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) < wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_ucomineq_ss(__m128 __a, __m128 __b)
{
return wasm_f32x4_extract_lane((v128_t)__a, 0) != wasm_f32x4_extract_lane((v128_t)__b, 0);
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_cvtsi32_ss(__m128 __a, int __b)
{
__f32x4 __v = (__f32x4)__a;
__v[0] = (float)__b;
return (__m128)__v;
}
#define _mm_cvt_si2ss _mm_cvtsi32_ss
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cvtss_si32(__m128 __a)
{
float e = ((__f32x4)__a)[0];
if (e < 2147483648.0f && e >= -2147483648.0f && (lrint(e) != 0 || fabsf(e) < 2.f))
return lrint(e);
else
return (int)0x80000000;
}
#define _mm_cvt_ss2si _mm_cvtss_si32
static __inline__ int __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW)) _mm_cvttss_si32(__m128 __a)
{
float e = ((__f32x4)__a)[0];
if (e < 2147483648.0f && e >= -2147483648.0f && (lrint(e) != 0 || fabsf(e) < 2.f))
return (int)e;
else
return (int)0x80000000;
}
#define _mm_cvtt_ss2si _mm_cvttss_si32
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_cvtsi64_ss(__m128 __a, long long __b)
{
__f32x4 __v = (__f32x4)__a;
__v[0] = (float)__b;
return (__m128)__v;
}
static __inline__ long long __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_cvtss_si64(__m128 __a)
{
float e = ((__f32x4)__a)[0];
long long x = llrintf(e);
if (e <= LLONG_MAX && e >= LLONG_MIN && (x != 0 || fabsf(e) < 2.f))
return x;
else
return 0x8000000000000000LL;
}
static __inline__ long long __attribute__((__always_inline__, __nodebug__, DIAGNOSE_SLOW))
_mm_cvttss_si64(__m128 __a)
{
float e = ((__f32x4)__a)[0];
long long x = llrintf(e);
if (e <= LLONG_MAX && e >= LLONG_MIN && (x != 0 || fabsf(e) < 2.f))
return (long long)e;
else
return 0x8000000000000000LL;
}
static __inline__ float __attribute__((__always_inline__, __nodebug__))
_mm_cvtss_f32(__m128 __a)
{
return (float)((__f32x4)__a)[0];
}
#define _mm_malloc(__size, __align) memalign((__align), (__size))
#define _mm_free free
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_undefined()
{
__m128 val;
return val;
}
static __inline__ __m128 __attribute__((__always_inline__, __nodebug__))
_mm_undefined_ps()
{
__m128 val;
return val;
}
#define _MM_EXCEPT_MASK 0x003f
#define _MM_EXCEPT_INVALID 0x0001
#define _MM_EXCEPT_DENORM 0x0002
#define _MM_EXCEPT_DIV_ZERO 0x0004
#define _MM_EXCEPT_OVERFLOW 0x0008
#define _MM_EXCEPT_UNDERFLOW 0x0010
#define _MM_EXCEPT_INEXACT 0x0020
#define _MM_MASK_MASK 0x1f80
#define _MM_MASK_INVALID 0x0080
#define _MM_MASK_DENORM 0x0100
#define _MM_MASK_DIV_ZERO 0x0200
#define _MM_MASK_OVERFLOW 0x0400
#define _MM_MASK_UNDERFLOW 0x0800
#define _MM_MASK_INEXACT 0x1000
#define _MM_ROUND_MASK 0x6000
#define _MM_ROUND_NEAREST 0x0000
#define _MM_ROUND_DOWN 0x2000
#define _MM_ROUND_UP 0x4000
#define _MM_ROUND_TOWARD_ZERO 0x6000
#define _MM_FLUSH_ZERO_MASK 0x8000
#define _MM_FLUSH_ZERO_ON 0x8000
#define _MM_FLUSH_ZERO_OFF 0x0000
static __inline__ int __attribute__((__always_inline__, __nodebug__))
_mm_getcsr()
{
return _MM_MASK_INEXACT | _MM_MASK_DENORM | _MM_MASK_DIV_ZERO | _MM_MASK_OVERFLOW | _MM_MASK_UNDERFLOW | _MM_MASK_INVALID
| _MM_ROUND_NEAREST | _MM_FLUSH_ZERO_OFF;
}
#define _MM_GET_EXCEPTION_STATE() (_mm_getcsr() & _MM_EXCEPT_MASK)
#define _MM_GET_EXCEPTION_MASK() (_mm_getcsr() & _MM_MASK_MASK)
#define _MM_GET_ROUNDING_MODE() (_mm_getcsr() & _MM_ROUND_MASK)
#define _MM_GET_FLUSH_ZERO_MODE() (_mm_getcsr() & _MM_FLUSH_ZERO_MASK)
// Unavailable functions:
// void _MM_SET_EXCEPTION_STATE(unsigned int __a);
// void _MM_SET_EXCEPTION_MASK(unsigned int __a);
// void _MM_GET_ROUNDING_MODE(unsigned int __a);
// void _MM_GET_FLUSH_ZERO_MODE(unsigned int __a);
#endif /* __emscripten_xmmintrin_h__ */
+327
View File
@@ -0,0 +1,327 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
mod shim;
use libwebp_sys as ffi;
use std::ffi::CStr;
use std::ptr;
use wasm_bindgen::prelude::*;
const DEMUX_ABI: i32 = ffi::WEBP_DEMUX_ABI_VERSION as i32;
const MUX_ABI: i32 = ffi::WEBP_MUX_ABI_VERSION as i32;
const ENCODER_ABI: i32 = ffi::WEBP_ENCODER_ABI_VERSION as i32;
const DECODER_ABI: i32 = ffi::WEBP_DECODER_ABI_VERSION as i32;
const LOSSLESS_FORMAT: i32 = 2;
fn webp_data(bytes: &[u8]) -> ffi::WebPData {
ffi::WebPData {
bytes: bytes.as_ptr(),
size: bytes.len(),
}
}
#[wasm_bindgen]
pub fn webp_probe(bytes: &[u8]) -> Result<Box<[u32]>, JsError> {
let data = webp_data(bytes);
let dmux = unsafe { ffi::WebPDemuxInternal(&data, 0, ptr::null_mut(), DEMUX_ABI) };
if dmux.is_null() {
return Err(JsError::new("webp demux failed"));
}
let get = |feature| unsafe { ffi::WebPDemuxGetI(dmux, feature) };
let width = get(ffi::WebPFormatFeature::WEBP_FF_CANVAS_WIDTH);
let height = get(ffi::WebPFormatFeature::WEBP_FF_CANVAS_HEIGHT);
let frame_count = get(ffi::WebPFormatFeature::WEBP_FF_FRAME_COUNT);
let loop_count = get(ffi::WebPFormatFeature::WEBP_FF_LOOP_COUNT);
let flags = get(ffi::WebPFormatFeature::WEBP_FF_FORMAT_FLAGS);
let has_alpha = flags & ffi::WebPFeatureFlags::ALPHA_FLAG as u32 != 0;
let all_lossless = unsafe { all_frames_lossless(dmux) };
unsafe { ffi::WebPDemuxDelete(dmux) };
Ok(Box::new([
width,
height,
frame_count,
loop_count,
has_alpha as u32,
all_lossless as u32,
]))
}
unsafe fn all_frames_lossless(dmux: *const ffi::WebPDemuxer) -> bool {
unsafe {
let mut iter: ffi::WebPIterator = std::mem::zeroed();
if ffi::WebPDemuxGetFrame(dmux, 1, &mut iter) == 0 {
return false;
}
let mut lossless = true;
loop {
let mut features: ffi::WebPBitstreamFeatures = std::mem::zeroed();
let status = ffi::WebPGetFeaturesInternal(
iter.fragment.bytes,
iter.fragment.size,
&mut features,
DECODER_ABI,
);
if status != ffi::VP8StatusCode::VP8_STATUS_OK || features.format != LOSSLESS_FORMAT {
lossless = false;
break;
}
if ffi::WebPDemuxNextFrame(&mut iter) == 0 {
break;
}
}
ffi::WebPDemuxReleaseIterator(&mut iter);
lossless
}
}
#[wasm_bindgen]
pub struct AnimDecoder {
dec: *mut ffi::WebPAnimDecoder,
_bytes: Box<[u8]>,
frame: *mut u8,
timestamp: i32,
}
#[wasm_bindgen]
impl AnimDecoder {
#[wasm_bindgen(constructor)]
pub fn new(bytes: Box<[u8]>) -> Result<AnimDecoder, JsError> {
let dec = unsafe {
let mut options: ffi::WebPAnimDecoderOptions = std::mem::zeroed();
if ffi::WebPAnimDecoderOptionsInitInternal(&mut options, DEMUX_ABI) == 0 {
return Err(JsError::new("webp decoder options init failed"));
}
options.color_mode = ffi::WEBP_CSP_MODE::MODE_RGBA;
options.use_threads = 0;
let data = webp_data(&bytes);
ffi::WebPAnimDecoderNewInternal(&data, &options, DEMUX_ABI)
};
if dec.is_null() {
return Err(JsError::new("webp decoder init failed"));
}
Ok(AnimDecoder {
dec,
_bytes: bytes,
frame: ptr::null_mut(),
timestamp: 0,
})
}
#[wasm_bindgen(js_name = next)]
pub fn next_frame(&mut self) -> Result<bool, JsError> {
if unsafe { ffi::WebPAnimDecoderHasMoreFrames(self.dec) } == 0 {
return Ok(false);
}
let mut frame = ptr::null_mut();
let mut timestamp = 0;
if unsafe { ffi::WebPAnimDecoderGetNext(self.dec, &mut frame, &mut timestamp) } == 0 {
return Err(JsError::new("webp frame decode failed"));
}
self.frame = frame;
self.timestamp = timestamp;
Ok(true)
}
#[wasm_bindgen(getter)]
pub fn frame_ptr(&self) -> u32 {
self.frame as usize as u32
}
#[wasm_bindgen(getter)]
pub fn end_timestamp(&self) -> i32 {
self.timestamp
}
}
impl Drop for AnimDecoder {
fn drop(&mut self) {
unsafe { ffi::WebPAnimDecoderDelete(self.dec) };
}
}
#[wasm_bindgen]
pub struct AnimEncoder {
enc: *mut ffi::WebPAnimEncoder,
width: u32,
height: u32,
lossless: bool,
}
#[wasm_bindgen]
impl AnimEncoder {
#[wasm_bindgen(constructor)]
pub fn new(
width: u32,
height: u32,
loop_count: u32,
lossless: bool,
) -> Result<AnimEncoder, JsError> {
let (kmin, kmax) = if lossless { (9, 17) } else { (4, 5) };
let enc = unsafe {
let mut options: ffi::WebPAnimEncoderOptions = std::mem::zeroed();
if ffi::WebPAnimEncoderOptionsInitInternal(&mut options, MUX_ABI) == 0 {
return Err(JsError::new("webp encoder options init failed"));
}
options.anim_params.loop_count = loop_count.min(i32::MAX as u32) as i32;
options.anim_params.bgcolor = 0;
options.minimize_size = 0;
options.allow_mixed = 0;
options.kmin = kmin;
options.kmax = kmax;
ffi::WebPAnimEncoderNewInternal(width as i32, height as i32, &options, MUX_ABI)
};
if enc.is_null() {
return Err(JsError::new("webp encoder init failed"));
}
Ok(AnimEncoder {
enc,
width,
height,
lossless,
})
}
pub fn add(
&mut self,
rgba: &[u8],
start_ms: i32,
quality: f32,
method: i32,
) -> Result<(), JsError> {
if rgba.len() as u64 != u64::from(self.width) * u64::from(self.height) * 4 {
return Err(JsError::new("webp frame size mismatch"));
}
let added = unsafe {
let mut config: ffi::WebPConfig = std::mem::zeroed();
if ffi::WebPConfigInitInternal(
&mut config,
ffi::WebPPreset::WEBP_PRESET_DEFAULT,
quality,
ENCODER_ABI,
) == 0
{
return Err(JsError::new("webp config init failed"));
}
config.quality = quality;
config.method = method;
config.lossless = self.lossless as i32;
config.thread_level = 0;
config.alpha_quality = 100;
config.exact = 0;
config.use_sharp_yuv = 0;
let mut picture: ffi::WebPPicture = std::mem::zeroed();
if ffi::WebPPictureInitInternal(&mut picture, ENCODER_ABI) == 0 {
return Err(JsError::new("webp picture init failed"));
}
picture.width = self.width as i32;
picture.height = self.height as i32;
picture.use_argb = 1;
if ffi::WebPPictureImportRGBA(&mut picture, rgba.as_ptr(), self.width as i32 * 4) == 0 {
ffi::WebPPictureFree(&mut picture);
return Err(JsError::new("webp picture import failed"));
}
let added = ffi::WebPAnimEncoderAdd(self.enc, &mut picture, start_ms, &config);
ffi::WebPPictureFree(&mut picture);
added
};
if added == 0 {
return Err(self.error("webp frame encode failed"));
}
Ok(())
}
pub fn finish(&mut self, end_ms: i32) -> Result<Vec<u8>, JsError> {
if unsafe { ffi::WebPAnimEncoderAdd(self.enc, ptr::null_mut(), end_ms, ptr::null()) } == 0 {
return Err(self.error("webp encoder flush failed"));
}
let mut data = ffi::WebPData::default();
if unsafe { ffi::WebPAnimEncoderAssemble(self.enc, &mut data) } == 0 {
return Err(self.error("webp assemble failed"));
}
let out = unsafe { std::slice::from_raw_parts(data.bytes, data.size) }.to_vec();
unsafe { ffi::WebPFree(data.bytes as *mut _) };
Ok(out)
}
fn error(&self, what: &str) -> JsError {
let message = unsafe { ffi::WebPAnimEncoderGetError(self.enc) };
if message.is_null() {
return JsError::new(what);
}
let detail = unsafe { CStr::from_ptr(message) }.to_string_lossy();
JsError::new(&format!("{what}: {detail}"))
}
}
impl Drop for AnimEncoder {
fn drop(&mut self) {
unsafe { ffi::WebPAnimEncoderDelete(self.enc) };
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(width: u32, height: u32, seed: u8) -> Vec<u8> {
(0..width * height)
.flat_map(|index| {
let value = (index as u8).wrapping_mul(seed);
[
value,
seed,
value ^ seed,
if index % 3 == 0 { 0 } else { 255 },
]
})
.collect()
}
fn encode(lossless: bool) -> Vec<u8> {
let mut encoder = AnimEncoder::new(16, 8, 3, lossless).expect("encoder");
let delays = [40, 70, 10];
let mut start = 0;
for (index, delay) in delays.iter().enumerate() {
encoder
.add(&frame(16, 8, index as u8 + 1), start, 75.0, 4)
.expect("add");
start += delay;
}
encoder.finish(start).expect("finish")
}
#[test]
fn probe_reports_canvas_frames_loop_alpha_and_lossless() {
let lossless = encode(true);
assert_eq!(
&*webp_probe(&lossless).expect("probe"),
&[16, 8, 3, 3, 1, 1]
);
let lossy = encode(false);
let probe = webp_probe(&lossy).expect("probe");
assert_eq!(&probe[..5], &[16, 8, 3, 3, 1]);
assert_eq!(probe[5], 0);
}
#[test]
fn lossless_round_trip_keeps_pixels_and_end_timestamps() {
let bytes = encode(true);
let mut decoder = AnimDecoder::new(bytes.into_boxed_slice()).expect("decoder");
let mut ends = Vec::new();
let mut index = 0u8;
while decoder.next_frame().expect("next") {
let pixels = unsafe { std::slice::from_raw_parts(decoder.frame, 16 * 8 * 4) };
let expected = frame(16, 8, index + 1);
for (got, want) in pixels.chunks(4).zip(expected.chunks(4)) {
assert_eq!(got[3], want[3]);
if want[3] != 0 {
assert_eq!(got, want);
}
}
ends.push(decoder.end_timestamp());
index += 1;
}
assert_eq!(ends, [40, 110, 120]);
}
}
+110
View File
@@ -0,0 +1,110 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::alloc::{Layout, alloc, alloc_zeroed, dealloc};
use std::ffi::{c_int, c_void};
const ALIGN: usize = 16;
unsafe fn shim_alloc(size: usize, zeroed: bool) -> *mut c_void {
let Some(full) = size.checked_add(ALIGN) else {
return std::ptr::null_mut();
};
let Ok(layout) = Layout::from_size_align(full, ALIGN) else {
return std::ptr::null_mut();
};
unsafe {
let ptr = if zeroed {
alloc_zeroed(layout)
} else {
alloc(layout)
};
if ptr.is_null() {
return std::ptr::null_mut();
}
ptr.cast::<usize>().write(full);
ptr.add(ALIGN).cast()
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fluxwebp_shim_malloc(size: usize) -> *mut c_void {
unsafe { shim_alloc(size, false) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fluxwebp_shim_calloc(nmemb: usize, size: usize) -> *mut c_void {
match nmemb.checked_mul(size) {
Some(total) => unsafe { shim_alloc(total, true) },
None => std::ptr::null_mut(),
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fluxwebp_shim_free(ptr: *mut c_void) {
if ptr.is_null() {
return;
}
unsafe {
let base = ptr.cast::<u8>().sub(ALIGN);
let full = base.cast::<usize>().read();
dealloc(base, Layout::from_size_align_unchecked(full, ALIGN));
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fluxwebp_shim_qsort(
base: *mut c_void,
n: usize,
size: usize,
compar: unsafe extern "C" fn(*const c_void, *const c_void) -> c_int,
) {
if n < 2 || size == 0 {
return;
}
unsafe {
let bytes = std::slice::from_raw_parts_mut(base.cast::<u8>(), n * size);
let copy = bytes.to_vec();
let mut idx: Vec<usize> = (0..n).collect();
idx.sort_by(|&a, &b| {
let r = compar(
copy.as_ptr().add(a * size).cast(),
copy.as_ptr().add(b * size).cast(),
);
r.cmp(&0)
});
for (dst, &src) in idx.iter().enumerate() {
bytes[dst * size..dst * size + size]
.copy_from_slice(&copy[src * size..src * size + size]);
}
}
}
#[unsafe(no_mangle)]
pub extern "C" fn fluxwebp_shim_pow(x: f64, y: f64) -> f64 {
x.powf(y)
}
#[unsafe(no_mangle)]
pub extern "C" fn fluxwebp_shim_log(x: f64) -> f64 {
x.ln()
}
#[unsafe(no_mangle)]
pub extern "C" fn fluxwebp_shim_log10(x: f64) -> f64 {
x.log10()
}
#[unsafe(no_mangle)]
pub extern "C" fn fluxwebp_shim_expf(x: f32) -> f32 {
x.exp()
}
#[unsafe(no_mangle)]
pub extern "C" fn fluxwebp_shim_logf(x: f32) -> f32 {
x.ln()
}
#[unsafe(no_mangle)]
pub extern "C" fn fluxwebp_shim_round(x: f64) -> f64 {
x.round()
}
@@ -11,6 +11,18 @@ const FONT_LICENSE_FILES = [
{source: 'LICENSE-IBM-PLEX.txt', asset: 'assets/fonts-LICENSE-IBM-PLEX.txt'},
];
const WASM_LICENSE_FILES = [
{source: 'libfluxcore/NOTICE.md', asset: 'assets/libfluxcore-NOTICE.txt'},
{source: 'libfluxcore/LICENSE-ZSTD.txt', asset: 'assets/libfluxcore-LICENSE-ZSTD.txt'},
{source: 'libfluxcore/LICENSE-ZSTD-SYS.txt', asset: 'assets/libfluxcore-LICENSE-ZSTD-SYS.txt'},
{source: 'libfluxcore/LICENSE-ZSTD-RS.txt', asset: 'assets/libfluxcore-LICENSE-ZSTD-RS.txt'},
{source: 'libfluxwebp/NOTICE.md', asset: 'assets/libfluxwebp-NOTICE.txt'},
{source: 'libfluxwebp/LICENSE-LIBWEBP.txt', asset: 'assets/libfluxwebp-LICENSE-LIBWEBP.txt'},
{source: 'libfluxwebp/PATENTS-LIBWEBP.txt', asset: 'assets/libfluxwebp-PATENTS-LIBWEBP.txt'},
{source: 'libfluxwebp/LICENSE-LIBWEBP-SYS.txt', asset: 'assets/libfluxwebp-LICENSE-LIBWEBP-SYS.txt'},
{source: 'libfluxwebp/simd/LICENSE', asset: 'assets/libfluxwebp-LICENSE-EMSCRIPTEN.txt'},
];
function resolveStaticCdnEndpoint(staticCdnEndpoint) {
const value = staticCdnEndpoint?.trim().replace(/\/+$/, '');
return value || STATIC_CDN_ENDPOINT_PLACEHOLDER;
@@ -86,17 +98,18 @@ export class StaticFilesPlugin {
constructor(options = {}) {
this.staticCdnEndpoint = options.staticCdnEndpoint;
this.fontsDir = options.fontsDir;
this.wasmCratesDir = options.wasmCratesDir;
}
emitFontLicenses(compilation) {
if (!this.fontsDir) {
emitLicenseFiles(compilation, dir, files, subject) {
if (!dir) {
return;
}
for (const {source, asset} of FONT_LICENSE_FILES) {
const sourcePath = path.join(this.fontsDir, source);
for (const {source, asset} of files) {
const sourcePath = path.join(dir, source);
if (!fs.existsSync(sourcePath)) {
throw new Error(
`StaticFilesPlugin: ${sourcePath} is missing. The bundled fonts may not be redistributed without it.`,
`StaticFilesPlugin: ${sourcePath} is missing. The bundled ${subject} may not be redistributed without it.`,
);
}
compilation.emitAsset(asset, new sources.RawSource(fs.readFileSync(sourcePath)));
@@ -117,7 +130,8 @@ export class StaticFilesPlugin {
new sources.RawSource(generateBrowserConfig(this.staticCdnEndpoint)),
);
compilation.emitAsset('robots.txt', new sources.RawSource(generateRobotsTxt()));
this.emitFontLicenses(compilation);
this.emitLicenseFiles(compilation, this.fontsDir, FONT_LICENSE_FILES, 'fonts');
this.emitLicenseFiles(compilation, this.wasmCratesDir, WASM_LICENSE_FILES, 'WebAssembly modules');
},
);
});
@@ -3,6 +3,7 @@
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
import * as Modal from '@app/features/app/components/dialogs/Modal';
import {PRODUCT_NAME, SUPPORT_EMAIL} from '@app/features/app/config/I18nDisplayConstants';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import {
type RequiredActionFlow,
@@ -20,6 +21,7 @@ import {
ESCAPE_SUCCESS_EMAIL_REMAINS_TOAST_DESCRIPTOR,
ESCAPE_SUCCESS_TOAST_DESCRIPTOR,
ESCAPE_UNAVAILABLE_DESCRIPTOR,
ESCAPE_UNAVAILABLE_SELF_HOSTED_DESCRIPTOR,
GET_NEW_CODE_DESCRIPTOR,
NEXT_DESCRIPTOR,
RESEND_EMAIL_DESCRIPTOR,
@@ -75,7 +77,7 @@ import * as UserCommands from '@app/features/user/commands/UserCommands';
import Users from '@app/features/user/state/Users';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {PhoneGateEscapePreviewResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import type {MessageDescriptor} from '@lingui/core';
import type {I18n, MessageDescriptor} from '@lingui/core';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
@@ -185,6 +187,12 @@ function getIntroDescription(flow: RequiredActionFlow | null, isEmailBounced: bo
return STEP_INTRO_GENERIC_DESCRIPTION_DESCRIPTOR;
}
function escapeUnavailableMessage(i18n: I18n): string {
return RuntimeConfig.isSelfHosted()
? i18n._(ESCAPE_UNAVAILABLE_SELF_HOSTED_DESCRIPTOR)
: i18n._(ESCAPE_UNAVAILABLE_DESCRIPTOR, {supportEmail: SUPPORT_EMAIL});
}
const RequiredActionModal: React.FC<{mock?: boolean}> = observer(({mock = false}) => {
const {i18n} = useLingui();
const user = Users.currentUser;
@@ -394,7 +402,7 @@ const RequiredActionModal: React.FC<{mock?: boolean}> = observer(({mock = false}
const remaining = updated.required_actions;
const phoneRemains = remaining.some((action) => action.includes('PHONE'));
if (phoneRemains) {
setActionError(i18n._(ESCAPE_UNAVAILABLE_DESCRIPTOR, {supportEmail: SUPPORT_EMAIL}));
setActionError(escapeUnavailableMessage(i18n));
return;
}
ToastCommands.success(
@@ -404,7 +412,7 @@ const RequiredActionModal: React.FC<{mock?: boolean}> = observer(({mock = false}
);
} catch (error) {
if (failureCode(error) === APIErrorCodes.PHONE_GATE_ESCAPE_UNAVAILABLE) {
setActionError(i18n._(ESCAPE_UNAVAILABLE_DESCRIPTOR, {supportEmail: SUPPORT_EMAIL}));
setActionError(escapeUnavailableMessage(i18n));
} else if (hadGuildsToLeave) {
setActionError(i18n._(ESCAPE_FAILED_PARTIAL_DESCRIPTOR));
} else {
@@ -265,6 +265,11 @@ export const SUPPORT_LINK_LABEL_DESCRIPTOR = msg({
message: 'Contact support',
comment: 'Link label in the required-action modal. Opens an email to Fluxer support.',
});
export const INSTANCE_ADMIN_CONTACT_DESCRIPTOR = msg({
message: 'Contact the administrators of this instance for help.',
comment:
'Shown in the required-action modal on a self-hosted instance in place of the Contact support link. The instance is run by its own administrators, not by the app maker.',
});
export const ALT_ROUTES_TITLE_DESCRIPTOR = msg({
message: "If you can't verify by SMS",
comment:
@@ -276,6 +281,12 @@ export const ALT_ROUTES_HUMAN_REVIEW_DESCRIPTOR = msg({
comment:
'Body of the human-review option in the required-action modal, rendered directly above the Contact support link. Sets the expectation that a reply takes time.',
});
export const ALT_ROUTES_HUMAN_REVIEW_SELF_HOSTED_DESCRIPTOR = msg({
message:
'Ask the administrators of this instance to review your account. This instance is run independently, and only its administrators can change your account.',
comment:
'Body of the human-review option in the required-action modal on a self-hosted instance. The instance is run by its own administrators, not by the app maker.',
});
export const ESCAPE_BUTTON_DESCRIPTOR = msg({
message: 'Set this check aside',
comment:
@@ -328,6 +339,11 @@ export const ESCAPE_CONFIRM_SUPPORT_DESCRIPTOR = msg({
comment:
'Last line of the set-aside confirmation in the required-action modal. supportEmail is inserted by code. The self-serve route never closes the support route.',
});
export const ESCAPE_CONFIRM_SUPPORT_SELF_HOSTED_DESCRIPTOR = msg({
message: 'You can still ask the administrators of this instance for a human review at any time.',
comment:
'Last line of the set-aside confirmation in the required-action modal on a self-hosted instance. The instance is run by its own administrators, not by the app maker.',
});
export const ESCAPE_CONFIRM_PRIMARY_NO_GUILDS_DESCRIPTOR = msg({
message: 'Set this check aside',
comment:
@@ -353,6 +369,11 @@ export const ESCAPE_UNAVAILABLE_DESCRIPTOR = msg({
comment:
'Shown in the required-action modal when setting the check aside is refused, and when it reported success but the phone requirement is still in force. supportEmail is inserted by code, and the address is named in full because the support link may have scrolled out of view.',
});
export const ESCAPE_UNAVAILABLE_SELF_HOSTED_DESCRIPTOR = msg({
message: 'This is not available on your account right now. Ask the administrators of this instance to review it.',
comment:
'Shown in the required-action modal on a self-hosted instance when setting the check aside is refused, and when it reported success but the phone requirement is still in force.',
});
export const ESCAPE_FAILED_NOTHING_CHANGED_DESCRIPTOR = msg({
message: 'That did not go through. Nothing changed. Try again.',
comment:
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SUPPORT_EMAIL} from '@app/features/app/config/I18nDisplayConstants';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {
ESCAPE_CONFIRM_EMAIL_REMAINS_DESCRIPTOR,
ESCAPE_CONFIRM_LEAVE_DESCRIPTOR,
@@ -9,6 +10,7 @@ import {
ESCAPE_CONFIRM_PRIMARY_NO_GUILDS_DESCRIPTOR,
ESCAPE_CONFIRM_PRIMARY_WITH_GUILDS_DESCRIPTOR,
ESCAPE_CONFIRM_SUPPORT_DESCRIPTOR,
ESCAPE_CONFIRM_SUPPORT_SELF_HOSTED_DESCRIPTOR,
ESCAPE_CONFIRM_TITLE_NO_GUILDS_DESCRIPTOR,
ESCAPE_CONFIRM_TITLE_WITH_GUILDS_DESCRIPTOR,
ESCAPE_HINT_NO_GUILDS_DESCRIPTOR,
@@ -60,7 +62,11 @@ export function buildPhoneGateEscapeConfirmCopy(i18n: I18n, plan: PhoneGateEscap
if (plan.emailStepRemains) {
bodyLines.push(i18n._(ESCAPE_CONFIRM_EMAIL_REMAINS_DESCRIPTOR));
}
bodyLines.push(i18n._(ESCAPE_CONFIRM_SUPPORT_DESCRIPTOR, {supportEmail: SUPPORT_EMAIL}));
bodyLines.push(
RuntimeConfig.isSelfHosted()
? i18n._(ESCAPE_CONFIRM_SUPPORT_SELF_HOSTED_DESCRIPTOR)
: i18n._(ESCAPE_CONFIRM_SUPPORT_DESCRIPTOR, {supportEmail: SUPPORT_EMAIL}),
);
return {
title: leaving
? i18n._(ESCAPE_CONFIRM_TITLE_WITH_GUILDS_DESCRIPTOR, {count: plan.guildNames.length})
@@ -4,10 +4,12 @@ import {showGenericErrorModal} from '@app/features/app/components/alerts/Generic
import * as Modal from '@app/features/app/components/dialogs/Modal';
import {ExternalLink} from '@app/features/app/components/shared/ExternalLink';
import {EXAMPLE_EMAIL, SUPPORT_EMAIL, SUPPORT_EMAIL_MAILTO} from '@app/features/app/config/I18nDisplayConstants';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import styles from '@app/features/auth/components/modals/RequiredActionModal.module.css';
import {
A_NEW_VERIFICATION_CODE_HAS_BEEN_SENT_DESCRIPTOR,
ALT_ROUTES_HUMAN_REVIEW_DESCRIPTOR,
ALT_ROUTES_HUMAN_REVIEW_SELF_HOSTED_DESCRIPTOR,
ALT_ROUTES_TITLE_DESCRIPTOR,
CAPTCHA_REQUIRED_DESCRIPTOR,
CODE_DID_NOT_WORK_DESCRIPTOR,
@@ -20,6 +22,7 @@ import {
ENTER_VALID_PHONE_DESCRIPTOR,
ESCAPE_BUTTON_DESCRIPTOR,
FAILED_TO_RESEND_VERIFICATION_CODE_PLEASE_TRY_AGAIN_DESCRIPTOR,
INSTANCE_ADMIN_CONTACT_DESCRIPTOR,
NEW_EMAIL_DESCRIPTOR,
PHONE_ALREADY_USED_DESCRIPTOR,
PHONE_CANNOT_BE_USED_DESCRIPTOR,
@@ -290,6 +293,13 @@ export const ValueBlock: React.FC<ValueBlockProps> = ({label, value}) => (
);
export const SupportLinkLine: React.FC = () => {
const {i18n} = useLingui();
if (RuntimeConfig.isSelfHosted()) {
return (
<span className={styles.supportLink} data-flx="auth.required-action-modal.support-link-line.instance-admin">
{i18n._(INSTANCE_ADMIN_CONTACT_DESCRIPTOR)}
</span>
);
}
return (
<ExternalLink
href={SUPPORT_EMAIL_MAILTO}
@@ -311,6 +321,7 @@ interface RequiredActionAltRoutesProps {
export const RequiredActionAltRoutes: React.FC<RequiredActionAltRoutesProps> = ({escapeAction}) => {
const {i18n} = useLingui();
const selfHosted = RuntimeConfig.isSelfHosted();
return (
<div data-flx="auth.required-action-modal.alt-routes">
<p
@@ -322,11 +333,13 @@ export const RequiredActionAltRoutes: React.FC<RequiredActionAltRoutesProps> = (
{i18n._(ALT_ROUTES_TITLE_DESCRIPTOR)}
</p>
<p className={styles.altRoutesBody} data-flx="auth.required-action-modal.alt-routes.body">
{i18n._(ALT_ROUTES_HUMAN_REVIEW_DESCRIPTOR)}
{i18n._(selfHosted ? ALT_ROUTES_HUMAN_REVIEW_SELF_HOSTED_DESCRIPTOR : ALT_ROUTES_HUMAN_REVIEW_DESCRIPTOR)}
</p>
<div className={styles.supportBlock} data-flx="auth.required-action-modal.alt-routes.support-block">
<SupportLinkLine data-flx="auth.required-action.required-action-shared.required-action-alt-routes.support-link-line" />
</div>
{selfHosted ? null : (
<div className={styles.supportBlock} data-flx="auth.required-action-modal.alt-routes.support-block">
<SupportLinkLine data-flx="auth.required-action.required-action-shared.required-action-alt-routes.support-link-line" />
</div>
)}
{escapeAction ? (
<>
<div className={styles.altRoutesDivider} data-flx="auth.required-action-modal.alt-routes.divider" />
@@ -20,6 +20,10 @@ export function hasRichEmbedContent(embed: RichEmbedContentFields): boolean {
);
}
export function embedAllowsMarkdown(embed: Pick<MessageEmbed, 'type'>): boolean {
return embed.type === MessageEmbedTypes.RICH || embed.type === MessageEmbedTypes.BLUESKY;
}
type MediaOnlyEmbedFields = RichEmbedContentFields & Pick<MessageEmbed, 'image' | 'thumbnail' | 'video' | 'audio'>;
export function isMediaOnlyEmbed(embed: MediaOnlyEmbedFields): boolean {
@@ -205,6 +205,7 @@ export const BlueskyEmbed: FC<BlueskyEmbedProps> = observer(
<EmbedTitle
title={embed.title}
url={embed.url}
markdown
messageId={message.id}
channelId={message.channelId}
data-flx="channel.embeds.channel-embed.bluesky-embed.embed-title"
@@ -212,6 +213,7 @@ export const BlueskyEmbed: FC<BlueskyEmbedProps> = observer(
)}
<EmbedDescription
description={embed.description}
markdown
messageId={message.id}
channelId={message.channelId}
data-flx="channel.embeds.channel-embed.bluesky-embed.embed-description"
@@ -255,8 +257,6 @@ export const BlueskyEmbed: FC<BlueskyEmbedProps> = observer(
<EmbedFooterComponent
footer={embed.footer}
timestamp={embed.timestamp ? new Date(embed.timestamp) : undefined}
messageId={message.id}
channelId={message.channelId}
data-flx="channel.embeds.channel-embed.bluesky-embed.embed-footer-component"
/>
)}
@@ -69,47 +69,58 @@ export const EmbedAuthorComponent: FC<{author?: EmbedAuthor}> = observer(({autho
</div>
);
});
export const EmbedTitle: FC<{title?: string; url?: string; messageId?: string; channelId?: string}> = observer(
({title, url, messageId, channelId}) => {
if (title == null || title.length === 0) return null;
const options = {
context: MarkdownContext.RESTRICTED_INLINE_REPLY,
messageId,
channelId,
disableEmojiInteractions: Boolean(url),
};
return (
<div className={styles.embedTitle} data-flx="channel.embeds.embed.embed-title.embed-title">
{url ? (
<EmbedLink url={url} data-flx="channel.embeds.channel-embed.embed-parts.embed-title.embed-link">
<SafeMarkdown content={title} options={options} data-flx="channel.embeds.embed.embed-title.safe-markdown" />
</EmbedLink>
) : (
<span data-flx="channel.embeds.embed.embed-title.span">
<SafeMarkdown
content={title}
options={options}
data-flx="channel.embeds.embed.embed-title.safe-markdown--2"
/>
</span>
)}
</div>
);
},
);
export const EmbedTitle: FC<{
title?: string;
url?: string;
markdown: boolean;
messageId?: string;
channelId?: string;
}> = observer(({title, url, markdown, messageId, channelId}) => {
if (title == null || title.length === 0) return null;
const content = markdown ? (
<SafeMarkdown
content={title}
options={{
context: MarkdownContext.RESTRICTED_INLINE_REPLY,
messageId,
channelId,
disableEmojiInteractions: Boolean(url),
}}
data-flx="channel.embeds.embed.embed-title.safe-markdown"
/>
) : (
title
);
return (
<div className={styles.embedTitle} data-flx="channel.embeds.embed.embed-title.embed-title">
{url ? (
<EmbedLink url={url} data-flx="channel.embeds.channel-embed.embed-parts.embed-title.embed-link">
{content}
</EmbedLink>
) : (
<span data-flx="channel.embeds.embed.embed-title.span">{content}</span>
)}
</div>
);
});
export const EmbedDescription: FC<{
messageId?: string;
channelId?: string;
description?: string;
}> = observer(({messageId, channelId, description}) => {
markdown: boolean;
}> = observer(({messageId, channelId, description, markdown}) => {
if (!description) return null;
return (
<div className={styles.embedDescription} data-flx="channel.embeds.embed.embed-description.embed-description">
<SafeMarkdown
content={description}
options={{context: MarkdownContext.RESTRICTED_EMBED_DESCRIPTION, messageId, channelId}}
data-flx="channel.embeds.embed.embed-description.safe-markdown"
/>
{markdown ? (
<SafeMarkdown
content={description}
options={{context: MarkdownContext.RESTRICTED_EMBED_DESCRIPTION, messageId, channelId}}
data-flx="channel.embeds.embed.embed-description.safe-markdown"
/>
) : (
description
)}
</div>
);
});
@@ -182,9 +193,7 @@ export const EmbedFields: FC<{fields?: ReadonlyArray<EmbedField>; messageId?: st
export const EmbedFooterComponent: FC<{
timestamp?: Date;
footer?: EmbedFooter;
messageId?: string;
channelId?: string;
}> = observer(({timestamp, footer, messageId, channelId}) => {
}> = observer(({timestamp, footer}) => {
const {i18n} = useLingui();
const allowIconMotion = useShouldAnimate({kind: 'gif'});
const formattedTimestamp = timestamp ? DateUtils.getRelativeDateString(timestamp, i18n) : undefined;
@@ -206,13 +215,7 @@ export const EmbedFooterComponent: FC<{
/>
)}
<div className={styles.embedFooterText} data-flx="channel.embeds.embed.embed-footer-component.embed-footer-text">
{footer?.text && (
<SafeMarkdown
content={footer.text}
options={{context: MarkdownContext.RESTRICTED_INLINE_REPLY, messageId, channelId}}
data-flx="channel.embeds.embed.embed-footer-component.safe-markdown"
/>
)}
{footer?.text}
{formattedTimestamp && (
<>
{footer?.text && (
@@ -25,6 +25,7 @@ import {
} from '@app/features/channel/components/embeds/channel_embed/EmbedParts';
import {
EMBED_TEXT_OUTER_WIDTH,
embedAllowsMarkdown,
formatResponsiveEmbedWidth,
} from '@app/features/channel/components/embeds/EmbedRenderUtils';
import markupStyles from '@app/features/theme/styles/Markup.module.css';
@@ -83,6 +84,7 @@ export const RichEmbed: FC<EmbedProps> = observer(
<EmbedTitle
title={embed.title}
url={embed.url}
markdown={embedAllowsMarkdown(embed)}
messageId={message.id}
channelId={message.channelId}
data-flx="channel.embeds.channel-embed.rich-embed.embed-title"
@@ -90,6 +92,7 @@ export const RichEmbed: FC<EmbedProps> = observer(
{!isYouTubeEmbed && (
<EmbedDescription
description={embed.description}
markdown={embedAllowsMarkdown(embed)}
messageId={message.id}
channelId={message.channelId}
data-flx="channel.embeds.channel-embed.rich-embed.embed-description"
@@ -127,8 +130,6 @@ export const RichEmbed: FC<EmbedProps> = observer(
<EmbedFooterComponent
footer={embed.footer}
timestamp={embed.timestamp ? new Date(embed.timestamp) : undefined}
messageId={message.id}
channelId={message.channelId}
data-flx="channel.embeds.channel-embed.rich-embed.embed-footer-component"
/>
)}
@@ -33,7 +33,7 @@ import {Form} from '@app/features/ui/components/form/Form';
import {Input} from '@app/features/ui/components/form/FormInput';
import {Scroller} from '@app/features/ui/components/Scroller';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {canCropFormat} from '@app/features/voice/utils/MediaCapabilities';
import {canCropFile} from '@app/features/voice/utils/MediaCapabilities';
import {useRemoteFormReset} from '@app/lib/forms/RemoteFormReset';
import {assignTransientUploadFieldMutation} from '@app/lib/forms/TransientUploadFields';
import {msg} from '@lingui/core/macro';
@@ -136,7 +136,7 @@ export const EditGroupBottomSheet: React.FC<EditGroupBottomSheetProps> = observe
return;
}
const svg = isSvgFile(file);
if (!svg && !(await canCropFormat(file.type))) {
if (!svg && !(await canCropFile(file))) {
showChannelErrorModal({
title: i18n._(UNSUPPORTED_ICON_FORMAT_DESCRIPTOR),
message: getAssetFormatErrorMessage(i18n, 'guild_icon', 'unsupported_mime'),
@@ -159,7 +159,6 @@ export const EditGroupBottomSheet: React.FC<EditGroupBottomSheetProps> = observe
<AssetCropModal
assetType={AssetType.CHANNEL_ICON}
imageUrl={base64}
sourceMimeType={svg ? 'image/svg+xml' : file.type}
onCropComplete={(croppedBlob) => {
const reader = new FileReader();
reader.onload = () => {
@@ -32,7 +32,7 @@ import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import {Form} from '@app/features/ui/components/form/Form';
import {Input} from '@app/features/ui/components/form/FormInput';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {canCropFormat} from '@app/features/voice/utils/MediaCapabilities';
import {canCropFile} from '@app/features/voice/utils/MediaCapabilities';
import {useRemoteFormReset} from '@app/lib/forms/RemoteFormReset';
import {assignTransientUploadFieldMutation} from '@app/lib/forms/TransientUploadFields';
import {msg} from '@lingui/core/macro';
@@ -127,7 +127,7 @@ export const EditGroupModal = observer(({channelId}: {channelId: string}) => {
return;
}
const svg = isSvgFile(file);
if (!svg && !(await canCropFormat(file.type))) {
if (!svg && !(await canCropFile(file))) {
showChannelErrorModal({
title: i18n._(UNSUPPORTED_ICON_FORMAT_DESCRIPTOR),
message: getAssetFormatErrorMessage(i18n, 'guild_icon', 'unsupported_mime'),
@@ -150,7 +150,6 @@ export const EditGroupModal = observer(({channelId}: {channelId: string}) => {
<AssetCropModal
assetType={AssetType.CHANNEL_ICON}
imageUrl={base64}
sourceMimeType={svg ? 'image/svg+xml' : file.type}
onCropComplete={(croppedBlob) => {
const reader = new FileReader();
reader.onload = () => {
@@ -303,7 +303,8 @@ export class Channel {
const otherOverwrites = Object.keys(other.permissionOverwrites);
if (thisOverwrites.length !== otherOverwrites.length) return false;
for (const key of thisOverwrites) {
if (!this.permissionOverwrites[key].equals(other.permissionOverwrites[key])) {
const otherOverwrite = other.permissionOverwrites[key];
if (otherOverwrite == null || !this.permissionOverwrites[key].equals(otherOverwrite)) {
return false;
}
}
@@ -6,7 +6,6 @@ import type {VoiceNoiseSuppressionAssignmentResponse} from '@fluxer/schema/src/d
import {
type ExperimentAssignmentsResponse,
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
readScreenShareDeliveryAssignment,
readVoiceNoiseSuppressionAssignment,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
@@ -123,8 +122,6 @@ describe('ExperimentAssignments cold start', () => {
await settle();
expect(ExperimentAssignments.response).toBe(INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE);
expect(ExperimentAssignments.response.assignments.voice_noise_suppression).toBeUndefined();
expect(ExperimentAssignments.response.assignments.screen_share_delivery).toBeUndefined();
expect(readScreenShareDeliveryAssignment(ExperimentAssignments.response).enabled).toBe(false);
});
it('keeps the inert envelope while unauthenticated and retries later', async () => {
@@ -170,32 +167,6 @@ describe('ExperimentAssignments response handling', () => {
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
});
it('adopts a screen share delivery assignment beside the voice one', async () => {
await adopt({
...CANARY_ENVELOPE,
assignments: {...CANARY_ENVELOPE.assignments, screen_share_delivery: {enabled: true}},
});
expect(readScreenShareDeliveryAssignment(ExperimentAssignments.response).enabled).toBe(true);
expect(readVoiceNoiseSuppressionAssignment(ExperimentAssignments.response)).toEqual(CANARY_ASSIGNMENT);
});
it('reads screen share delivery as disabled when the envelope omits it', async () => {
await adopt(CANARY_ENVELOPE);
expect(readScreenShareDeliveryAssignment(ExperimentAssignments.response).enabled).toBe(false);
});
it('discards an envelope with a malformed screen share delivery assignment', async () => {
await adopt(CANARY_ENVELOPE);
vi.mocked(http.get).mockResolvedValue(
reply(200, {
...CANARY_ENVELOPE,
assignments: {...CANARY_ENVELOPE.assignments, screen_share_delivery: {enabled: 'yes'}},
}),
);
await vi.advanceTimersByTimeAsync(400_000);
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
});
it('accepts an envelope that carries no voice noise suppression assignment', async () => {
await adopt({poll_interval_seconds: 600, poll_jitter_percent: 0, assignments: {}});
expect(ExperimentAssignments.response.assignments.voice_noise_suppression).toBeUndefined();
@@ -218,7 +218,7 @@ export const getAssetAspectRatioRange = (type: AssetType) => {
};
export const clampAssetAspectRatio = (type: AssetType, aspectRatio: number | undefined): number | undefined =>
clampAspectRatio(aspectRatio, getAssetAspectRatioRange(type));
export const canSkipOriginalAssetImage = (type: AssetType, dimensions: ImageDimensions): boolean => {
const canSkipOriginalAssetImage = (type: AssetType, dimensions: ImageDimensions): boolean => {
const config = getAssetConfig(type);
return isOriginalImageWithinAssetBounds(
dimensions,
@@ -298,20 +298,18 @@ const getErrorMessage = (i18n: I18n, assetType: AssetType): string => {
interface AssetCropModalProps {
imageUrl: string;
sourceMimeType: string;
assetType: AssetType;
onCropComplete: (croppedImageBlob: Blob) => void;
onSkip?: () => void;
}
export const AssetCropModal: React.FC<AssetCropModalProps> = observer(
({imageUrl, sourceMimeType, assetType, onCropComplete, onSkip}) => {
({imageUrl, assetType, onCropComplete, onSkip}) => {
const {i18n} = useLingui();
const config = getAssetConfig(assetType);
return (
<ImageCropModal
imageUrl={imageUrl}
sourceMimeType={sourceMimeType}
onCropComplete={onCropComplete}
onSkip={onSkip}
title={getTitle(i18n, assetType)}
@@ -6,12 +6,15 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {Trans} from '@lingui/react/macro';
const ANIMATED_NOT_SUPPORTED_DESCRIPTOR = msg({
message: 'Animated {avifFormatLabel} not supported',
comment: 'Modal title shown when an animated AVIF upload is rejected by the client.',
});
const BROWSER_CAN_T_CROP_ANIMATED_DESCRIPTOR = msg({
message: "This browser can't crop animated {avifFormatLabel} files. Upload a GIF or WebP instead.",
comment: 'Modal body shown when an animated AVIF upload is rejected because this browser cannot crop it.',
});
interface ShowAnimatedAvifUnsupportedModalOptions {
i18n: I18n;
@@ -22,11 +25,7 @@ export function showAnimatedAvifUnsupportedModal({i18n}: ShowAnimatedAvifUnsuppo
modal(() => (
<GenericErrorModal
title={i18n._(ANIMATED_NOT_SUPPORTED_DESCRIPTOR, {avifFormatLabel: AVIF_FORMAT_LABEL})}
message={
<Trans>
Animated {AVIF_FORMAT_LABEL} files aren't supported. Upload a static {AVIF_FORMAT_LABEL} file instead.
</Trans>
}
message={i18n._(BROWSER_CAN_T_CROP_ANIMATED_DESCRIPTOR, {avifFormatLabel: AVIF_FORMAT_LABEL})}
data-flx="expressions.animated-avif-modal-utils.show-animated-avif-unsupported-modal.confirm-modal"
/>
)),
@@ -1,90 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const GIF_EXT = '.gif';
const WEBP_EXT = '.webp';
const PNG_EXT = '.png';
const AVIF_EXT = '.avif';
export type InspectedImageFormat = 'unknown' | 'png' | 'gif' | 'webp' | 'avif' | 'jpeg';
export type AnimatedImageFormat = 'gif' | 'webp' | 'avif' | 'apng';
export interface InspectedImage {
format: InspectedImageFormat;
animated: boolean;
}
function getFileExtension(file: File): string {
const name = (file.name || '').toLowerCase();
const dotIndex = name.lastIndexOf('.');
return dotIndex === -1 ? '' : name.substring(dotIndex);
const SNIFFED_FORMATS: ReadonlyArray<InspectedImageFormat> = ['unknown', 'png', 'gif', 'webp', 'avif', 'jpeg'];
export async function inspectImageBytes(bytes: Uint8Array): Promise<InspectedImage> {
try {
const {detectAnimatedImage, sniffImageFormat} = await import('@app/features/platform/utils/LibFluxcore');
const format = SNIFFED_FORMATS[await sniffImageFormat(bytes)] ?? 'unknown';
if (format === 'unknown' || format === 'jpeg') {
return {format, animated: false};
}
return {format, animated: await detectAnimatedImage(bytes)};
} catch {
return {format: 'unknown', animated: false};
}
}
export async function inspectImageFile(file: File): Promise<InspectedImage> {
try {
return await inspectImageBytes(new Uint8Array(await file.arrayBuffer()));
} catch {
return {format: 'unknown', animated: false};
}
}
export async function isAnimatedFile(file: File): Promise<boolean> {
try {
const arrayBuffer = await file.arrayBuffer();
const {detectAnimatedImage} = await import('@app/features/platform/utils/LibFluxcore');
return await detectAnimatedImage(new Uint8Array(arrayBuffer));
} catch {
return false;
}
}
export function getAnimatedFormatLabel(file: File): string | null {
const mime = (file.type || '').toLowerCase();
const ext = getFileExtension(file);
if (mime.includes('gif') || ext === GIF_EXT) {
return 'GIF';
}
if (mime.includes('webp') || ext === WEBP_EXT) {
return 'WebP';
}
if (mime.includes('png') || ext === PNG_EXT) {
return 'APNG';
}
if (mime.includes('avif') || ext === AVIF_EXT) {
return 'AVIF';
}
return null;
}
export function getAnimatedImageFormat(mime: string, ext?: string): AnimatedImageFormat {
const lowerMime = mime.toLowerCase();
const lowerExt = ext?.toLowerCase() || '';
if (lowerMime.includes('gif') || lowerExt === '.gif') {
return 'gif';
}
if (lowerMime.includes('webp') || lowerExt === '.webp') {
return 'webp';
}
if (lowerMime.includes('avif') || lowerExt === '.avif') {
return 'avif';
}
if (lowerMime.includes('png') || lowerExt === '.png') {
return 'apng';
}
return 'gif';
}
export interface HandleAnimatedNonGifOptions {
file: File;
isGif: boolean;
animated: boolean;
onAnimatedAvif: () => void;
onOtherAnimated: () => void;
}
export function shouldHandleAnimatedNonGifUpload({
file,
isGif,
animated,
onAnimatedAvif,
onOtherAnimated,
}: HandleAnimatedNonGifOptions): boolean {
if (!animated) {
return false;
}
const format = getAnimatedImageFormat(file.type);
if (isGif || format === 'gif' || format === 'apng') {
return false;
}
if (format === 'avif') {
onAnimatedAvif();
return true;
}
onOtherAnimated();
return true;
return (await inspectImageFile(file)).animated;
}
@@ -19,13 +19,6 @@ export function isSvgFile(file: ImageUploadFileLike): boolean {
return (file.name ?? '').toLowerCase().endsWith('.svg');
}
export function getImageUploadMimeType(file: ImageUploadFileLike): string {
if (isSvgFile(file)) return 'image/svg+xml';
const type = normalizeImageMimeType(file.type);
if (type.startsWith('image/')) return type;
return 'image/png';
}
function readBlobAsDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -0,0 +1,137 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type AnimatedCropSourceFormat,
type CropParams,
CropPipelineError,
} from '@app/features/expressions/workers/AnimatedImageCropMessages';
export const MAX_SOURCE_CANVAS_PIXELS = 16_777_216;
export const MAX_FRAMES = 20_000;
export const MAX_OUTPUT_TOTAL_PIXELS = 1_073_741_824;
export const MAX_SOURCE_TOTAL_PIXELS = 1_073_741_824;
export const MAX_PLAYS = 65_535;
export const LOSSLESS_MAX_WORK_PIXELS = 16_777_216;
export const QUALITY_RUNGS: ReadonlyArray<number> = [75, 60, 45, 30];
export const SIZE_RATIO: ReadonlyArray<number> = [1, 0.889, 0.782, 0.595];
const LADDER_TARGET_FRACTION = 0.95;
const LADDER_GIVE_UP_FRACTION = 1.15;
const DEFAULT_AVIF_DELAY_MS = 100;
export interface EncodeRung {
quality: number;
method: number;
lossless: boolean;
}
export const LOSSLESS_RUNG: EncodeRung = {quality: 75, method: 4, lossless: true};
export function chooseMethod(workPixels: number): number {
if (workPixels <= 67_108_864) return 4;
if (workPixels <= 268_435_456) return 2;
return 0;
}
export function lossyRung(index: number, method: number): EncodeRung {
return {quality: QUALITY_RUNGS[index], method, lossless: false};
}
export function nextRung(index: number, sizeBytes: number, capBytes: number): number | 'too_large' {
const last = QUALITY_RUNGS.length - 1;
if (index >= last) return 'too_large';
const target = LADDER_TARGET_FRACTION * capBytes;
for (let candidate = index + 1; candidate <= last; candidate++) {
if ((sizeBytes * SIZE_RATIO[candidate]) / SIZE_RATIO[index] <= target) return candidate;
}
if ((sizeBytes * SIZE_RATIO[last]) / SIZE_RATIO[index] > LADDER_GIVE_UP_FRACTION * capBytes) return 'too_large';
return last;
}
export function usesLosslessFirstRung(sourceLossless: boolean, workPixels: number): boolean {
return sourceLossless && workPixels <= LOSSLESS_MAX_WORK_PIXELS;
}
export function assertSourceBudget(width: number, height: number, frameCount: number): void {
if (!(width > 0 && height > 0 && frameCount > 0)) {
throw new CropPipelineError(
'decode_failed',
`Invalid animation header ${width}x${height} with ${frameCount} frames`,
);
}
if (width * height > MAX_SOURCE_CANVAS_PIXELS) {
throw new CropPipelineError('budget_exceeded', `Canvas ${width}x${height} is larger than the decode budget`);
}
if (frameCount > MAX_FRAMES) {
throw new CropPipelineError('budget_exceeded', `${frameCount} frames is more than ${MAX_FRAMES}`);
}
if (frameCount * width * height > MAX_SOURCE_TOTAL_PIXELS) {
throw new CropPipelineError(
'budget_exceeded',
`${frameCount} frames at ${width}x${height} is too much decode work`,
);
}
}
export function assertOutputBudget(frameCount: number, outputWidth: number, outputHeight: number): void {
if (!(outputWidth > 0 && outputHeight > 0)) {
throw new CropPipelineError('internal', `Invalid output size ${outputWidth}x${outputHeight}`);
}
if (frameCount * outputWidth * outputHeight > MAX_OUTPUT_TOTAL_PIXELS) {
throw new CropPipelineError(
'budget_exceeded',
`${frameCount} frames at ${outputWidth}x${outputHeight} is too much output`,
);
}
}
export function isNoopCrop(
crop: CropParams,
canvasWidth: number,
canvasHeight: number,
format: AnimatedCropSourceFormat,
sourceBytes: number,
capBytes: number,
): boolean {
return (
(format === 'gif' || format === 'webp') &&
crop.x === 0 &&
crop.y === 0 &&
crop.width === canvasWidth &&
crop.height === canvasHeight &&
crop.rotation === 0 &&
crop.outputWidth === canvasWidth &&
crop.outputHeight === canvasHeight &&
sourceBytes <= capBytes
);
}
export function clampPlays(plays: number): number {
return Math.min(Math.max(Math.trunc(plays), 0), MAX_PLAYS);
}
export function gifPlaysFromRepeat(repeat: number | null): number {
if (repeat === null) return 1;
if (repeat === 0) return 0;
return clampPlays(repeat + 1);
}
export function gifRepeatFromPlays(plays: number): number {
if (plays === 1) return -1;
if (plays === 0) return 0;
return clampPlays(plays - 1);
}
export function avifPlaysFromRepetitionCount(repetitionCount: number | undefined): number {
if (repetitionCount === undefined || !Number.isFinite(repetitionCount)) return 0;
return clampPlays(repetitionCount + 1);
}
export function apngDelayMs(numerator: number, denominator: number): number {
return (numerator * 1000) / (denominator || 100);
}
export function avifDelayMs(durationMicros: number | null): number {
return durationMicros == null ? DEFAULT_AVIF_DELAY_MS : durationMicros / 1000;
}
@@ -0,0 +1,662 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
apngDelayMs,
assertSourceBudget,
avifDelayMs,
avifPlaysFromRepetitionCount,
clampPlays,
gifPlaysFromRepeat,
gifRepeatFromPlays,
MAX_SOURCE_CANVAS_PIXELS,
} from '@app/features/expressions/workers/AnimatedCropPlan';
import {CropPipelineError} from '@app/features/expressions/workers/AnimatedImageCropMessages';
import {
canDecodeAnimatedAvif,
closeImageDecoder,
drawVideoFrameToCanvas,
type FluxerImageDecoderDecodedFrame,
getImageDecoderConstructor,
} from '@app/features/platform/utils/ImageDecoderInterop';
import {Unzlib} from 'fflate';
import {GIFEncoder, type GifPaletteColor} from 'gifenc';
import {decompressFrame, type ParsedGif, parseGIF} from 'gifuct-js';
import UPNG from 'upng-js';
export interface DecodedFrame {
rgba: Uint8Array;
delayMs: number;
}
export interface FrameSource {
width: number;
height: number;
frameCount: number;
plays: number;
next(): DecodedFrame | null | Promise<DecodedFrame | null>;
close(): void;
}
export interface GifFrameSink {
add(rgba: Uint8Array, delayMs: number): boolean;
finish(): Uint8Array;
close(): void;
}
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
type GifBlock = ParsedGif['frames'][number];
type GifImageBlock = Extract<GifBlock, {image: unknown}>;
const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const GIF_EXTENSION_INTRODUCER = 0x21;
const GIF_APPLICATION_LABEL = 0xff;
const GIF_LOOP_APPLICATIONS = new Set(['NETSCAPE2.0', 'ANIMEXTS1.0']);
const GIF_MAX_DELAY_CS = 0xffff;
const GIF_TRANSPARENT_INDEX = 0;
const GIF_MAX_OPAQUE_COLORS = 255;
const GIF_MAX_LZW_CODE_SIZE = 11;
const GIF_VERSION_87_MINOR = 0x37;
const APNG_DISPOSE_BACKGROUND = 1;
const APNG_DISPOSE_PREVIOUS = 2;
const APNG_BLEND_OVER = 1;
const PNG_CHANNELS: Readonly<Record<number, number>> = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4};
const ADAM7_PASSES: ReadonlyArray<readonly [number, number, number, number]> = [
[0, 0, 8, 8],
[4, 0, 8, 8],
[0, 4, 4, 8],
[2, 0, 4, 4],
[0, 2, 2, 4],
[1, 0, 2, 2],
[0, 1, 1, 2],
];
const INFLATE_SLICE_BYTES = 16_384;
const INFLATE_SLACK_BYTES = 65_536;
function decodeFailed(message: string): CropPipelineError {
return new CropPipelineError('decode_failed', message);
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
if (bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
return bytes.buffer;
}
return bytes.slice().buffer;
}
function clipRect(rect: Rect, canvasWidth: number, canvasHeight: number): Rect {
const x = Math.min(rect.x, canvasWidth);
const y = Math.min(rect.y, canvasHeight);
return {
x,
y,
width: Math.max(0, Math.min(rect.x + rect.width, canvasWidth) - x),
height: Math.max(0, Math.min(rect.y + rect.height, canvasHeight) - y),
};
}
function clearRect(canvas: Uint8Array, canvasWidth: number, rect: Rect): void {
for (let row = 0; row < rect.height; row++) {
const start = ((rect.y + row) * canvasWidth + rect.x) * 4;
canvas.fill(0, start, start + rect.width * 4);
}
}
function saveRect(canvas: Uint8Array, canvasWidth: number, rect: Rect): Uint8Array {
const saved = new Uint8Array(rect.width * rect.height * 4);
for (let row = 0; row < rect.height; row++) {
const start = ((rect.y + row) * canvasWidth + rect.x) * 4;
saved.set(canvas.subarray(start, start + rect.width * 4), row * rect.width * 4);
}
return saved;
}
function restoreRect(canvas: Uint8Array, canvasWidth: number, rect: Rect, saved: Uint8Array): void {
for (let row = 0; row < rect.height; row++) {
const start = ((rect.y + row) * canvasWidth + rect.x) * 4;
canvas.set(saved.subarray(row * rect.width * 4, (row + 1) * rect.width * 4), start);
}
}
function readGifRepeat(bytes: Uint8Array): number | null {
let pos = 13;
if (bytes[10] & 0x80) pos += 3 * (2 << (bytes[10] & 0x07));
while (pos + 1 < bytes.length && bytes[pos] === GIF_EXTENSION_INTRODUCER) {
const label = bytes[pos + 1];
pos += 2;
let isLoopApplication = false;
if (label === GIF_APPLICATION_LABEL && pos < bytes.length) {
const blockSize = bytes[pos];
isLoopApplication = GIF_LOOP_APPLICATIONS.has(
String.fromCharCode(...bytes.subarray(pos + 1, pos + 1 + blockSize)),
);
pos += 1 + blockSize;
}
while (pos < bytes.length && bytes[pos] !== 0) {
const size = bytes[pos];
if (isLoopApplication && size >= 3 && bytes[pos + 1] === 1 && pos + 3 < bytes.length) {
return bytes[pos + 2] | (bytes[pos + 3] << 8);
}
pos += size + 1;
}
pos += 1;
}
return null;
}
export function openGifFrameSource(bytes: Uint8Array): FrameSource {
if (bytes.length < 13 || bytes[0] !== 0x47 || bytes[1] !== 0x49 || bytes[2] !== 0x46) {
throw decodeFailed('Not a GIF file');
}
const plays = gifPlaysFromRepeat(readGifRepeat(bytes));
const parsed = parseGIF(toArrayBuffer(bytes));
const frames = parsed.frames.filter((block): block is GifImageBlock => 'image' in block && block.image != null);
if (frames.some((frame) => !(frame.image.data.minCodeSize <= GIF_MAX_LZW_CODE_SIZE))) {
throw decodeFailed('GIF frame has an invalid LZW code size');
}
const screenWidth = bytes[6] | (bytes[7] << 8);
const screenHeight = bytes[8] | (bytes[9] << 8);
const first = frames[0]?.image.descriptor;
const width = Math.max(screenWidth, first ? first.left + first.width : 0);
const height = Math.max(screenHeight, first ? first.top + first.height : 0);
if (
first &&
(screenWidth < first.width || screenHeight < first.height || bytes[4] === GIF_VERSION_87_MINOR) &&
(width !== first.width || height !== first.height)
) {
throw decodeFailed('GIF logical screen does not fit its first frame');
}
let canvas: Uint8Array | null = null;
let index = 0;
let pendingDisposal: {rect: Rect; saved: Uint8Array | null} | null = null;
return {
width,
height,
frameCount: frames.length,
plays,
next() {
if (index >= frames.length) return null;
const frame = frames[index++];
const descriptor = frame.image.descriptor;
if (descriptor.width * descriptor.height > MAX_SOURCE_CANVAS_PIXELS) {
throw new CropPipelineError(
'budget_exceeded',
`GIF frame ${descriptor.width}x${descriptor.height} is too large`,
);
}
if (!canvas) {
assertSourceBudget(width, height, frames.length);
canvas = new Uint8Array(width * height * 4);
}
if (pendingDisposal) {
if (pendingDisposal.saved) restoreRect(canvas, width, pendingDisposal.rect, pendingDisposal.saved);
else clearRect(canvas, width, pendingDisposal.rect);
pendingDisposal = null;
}
const decoded = decompressFrame(frame, parsed.gct, false);
const rect = clipRect(
{x: descriptor.left, y: descriptor.top, width: descriptor.width, height: descriptor.height},
width,
height,
);
const disposal = frame.gce?.extras.disposal ?? 0;
if (disposal === 3) pendingDisposal = {rect, saved: saveRect(canvas, width, rect)};
else if (disposal === 2) pendingDisposal = {rect, saved: null};
const colorTable = decoded.colorTable ?? [];
const transparentIndex = frame.gce?.extras.transparentColorGiven ? frame.gce.transparentColorIndex : -1;
const pixels = decoded.pixels;
for (let row = 0; row < rect.height; row++) {
const sourceRow = (rect.y - descriptor.top + row) * descriptor.width + (rect.x - descriptor.left);
let target = ((rect.y + row) * width + rect.x) * 4;
for (let column = 0; column < rect.width; column++, target += 4) {
const colorIndex = pixels[sourceRow + column];
if (colorIndex === transparentIndex) continue;
const color = colorTable[colorIndex];
if (!color) continue;
canvas[target] = color[0];
canvas[target + 1] = color[1];
canvas[target + 2] = color[2];
canvas[target + 3] = 255;
}
}
return {rgba: canvas, delayMs: (frame.gce?.delay ?? 0) * 10};
},
close() {
canvas = null;
},
};
}
interface Range {
start: number;
end: number;
}
interface IsoBox extends Range {
type: string;
}
interface ApngFrameChunks {
rect: Rect;
delayMs: number;
dispose: number;
blend: number;
data: Array<Uint8Array>;
}
function readU32(bytes: Uint8Array, offset: number): number {
return ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0;
}
function readU16(bytes: Uint8Array, offset: number): number {
return (bytes[offset] << 8) | bytes[offset + 1];
}
function writeChunk(out: Uint8Array, offset: number, type: string, parts: Array<Uint8Array>): number {
const length = parts.reduce((sum, part) => sum + part.length, 0);
new DataView(out.buffer, out.byteOffset).setUint32(offset, length);
for (let i = 0; i < 4; i++) out[offset + 4 + i] = type.charCodeAt(i);
let cursor = offset + 8;
for (const part of parts) {
out.set(part, cursor);
cursor += part.length;
}
return cursor + 4;
}
function buildFrameHeaderPng(
ihdr: Uint8Array,
palette: Uint8Array | null,
transparency: Uint8Array | null,
rect: Rect,
): Uint8Array {
const header = ihdr.slice();
new DataView(header.buffer).setUint32(0, rect.width);
new DataView(header.buffer).setUint32(4, rect.height);
const chunks: Array<[string, Array<Uint8Array>]> = [['IHDR', [header]]];
if (palette) chunks.push(['PLTE', [palette]]);
if (transparency) chunks.push(['tRNS', [transparency]]);
chunks.push(['IEND', []]);
const size = chunks.reduce(
(sum, [, parts]) => sum + 12 + parts.reduce((partSum, part) => partSum + part.length, 0),
PNG_SIGNATURE.length,
);
const out = new Uint8Array(size);
out.set(PNG_SIGNATURE, 0);
let offset = PNG_SIGNATURE.length;
for (const [type, parts] of chunks) offset = writeChunk(out, offset, type, parts);
return out;
}
function filteredPngByteLength(ihdr: Uint8Array, width: number, height: number): number {
const channels = PNG_CHANNELS[ihdr[9]];
if (channels === undefined) throw decodeFailed(`PNG color type ${ihdr[9]} is not supported`);
const bitsPerPixel = channels * ihdr[8];
const rowBytes = (rowWidth: number) => 1 + Math.ceil((rowWidth * bitsPerPixel) / 8);
if (ihdr[12] === 0) return height * rowBytes(width);
let total = 0;
for (const [column, row, columnStep, rowStep] of ADAM7_PASSES) {
const passWidth = Math.ceil((width - column) / columnStep);
const passHeight = Math.ceil((height - row) / rowStep);
if (passWidth > 0 && passHeight > 0) total += passHeight * rowBytes(passWidth);
}
return total;
}
function inflateFrame(parts: Array<Uint8Array>, out: Uint8Array, frameIndex: number): Uint8Array {
const filteredLength = out.length;
const limit = filteredLength * 2 + INFLATE_SLACK_BYTES;
let inflated = 0;
const inflater = new Unzlib((chunk) => {
if (inflated < filteredLength) out.set(chunk.subarray(0, filteredLength - inflated), inflated);
inflated += chunk.length;
if (inflated > limit) throw decodeFailed(`APNG frame ${frameIndex} inflates past its dimensions`);
});
for (const part of parts) {
for (let offset = 0; offset < part.length && inflated < filteredLength; offset += INFLATE_SLICE_BYTES) {
inflater.push(part.subarray(offset, offset + INFLATE_SLICE_BYTES));
}
}
if (inflated < filteredLength) throw decodeFailed(`APNG frame ${frameIndex} is truncated`);
return out;
}
function blendOver(canvas: Uint8Array, target: number, rgba: Uint8Array, source: number): void {
const sourceAlpha = rgba[source + 3];
if (sourceAlpha === 0) return;
const destinationAlpha = canvas[target + 3];
if (sourceAlpha === 255 || destinationAlpha === 0) {
canvas.set(rgba.subarray(source, source + 4), target);
return;
}
const destinationWeight = destinationAlpha * (255 - sourceAlpha);
const outAlpha = sourceAlpha * 255 + destinationWeight;
for (let channel = 0; channel < 3; channel++) {
canvas[target + channel] = Math.round(
(rgba[source + channel] * sourceAlpha * 255 + canvas[target + channel] * destinationWeight) / outAlpha,
);
}
canvas[target + 3] = Math.round(outAlpha / 255);
}
export function openApngFrameSource(bytes: Uint8Array): FrameSource {
for (let i = 0; i < PNG_SIGNATURE.length; i++) {
if (bytes[i] !== PNG_SIGNATURE[i]) throw decodeFailed('Not a PNG file');
}
let ihdr: Uint8Array | null = null;
let palette: Uint8Array | null = null;
let transparency: Uint8Array | null = null;
let plays = 0;
let animated = false;
const frames: Array<ApngFrameChunks> = [];
let pos = PNG_SIGNATURE.length;
for (;;) {
if (pos + 8 > bytes.length) throw decodeFailed('PNG ended before IEND');
const length = readU32(bytes, pos);
const type = String.fromCharCode(bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7]);
const dataStart = pos + 8;
const dataEnd = dataStart + length;
if (dataEnd + 4 > bytes.length) throw decodeFailed(`PNG chunk ${type} overruns the file`);
const data = bytes.subarray(dataStart, dataEnd);
pos = dataEnd + 4;
if (type === 'IEND') break;
if (type === 'IHDR' && length === 13) ihdr = data;
else if (type === 'PLTE') palette = data;
else if (type === 'tRNS') transparency = data;
else if (type === 'acTL' && length >= 8) {
animated = true;
plays = clampPlays(readU32(data, 4));
} else if (type === 'fcTL' && length >= 26) {
frames.push({
rect: {x: readU32(data, 12), y: readU32(data, 16), width: readU32(data, 4), height: readU32(data, 8)},
delayMs: apngDelayMs(readU16(data, 20), readU16(data, 22)),
dispose: data[24],
blend: data[25],
data: [],
});
} else if (type === 'IDAT' && frames.length === 1) frames[0].data.push(data);
else if (type === 'fdAT' && frames.length > 0 && length > 4) frames[frames.length - 1].data.push(data.subarray(4));
}
if (!ihdr || !animated) throw decodeFailed('PNG is not animated');
const header = ihdr;
const width = readU32(header, 0);
const height = readU32(header, 4);
let canvas: Uint8Array | null = null;
let scratch = new Uint8Array(0);
let index = 0;
let pendingDisposal: {rect: Rect; saved: Uint8Array | null} | null = null;
return {
width,
height,
frameCount: frames.length,
plays,
next() {
if (index >= frames.length) return null;
const frameIndex = index++;
const frame = frames[frameIndex];
const {rect} = frame;
if (
rect.width === 0 ||
rect.height === 0 ||
rect.x + rect.width > width ||
rect.y + rect.height > height ||
frame.data.length === 0
) {
throw decodeFailed(`APNG frame ${frameIndex} is outside the canvas or empty`);
}
if (!canvas) {
assertSourceBudget(width, height, frames.length);
canvas = new Uint8Array(width * height * 4);
}
if (pendingDisposal) {
if (pendingDisposal.saved) restoreRect(canvas, width, pendingDisposal.rect, pendingDisposal.saved);
else clearRect(canvas, width, pendingDisposal.rect);
pendingDisposal = null;
}
const filteredLength = filteredPngByteLength(header, rect.width, rect.height);
if (scratch.length < filteredLength) scratch = new Uint8Array(filteredLength);
const inflated = inflateFrame(frame.data, scratch.subarray(0, filteredLength), frameIndex);
const image = UPNG.decode(toArrayBuffer(buildFrameHeaderPng(header, palette, transparency, rect)), inflated);
const rgba = new Uint8Array(UPNG.toRGBA8(image)[0]);
if (rgba.length !== rect.width * rect.height * 4) throw decodeFailed(`APNG frame ${frameIndex} did not decode`);
const dispose =
frameIndex === 0 && frame.dispose === APNG_DISPOSE_PREVIOUS ? APNG_DISPOSE_BACKGROUND : frame.dispose;
if (dispose === APNG_DISPOSE_PREVIOUS) pendingDisposal = {rect, saved: saveRect(canvas, width, rect)};
else if (dispose === APNG_DISPOSE_BACKGROUND) pendingDisposal = {rect, saved: null};
for (let row = 0; row < rect.height; row++) {
const target = ((rect.y + row) * width + rect.x) * 4;
const source = row * rect.width * 4;
if (frame.blend === APNG_BLEND_OVER) {
for (let column = 0; column < rect.width; column++) {
blendOver(canvas, target + column * 4, rgba, source + column * 4);
}
} else {
canvas.set(rgba.subarray(source, source + rect.width * 4), target);
}
}
return {rgba: canvas, delayMs: frame.delayMs};
},
close() {
canvas = null;
scratch = new Uint8Array(0);
},
};
}
function readFourCc(bytes: Uint8Array, offset: number): string {
return String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
}
function isoChildren(bytes: Uint8Array, parent: Range): Array<IsoBox> {
const boxes: Array<IsoBox> = [];
let pos = parent.start;
while (pos + 8 <= parent.end) {
let size = readU32(bytes, pos);
let headerSize = 8;
if (size === 1) {
if (pos + 16 > parent.end) break;
size = readU32(bytes, pos + 8) * 2 ** 32 + readU32(bytes, pos + 12);
headerSize = 16;
} else if (size === 0) {
size = parent.end - pos;
}
if (size < headerSize || pos + size > parent.end) break;
boxes.push({type: readFourCc(bytes, pos + 4), start: pos + headerSize, end: pos + size});
pos += size;
}
return boxes;
}
function isoChild(bytes: Uint8Array, parent: Range | undefined, type: string): IsoBox | undefined {
return parent && isoChildren(bytes, parent).find((box) => box.type === type);
}
function readTrackDelaysMs(bytes: Uint8Array, trak: IsoBox, frameCount: number): Array<number> | null {
const mdia = isoChild(bytes, trak, 'mdia');
const mdhd = isoChild(bytes, mdia, 'mdhd');
const stts = isoChild(bytes, isoChild(bytes, isoChild(bytes, mdia, 'minf'), 'stbl'), 'stts');
if (!mdhd || !stts || stts.end - stts.start < 8) return null;
const timescaleOffset = mdhd.start + (bytes[mdhd.start] === 1 ? 20 : 12);
if (timescaleOffset + 4 > mdhd.end) return null;
const timescale = readU32(bytes, timescaleOffset);
const entryCount = readU32(bytes, stts.start + 4);
if (timescale === 0 || stts.start + 8 + entryCount * 8 > stts.end) return null;
const delays: Array<number> = [];
for (let entry = 0; entry < entryCount; entry++) {
const sampleCount = readU32(bytes, stts.start + 8 + entry * 8);
const delayMs = (readU32(bytes, stts.start + 12 + entry * 8) * 1000) / timescale;
for (let sample = 0; sample < sampleCount; sample++) {
if (delays.length === frameCount) return null;
delays.push(delayMs);
}
}
return delays.length === frameCount ? delays : null;
}
function readAvifFrameDelaysMs(bytes: Uint8Array, frameCount: number): Array<number> | null {
const moov = isoChild(bytes, {start: 0, end: bytes.length}, 'moov');
if (!moov) return null;
for (const trak of isoChildren(bytes, moov)) {
if (trak.type !== 'trak') continue;
const hdlr = isoChild(bytes, isoChild(bytes, trak, 'mdia'), 'hdlr');
if (!hdlr || hdlr.end - hdlr.start < 12 || readFourCc(bytes, hdlr.start + 8) !== 'pict') continue;
if (isoChild(bytes, isoChild(bytes, trak, 'tref'), 'auxl')) continue;
return readTrackDelaysMs(bytes, trak, frameCount);
}
return null;
}
export async function openAvifFrameSource(bytes: Uint8Array): Promise<FrameSource> {
const ImageDecoderConstructor = getImageDecoderConstructor();
if (!ImageDecoderConstructor || !(await canDecodeAnimatedAvif())) {
throw new CropPipelineError('animated_avif_unsupported', 'This browser cannot decode animated AVIF frames');
}
const decoder = new ImageDecoderConstructor({data: bytes, type: 'image/avif'});
let pending: FluxerImageDecoderDecodedFrame | null = null;
try {
await Promise.all([decoder.tracks.ready, decoder.completed]);
const track = decoder.tracks.selectedTrack;
const frameCount = track?.frameCount ?? 0;
if (frameCount === 0) throw decodeFailed('AVIF has no frames');
pending = await decoder.decode({frameIndex: 0});
const width = pending.image.displayWidth;
const height = pending.image.displayHeight;
assertSourceBudget(width, height, frameCount);
const containerDelays = readAvifFrameDelaysMs(bytes, frameCount);
let context: OffscreenCanvasRenderingContext2D | null = null;
let index = 0;
return {
width,
height,
frameCount,
plays: avifPlaysFromRepetitionCount(track?.repetitionCount),
async next() {
if (index >= frameCount) return null;
const frameIndex = index++;
const decoded = pending ?? (await decoder.decode({frameIndex}));
pending = null;
try {
if (!context) {
context = new OffscreenCanvas(width, height).getContext('2d', {willReadFrequently: true});
if (!context) throw decodeFailed('OffscreenCanvas 2d context is unavailable');
}
context.clearRect(0, 0, width, height);
drawVideoFrameToCanvas(context, decoded.image);
const imageData = context.getImageData(0, 0, width, height);
return {
rgba: new Uint8Array(imageData.data.buffer, imageData.data.byteOffset, imageData.data.byteLength),
delayMs: containerDelays?.[frameIndex] ?? avifDelayMs(decoded.image.duration),
};
} finally {
decoded.image.close();
}
},
close() {
pending?.image.close();
pending = null;
closeImageDecoder(decoder);
},
};
} catch (error) {
pending?.image.close();
closeImageDecoder(decoder);
throw error;
}
}
interface PendingGifFrame {
index: Uint8Array;
palette: Array<GifPaletteColor>;
delayMs: number;
dispose: number;
}
export function openGifFrameSink(width: number, height: number, plays: number, capBytes: number): GifFrameSink {
const encoder = GIFEncoder();
const pixelCount = width * height;
let previous: Uint8Array | null = null;
let pending: PendingGifFrame | null = null;
let wroteFirst = false;
function write(frame: PendingGifFrame): void {
encoder.writeFrame(frame.index, width, height, {
palette: frame.palette,
delay: Math.min(frame.delayMs, GIF_MAX_DELAY_CS * 10),
transparent: true,
transparentIndex: GIF_TRANSPARENT_INDEX,
dispose: frame.dispose,
...(wroteFirst ? {} : {repeat: gifRepeatFromPlays(plays)}),
});
wroteFirst = true;
}
function needsFullFrame(rgba: Uint8Array): boolean {
if (!previous) return true;
for (let offset = 3; offset < rgba.length; offset += 4) {
if (previous[offset] === 255 && rgba[offset] === 0) return true;
}
return false;
}
return {
add(rgba, delayMs) {
if (rgba.length !== pixelCount * 4) return false;
for (let offset = 3; offset < rgba.length; offset += 4) {
const alpha = rgba[offset];
if (alpha !== 0 && alpha !== 255) return false;
}
const full = needsFullFrame(rgba);
const index = new Uint8Array(pixelCount);
const palette: Array<GifPaletteColor> = [[0, 0, 0]];
const colors = new Map<number, number>();
for (let pixel = 0, offset = 0; pixel < pixelCount; pixel++, offset += 4) {
if (rgba[offset + 3] === 0) continue;
if (
!full &&
previous &&
previous[offset + 3] === 255 &&
previous[offset] === rgba[offset] &&
previous[offset + 1] === rgba[offset + 1] &&
previous[offset + 2] === rgba[offset + 2]
) {
continue;
}
const key = (rgba[offset] << 16) | (rgba[offset + 1] << 8) | rgba[offset + 2];
let colorIndex = colors.get(key);
if (colorIndex === undefined) {
if (palette.length > GIF_MAX_OPAQUE_COLORS) return false;
colorIndex = palette.length;
colors.set(key, colorIndex);
palette.push([rgba[offset], rgba[offset + 1], rgba[offset + 2]]);
}
index[pixel] = colorIndex;
}
if (pending) {
pending.dispose = full ? 2 : 1;
write(pending);
if (encoder.bytesView().length > capBytes) return false;
}
pending = {index, palette, delayMs, dispose: 1};
if (previous) previous.set(rgba);
else previous = rgba.slice();
return true;
},
finish() {
if (pending) write(pending);
pending = null;
encoder.finish();
return encoder.bytes();
},
close() {
pending = null;
previous = null;
},
};
}

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