Compare commits

...
25 changed files with 1478 additions and 80 deletions

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
self-hosted-runner: self-hosted-runner:
labels: labels:
- fluxer-desktop-macos-arm64 - fluxer-desktop-macos-arm64
- fluxer-electron-build
config-variables: null config-variables: null
@@ -0,0 +1,108 @@
name: build desktop Electron
on:
workflow_dispatch:
inputs:
prebuilt_release_tag:
description: Draft release containing prebuilt Electron distributions. Leave empty to compile Electron on the build runner instead.
required: false
default: desktop-electron-runtime-44.4.1-fluxer.1
type: string
permissions:
contents: read
concurrency:
group: desktop-electron-build
cancel-in-progress: false
jobs:
build:
name: Build patched Electron (linux-${{ matrix.arch }})
if: inputs.prebuilt_release_tag == ''
runs-on: [self-hosted, Linux, X64, fluxer-electron-build]
timeout-minutes: 1440
strategy:
fail-fast: false
max-parallel: 1
matrix:
arch: [x64, arm64]
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Build patched Electron distribution
env:
CONTAINER_ENGINE: podman
CONTAINER_CPUS: "8"
CONTAINER_MEMORY: 48g
BUILD_JOBS: "8"
run: >-
bash fluxer_desktop/electron/build-linux.sh
--arch "${{ matrix.arch }}"
--build-root /srv/fluxer-electron-build/work
--output-dir "${{ runner.temp }}/fluxer-electron-artifacts"
- name: Upload patched Electron distribution
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-electron-linux-${{ matrix.arch }}
path: |
${{ runner.temp }}/fluxer-electron-artifacts/electron-v44.4.1-linux-${{ matrix.arch }}.zip
${{ runner.temp }}/fluxer-electron-artifacts/electron-v44.4.1-linux-${{ matrix.arch }}.fluxer.json
if-no-files-found: error
retention-days: 30
compression-level: 0
import:
name: Import patched Electron (linux-${{ matrix.arch }})
if: inputs.prebuilt_release_tag != ''
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: write
strategy:
fail-fast: false
matrix:
arch: [x64, arm64]
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 26
- name: Download and verify build artifacts
env:
GH_TOKEN: ${{ github.token }}
PREBUILT_RELEASE_TAG: ${{ inputs.prebuilt_release_tag }}
ELECTRON_ARCH: ${{ matrix.arch }}
FLUXER_ELECTRON_DIST: ${{ runner.temp }}/fluxer-electron-artifacts
shell: bash
run: |
set -euo pipefail
gh release view "$PREBUILT_RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--json isDraft --jq '.isDraft' | grep -qx true
gh release download "$PREBUILT_RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--pattern "electron-v44.4.1-linux-$ELECTRON_ARCH.zip" \
--pattern "electron-v44.4.1-linux-$ELECTRON_ARCH.fluxer.json" \
--dir "$FLUXER_ELECTRON_DIST"
node <<'NODE'
const {verifyLinuxElectronArchive} = require('./fluxer_desktop/scripts/custom-electron.cjs');
verifyLinuxElectronArchive(process.env.FLUXER_ELECTRON_DIST, process.env.ELECTRON_ARCH)
.then(() => console.log('Patched Electron artifact verified.'))
.catch((error) => { console.error(error); process.exitCode = 1; });
NODE
- name: Upload patched Electron distribution
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-electron-linux-${{ matrix.arch }}
path: |
${{ runner.temp }}/fluxer-electron-artifacts/electron-v44.4.1-linux-${{ matrix.arch }}.zip
${{ runner.temp }}/fluxer-electron-artifacts/electron-v44.4.1-linux-${{ matrix.arch }}.fluxer.json
if-no-files-found: error
retention-days: 30
compression-level: 0
+40
View File
@@ -21,6 +21,11 @@ on:
required: false required: false
default: "" default: ""
type: string type: string
electron_build_run_id:
description: Successful manual main-branch build desktop Electron workflow run supplying the patched Linux Electron distributions. Required when building Linux.
required: false
default: ""
type: string
shell_contract: shell_contract:
description: "Did the shell contract change since the oldest shell these modules still reach? unchanged refuses if it did. compatible keeps older shells on these modules because the renderer still works on them. raise stops sending these modules to shells without the new contract: Linux and managed shells keep their current renderer, Windows and macOS update their shell first." description: "Did the shell contract change since the oldest shell these modules still reach? unchanged refuses if it did. compatible keeps older shells on these modules because the renderer still works on them. raise stops sending these modules to shells without the new contract: Linux and managed shells keep their current renderer, Windows and macOS update their shell first."
required: true required: true
@@ -51,6 +56,7 @@ jobs:
timeout-minutes: 25 timeout-minutes: 25
permissions: permissions:
contents: read contents: read
actions: read
outputs: outputs:
version: ${{ steps.meta.outputs.version }} version: ${{ steps.meta.outputs.version }}
pub_date: ${{ steps.meta.outputs.pub_date }} pub_date: ${{ steps.meta.outputs.pub_date }}
@@ -64,6 +70,30 @@ jobs:
ref: main ref: main
fetch-depth: 0 fetch-depth: 0
- name: Validate patched Electron build
env:
ELECTRON_BUILD_RUN_ID: ${{ inputs.electron_build_run_id }}
SKIP_TARGETS: ${{ inputs.skip_targets }}
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
skipped=",$(printf '%s' "$SKIP_TARGETS" | tr '[:space:]' ','),"
if [[ "$skipped" == *,linux,* ]] || \
{ [[ "$skipped" == *,linux-x64,* ]] && [[ "$skipped" == *,linux-arm64,* ]]; }; then
exit 0
fi
if ! [[ "$ELECTRON_BUILD_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then
echo 'Provide electron_build_run_id from a successful build desktop Electron workflow.' >&2
exit 1
fi
if ! gh api "repos/$GITHUB_REPOSITORY/actions/runs/$ELECTRON_BUILD_RUN_ID" \
--jq 'select(.status == "completed" and .conclusion == "success" and .event == "workflow_dispatch" and .head_branch == "main" and .repository.full_name != null and .head_repository.full_name == .repository.full_name and .path == ".github/workflows/build-desktop-electron.yaml") | .id' \
| grep -qx "$ELECTRON_BUILD_RUN_ID"; then
echo 'Use a successful manual build desktop Electron run from this repository on main.' >&2
exit 1
fi
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
@@ -613,12 +643,22 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step analyse_velopack_paths --step analyse_velopack_paths
- name: Download patched Electron distribution (Linux)
if: matrix.platform == 'linux'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.electron_build_run_id }}
name: desktop-electron-linux-${{ matrix.arch }}
path: ${{ runner.temp }}/fluxer-electron-dist
- name: Build Electron app (Linux) - name: Build Electron app (Linux)
if: matrix.platform == 'linux' if: matrix.platform == 'linux'
working-directory: ${{ env.WORKDIR }}/fluxer_desktop working-directory: ${{ env.WORKDIR }}/fluxer_desktop
env: env:
BUILD_CHANNEL: ${{ env.BUILD_CHANNEL }} BUILD_CHANNEL: ${{ env.BUILD_CHANNEL }}
USE_SYSTEM_FPM: true USE_SYSTEM_FPM: true
FLUXER_ELECTRON_DIST: ${{ runner.temp }}/fluxer-electron-dist
run: >- run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step build_app_linux --step build_app_linux
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {getConfig} from '@app/api/Config';
import {getInstanceConfigRepository, getLimitConfigService} from '@app/api/middleware/ServiceSingletons';
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 {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import type {LimitRule} from '@fluxer/limits/src/LimitTypes';
import type {LimitConfigGetResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import type {z} from 'zod';
type LimitConfigResponse = z.infer<typeof LimitConfigGetResponse>;
const REMOVED_KEY = 'max_created_packs';
describe.each([true, false])('limit config with a key from an older release (self-hosted: %s)', (selfHosted) => {
let harness: ApiTestHarness;
let originalSelfHosted: boolean;
beforeAll(async () => {
originalSelfHosted = getConfig().instance.selfHosted;
getConfig().instance.selfHosted = selfHosted;
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
await getLimitConfigService().refreshCache();
});
afterAll(async () => {
await harness.shutdown();
getConfig().instance.selfHosted = originalSelfHosted;
});
async function createAdmin(): Promise<TestAccount> {
return await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
]);
}
async function storeRuleFromOlderRelease(): Promise<void> {
const config = getLimitConfigService().getConfigSnapshot();
const legacyRule = {
id: 'vip',
filters: {traits: ['vip']},
limits: {max_bio_length: 200, [REMOVED_KEY]: 5},
} as LimitRule;
await getInstanceConfigRepository().setLimitConfig({
traitDefinitions: [...config.traitDefinitions, 'vip'],
rules: [...config.rules, legacyRule],
});
await getLimitConfigService().refreshCache();
}
it('leaves the removed key out when reading the stored config', async () => {
const admin = await createAdmin();
await storeRuleFromOlderRelease();
const response = await createBuilder<LimitConfigResponse>(harness, admin.token)
.get('/admin/limit-config')
.execute();
const vip = response.limit_config.rules.find((rule) => rule.id === 'vip');
expect(vip?.limits).toEqual({max_bio_length: 200});
});
it('saves the config it read back and drops the removed key from storage', async () => {
const admin = await createAdmin();
await storeRuleFromOlderRelease();
const {limit_config: config} = await createBuilder<LimitConfigResponse>(harness, admin.token)
.get('/admin/limit-config')
.execute();
await createBuilder<LimitConfigResponse>(harness, admin.token)
.put('/admin/limit-config')
.body({
limit_config: {
traitDefinitions: config.traitDefinitions,
rules: config.rules.map(({modifiedFields: _modifiedFields, ...rule}) => rule),
},
})
.execute();
const {config: stored} = await getInstanceConfigRepository().readLimitConfigInputs();
const vip = stored?.rules.find((rule) => rule.id === 'vip');
expect(vip?.limits).toEqual({max_bio_length: 200});
});
it('drops a rule whose only limits were removed keys', async () => {
const admin = await createAdmin();
const config = getLimitConfigService().getConfigSnapshot();
await getInstanceConfigRepository().setLimitConfig({
traitDefinitions: [...config.traitDefinitions, 'packs'],
rules: [...config.rules, {id: 'packs', filters: {traits: ['packs']}, limits: {[REMOVED_KEY]: 5}} as LimitRule],
});
await getLimitConfigService().refreshCache();
const {limit_config: read} = await createBuilder<LimitConfigResponse>(harness, admin.token)
.get('/admin/limit-config')
.execute();
expect(read.rules.some((rule) => rule.id === 'packs')).toBe(false);
await createBuilder<LimitConfigResponse>(harness, admin.token)
.put('/admin/limit-config')
.body({
limit_config: {
traitDefinitions: read.traitDefinitions,
rules: read.rules.map(({modifiedFields: _modifiedFields, ...rule}) => rule),
},
})
.execute();
});
it('names the unknown key in the validation error', async () => {
const admin = await createAdmin();
const {limit_config: config} = await createBuilder<LimitConfigResponse>(harness, admin.token)
.get('/admin/limit-config')
.execute();
const response = await createBuilder<{errors: Array<{path: string; code: string}>}>(harness, admin.token)
.put('/admin/limit-config')
.body({
limit_config: {
traitDefinitions: [...config.traitDefinitions, 'vip'],
rules: [
...config.rules.map(({modifiedFields: _modifiedFields, ...rule}) => rule),
{id: 'vip', filters: {traits: ['vip']}, limits: {max_bio_length: 200, [REMOVED_KEY]: 5}},
],
},
})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
.execute();
expect(response.errors).toEqual([
expect.objectContaining({
path: `limit_config.rules.${config.rules.length}.limits.${REMOVED_KEY}`,
code: ValidationErrorCodes.INVALID_FORMAT,
}),
]);
});
});
+12 -8
View File
@@ -103,7 +103,7 @@ export function mergeWithCurrentDefaults(
const mergedRules: Array<LimitRule> = []; const mergedRules: Array<LimitRule> = [];
const existingRulesMap = new Map<string, LimitRule>(); const existingRulesMap = new Map<string, LimitRule>();
for (const rule of stored.rules) { for (const rule of stored.rules) {
existingRulesMap.set(rule.id, {...rule, limits: clampToAllowedRange(rule.limits)}); existingRulesMap.set(rule.id, {...rule, limits: readKnownLimits(rule.limits)});
} }
for (const defaultRule of newDefaults.rules) { for (const defaultRule of newDefaults.rules) {
const existingRule = existingRulesMap.get(defaultRule.id); const existingRule = existingRulesMap.get(defaultRule.id);
@@ -125,6 +125,9 @@ export function mergeWithCurrentDefaults(
existingRulesMap.delete(defaultRule.id); existingRulesMap.delete(defaultRule.id);
} }
for (const [, rule] of existingRulesMap) { for (const [, rule] of existingRulesMap) {
if (Object.keys(rule.limits).length === 0) {
continue;
}
const modifiedFields = findModifiedLimits(rule.limits, defaultBaseRule.limits); const modifiedFields = findModifiedLimits(rule.limits, defaultBaseRule.limits);
mergedRules.push({ mergedRules.push({
id: rule.id, id: rule.id,
@@ -231,16 +234,17 @@ function wasSavedUnderOtherDefaults(
); );
} }
function clampToAllowedRange(limits: Partial<Record<LimitKey, number>>): Partial<Record<LimitKey, number>> { function readKnownLimits(limits: Partial<Record<LimitKey, number>>): Partial<Record<LimitKey, number>> {
const clamped = {...limits}; const known: Partial<Record<LimitKey, number>> = {};
for (const key of LIMIT_KEYS) { for (const key of LIMIT_KEYS) {
const value = clamped[key]; const value = limits[key];
const {min = 0, max} = LIMIT_KEY_METADATA[key]; if (value === undefined) {
if (value !== undefined && max !== undefined) { continue;
clamped[key] = Math.min(Math.max(value, min), max);
} }
const {min = 0, max} = LIMIT_KEY_METADATA[key];
known[key] = max === undefined ? value : Math.min(Math.max(value, min), max);
} }
return clamped; return known;
} }
function pickModifiedLimits( function pickModifiedLimits(
@@ -480,6 +480,28 @@ describe('InstanceSnapshotStore.resolve on an official web page', () => {
expect(resolution.snapshot.webAppEndpoint).toBe('https://alias.example.test'); expect(resolution.snapshot.webAppEndpoint).toBe('https://alias.example.test');
}); });
test('an alias host also takes over the gateway and media the canonical host serves', async () => {
stubBrowserDocument('https://gateway-alias.example.test', {servesOwnApi: true});
const canonical = discoveryDocument('canonical.example.test');
vi.stubGlobal(
'fetch',
vi.fn(async () =>
jsonResponse({
...canonical,
endpoints: {
...canonical.endpoints,
gateway: 'wss://canonical.example.test/gateway',
media: 'https://canonical.example.test/media',
},
}),
),
);
const resolution = await InstanceSnapshotStore.resolve({input: 'https://gateway-alias.example.test', signal: null});
expect(resolution.snapshot.gatewayEndpoint).toBe('wss://gateway-alias.example.test/gateway');
expect(resolution.snapshot.mediaEndpoint).toBe('https://gateway-alias.example.test/media');
expect(resolution.snapshot.staticCdnEndpoint).toBe('https://cdn.canonical.example.test');
});
test('a host that is not marked as serving its own API still refuses a foreign API declaration', async () => { test('a host that is not marked as serving its own API still refuses a foreign API declaration', async () => {
stubBrowserDocument('https://fluxer.com'); stubBrowserDocument('https://fluxer.com');
vi.stubGlobal('fetch', serveLikeProduction('https://web.fluxer.app')); vi.stubGlobal('fetch', serveLikeProduction('https://web.fluxer.app'));
+96 -3
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use crate::config::{AppProxyConfig, HttpEndpoint}; use crate::config::{AppProxyConfig, HttpEndpoint};
use crate::csp::InstanceCspSources;
use axum::http::header; use axum::http::header;
use reqwest::Url; use reqwest::Url;
use std::sync::Arc; use std::sync::Arc;
@@ -15,6 +16,15 @@ const BRANDING_IMAGES: [(&str, &str); 5] = [
("wordmark_url", "FLUXER_APP_WORDMARK_URL"), ("wordmark_url", "FLUXER_APP_WORDMARK_URL"),
("favicon_url", "FLUXER_APP_FAVICON_URL"), ("favicon_url", "FLUXER_APP_FAVICON_URL"),
]; ];
const CONNECT_ENDPOINTS: [&str; 7] = [
"api",
"api_client",
"api_public",
"gateway",
"media",
"upload_relay",
"static_cdn",
];
const PRODUCT_NAME: (&str, &str) = ("product_name", "FLUXER_APP_PRODUCT_NAME"); const PRODUCT_NAME: (&str, &str) = ("product_name", "FLUXER_APP_PRODUCT_NAME");
const ICON_URL: (&str, &str) = ("icon_url", "FLUXER_APP_ICON_URL"); const ICON_URL: (&str, &str) = ("icon_url", "FLUXER_APP_ICON_URL");
const FAVICON_URL: (&str, &str) = ("favicon_url", "FLUXER_APP_FAVICON_URL"); const FAVICON_URL: (&str, &str) = ("favicon_url", "FLUXER_APP_FAVICON_URL");
@@ -33,9 +43,45 @@ where
pub fn snapshot_from_discovery(discovery: &serde_json::Value) -> Option<BrandingSnapshot> { pub fn snapshot_from_discovery(discovery: &serde_json::Value) -> Option<BrandingSnapshot> {
let branding = discovery.pointer("/app_public/branding")?.as_object()?; let branding = discovery.pointer("/app_public/branding")?.as_object()?;
Some(snapshot_from_fields(|(key, _)| { Some(BrandingSnapshot {
branding.get(key)?.as_str().map(str::to_owned) instance_sources: instance_sources_from_discovery(discovery),
})) ..snapshot_from_fields(|(key, _)| branding.get(key)?.as_str().map(str::to_owned))
})
}
fn instance_sources_from_discovery(discovery: &serde_json::Value) -> InstanceCspSources {
let endpoint = |key: &str| {
discovery
.pointer(&format!("/endpoints/{key}"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
};
let http_origin = |key: &'static str| {
endpoint(key).and_then(|value| HttpEndpoint::origin_of(key, value).ok())
};
let mut connect_origins: Vec<String> = Vec::new();
for key in CONNECT_ENDPOINTS {
let Some(origin) = endpoint(key).and_then(connect_origin) else {
continue;
};
if !connect_origins.contains(&origin) {
connect_origins.push(origin);
}
}
InstanceCspSources {
static_cdn_endpoint: http_origin("static_cdn"),
media_endpoint: http_origin("media"),
connect_origins,
}
}
fn connect_origin(value: &str) -> Option<String> {
let url = Url::parse(value).ok()?;
if !matches!(url.scheme(), "http" | "https" | "ws" | "wss") || url.host_str().is_none() {
return None;
}
Some(url.origin().ascii_serialization())
} }
fn snapshot_from_fields( fn snapshot_from_fields(
@@ -50,6 +96,7 @@ fn snapshot_from_fields(
let favicon_url = non_empty(field(FAVICON_URL)).or_else(|| icon_url.clone()); let favicon_url = non_empty(field(FAVICON_URL)).or_else(|| icon_url.clone());
BrandingSnapshot { BrandingSnapshot {
image_origins: image_origins.into(), image_origins: image_origins.into(),
instance_sources: InstanceCspSources::default(),
product_name: non_empty(field(PRODUCT_NAME)).map(Into::into), product_name: non_empty(field(PRODUCT_NAME)).map(Into::into),
icon_url: icon_url.as_deref().and_then(absolute_url).map(Into::into), icon_url: icon_url.as_deref().and_then(absolute_url).map(Into::into),
favicon_url: favicon_url favicon_url: favicon_url
@@ -137,6 +184,7 @@ async fn fetch_discovery(
#[derive(Clone, Debug, Default, PartialEq, Eq)] #[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct BrandingSnapshot { pub struct BrandingSnapshot {
pub image_origins: Arc<[HttpEndpoint]>, pub image_origins: Arc<[HttpEndpoint]>,
pub instance_sources: InstanceCspSources,
pub product_name: Option<Arc<str>>, pub product_name: Option<Arc<str>>,
pub icon_url: Option<Arc<str>>, pub icon_url: Option<Arc<str>>,
pub favicon_url: Option<Arc<str>>, pub favicon_url: Option<Arc<str>>,
@@ -163,6 +211,10 @@ impl InstanceBranding {
self.0.borrow().image_origins.clone() self.0.borrow().image_origins.clone()
} }
pub fn instance_sources(&self) -> InstanceCspSources {
self.0.borrow().instance_sources.clone()
}
pub fn snapshot(&self) -> Arc<BrandingSnapshot> { pub fn snapshot(&self) -> Arc<BrandingSnapshot> {
self.0.borrow().clone() self.0.borrow().clone()
} }
@@ -191,6 +243,7 @@ impl InstanceBranding {
.collect(); .collect();
tracing::info!( tracing::info!(
?origins, ?origins,
instance_origins = ?snapshot.instance_sources.connect_origins,
product_name = snapshot.product_name.as_deref(), product_name = snapshot.product_name.as_deref(),
icon_url = snapshot.icon_url.as_deref(), icon_url = snapshot.icon_url.as_deref(),
favicon_url = snapshot.favicon_url.as_deref(), favicon_url = snapshot.favicon_url.as_deref(),
@@ -339,6 +392,46 @@ mod tests {
); );
} }
#[test]
fn discovery_endpoints_yield_the_instance_origins_the_page_must_reach() {
let discovery = serde_json::json!({
"endpoints": {
"api": "https://chat.example.com/api",
"api_client": "https://chat.example.com/api",
"gateway": "wss://chat.example.com/gateway",
"media": "https://chat.example.com/media",
"static_cdn": "https://static.example.com",
"marketing": "https://www.example.com",
},
"app_public": {"branding": {}},
});
let sources = snapshot_from_discovery(&discovery)
.unwrap()
.instance_sources;
assert_eq!(
sources.connect_origins,
vec![
"https://chat.example.com",
"wss://chat.example.com",
"https://static.example.com",
]
);
assert_eq!(
sources
.media_endpoint
.as_ref()
.map(HttpEndpoint::csp_origin),
Some("https://chat.example.com")
);
assert_eq!(
sources
.static_cdn_endpoint
.as_ref()
.map(HttpEndpoint::csp_origin),
Some("https://static.example.com")
);
}
#[test] #[test]
fn a_document_without_branding_is_not_a_discovery_snapshot() { fn a_document_without_branding_is_not_a_discovery_snapshot() {
assert!(snapshot_from_discovery(&serde_json::json!({"api_code_version": "v1"})).is_none()); assert!(snapshot_from_discovery(&serde_json::json!({"api_code_version": "v1"})).is_none());
+158 -22
View File
@@ -80,6 +80,13 @@ fn has_src_attribute(attributes: &str) -> bool {
}) })
} }
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct InstanceCspSources {
pub static_cdn_endpoint: Option<HttpEndpoint>,
pub media_endpoint: Option<HttpEndpoint>,
pub connect_origins: Vec<String>,
}
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct RuntimeCspSources { pub struct RuntimeCspSources {
pub static_cdn_endpoint: Option<HttpEndpoint>, pub static_cdn_endpoint: Option<HttpEndpoint>,
@@ -206,6 +213,7 @@ impl CompiledCspPolicy {
&[InlineScriptHash::of("")], &[InlineScriptHash::of("")],
configured_sources, configured_sources,
&[], &[],
&InstanceCspSources::default(),
)) ))
.map_err(CspCompileError::InvalidSpaPolicy)?; .map_err(CspCompileError::InvalidSpaPolicy)?;
Ok(Self { Ok(Self {
@@ -223,12 +231,14 @@ impl CompiledCspPolicy {
&self, &self,
script_hashes: &[InlineScriptHash], script_hashes: &[InlineScriptHash],
branding_image_origins: &[HttpEndpoint], branding_image_origins: &[HttpEndpoint],
instance_sources: &InstanceCspSources,
) -> CspHeaderValues { ) -> CspHeaderValues {
let enforced = HeaderValue::from_str(&build_csp( let enforced = HeaderValue::from_str(&build_csp(
&self.config, &self.config,
script_hashes, script_hashes,
&self.runtime_sources, &self.runtime_sources,
branding_image_origins, branding_image_origins,
instance_sources,
)) ))
.expect( .expect(
"every CSP source is a validated keyword, scheme, ASCII origin, or base64 hash, so a \ "every CSP source is a validated keyword, scheme, ASCII origin, or base64 hash, so a \
@@ -246,18 +256,27 @@ fn build_csp(
script_hashes: &[InlineScriptHash], script_hashes: &[InlineScriptHash],
runtime_sources: &RuntimeCspSources, runtime_sources: &RuntimeCspSources,
branding_image_origins: &[HttpEndpoint], branding_image_origins: &[HttpEndpoint],
instance_sources: &InstanceCspSources,
) -> String { ) -> String {
build_csp_directives( build_csp_directives(
config, config,
Some(script_hashes), Some(script_hashes),
runtime_sources, runtime_sources,
branding_image_origins, branding_image_origins,
instance_sources,
) )
.join("; ") .join("; ")
} }
fn build_asset_csp(config: &CspConfig, runtime_sources: &RuntimeCspSources) -> String { fn build_asset_csp(config: &CspConfig, runtime_sources: &RuntimeCspSources) -> String {
build_csp_directives(config, None, runtime_sources, &[]).join("; ") build_csp_directives(
config,
None,
runtime_sources,
&[],
&InstanceCspSources::default(),
)
.join("; ")
} }
fn build_csp_directives( fn build_csp_directives(
@@ -265,6 +284,7 @@ fn build_csp_directives(
script_hashes: Option<&[InlineScriptHash]>, script_hashes: Option<&[InlineScriptHash]>,
runtime_sources: &RuntimeCspSources, runtime_sources: &RuntimeCspSources,
branding_image_origins: &[HttpEndpoint], branding_image_origins: &[HttpEndpoint],
instance_sources: &InstanceCspSources,
) -> Vec<String> { ) -> Vec<String> {
let mut directives = Vec::with_capacity(14); let mut directives = Vec::with_capacity(14);
@@ -281,17 +301,17 @@ fn build_csp_directives(
script.splice(1..1, hashes.iter().map(|hash| hash.as_source().to_owned())); script.splice(1..1, hashes.iter().map(|hash| hash.as_source().to_owned()));
} }
extend_from(&mut script, &config.extra_script_src, SCRIPT_SOURCES); extend_from(&mut script, &config.extra_script_src, SCRIPT_SOURCES);
extend_runtime_sources(&mut script, runtime_sources, true, false); extend_runtime_sources(&mut script, runtime_sources, instance_sources, true, false);
directives.push(format!("script-src {}", script.join(" "))); directives.push(format!("script-src {}", script.join(" ")));
let mut style = vec!["'self'".to_owned(), "'unsafe-inline'".to_owned()]; let mut style = vec!["'self'".to_owned(), "'unsafe-inline'".to_owned()];
extend_from(&mut style, &config.extra_style_src, STYLE_SOURCES); extend_from(&mut style, &config.extra_style_src, STYLE_SOURCES);
extend_runtime_sources(&mut style, runtime_sources, true, true); extend_runtime_sources(&mut style, runtime_sources, instance_sources, true, true);
directives.push(format!("style-src {}", style.join(" "))); directives.push(format!("style-src {}", style.join(" ")));
let mut img = vec!["'self'".to_owned(), "blob:".to_owned(), "data:".to_owned()]; let mut img = vec!["'self'".to_owned(), "blob:".to_owned(), "data:".to_owned()];
extend_from(&mut img, &config.extra_img_src, IMAGE_SOURCES); extend_from(&mut img, &config.extra_img_src, IMAGE_SOURCES);
extend_runtime_sources(&mut img, runtime_sources, true, true); extend_runtime_sources(&mut img, runtime_sources, instance_sources, true, true);
for origin in branding_image_origins { for origin in branding_image_origins {
push_endpoint_source(&mut img, Some(origin)); push_endpoint_source(&mut img, Some(origin));
} }
@@ -299,18 +319,23 @@ fn build_csp_directives(
let mut media = vec!["'self'".to_owned(), "blob:".to_owned()]; let mut media = vec!["'self'".to_owned(), "blob:".to_owned()];
extend_from(&mut media, &config.extra_media_src, MEDIA_SOURCES); extend_from(&mut media, &config.extra_media_src, MEDIA_SOURCES);
extend_runtime_sources(&mut media, runtime_sources, true, true); extend_runtime_sources(&mut media, runtime_sources, instance_sources, true, true);
directives.push(format!("media-src {}", media.join(" "))); directives.push(format!("media-src {}", media.join(" ")));
let mut font = vec!["'self'".to_owned(), "data:".to_owned()]; let mut font = vec!["'self'".to_owned(), "data:".to_owned()];
extend_from(&mut font, &config.extra_font_src, FONT_SOURCES); extend_from(&mut font, &config.extra_font_src, FONT_SOURCES);
extend_runtime_sources(&mut font, runtime_sources, true, true); extend_runtime_sources(&mut font, runtime_sources, instance_sources, true, true);
directives.push(format!("font-src {}", font.join(" "))); directives.push(format!("font-src {}", font.join(" ")));
let mut connect = vec!["'self'".to_owned(), "blob:".to_owned(), "data:".to_owned()]; let mut connect = vec!["'self'".to_owned(), "blob:".to_owned(), "data:".to_owned()];
extend_from(&mut connect, &config.extra_connect_src, CONNECT_SOURCES); extend_from(&mut connect, &config.extra_connect_src, CONNECT_SOURCES);
extend_runtime_sources(&mut connect, runtime_sources, true, true); extend_runtime_sources(&mut connect, runtime_sources, instance_sources, true, true);
extend_runtime_s3_sources(&mut connect, runtime_sources); extend_runtime_s3_sources(&mut connect, runtime_sources);
for origin in &instance_sources.connect_origins {
if !connect.contains(origin) {
connect.push(origin.clone());
}
}
directives.push(format!("connect-src {}", connect.join(" "))); directives.push(format!("connect-src {}", connect.join(" ")));
let mut frame = vec!["'self'".to_owned()]; let mut frame = vec!["'self'".to_owned()];
@@ -319,12 +344,18 @@ fn build_csp_directives(
let mut worker = vec!["'self'".to_owned(), "blob:".to_owned()]; let mut worker = vec!["'self'".to_owned(), "blob:".to_owned()];
extend_from(&mut worker, &config.extra_worker_src, WORKER_SOURCES); extend_from(&mut worker, &config.extra_worker_src, WORKER_SOURCES);
extend_runtime_sources(&mut worker, runtime_sources, true, false); extend_runtime_sources(&mut worker, runtime_sources, instance_sources, true, false);
directives.push(format!("worker-src {}", worker.join(" "))); directives.push(format!("worker-src {}", worker.join(" ")));
let mut manifest = vec!["'self'".to_owned()]; let mut manifest = vec!["'self'".to_owned()];
extend_from(&mut manifest, &config.extra_manifest_src, MANIFEST_SOURCES); extend_from(&mut manifest, &config.extra_manifest_src, MANIFEST_SOURCES);
extend_runtime_sources(&mut manifest, runtime_sources, true, false); extend_runtime_sources(
&mut manifest,
runtime_sources,
instance_sources,
true,
false,
);
directives.push(format!("manifest-src {}", manifest.join(" "))); directives.push(format!("manifest-src {}", manifest.join(" ")));
directives.push("object-src 'none'".to_owned()); directives.push("object-src 'none'".to_owned());
@@ -341,14 +372,17 @@ fn build_csp_directives(
fn extend_runtime_sources( fn extend_runtime_sources(
target: &mut Vec<String>, target: &mut Vec<String>,
runtime_sources: &RuntimeCspSources, runtime_sources: &RuntimeCspSources,
instance_sources: &InstanceCspSources,
include_static: bool, include_static: bool,
include_media: bool, include_media: bool,
) { ) {
if include_static { if include_static {
push_endpoint_source(target, runtime_sources.static_cdn_endpoint.as_ref()); push_endpoint_source(target, runtime_sources.static_cdn_endpoint.as_ref());
push_endpoint_source(target, instance_sources.static_cdn_endpoint.as_ref());
} }
if include_media { if include_media {
push_endpoint_source(target, runtime_sources.media_endpoint.as_ref()); push_endpoint_source(target, runtime_sources.media_endpoint.as_ref());
push_endpoint_source(target, instance_sources.media_endpoint.as_ref());
} }
} }
@@ -448,7 +482,13 @@ mod tests {
#[test] #[test]
fn build_csp_includes_required_directives() { fn build_csp_includes_required_directives() {
let config = default_csp_config(); let config = default_csp_config();
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]); let csp = build_csp(
&config,
&[hash_of("boot()")],
&runtime_sources(),
&[],
&InstanceCspSources::default(),
);
assert!(csp.contains("default-src")); assert!(csp.contains("default-src"));
assert!(csp.contains("script-src")); assert!(csp.contains("script-src"));
assert!(csp.contains("style-src")); assert!(csp.contains("style-src"));
@@ -467,7 +507,13 @@ mod tests {
#[test] #[test]
fn build_csp_allows_the_apex_domain_for_images_and_media() { fn build_csp_allows_the_apex_domain_for_images_and_media() {
let config = default_csp_config(); let config = default_csp_config();
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]); let csp = build_csp(
&config,
&[hash_of("boot()")],
&runtime_sources(),
&[],
&InstanceCspSources::default(),
);
for name in ["img-src ", "media-src "] { for name in ["img-src ", "media-src "] {
let directive = csp let directive = csp
.split("; ") .split("; ")
@@ -485,7 +531,13 @@ mod tests {
#[test] #[test]
fn build_csp_allows_blob_connections_for_camera_background_media() { fn build_csp_allows_blob_connections_for_camera_background_media() {
let config = default_csp_config(); let config = default_csp_config();
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]); let csp = build_csp(
&config,
&[hash_of("boot()")],
&runtime_sources(),
&[],
&InstanceCspSources::default(),
);
let connect = csp let connect = csp
.split("; ") .split("; ")
.find(|directive| directive.starts_with("connect-src ")) .find(|directive| directive.starts_with("connect-src "))
@@ -520,6 +572,7 @@ mod tests {
&[hash_of("first()"), hash_of("second()")], &[hash_of("first()"), hash_of("second()")],
&runtime_sources(), &runtime_sources(),
&[], &[],
&InstanceCspSources::default(),
); );
let script = csp let script = csp
.split("; ") .split("; ")
@@ -548,6 +601,7 @@ mod tests {
&[hash_of("boot()")], &[hash_of("boot()")],
&runtime_sources(), &runtime_sources(),
&[], &[],
&InstanceCspSources::default(),
); );
assert!(!csp.contains("hcaptcha")); assert!(!csp.contains("hcaptcha"));
assert!(!csp.contains("challenges.cloudflare.com")); assert!(!csp.contains("challenges.cloudflare.com"));
@@ -556,7 +610,13 @@ mod tests {
#[test] #[test]
fn csp_no_double_spaces_or_trailing_semicolons() { fn csp_no_double_spaces_or_trailing_semicolons() {
let config = default_csp_config(); let config = default_csp_config();
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]); let csp = build_csp(
&config,
&[hash_of("boot()")],
&runtime_sources(),
&[],
&InstanceCspSources::default(),
);
assert!(!csp.contains(" "), "CSP contains double spaces"); assert!(!csp.contains(" "), "CSP contains double spaces");
assert!(!csp.ends_with(';'), "CSP ends with semicolon"); assert!(!csp.ends_with(';'), "CSP ends with semicolon");
assert!(!csp.ends_with("; "), "CSP ends with semicolon+space"); assert!(!csp.ends_with("; "), "CSP ends with semicolon+space");
@@ -574,14 +634,26 @@ mod tests {
), ),
..Default::default() ..Default::default()
}; };
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]); let csp = build_csp(
&config,
&[hash_of("boot()")],
&runtime_sources(),
&[],
&InstanceCspSources::default(),
);
assert!(csp.contains("report-uri https://example.com/csp-report")); assert!(csp.contains("report-uri https://example.com/csp-report"));
} }
#[test] #[test]
fn build_csp_excludes_report_uri_when_none() { fn build_csp_excludes_report_uri_when_none() {
let config = default_csp_config(); let config = default_csp_config();
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]); let csp = build_csp(
&config,
&[hash_of("boot()")],
&runtime_sources(),
&[],
&InstanceCspSources::default(),
);
assert!(!csp.contains("report-uri")); assert!(!csp.contains("report-uri"));
} }
@@ -593,7 +665,13 @@ mod tests {
media_endpoint: Some(endpoint("https://media.example.test")), media_endpoint: Some(endpoint("https://media.example.test")),
..Default::default() ..Default::default()
}; };
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources, &[]); let csp = build_csp(
&config,
&[hash_of("boot()")],
&runtime_sources,
&[],
&InstanceCspSources::default(),
);
assert!(csp.contains("style-src 'self' 'unsafe-inline'")); assert!(csp.contains("style-src 'self' 'unsafe-inline'"));
assert!(csp.contains("https://static.example.test")); assert!(csp.contains("https://static.example.test"));
assert!(csp.contains("https://media.example.test")); assert!(csp.contains("https://media.example.test"));
@@ -635,7 +713,13 @@ mod tests {
..Default::default() ..Default::default()
}; };
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources, &[]); let csp = build_csp(
&config,
&[hash_of("boot()")],
&runtime_sources,
&[],
&InstanceCspSources::default(),
);
assert!(csp.contains("http://localhost:3900")); assert!(csp.contains("http://localhost:3900"));
assert!(csp.contains("http://fluxer-uploads.localhost:3900")); assert!(csp.contains("http://fluxer-uploads.localhost:3900"));
@@ -673,7 +757,7 @@ mod tests {
}; };
let policy = CompiledCspPolicy::compile(default_csp_config(), &configured).unwrap(); let policy = CompiledCspPolicy::compile(default_csp_config(), &configured).unwrap();
let headers = policy.spa_headers(&[hash_of("boot()")], &[]); let headers = policy.spa_headers(&[hash_of("boot()")], &[], &InstanceCspSources::default());
let header = headers.enforced.to_str().unwrap(); let header = headers.enforced.to_str().unwrap();
assert!(header.contains(hash_of("boot()").as_source())); assert!(header.contains(hash_of("boot()").as_source()));
@@ -689,7 +773,7 @@ mod tests {
config.media_endpoint = Some(endpoint("https://media.configured.test/")); config.media_endpoint = Some(endpoint("https://media.configured.test/"));
let policy = CompiledCspPolicy::from_config(&config).unwrap(); let policy = CompiledCspPolicy::from_config(&config).unwrap();
let headers = policy.spa_headers(&[hash_of("boot()")], &[]); let headers = policy.spa_headers(&[hash_of("boot()")], &[], &InstanceCspSources::default());
let header = headers.enforced.to_str().unwrap(); let header = headers.enforced.to_str().unwrap();
for directive in [ for directive in [
"img-src", "img-src",
@@ -740,8 +824,18 @@ mod tests {
); );
let hashes = [hash_of("boot()")]; let hashes = [hash_of("boot()")];
assert_eq!( assert_eq!(
policy.spa_headers(&hashes, &[]).enforced.to_str().unwrap(), policy
build_csp(&config, &hashes, &sources, &[]) .spa_headers(&hashes, &[], &InstanceCspSources::default())
.enforced
.to_str()
.unwrap(),
build_csp(
&config,
&hashes,
&sources,
&[],
&InstanceCspSources::default()
)
); );
} }
@@ -753,7 +847,11 @@ mod tests {
endpoint("http://assets.example.test:8443"), endpoint("http://assets.example.test:8443"),
]; ];
let headers = policy.spa_headers(&[hash_of("boot()")], &branding); let headers = policy.spa_headers(
&[hash_of("boot()")],
&branding,
&InstanceCspSources::default(),
);
let header = headers.enforced.to_str().unwrap(); let header = headers.enforced.to_str().unwrap();
for directive in header.split("; ") { for directive in header.split("; ") {
@@ -780,6 +878,43 @@ mod tests {
); );
} }
#[test]
fn discovered_instance_origins_are_granted_to_the_page() {
let policy = CompiledCspPolicy::compile(default_csp_config(), &runtime_sources()).unwrap();
let instance = InstanceCspSources {
static_cdn_endpoint: None,
media_endpoint: Some(endpoint("https://chat.example.com/media")),
connect_origins: vec![
"https://chat.example.com".to_owned(),
"wss://chat.example.com".to_owned(),
],
};
let headers = policy.spa_headers(&[hash_of("boot()")], &[], &instance);
let header = headers.enforced.to_str().unwrap();
let directive = |name: &str| {
header
.split("; ")
.find(|directive| directive.starts_with(&format!("{name} ")))
.unwrap()
.split(' ')
.collect::<Vec<_>>()
};
assert!(directive("connect-src").contains(&"wss://chat.example.com"));
assert!(directive("connect-src").contains(&"https://chat.example.com"));
assert!(directive("img-src").contains(&"https://chat.example.com"));
assert!(directive("media-src").contains(&"https://chat.example.com"));
assert!(!directive("script-src").contains(&"https://chat.example.com"));
assert!(
!policy
.asset_header()
.to_str()
.unwrap()
.contains("chat.example.com")
);
}
#[test] #[test]
fn a_branding_image_origin_the_policy_already_grants_is_not_repeated() { fn a_branding_image_origin_the_policy_already_grants_is_not_repeated() {
let configured = RuntimeCspSources { let configured = RuntimeCspSources {
@@ -791,6 +926,7 @@ mod tests {
let headers = policy.spa_headers( let headers = policy.spa_headers(
&[hash_of("boot()")], &[hash_of("boot()")],
&[endpoint("https://media.example.test")], &[endpoint("https://media.example.test")],
&InstanceCspSources::default(),
); );
let header = headers.enforced.to_str().unwrap(); let header = headers.enforced.to_str().unwrap();
let img = header let img = header
+2
View File
@@ -226,6 +226,7 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap, request_path: &s
let csp = state.csp.spa_headers( let csp = state.csp.spa_headers(
&inline_script_hashes(&html), &inline_script_hashes(&html),
&state.branding.image_origins(), &state.branding.image_origins(),
&state.branding.instance_sources(),
); );
build_spa_response(html.into_boxed_str(), &csp, should_bust_dev_assets) build_spa_response(html.into_boxed_str(), &csp, should_bust_dev_assets)
} }
@@ -1050,6 +1051,7 @@ mod tests {
icon_url: Some(Arc::from("https://example.test/icon.png")), icon_url: Some(Arc::from("https://example.test/icon.png")),
favicon_url: Some(Arc::from("https://example.test/favicon.png")), favicon_url: Some(Arc::from("https://example.test/favicon.png")),
theme_color: Some(Arc::from("#7b2ce4")), theme_color: Some(Arc::from("#7b2ce4")),
..BrandingSnapshot::default()
} }
} }
+50 -5
View File
@@ -63,7 +63,22 @@ if (!channel) {
const isCanary = buildChannel === 'canary'; const isCanary = buildChannel === 'canary';
const isStable = buildChannel === 'stable'; const isStable = buildChannel === 'stable';
const isLinuxBuild = process.argv.includes('--linux'); const platformArguments = new Set(process.argv.map((argument) => argument.split('=', 1)[0]));
const linuxRequested = ['--linux', '-l'].some((selector) => platformArguments.has(selector));
const macRequested = ['--mac', '--macos', '-m', '-o'].some((selector) => platformArguments.has(selector));
const windowsRequested = ['--win', '--windows', '-w'].some((selector) => platformArguments.has(selector));
const targetPlatform = linuxRequested
? 'linux'
: macRequested
? 'darwin'
: windowsRequested
? 'win32'
: process.platform;
const isLinuxBuild = targetPlatform === 'linux';
const customLinuxElectron =
isLinuxBuild && process.env.FLUXER_ELECTRON_DIST?.trim() ? require('./scripts/custom-electron.cjs') : null;
const linuxElectronDist = customLinuxElectron?.resolveLinuxElectronDist() ?? null;
const verifiedLinuxElectronArtifacts = new Map();
const productName = channel.productName; const productName = channel.productName;
const linuxOptDirName = channel.linuxOptDirName; const linuxOptDirName = channel.linuxOptDirName;
const installedProductName = isLinuxBuild ? linuxOptDirName : productName; const installedProductName = isLinuxBuild ? linuxOptDirName : productName;
@@ -99,9 +114,6 @@ const legacyLinuxCanaryOptDir = '/opt/Fluxer Canary';
const legacyLinuxOptDirSweepScript = path.resolve(__dirname, 'packaging/linux/rpm-post-transaction.sh'); const legacyLinuxOptDirSweepScript = path.resolve(__dirname, 'packaging/linux/rpm-post-transaction.sh');
const legacyLinuxOptDirRpmFpmArgs = isCanary ? ['--rpm-posttrans', legacyLinuxOptDirSweepScript] : []; const legacyLinuxOptDirRpmFpmArgs = isCanary ? ['--rpm-posttrans', legacyLinuxOptDirSweepScript] : [];
const macOSMinimumSystemVersion = '13.0'; const macOSMinimumSystemVersion = '13.0';
const isMacBuild = process.argv.includes('--mac');
const isWindowsBuild = process.argv.includes('--win');
const targetPlatform = isLinuxBuild ? 'linux' : isMacBuild ? 'darwin' : isWindowsBuild ? 'win32' : process.platform;
const metadataName = isLinuxBuild ? linuxPackageName : packageName; const metadataName = isLinuxBuild ? linuxPackageName : packageName;
const shellVersion = process.env.VERSION || readBundledRendererVersion(); const shellVersion = process.env.VERSION || readBundledRendererVersion();
const supportedTargetArchs = ['x64', 'arm64']; const supportedTargetArchs = ['x64', 'arm64'];
@@ -797,6 +809,27 @@ async function verifyNativePackageInputs(context) {
await verifyDarwinNativeArchitectures(platform, arch, entries, 'before packaging'); await verifyDarwinNativeArchitectures(platform, arch, entries, 'before packaging');
} }
async function beforePack(context) {
if (linuxElectronDist && context.electronPlatformName !== 'linux') {
throw new Error(
'The patched Linux Electron distribution cannot package other platforms. Use separate electron-builder invocations for Linux and macOS or Windows.',
);
}
if (context.electronPlatformName === 'linux') {
if (!linuxElectronDist) {
throw new Error(
'Linux packaging requires patched Electron. Set FLUXER_ELECTRON_DIST to the directory containing its ZIP and build manifest.',
);
}
const arch = context.arch === 1 ? 'x64' : context.arch === 3 ? 'arm64' : context.arch;
verifiedLinuxElectronArtifacts.set(
arch,
await customLinuxElectron.verifyLinuxElectronArchive(linuxElectronDist, arch),
);
}
await verifyNativePackageInputs(context);
}
async function verifyPackagedNativeArtifacts(context) { async function verifyPackagedNativeArtifacts(context) {
const platform = context.electronPlatformName; const platform = context.electronPlatformName;
const arch = normalizeArch(context.arch); const arch = normalizeArch(context.arch);
@@ -1147,6 +1180,15 @@ async function readAsarEntry(asarPath, entryPath) {
} }
async function afterPack(context) { async function afterPack(context) {
if (context.electronPlatformName === 'linux') {
if (!customLinuxElectron) {
throw new Error('Custom Electron was not configured before Linux packaging.');
}
await customLinuxElectron.verifyPackagedLinuxElectron(
path.join(context.appOutDir, linuxPackageName),
verifiedLinuxElectronArtifacts.get(normalizeArch(context.arch)),
);
}
await copyMissingPackagedNativeArtifacts(context); await copyMissingPackagedNativeArtifacts(context);
await cleanupNativeBuildIntermediates(context); await cleanupNativeBuildIntermediates(context);
await addLinuxLegacyBinarySymlink(context); await addLinuxLegacyBinarySymlink(context);
@@ -1613,6 +1655,9 @@ async function verifyLinuxArtifactContracts(buildResult) {
} }
module.exports = { module.exports = {
...(customLinuxElectron
? {electronVersion: customLinuxElectron.expectedElectronVersion, electronDist: linuxElectronDist}
: {}),
appId, appId,
productName: installedProductName, productName: installedProductName,
copyright: 'Copyright © 2026 Fluxer Platform AB', copyright: 'Copyright © 2026 Fluxer Platform AB',
@@ -1732,7 +1777,7 @@ module.exports = {
schemes: [channel.protocolScheme], schemes: [channel.protocolScheme],
}, },
], ],
beforePack: verifyNativePackageInputs, beforePack,
afterPack, afterPack,
afterAllArtifactBuild: verifyLinuxArtifactContracts, afterAllArtifactBuild: verifyLinuxArtifactContracts,
toolsets: { toolsets: {
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env bash
set -euo pipefail
readonly ELECTRON_VERSION=44.4.1
readonly ELECTRON_REVISION=09d1a6590e3148a1a2132b07a5b107b7c2414191
readonly DEPOT_TOOLS_REVISION=889099791deff38466d471fa6590951c91a1d8ab
readonly BUILD_IMAGE='ghcr.io/electron/build:daad061f4b99a0ae1c841be4aa09188280a9c8a4@sha256:7a161ad561103ebbfb5ff18eaf47cb579ca0c528bb7ec9ed8cad30a41d5fa63e'
readonly PATCH_NAME=fix_synchronize_vaapi_surfaces_before_output.patch
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
readonly SCRIPT_DIR
die() { printf 'Error: %s\n' "$*" >&2; exit 1; }
usage() {
cat <<'USAGE'
Build patched Electron 44.4.1 on a Linux x64 host with Podman or Docker.
Usage: build-linux.sh [--arch x64|arm64|all] [--build-root PATH] [--output-dir PATH] [--check]
--arch Target architecture; all builds x64 then arm64 (default: all).
--build-root Dedicated persistent cache, source and output tree.
Default: ${XDG_CACHE_HOME:-$HOME/.cache}/fluxer/electron-44.4.1
--output-dir Distribution ZIPs and .fluxer.json manifests (default: BUILD_ROOT/artifacts).
--check Validate host resources and configuration without pulling or compiling.
Requires at least 300 GiB free in the build filesystem. Recommended/default
container memory is 48 GiB; use CONTAINER_MEMORY=32g for the lower-memory minimum.
Available host RAM must cover the selected container limit.
The build uses host networking and builds architectures sequentially.
Optional environment: CONTAINER_ENGINE=podman|docker, CONTAINER_CPUS=8,
CONTAINER_MEMORY=48g, CONTAINER_CPUSET=0-7, BUILD_JOBS=8.
Without CONTAINER_CPUSET, CPU affinity is limited to the smaller CPU/job budget
using CPUs permitted by the host. An explicit CPU set overrides that selection.
The container runs as root; completed archives are returned to the invoking UID/GID.
USAGE
}
inside_container() {
[[ ${FLUXER_ELECTRON_CONTAINER:-} == 1 && "$PWD" == /work ]] || die 'Internal container invocation is invalid.'
export DEPOT_TOOLS_UPDATE=0
export DEPOT_TOOLS_METRICS=0
export GIT_TERMINAL_PROMPT=0
export GIT_CACHE_PATH=/work/git-cache
export PATH="/work/depot_tools:$PATH"
local arch
checkout_pinned depot_tools https://chromium.googlesource.com/chromium/tools/depot_tools.git "$DEPOT_TOOLS_REVISION"
[[ -z $(git -C depot_tools status --porcelain --untracked-files=no) ]] || die 'Cached depot_tools contains tracked edits.'
DEPOT_TOOLS_DIR=/work/depot_tools /work/depot_tools/ensure_bootstrap
[[ -s depot_tools/python3_bin_reldir.txt ]] || die 'Pinned depot_tools Python bootstrap did not complete.'
depot_tools/python-bin/python3 --version
checkout_pinned src/electron https://github.com/electron/electron.git "$ELECTRON_REVISION"
python3 - "$SCRIPT_DIR/patches/$PATCH_NAME" "src/electron/patches/chromium/$PATCH_NAME" <<'PY'
import pathlib
import shutil
import subprocess
import sys
source, destination = map(pathlib.Path, sys.argv[1:])
allowed = {"patches/chromium/.patches", "patches/chromium/" + destination.name}
changes = subprocess.check_output(
["git", "-C", "src/electron", "status", "--porcelain=v1", "-z"], text=True
).split("\0")
for change in filter(None, changes):
if change[:2].strip() not in {"M", "??"} or change[3:] not in allowed:
raise SystemExit("Cached Electron has unrelated edits: " + repr(change))
manifest = destination.parent / ".patches"
upstream = subprocess.check_output(
["git", "-C", "src/electron", "show", "HEAD:patches/chromium/.patches"]
)
patched = upstream.rstrip(b"\n") + b"\n" + destination.name.encode() + b"\n"
if manifest.read_bytes() not in (upstream, patched):
raise SystemExit("Cached Chromium patch manifest differs from the pinned upstream series")
shutil.copyfile(source, destination)
manifest.write_bytes(patched)
PY
gclient config --name src/electron --unmanaged --custom-var=checkout_arm64=True https://github.com/electron/electron
gclient sync --no-history --with_branch_heads --with_tags --jobs "$BUILD_JOBS"
git -C src apply --reverse --check "$SCRIPT_DIR/patches/$PATCH_NAME"
for arch in "$@"; do
python3 src/electron/script/pgo/download-profiles.py --targets "linux-$arch,v8-builtins"
(
cd src
gn gen "out/Release-$arch" --fail-on-unused-args --args="import(\"//electron/build/args/release.gn\") target_cpu=\"$arch\" override_electron_version=\"$ELECTRON_VERSION\" use_remoteexec=false"
python3 - "$arch" <<'PY'
import json
import subprocess
import sys
arch = sys.argv[1]
arguments = json.loads(subprocess.check_output(
["gn", "args", "out/Release-" + arch, "--list", "--short", "--json"], text=True
))
resolved = {
argument["name"]: argument.get("current", argument["default"])["value"]
for argument in arguments
}
expected = {
"target_cpu": json.dumps(arch),
"use_vaapi": "true",
"enable_platform_hevc": "true",
"enable_hevc_parser_and_hw_decoder": "true",
}
for name, value in expected.items():
if resolved.get(name) != value:
raise SystemExit(f"Electron build requires {name} = {value}; resolved {resolved.get(name)!r}")
print(f"Verified GN argument: {name} = {value}", flush=True)
PY
autoninja -C "out/Release-$arch" -j "$BUILD_JOBS" electron:electron_dist_zip
python3 electron/script/zip_manifests/check-zip-manifest.py "out/Release-$arch/dist.zip" "electron/script/zip_manifests/dist_zip.linux.$arch.manifest"
)
python3 - "$arch" "$ELECTRON_VERSION" "$ELECTRON_REVISION" "$SCRIPT_DIR/patches/$PATCH_NAME" <<'PY'
import hashlib
import json
import os
import pathlib
import shutil
import struct
import sys
import tempfile
import zipfile
arch, version, revision, patch_name = sys.argv[1:]
source = pathlib.Path("src/out/Release-" + arch + "/dist.zip")
with zipfile.ZipFile(source) as archive:
if archive.testzip() is not None:
raise SystemExit("Built Electron ZIP has a corrupt member")
with archive.open("electron") as executable:
header = executable.read(64)
if len(header) != 64 or header[:6] != b"\x7fELF\x02\x01" or struct.unpack_from("<H", header, 18)[0] != {"x64": 62, "arm64": 183}[arch]:
raise SystemExit("Built Electron executable has the wrong ELF architecture")
marker = b"failed to synchronize VA surface before output"
tail = header
has_patch_marker = marker in header
while chunk := executable.read(1024 * 1024):
window = tail + chunk
has_patch_marker = has_patch_marker or marker in window
tail = window[-(len(marker) - 1):]
if not has_patch_marker:
raise SystemExit("Built Electron executable is missing the VA-API synchronization patch marker")
if archive.read("version").decode().strip() != version:
raise SystemExit("Built Electron version differs from the pinned version")
def sha256(path):
digest = hashlib.sha256()
with open(path, "rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
output = pathlib.Path("/artifacts")
name = "electron-v" + version + "-linux-" + arch
archive_path = output / (name + ".zip")
manifest_path = output / (name + ".fluxer.json")
manifest = {
"schemaVersion": 1,
"electronVersion": version,
"electronRevision": revision,
"chromiumPatchSha256": sha256(patch_name),
"platform": "linux",
"arch": arch,
"archiveName": archive_path.name,
"archiveSha256": sha256(source),
}
for destination, contents in [(archive_path, None), (manifest_path, json.dumps(manifest, indent=2) + "\n")]:
descriptor, temporary = tempfile.mkstemp(prefix="." + destination.name + ".", dir=output)
try:
with os.fdopen(descriptor, "wb") as stream:
if contents is None:
with source.open("rb") as runtime:
shutil.copyfileobj(runtime, stream)
else:
stream.write(contents.encode())
os.chmod(temporary, 0o644)
os.chown(temporary, int(os.environ["FLUXER_HOST_UID"]), int(os.environ["FLUXER_HOST_GID"]))
os.replace(temporary, destination)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
print("Prepared " + str(archive_path) + " and " + str(manifest_path), flush=True)
PY
done
}
checkout_pinned() {
local directory=$1 remote=$2 revision=$3 current
if [[ ! -d $directory/.git ]]; then
[[ ! -e $directory || -z $(ls -A "$directory") ]] || die "Source directory is not an empty Git checkout: $directory"
git init -q "$directory"
fi
if ! git -C "$directory" remote get-url origin >/dev/null 2>&1; then
git -C "$directory" remote add origin "$remote"
fi
[[ $(git -C "$directory" remote get-url origin) == "$remote" ]] || die "Cached Git remote differs: $directory"
if ! current=$(git -C "$directory" rev-parse --verify HEAD 2>/dev/null); then
git -C "$directory" fetch --depth=1 origin "$revision"
git -C "$directory" checkout --detach FETCH_HEAD
current=$(git -C "$directory" rev-parse HEAD)
fi
[[ $current == "$revision" ]] || die "Cached revision differs in $directory; choose a new build root."
}
if [[ ${1:-} == --inside-container ]]; then
shift
inside_container "$@"
exit
fi
ARCH=all
BUILD_ROOT="${XDG_CACHE_HOME:-$HOME/.cache}/fluxer/electron-$ELECTRON_VERSION"
OUTPUT_DIR=''
CHECK_ONLY=0
while (($#)); do
case $1 in
--arch|--build-root|--output-dir)
(($# >= 2)) || die "Missing value for $1"
case $1 in
--arch) ARCH=$2 ;;
--build-root) BUILD_ROOT=$2 ;;
--output-dir) OUTPUT_DIR=$2 ;;
esac
shift 2 ;;
--check) CHECK_ONLY=1; shift ;;
--help|-h) usage; exit ;;
*) die "Unknown argument: $1" ;;
esac
done
case $ARCH in
all) ARCHES=(x64 arm64) ;;
x64|arm64) ARCHES=("$ARCH") ;;
*) die '--arch must be x64, arm64 or all.' ;;
esac
[[ $(uname -s) == Linux && $(uname -m) == x86_64 ]] || die 'Build on a Linux x64 host; macOS and emulated Linux builds are unsupported.'
for command in python3 flock; do command -v "$command" >/dev/null || die "Required command is unavailable: $command"; done
if [[ -z ${CONTAINER_ENGINE:-} ]]; then
if command -v podman >/dev/null; then CONTAINER_ENGINE=podman; else CONTAINER_ENGINE=docker; fi
fi
case $CONTAINER_ENGINE in podman|docker) ;; *) die 'CONTAINER_ENGINE must be podman or docker.' ;; esac
command -v "$CONTAINER_ENGINE" >/dev/null || die "Required container engine is unavailable: $CONTAINER_ENGINE"
BUILD_JOBS=${BUILD_JOBS:-8}
CONTAINER_CPUS=${CONTAINER_CPUS:-8}
CONTAINER_MEMORY=${CONTAINER_MEMORY:-48g}
[[ $BUILD_JOBS =~ ^[1-9][0-9]*$ ]] || die 'BUILD_JOBS must be a positive integer.'
[[ $CONTAINER_CPUS =~ ^[1-9][0-9]*$ ]] || die 'CONTAINER_CPUS must be a positive integer.'
CONTAINER_CPUSET=$(python3 - "$CONTAINER_CPUS" "$BUILD_JOBS" "${CONTAINER_CPUSET:-}" <<'PY'
import os
import re
import sys
allowed = sorted(os.sched_getaffinity(0))
if not allowed:
raise SystemExit("No CPUs are permitted by the host affinity")
explicit = sys.argv[3]
if explicit:
if not re.fullmatch(r"[0-9]+(?:-[0-9]+)?(?:,[0-9]+(?:-[0-9]+)?)*", explicit):
raise SystemExit("CONTAINER_CPUSET must be a comma-separated CPU list or ranges, e.g. 0-7,9")
requested = set()
for part in explicit.split(","):
bounds = [int(value) for value in part.split("-")]
first, last = bounds[0], bounds[-1]
if first > last or last > allowed[-1]:
raise SystemExit("CONTAINER_CPUSET contains an invalid or unavailable CPU range: " + part)
requested.update(range(first, last + 1))
unavailable = requested.difference(allowed)
if unavailable:
raise SystemExit("CONTAINER_CPUSET includes CPUs outside the host affinity: " + ",".join(map(str, sorted(unavailable))))
print(explicit)
else:
budget = min(int(sys.argv[1]), int(sys.argv[2]), len(allowed))
print(",".join(map(str, allowed[:budget])))
PY
)
BUILD_ROOT=$(python3 -c 'import pathlib,sys; print(pathlib.Path(sys.argv[1]).expanduser().resolve())' "$BUILD_ROOT")
OUTPUT_DIR=${OUTPUT_DIR:-$BUILD_ROOT/artifacts}
OUTPUT_DIR=$(python3 -c 'import pathlib,sys; print(pathlib.Path(sys.argv[1]).expanduser().resolve())' "$OUTPUT_DIR")
python3 - "$BUILD_ROOT" "$CONTAINER_MEMORY" "$CONTAINER_CPUS" <<'PY'
import os
import pathlib
import re
import shutil
import sys
root = pathlib.Path(sys.argv[1])
if root == pathlib.Path("/") or root == pathlib.Path.home():
raise SystemExit("Choose a dedicated build root, not / or your home directory")
probe = root
while not probe.exists():
probe = probe.parent
free = shutil.disk_usage(probe).free / 2**30
if free < 300:
raise SystemExit(f"Build filesystem has {free:.1f} GiB free; at least 300 GiB is required")
memory = dict(line.split(":", 1) for line in pathlib.Path("/proc/meminfo").read_text().splitlines())
total = int(memory["MemTotal"].split()[0]) * 1024
available = int(memory["MemAvailable"].split()[0]) * 1024
match = re.fullmatch(r"([1-9][0-9]*)([mgMG])", sys.argv[2])
if not match:
raise SystemExit("CONTAINER_MEMORY must use whole megabytes or gigabytes, e.g. 48g")
limit = int(match[1]) * {"m": 2**20, "g": 2**30}[match[2].lower()]
if total < 31 * 2**30 or limit < 32 * 2**30 or available < limit:
raise SystemExit(f"Insufficient RAM: host {total / 2**30:.1f} GiB total, {available / 2**30:.1f} GiB available, container limit {limit / 2**30:.1f} GiB; need at least a 32 GiB container limit and available RAM covering that limit")
if int(sys.argv[3]) > (os.cpu_count() or 1):
raise SystemExit("CONTAINER_CPUS exceeds the host's logical CPU count")
print(f"Host ready: {free:.1f} GiB free, {available / 2**30:.1f} GiB available RAM; container limit {limit / 2**30:.1f} GiB", flush=True)
PY
"$CONTAINER_ENGINE" info >/dev/null
printf 'Engine: %s\nImage: %s\nBuild root: %s\nOutput: %s\nTargets: %s\nCPU set: %s\n' "$CONTAINER_ENGINE" "$BUILD_IMAGE" "$BUILD_ROOT" "$OUTPUT_DIR" "${ARCHES[*]}" "$CONTAINER_CPUSET"
((CHECK_ONLY == 0)) || exit
if [[ -d $BUILD_ROOT && ! -f $BUILD_ROOT/.fluxer-electron-build-id && -n $(ls -A "$BUILD_ROOT") ]]; then
die 'Build root is not empty and has no ownership marker; choose a dedicated empty directory.'
fi
mkdir -p "$BUILD_ROOT" "$OUTPUT_DIR"
exec 9>"$BUILD_ROOT/.fluxer-electron-build.lock"
flock -n 9 || die 'Another build is using this build root.'
if [[ -f $BUILD_ROOT/.fluxer-electron-build-id ]]; then
[[ $(cat "$BUILD_ROOT/.fluxer-electron-build-id") == "$ELECTRON_REVISION" ]] || die 'Build root belongs to a different Electron revision.'
else
printf '%s\n' "$ELECTRON_REVISION" > "$BUILD_ROOT/.fluxer-electron-build-id"
fi
mkdir -p "$BUILD_ROOT/container-home" "$BUILD_ROOT/git-cache"
ENGINE_ARGS=(run --rm --platform linux/amd64 --user 0 --network host --workdir /work --cpus "$CONTAINER_CPUS" --cpuset-cpus "$CONTAINER_CPUSET" --memory "$CONTAINER_MEMORY")
ENGINE_ARGS+=(
--volume "$BUILD_ROOT:/work"
--volume "$OUTPUT_DIR:/artifacts"
--volume "$BUILD_ROOT/container-home:/root"
--volume "$SCRIPT_DIR:/fluxer-electron:ro"
--env FLUXER_ELECTRON_CONTAINER=1
--env "FLUXER_HOST_UID=$(id -u)"
--env "FLUXER_HOST_GID=$(id -g)"
--env "BUILD_JOBS=$BUILD_JOBS"
)
"$CONTAINER_ENGINE" pull "$BUILD_IMAGE"
"$CONTAINER_ENGINE" "${ENGINE_ARGS[@]}" "$BUILD_IMAGE" bash /fluxer-electron/build-linux.sh --inside-container "${ARCHES[@]}"
@@ -0,0 +1,78 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Fluxer <[email protected]>
Date: Sat, 17 Oct 2026 14:10:46 +0000
Subject: fix: synchronize VA-API surfaces before output
diff --git a/media/gpu/vaapi/vaapi_video_decoder.cc b/media/gpu/vaapi/vaapi_video_decoder.cc
--- a/media/gpu/vaapi/vaapi_video_decoder.cc
+++ b/media/gpu/vaapi/vaapi_video_decoder.cc
@@ -223,6 +223,7 @@
transcryption_ = false;
SetState(State::kUninitialized);
+ surface_sync_failed_ = false;
}
DCHECK(!current_decode_task_);
DCHECK(decode_task_queue_.empty());
@@ -404,6 +405,11 @@
{
TRACE_EVENT("media,gpu", "VaapiVideoDecoder::Decode");
decode_result = decoder_->Decode();
+ }
+ if (surface_sync_failed_) {
+ SetState({DecoderStatus::Codes::kFailed,
+ "failed to synchronize VA surface before output"});
+ return;
}
switch (decode_result) {
case AcceleratedVideoDecoder::kRanOutOfStreamData:
@@ -559,6 +565,10 @@
DVLOGF(4);
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
DCHECK_EQ(state_, State::kDecoding);
+ if (surface_sync_failed_) {
+ return;
+ }
+
CHECK_NE(va_surface_id, VA_INVALID_SURFACE);
// Find the timestamp associated with |buffer_id|. It's possible that a
@@ -624,6 +634,11 @@
gfx::HDRMetadata hdr_metadata = static_hdr_metadata_;
hdr_metadata.MergeMetadataFrom(dynamic_hdr_metadata);
frame->set_hdr_metadata(hdr_metadata);
+
+ if (!vaapi_wrapper_->SyncSurface(va_surface_id)) {
+ surface_sync_failed_ = true;
+ return;
+ }
output_cb_.Run(std::move(frame));
}
@@ -1045,7 +1060,13 @@
// Flush will block until SurfaceReady() has been called for every frame
// currently decoding.
- if (!decoder_->Flush()) {
+ const bool flush_succeeded = decoder_->Flush();
+ if (surface_sync_failed_) {
+ SetState({DecoderStatus::Codes::kFailed,
+ "failed to synchronize VA surface before output"});
+ return;
+ }
+ if (!flush_succeeded) {
SetState({DecoderStatus::Codes::kFailed, "Failed to flush the decoder"});
return;
}
diff --git a/media/gpu/vaapi/vaapi_video_decoder.h b/media/gpu/vaapi/vaapi_video_decoder.h
--- a/media/gpu/vaapi/vaapi_video_decoder.h
+++ b/media/gpu/vaapi/vaapi_video_decoder.h
@@ -211,6 +211,8 @@
// The video decoder's state.
State state_ GUARDED_BY_CONTEXT(sequence_checker_) = State::kUninitialized;
+ bool surface_sync_failed_ GUARDED_BY_CONTEXT(sequence_checker_) = false;
+
// Callback used to notify the client when a frame is available for output.
PipelineOutputCB output_cb_ GUARDED_BY_CONTEXT(sequence_checker_);
+138
View File
@@ -0,0 +1,138 @@
const {execFile} = require('node:child_process');
const {createHash} = require('node:crypto');
const {createReadStream, statSync} = require('node:fs');
const fs = require('node:fs/promises');
const path = require('node:path');
const {promisify} = require('node:util');
const execFileAsync = promisify(execFile);
const expectedElectronVersion = '44.4.1';
const expectedElectronRevision = '09d1a6590e3148a1a2132b07a5b107b7c2414191';
const chromiumPatchPath = path.join(
__dirname,
'../electron/patches/fix_synchronize_vaapi_surfaces_before_output.patch',
);
function resolveLinuxElectronDist() {
const configuredDirectory = process.env.FLUXER_ELECTRON_DIST?.trim();
if (!configuredDirectory) {
throw new Error(
'Linux packaging requires patched Electron. Set FLUXER_ELECTRON_DIST to the directory containing the custom Electron ZIP and its .fluxer.json build manifest.',
);
}
const directory = path.resolve(configuredDirectory);
if (!statSync(directory, {throwIfNoEntry: false})?.isDirectory()) {
throw new Error(`FLUXER_ELECTRON_DIST is not a directory: ${directory}`);
}
return directory;
}
async function sha256File(filePath) {
const hash = createHash('sha256');
for await (const chunk of createReadStream(filePath)) hash.update(chunk);
return hash.digest('hex');
}
const inspectArchive = `
import hashlib, json, stat, struct, sys, zipfile
archive_path, arch, version = sys.argv[1:]
with zipfile.ZipFile(archive_path) as archive:
for required in ('electron', 'version', 'LICENSE', 'LICENSES.chromium.html'):
matches = [entry for entry in archive.infolist() if entry.filename == required]
if len(matches) != 1 or matches[0].is_dir() or stat.S_ISLNK(matches[0].external_attr >> 16):
raise ValueError('Expected one regular ZIP entry: ' + required)
if archive.read('version').decode('utf-8').strip() != version:
raise ValueError('Electron ZIP version does not match ' + version)
with archive.open('electron') as executable:
header = executable.read(20)
if len(header) != 20 or header[:6] != b'\\x7fELF\\x02\\x01':
raise ValueError('Electron must be a 64-bit little-endian ELF executable')
if struct.unpack_from('<H', header, 18)[0] != {'x64': 62, 'arm64': 183}[arch]:
raise ValueError('Electron ELF architecture does not match ' + arch)
digest = hashlib.sha256(header)
marker = b'failed to synchronize VA surface before output'
tail = header
has_patch_marker = marker in header
while chunk := executable.read(1024 * 1024):
digest.update(chunk)
window = tail + chunk
has_patch_marker = has_patch_marker or marker in window
tail = window[-(len(marker) - 1):]
if not has_patch_marker:
raise ValueError('Electron executable is missing the VA-API synchronization patch marker')
print(json.dumps({'binarySha256': digest.hexdigest()}))
`;
async function verifyLinuxElectronArchive(directory, arch) {
if (!['x64', 'arm64'].includes(arch)) {
throw new Error(`Custom Linux Electron does not support architecture: ${arch}`);
}
const artifactName = `electron-v${expectedElectronVersion}-linux-${arch}`;
const archiveName = `${artifactName}.zip`;
const manifestPath = path.join(directory, `${artifactName}.fluxer.json`);
let manifest;
try {
manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
} catch (error) {
throw new Error(`Cannot read custom Electron build manifest ${manifestPath}: ${error.message}`);
}
const expectedFields = {
schemaVersion: 1,
electronVersion: expectedElectronVersion,
electronRevision: expectedElectronRevision,
platform: 'linux',
arch,
archiveName,
};
for (const [field, expected] of Object.entries(expectedFields)) {
if (manifest?.[field] !== expected) {
throw new Error(`Custom Electron manifest ${field} must be ${JSON.stringify(expected)}: ${manifestPath}`);
}
}
const patchSha256 = await sha256File(chromiumPatchPath);
const acceptedPatchSha256s = new Set([patchSha256]);
if (patchSha256 === '21ea23b8a566f3f2dbdb69d0b361039dd9128c5e34a7ae89a8a61d7099808b8e') {
acceptedPatchSha256s.add('d39d05d4a00cd09eca43c7cd83383eaefa18cae7ec1b7efc26987bdc0f23c83e');
}
if (!acceptedPatchSha256s.has(manifest?.chromiumPatchSha256)) {
throw new Error(`Custom Electron manifest chromiumPatchSha256 does not match the current patch: ${manifestPath}`);
}
if (!/^[a-f0-9]{64}$/.test(manifest.archiveSha256)) {
throw new Error(`Custom Electron manifest has no valid archiveSha256: ${manifestPath}`);
}
const archivePath = path.join(directory, archiveName);
if ((await sha256File(archivePath)) !== manifest.archiveSha256) {
throw new Error(`Custom Electron ZIP checksum does not match its build manifest: ${archivePath}`);
}
let inspection;
try {
const {stdout} = await execFileAsync(
'python3',
['-c', inspectArchive, archivePath, arch, expectedElectronVersion],
{
maxBuffer: 64 * 1024,
},
);
inspection = JSON.parse(stdout);
} catch (error) {
throw new Error(`Cannot verify custom Electron ZIP ${archivePath}: ${error.stderr?.trim() || error.message}`);
}
if (!/^[a-f0-9]{64}$/.test(inspection.binarySha256)) {
throw new Error(`Custom Electron ZIP inspection returned no executable checksum: ${archivePath}`);
}
return {manifest, binarySha256: inspection.binarySha256};
}
async function verifyPackagedLinuxElectron(executablePath, artifact) {
if (!artifact) throw new Error('Custom Electron archive was not verified before Linux packaging.');
if ((await sha256File(executablePath)) !== artifact.binarySha256) {
throw new Error(`Packaged Linux executable does not match the verified custom Electron build: ${executablePath}`);
}
}
module.exports = {
expectedElectronVersion,
resolveLinuxElectronDist,
verifyLinuxElectronArchive,
verifyPackagedLinuxElectron,
};
@@ -294,6 +294,11 @@ export function appendLinuxChromiumFlagsConfig(channel: BuildChannel): void {
} }
} }
export function addLinuxHardwareVideoDecodeFeatures(features: Set<string>): void {
if (process.platform !== 'linux') return;
features.add('VaapiOnNvidiaGPUs');
}
export function addLinuxHardwareVideoEncodeFeatures(features: Set<string>): void { export function addLinuxHardwareVideoEncodeFeatures(features: Set<string>): void {
if (process.platform !== 'linux') return; if (process.platform !== 'linux') return;
features.add('AcceleratedVideoEncoder'); features.add('AcceleratedVideoEncoder');
+30 -3
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import child_process from 'node:child_process'; import child_process from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
@@ -27,6 +28,7 @@ const LEGACY_DESKTOP_FILE_BASENAME = `${LEGACY_LINUX_DESKTOP_ENTRY_ID}.desktop`;
const GENERIC_NAME_DEFAULT = 'Instant Messenger'; const GENERIC_NAME_DEFAULT = 'Instant Messenger';
const COMMENT_DEFAULT = 'Instant messaging and VoIP'; const COMMENT_DEFAULT = 'Instant messaging and VoIP';
export const GENERATED_MARKER = '# X-Generated-By=fluxer-desktop'; export const GENERATED_MARKER = '# X-Generated-By=fluxer-desktop';
const GENERATED_DIGEST_PREFIX = '# X-Generated-Digest=';
const DESKTOP_ACTIONS = [ const DESKTOP_ACTIONS = [
{ {
id: 'open-settings', id: 'open-settings',
@@ -137,7 +139,7 @@ function buildLocalizedEntryLines(key: string, defaultValue: string, localizedVa
return lines; return lines;
} }
export function buildDesktopFileContents(execPath: string, hidden: boolean): string { function buildDesktopFileBody(execPath: string, hidden: boolean): string {
const execLine = `${quoteExecArg(execPath)} %U`; const execLine = `${quoteExecArg(execPath)} %U`;
return [ return [
'[Desktop Entry]', '[Desktop Entry]',
@@ -162,6 +164,18 @@ export function buildDesktopFileContents(execPath: string, hidden: boolean): str
].join('\n'); ].join('\n');
} }
function digestDesktopFileBody(body: string): string {
return crypto.createHash('sha256').update(body, 'utf8').digest('hex');
}
export function buildDesktopFileContents(execPath: string, hidden: boolean): string {
const body = buildDesktopFileBody(execPath, hidden);
return body.replace(
`${GENERATED_MARKER}\n`,
`${GENERATED_MARKER}\n${GENERATED_DIGEST_PREFIX}${digestDesktopFileBody(body)}\n`,
);
}
function readDesktopEntryValue(contents: string, key: string): string | null { function readDesktopEntryValue(contents: string, key: string): string | null {
for (const line of contents.split('\n')) { for (const line of contents.split('\n')) {
const trimmed = line.trim(); const trimmed = line.trim();
@@ -187,6 +201,18 @@ function isStaleDesktopFile(contents: string): boolean {
return !isExecutableFile(tryExec); return !isExecutableFile(tryExec);
} }
function isUnmodifiedGeneratedFile(contents: string, execPath: string): boolean {
if (!contents.includes(GENERATED_MARKER)) return false;
const lines = contents.split('\n');
const digestIndex = lines.findIndex((line) => line.startsWith(GENERATED_DIGEST_PREFIX));
if (digestIndex === -1) {
return contents === buildDesktopFileBody(execPath, false) || contents === buildDesktopFileBody(execPath, true);
}
const digest = lines[digestIndex].slice(GENERATED_DIGEST_PREFIX.length).trim();
const body = lines.filter((_, index) => index !== digestIndex).join('\n');
return digestDesktopFileBody(body) === digest;
}
function readExistingDesktopFile(filePath: string): string | null { function readExistingDesktopFile(filePath: string): string | null {
try { try {
return fs.readFileSync(filePath, 'utf8'); return fs.readFileSync(filePath, 'utf8');
@@ -280,14 +306,15 @@ function syncUserDesktopEntry(): boolean {
const filePath = getDesktopFilePath(); const filePath = getDesktopFilePath();
const existing = readExistingDesktopFile(filePath); const existing = readExistingDesktopFile(filePath);
const systemEntry = findSystemDesktopEntry(); const systemEntry = findSystemDesktopEntry();
const owned = existing !== null && (isUnmodifiedGeneratedFile(existing, execPath) || isStaleDesktopFile(existing));
if (systemEntry) { if (systemEntry) {
if (existing !== null && (existing.includes(GENERATED_MARKER) || isStaleDesktopFile(existing))) { if (owned) {
return removeFile(filePath, 'Removed a user .desktop entry shadowing the system entry'); return removeFile(filePath, 'Removed a user .desktop entry shadowing the system entry');
} }
logger.debug('System-wide .desktop entry detected; skipping user-local copy', {systemEntry}); logger.debug('System-wide .desktop entry detected; skipping user-local copy', {systemEntry});
return false; return false;
} }
if (existing !== null && !existing.includes(GENERATED_MARKER) && !isStaleDesktopFile(existing)) { if (existing !== null && !owned) {
logger.debug('Linux .desktop entry was hand-edited; leaving untouched', {filePath}); logger.debug('Linux .desktop entry was hand-edited; leaving untouched', {filePath});
return false; return false;
} }
@@ -7,6 +7,7 @@ import {
getDesktopWindowBehaviorSettings, getDesktopWindowBehaviorSettings,
} from '@electron/common/DesktopConfig'; } from '@electron/common/DesktopConfig';
import { import {
addLinuxHardwareVideoDecodeFeatures,
addLinuxHardwareVideoEncodeFeatures, addLinuxHardwareVideoEncodeFeatures,
addWindowsHardwareVideoEncodeFeatures, addWindowsHardwareVideoEncodeFeatures,
appendConfiguredChromiumSwitches, appendConfiguredChromiumSwitches,
@@ -53,6 +54,7 @@ export function applyPreReadyChromiumConfiguration(channel: BuildChannel, argv:
const disabledChromiumFeatures = new Set(BASE_DISABLED_CHROMIUM_FEATURES); const disabledChromiumFeatures = new Set(BASE_DISABLED_CHROMIUM_FEATURES);
const enabledChromiumFeatures = new Set<string>(); const enabledChromiumFeatures = new Set<string>();
if (!disableHardwareAcceleration) { if (!disableHardwareAcceleration) {
addLinuxHardwareVideoDecodeFeatures(enabledChromiumFeatures);
addLinuxHardwareVideoEncodeFeatures(enabledChromiumFeatures); addLinuxHardwareVideoEncodeFeatures(enabledChromiumFeatures);
addWindowsHardwareVideoEncodeFeatures(enabledChromiumFeatures); addWindowsHardwareVideoEncodeFeatures(enabledChromiumFeatures);
} }
@@ -98,6 +98,17 @@ describe('renderTrayIcon', () => {
assert.deepEqual(pixel(bitmap, 32, 6, 16), [0xff, 0xff, 0xff, 0xff]); assert.deepEqual(pixel(bitmap, 32, 6, 16), [0xff, 0xff, 0xff, 0xff]);
}); });
test('keeps the Linux monochrome pixmap within 256 pixels', () => {
const result = renderTrayIcon(FakeNativeImage.fromBitmap(256), 'white', false, 'linux');
const [representation] = result.representations.values();
assert.equal(representation.width, 256);
assert.equal(representation.height, 256);
const bitmap = result.toBitmap();
assert.deepEqual(pixel(bitmap, 256, 128, 50), [0xff, 0xff, 0xff, 0xff]);
assert.deepEqual(pixel(bitmap, 256, 128, 128), [0, 0, 0, 0]);
assert.deepEqual(pixel(bitmap, 256, 20, 128), [0, 0, 0, 0]);
});
test('skips representations whose bitmap does not match their size', () => { test('skips representations whose bitmap does not match their size', () => {
const base = FakeNativeImage.fromBitmap(16); const base = FakeNativeImage.fromBitmap(16);
base.toBitmap = () => Buffer.alloc(3); base.toBitmap = () => Buffer.alloc(3);
+38 -1
View File
@@ -18,6 +18,7 @@ const MIN_GLYPH_CONTRAST = 32;
const GLYPH_CUTOUT_START = 0.1; const GLYPH_CUTOUT_START = 0.1;
const GLYPH_CUTOUT_END = 0.5; const GLYPH_CUTOUT_END = 0.5;
const LINUX_MONOCHROME_CANVAS_RATIO = 22 / 16; const LINUX_MONOCHROME_CANVAS_RATIO = 22 / 16;
const LINUX_MAX_PIXMAP_SIZE = 256;
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const DOT_BGR: Bgr = [0x00, 0x00, 0xdd]; const DOT_BGR: Bgr = [0x00, 0x00, 0xdd];
const TEMPLATE_DOT_BGR: Bgr = [0x00, 0x00, 0x00]; const TEMPLATE_DOT_BGR: Bgr = [0x00, 0x00, 0x00];
@@ -149,6 +150,36 @@ function cropTrayGlyph(buffer: Buffer, width: number, height: number): TrayBitma
return {buffer: target, width: size, height: size}; return {buffer: target, width: size, height: size};
} }
function downscaleTrayBitmap(bitmap: TrayBitmap, size: number): TrayBitmap {
if (bitmap.width <= size) return bitmap;
const scale = bitmap.width / size;
const buffer = Buffer.alloc(size * size * 4);
const sums = [0, 0, 0, 0];
for (let y = 0; y < size; y++) {
const top = y * scale;
const bottom = top + scale;
for (let x = 0; x < size; x++) {
const left = x * scale;
const right = left + scale;
sums.fill(0);
for (let sy = Math.floor(top); sy < Math.min(bitmap.height, Math.ceil(bottom)); sy++) {
const weightY = Math.min(bottom, sy + 1) - Math.max(top, sy);
for (let sx = Math.floor(left); sx < Math.min(bitmap.width, Math.ceil(right)); sx++) {
const weight = weightY * (Math.min(right, sx + 1) - Math.max(left, sx));
const offset = (sy * bitmap.width + sx) * 4;
for (let channel = 0; channel < 4; channel++) {
sums[channel] += bitmap.buffer[offset + channel] * weight;
}
}
}
for (let channel = 0; channel < 4; channel++) {
buffer[(y * size + x) * 4 + channel] = Math.round(sums[channel] / (scale * scale));
}
}
}
return {buffer, width: size, height: size};
}
function addMonochromeTrayMargins(bitmap: TrayBitmap, size: number): TrayBitmap { function addMonochromeTrayMargins(bitmap: TrayBitmap, size: number): TrayBitmap {
const inset = Math.floor((size - bitmap.width) / 2); const inset = Math.floor((size - bitmap.width) / 2);
const buffer = Buffer.alloc(size * size * 4); const buffer = Buffer.alloc(size * size * 4);
@@ -210,7 +241,13 @@ export function renderTrayIcon(
appearance === 'template' ? TEMPLATE_DOT_BGR : DOT_BGR, appearance === 'template' ? TEMPLATE_DOT_BGR : DOT_BGR,
); );
} }
if (linuxMonochrome) bitmap = addMonochromeTrayMargins(bitmap, Math.ceil(logicalCanvasSize * scaleFactor)); if (linuxMonochrome) {
const canvasSize = Math.min(Math.ceil(logicalCanvasSize * scaleFactor), LINUX_MAX_PIXMAP_SIZE);
bitmap = addMonochromeTrayMargins(
downscaleTrayBitmap(bitmap, Math.floor(canvasSize / LINUX_MONOCHROME_CANVAS_RATIO)),
canvasSize,
);
}
result.addRepresentation({scaleFactor, ...bitmap}); result.addRepresentation({scaleFactor, ...bitmap});
} }
if (result.isEmpty()) { if (result.isEmpty()) {
@@ -1656,7 +1656,7 @@ Default `60000`. How often discovery is refetched, in milliseconds, and so the l
#### `FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS` #### `FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS`
Default empty. Hostnames, comma separated, without a scheme, port or path. When a request's `Host` is in the list, the page bootstrap points the web app and its API at that host, as `https://<host>` and `https://<host>/api`. Every other request gets the discovery values unchanged. Set it when the web app is served on more than one hostname and each hostname routes `/api` to the API. Invalid entries are logged and dropped. Default empty. Hostnames, comma separated, without a scheme, port or path. When a request's `Host` is in the list, the page bootstrap points the web app and its API at that host, as `https://<host>` and `https://<host>/api`. The Gateway, media, upload relay and static CDN endpoints move to that host too when discovery puts them on the same host as the API, so `wss://chat.example.com/gateway` becomes `wss://<host>/gateway`. Endpoints on other hosts stay as they are. Every other request gets the discovery values unchanged. Set it when the web app is served on more than one hostname and each hostname routes `/api`, `/gateway` and `/media` like the main one. Invalid entries are logged and dropped.
#### `FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS` #### `FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS`
@@ -1696,7 +1696,7 @@ Defaults to the crate version. The reported build of `admin` and `app-proxy`. `B
`FLUXER_CSP_EXTRA_DEFAULT_SRC`, `FLUXER_CSP_EXTRA_CONNECT_SRC`, `FLUXER_CSP_EXTRA_IMG_SRC`, `FLUXER_CSP_EXTRA_MEDIA_SRC`, `FLUXER_CSP_EXTRA_FONT_SRC`, `FLUXER_CSP_EXTRA_SCRIPT_SRC`, `FLUXER_CSP_EXTRA_STYLE_SRC`, `FLUXER_CSP_EXTRA_FRAME_SRC`, `FLUXER_CSP_EXTRA_WORKER_SRC`, and `FLUXER_CSP_EXTRA_MANIFEST_SRC` take one or more sources separated by commas, spaces, tabs, or newlines. Blank entries and sources the directive already lists are dropped. `FLUXER_CSP_REPORT_URI` sets a single `report-uri` value. `FLUXER_CSP_EXTRA_DEFAULT_SRC`, `FLUXER_CSP_EXTRA_CONNECT_SRC`, `FLUXER_CSP_EXTRA_IMG_SRC`, `FLUXER_CSP_EXTRA_MEDIA_SRC`, `FLUXER_CSP_EXTRA_FONT_SRC`, `FLUXER_CSP_EXTRA_SCRIPT_SRC`, `FLUXER_CSP_EXTRA_STYLE_SRC`, `FLUXER_CSP_EXTRA_FRAME_SRC`, `FLUXER_CSP_EXTRA_WORKER_SRC`, and `FLUXER_CSP_EXTRA_MANIFEST_SRC` take one or more sources separated by commas, spaces, tabs, or newlines. Blank entries and sources the directive already lists are dropped. `FLUXER_CSP_REPORT_URI` sets a single `report-uri` value.
`object-src`, `base-uri`, and `frame-ancestors` are fixed and have no override. `app-proxy` adds the static CDN endpoint and the media endpoint from its environment. The self-hosted image also adds the origin of each branding image to `img-src`. At start it takes them from `FLUXER_APP_ICON_URL`, `FLUXER_APP_SYMBOL_URL`, `FLUXER_APP_LOGO_URL`, `FLUXER_APP_WORDMARK_URL` and `FLUXER_APP_FAVICON_URL`. It then polls the discovery document every `DISCOVERY_REFRESH_INTERVAL_MS`, where a value saved in the admin dashboard wins. A dashboard change reaches the header within a minute by default with no restart. A stack on one hostname needs no extra sources. The usual reason to set one is a voice server on another hostname, which needs its WebSocket origin in `FLUXER_CSP_EXTRA_CONNECT_SRC`. [Voice media does not use the proxy](/operator/reverse-proxy/#voice-media-does-not-use-the-proxy) has that line in place. `object-src`, `base-uri`, and `frame-ancestors` are fixed and have no override. `app-proxy` adds the static CDN endpoint and the media endpoint from its environment. The self-hosted image also adds the origin of each branding image to `img-src`. At start it takes them from `FLUXER_APP_ICON_URL`, `FLUXER_APP_SYMBOL_URL`, `FLUXER_APP_LOGO_URL`, `FLUXER_APP_WORDMARK_URL` and `FLUXER_APP_FAVICON_URL`. It then polls the discovery document every `DISCOVERY_REFRESH_INTERVAL_MS`, where a value saved in the admin dashboard wins. A dashboard change reaches the header within a minute by default with no restart. From the same document it adds the origins of the API, gateway, media, upload relay and static CDN endpoints, so a web app served on a second hostname through `FLUXER_APP_ORIGIN_ALIASES` can still reach them. A stack on one hostname needs no extra sources. The usual reason to set one is a voice server on another hostname, which needs its WebSocket origin in `FLUXER_CSP_EXTRA_CONNECT_SRC`. [Voice media does not use the proxy](/operator/reverse-proxy/#voice-media-does-not-use-the-proxy) has that line in place.
A front proxy must not add a [Content-Security-Policy of its own](/operator/reverse-proxy/#send-no-content-security-policy-of-its-own). A front proxy must not add a [Content-Security-Policy of its own](/operator/reverse-proxy/#send-no-content-security-policy-of-its-own).
@@ -58,6 +58,8 @@ If both answer, skip ahead. On a Mac or a Windows PC, install [Docker Desktop](h
Debian and Ubuntu need Docker's apt repository from [Install Docker Engine](https://docs.docker.com/engine/install/), because the distribution's own `docker.io` package ships no Compose plugin. Follow the [post-installation steps](https://docs.docker.com/engine/install/linux-postinstall/) to add your user to the `docker` group, then log out and back in so the commands below run without `sudo`. Debian and Ubuntu need Docker's apt repository from [Install Docker Engine](https://docs.docker.com/engine/install/), because the distribution's own `docker.io` package ships no Compose plugin. Follow the [post-installation steps](https://docs.docker.com/engine/install/linux-postinstall/) to add your user to the `docker` group, then log out and back in so the commands below run without `sudo`.
Podman 5 works too, with `docker-compose` installed so `podman compose` has a provider. The installer picks Podman when `docker` is missing or does not answer. Podman logs every healthcheck run to the journal by default, so with Podman the installer writes `fluxer-healthcheck-events.conf` to `~/.config/containers/containers.conf.d/` (or `/etc/containers/containers.conf.d/` as root) to turn that off. Health checks still run. If you set `healthcheck_events` yourself, the installer leaves it alone.
Linux also needs `curl` and `openssl`, which the steps below use: Linux also needs `curl` and `openssl`, which the steps below use:
```bash ```bash
@@ -184,7 +186,7 @@ Leave `FLUXER_PUBLIC_ORIGIN` commented out, or keep it consistent with the publi
| `--update` | Upgrade: record, back up, refresh, pull, recreate, verify | | `--update` | Upgrade: record, back up, refresh, pull, recreate, verify |
| `--rollback` | Restore the images and stack files that the newest `--update` run recorded | | `--rollback` | Restore the images and stack files that the newest `--update` run recorded |
| `--allow-root` | Permit running as root | | `--allow-root` | Permit running as root |
| `--engine <command>` | Container engine to drive. Default `docker`, or `podman` when `docker` is absent | | `--engine <command>` | Container engine to drive. Default `docker`, or `podman` when `docker` is absent or does not answer |
Run `--dry-run` first to see what a set of flags does. The PowerShell script takes the same flags as `-Domain`, `-Email` and so on, and has no `-AllowRoot`. [Upgrading](/operator/upgrading/#run-the-upgrade) has the flags that only `--update` and `--rollback` read, and the exit codes. Run `--dry-run` first to see what a set of flags does. The PowerShell script takes the same flags as `-Domain`, `-Email` and so on, and has no `-AllowRoot`. [Upgrading](/operator/upgrading/#run-the-upgrade) has the flags that only `--update` and `--rollback` read, and the exit codes.
+9 -1
View File
@@ -240,7 +240,7 @@ function Show-FluxerUsage {
Write-FluxerLine ' -Domain <host> Hostname the instance answers on. Prompted when absent.' Write-FluxerLine ' -Domain <host> Hostname the instance answers on. Prompted when absent.'
Write-FluxerLine ' -Email <address> Contact email for web push. Default: admin@<domain>.' Write-FluxerLine ' -Email <address> Contact email for web push. Default: admin@<domain>.'
Write-FluxerLine ' -Engine <command> Container engine to drive. Default: docker, or podman when' Write-FluxerLine ' -Engine <command> Container engine to drive. Default: docker, or podman when'
Write-FluxerLine ' docker is absent.' Write-FluxerLine ' docker is absent or does not answer.'
Write-FluxerLine ' -Dir <path> Working directory. Default: the fluxer folder in the home' Write-FluxerLine ' -Dir <path> Working directory. Default: the fluxer folder in the home'
Write-FluxerLine ' directory, or the working directory itself when -Update or' Write-FluxerLine ' directory, or the working directory itself when -Update or'
Write-FluxerLine ' -Rollback is given and it holds an instance.' Write-FluxerLine ' -Rollback is given and it holds an instance.'
@@ -385,6 +385,14 @@ function Invoke-FluxerPreflight {
$script:FluxerEngine = $Engine $script:FluxerEngine = $Engine
} elseif ($null -ne (Get-Command docker -ErrorAction SilentlyContinue)) { } elseif ($null -ne (Get-Command docker -ErrorAction SilentlyContinue)) {
$script:FluxerEngine = 'docker' $script:FluxerEngine = 'docker'
if ($null -ne (Get-Command podman -ErrorAction SilentlyContinue) -and (Invoke-FluxerCapture @('info')).Code -ne 0) {
$script:FluxerEngine = 'podman'
if ((Invoke-FluxerCapture @('info')).Code -eq 0) {
Write-FluxerLine 'docker does not answer and podman does, so this run drives podman. Pass -Engine docker to use docker instead.'
} else {
$script:FluxerEngine = 'docker'
}
}
} elseif ($null -ne (Get-Command podman -ErrorAction SilentlyContinue)) { } elseif ($null -ne (Get-Command podman -ErrorAction SilentlyContinue)) {
$script:FluxerEngine = 'podman' $script:FluxerEngine = 'podman'
} else { } else {
+43 -12
View File
@@ -214,7 +214,7 @@ Options:
--domain <host> Hostname the instance answers on. Prompted when absent. --domain <host> Hostname the instance answers on. Prompted when absent.
--email <address> Contact email for web push. Default admin@<domain>. --email <address> Contact email for web push. Default admin@<domain>.
--engine <command> Container engine to drive. Default docker, or podman --engine <command> Container engine to drive. Default docker, or podman
when docker is absent. when docker is absent or does not answer.
--dir <path> Working directory. Default ~/fluxer, or the working --dir <path> Working directory. Default ~/fluxer, or the working
directory itself when --update or --rollback is given directory itself when --update or --rollback is given
and it holds an instance. and it holds an instance.
@@ -561,6 +561,10 @@ fluxer_resolve_engine() {
fluxer_engine=$opt_engine fluxer_engine=$opt_engine
elif command -v docker >/dev/null 2>&1; then elif command -v docker >/dev/null 2>&1; then
fluxer_engine='docker' fluxer_engine='docker'
if command -v podman >/dev/null 2>&1 && ! docker info >/dev/null 2>&1 && podman info >/dev/null 2>&1; then
fluxer_engine='podman'
fluxer_say 'docker does not answer and podman does, so this run drives podman. Pass --engine docker to use docker instead.'
fi
elif command -v podman >/dev/null 2>&1; then elif command -v podman >/dev/null 2>&1; then
fluxer_engine='podman' fluxer_engine='podman'
else else
@@ -576,18 +580,11 @@ fluxer_preflight() {
fluxer_fail 2 'Running as root. Use an account in the docker group, or pass --allow-root.' fluxer_fail 2 'Running as root. Use an account in the docker group, or pass --allow-root.'
fi fi
fluxer_resolve_engine fluxer_resolve_engine
if ! $fluxer_engine compose version >/dev/null 2>&1; then
fluxer_fail 2 "$fluxer_engine has no compose subcommand. $(fluxer_docker_hint)"
fi
if $fluxer_engine compose version 2>/dev/null | grep -q '^podman-compose version'; then
fluxer_fail 2 "$fluxer_engine compose runs podman-compose, which lacks compose ps -a and compose config --images that this script runs. $(fluxer_docker_hint)"
fi
fluxer_engine_report=$($fluxer_engine --version 2>/dev/null) fluxer_engine_report=$($fluxer_engine --version 2>/dev/null)
fluxer_engine_kind=$(printf '%s\n' "$fluxer_engine_report" | sed -n 's/^\([A-Za-z][A-Za-z]*\) version .*/\1/p' | tr 'A-Z' 'a-z') fluxer_engine_kind=$(printf '%s\n' "$fluxer_engine_report" | sed -n 's/^\([A-Za-z][A-Za-z]*\) version .*/\1/p' | tr 'A-Z' 'a-z')
fluxer_engine_version=$(printf '%s\n' "$fluxer_engine_report" | sed -n 's/^[A-Za-z][A-Za-z]* version v\{0,1\}\([0-9][0-9.]*\).*/\1/p') fluxer_engine_version=$(printf '%s\n' "$fluxer_engine_report" | sed -n 's/^[A-Za-z][A-Za-z]* version v\{0,1\}\([0-9][0-9.]*\).*/\1/p')
fluxer_compose_version=$($fluxer_engine compose version --short 2>/dev/null | sed -n 's/^v\{0,1\}\([0-9][0-9.]*\).*/\1/p') if [ -z "$fluxer_engine_version" ]; then
if [ -z "$fluxer_engine_version" ] || [ -z "$fluxer_compose_version" ]; then fluxer_fail 2 "Cannot read the engine version from $fluxer_engine. It answered \"$fluxer_engine_report\" to --version."
fluxer_fail 2 "Cannot read the engine and Compose versions from $fluxer_engine. It answered \"$fluxer_engine_report\" to --version."
fi fi
case $fluxer_engine_kind in case $fluxer_engine_kind in
podman) podman)
@@ -600,7 +597,17 @@ fluxer_preflight() {
;; ;;
esac esac
if ! fluxer_version_ge "$fluxer_engine_version" "$fluxer_min_engine"; then if ! fluxer_version_ge "$fluxer_engine_version" "$fluxer_min_engine"; then
fluxer_fail 2 "$fluxer_engine_label $fluxer_engine_version with Compose $fluxer_compose_version. Fluxer needs $fluxer_engine_label $fluxer_min_engine or newer." fluxer_fail 2 "$fluxer_engine_label $fluxer_engine_version. Fluxer needs $fluxer_engine_label $fluxer_min_engine or newer. $(fluxer_docker_hint)"
fi
if ! $fluxer_engine compose version >/dev/null 2>&1; then
fluxer_fail 2 "$fluxer_engine has no compose subcommand. $(fluxer_docker_hint)"
fi
if $fluxer_engine compose version 2>/dev/null | grep -q '^podman-compose version'; then
fluxer_fail 2 "$fluxer_engine compose runs podman-compose, which lacks compose ps -a and compose config --images that this script runs. $(fluxer_docker_hint)"
fi
fluxer_compose_version=$($fluxer_engine compose version --short 2>/dev/null | sed -n 's/^v\{0,1\}\([0-9][0-9.]*\).*/\1/p')
if [ -z "$fluxer_compose_version" ]; then
fluxer_fail 2 "Cannot read the Compose version from $fluxer_engine compose version --short."
fi fi
if ! fluxer_version_ge "$fluxer_compose_version" "$FLUXER_MIN_COMPOSE"; then if ! fluxer_version_ge "$fluxer_compose_version" "$FLUXER_MIN_COMPOSE"; then
fluxer_fail 2 "$fluxer_engine_label $fluxer_engine_version with Compose $fluxer_compose_version. Fluxer needs Compose $FLUXER_MIN_COMPOSE or newer." fluxer_fail 2 "$fluxer_engine_label $fluxer_engine_version with Compose $fluxer_compose_version. Fluxer needs Compose $FLUXER_MIN_COMPOSE or newer."
@@ -837,7 +844,11 @@ fluxer_print_plan() {
fluxer_say " email admin@$opt_domain, derived by compose" fluxer_say " email admin@$opt_domain, derived by compose"
fluxer_plan_non_secret=$((fluxer_plan_non_secret - 1)) fluxer_plan_non_secret=$((fluxer_plan_non_secret - 1))
fi fi
fluxer_say ' action download the stack files, write .env, start the stack' if [ "$opt_no_start" -eq 1 ]; then
fluxer_say ' action download the stack files, write .env, leave the stack stopped'
else
fluxer_say ' action download the stack files, write .env, start the stack'
fi
fluxer_say " .env keys $fluxer_plan_non_secret non-secret values and $(fluxer_secret_keys | wc -l | tr -d ' ') secrets" fluxer_say " .env keys $fluxer_plan_non_secret non-secret values and $(fluxer_secret_keys | wc -l | tr -d ' ') secrets"
fluxer_say ' files docker-compose.yml docker-compose.proxy.yml tunnel.compose.yml Caddyfile .env.example' fluxer_say ' files docker-compose.yml docker-compose.proxy.yml tunnel.compose.yml Caddyfile .env.example'
if [ -e "$opt_dir/.env" ]; then if [ -e "$opt_dir/.env" ]; then
@@ -2195,9 +2206,29 @@ fluxer_run_rollback() {
exit 0 exit 0
} }
# Podman writes a health_status event to the journal for every healthcheck run,
# with every label of the container in it, and this stack runs two dozen checks
# every ten seconds. Compose and this script read health from inspect, so the
# events have no reader here. An operator who set the key either way keeps it.
fluxer_quiet_podman_health_events() {
[ "$fluxer_engine_kind" = 'podman' ] || return 0
[ "$opt_dry_run" -eq 0 ] || return 0
if [ "$(id -u)" -eq 0 ]; then
fluxer_conf_dir=/etc/containers/containers.conf.d
else
fluxer_conf_dir=${XDG_CONFIG_HOME:-$HOME/.config}/containers/containers.conf.d
fi
if grep -qs '^[[:space:]]*healthcheck_events[[:space:]]*=' /usr/share/containers/containers.conf /etc/containers/containers.conf /etc/containers/containers.conf.d/*.conf "${XDG_CONFIG_HOME:-$HOME/.config}/containers/containers.conf" "${XDG_CONFIG_HOME:-$HOME/.config}/containers/containers.conf.d/"*.conf; then
return 0
fi
mkdir -p "$fluxer_conf_dir" && printf '[engine]\nhealthcheck_events = false\n' > "$fluxer_conf_dir/fluxer-healthcheck-events.conf" || return 0
fluxer_say "Wrote $fluxer_conf_dir/fluxer-healthcheck-events.conf, which stops Podman from logging every healthcheck run to the journal. Health checks still run."
}
fluxer_preflight fluxer_preflight
fluxer_validate_options fluxer_validate_options
fluxer_resolve_values fluxer_resolve_values
fluxer_quiet_podman_health_events
if [ "$opt_update" -eq 1 ]; then if [ "$opt_update" -eq 1 ]; then
fluxer_refresh_installer fluxer_refresh_installer
+112 -15
View File
@@ -9,7 +9,7 @@ use crate::metrics::{JobKind, JobRejection, Provider, elapsed_ms};
use crate::payload; use crate::payload;
use crate::providers::{self, SendOutcome}; use crate::providers::{self, SendOutcome};
use crate::retry::{self, RETRY_DEADLINE}; use crate::retry::{self, RETRY_DEADLINE};
use crate::rpc::RpcError; use crate::rpc::{RpcClient, RpcError};
use crate::server::AppState; use crate::server::AppState;
use crate::subscription::{Platform, Subscription}; use crate::subscription::{Platform, Subscription};
use crate::unix_millis; use crate::unix_millis;
@@ -353,13 +353,7 @@ async fn run_message_job(
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let started_ms = now_ms(); let started_ms = now_ms();
let deadline = Instant::now() + RETRY_DEADLINE; let deadline = Instant::now() + RETRY_DEADLINE;
let (badges, subscriptions) = lookup(deadline, || async { let (badges, subscriptions) = lookup_recipients(&state.rpc, &job.user_ids, deadline).await?;
tokio::try_join!(
state.rpc.badge_counts(&job.user_ids),
state.rpc.push_subscriptions(&job.user_ids)
)
})
.await?;
state.metrics.record_recipients(job.user_ids.len() as u64); state.metrics.record_recipients(job.user_ids.len() as u64);
let envelopes = job let envelopes = job
@@ -410,13 +404,7 @@ async fn run_clear_job(state: &AppState, sends: &Semaphore, job: ClearJob) -> an
let started_ms = now_ms(); let started_ms = now_ms();
let deadline = Instant::now() + RETRY_DEADLINE; let deadline = Instant::now() + RETRY_DEADLINE;
let user_ids = std::slice::from_ref(&job.user_id); let user_ids = std::slice::from_ref(&job.user_id);
let (badges, subscriptions) = lookup(deadline, || async { let (badges, subscriptions) = lookup_recipients(&state.rpc, user_ids, deadline).await?;
tokio::try_join!(
state.rpc.badge_counts(user_ids),
state.rpc.push_subscriptions(user_ids)
)
})
.await?;
state.metrics.record_recipients(1); state.metrics.record_recipients(1);
let envelope = payload::web_push_clear(&job, badge_of(&badges, &job.user_id)); let envelope = payload::web_push_clear(&job, badge_of(&badges, &job.user_id));
@@ -503,6 +491,24 @@ async fn run_ring_job(state: &AppState, sends: &Semaphore, job: RingJob) -> anyh
Ok(()) Ok(())
} }
async fn lookup_recipients(
rpc: &RpcClient,
user_ids: &[String],
deadline: Instant,
) -> Result<(HashMap<String, u32>, HashMap<String, Vec<Subscription>>), RpcError> {
let subscriptions = lookup(deadline, || rpc.push_subscriptions(user_ids)).await?;
let subscribed_ids: Vec<String> = user_ids
.iter()
.filter(|user_id| subscribed(&subscriptions, user_id))
.cloned()
.collect();
if subscribed_ids.is_empty() {
return Ok((HashMap::new(), subscriptions));
}
let badges = lookup(deadline, || rpc.badge_counts(&subscribed_ids)).await?;
Ok((badges, subscriptions))
}
async fn lookup<T, F, Fut>(deadline: Instant, mut call: F) -> Result<T, RpcError> async fn lookup<T, F, Fut>(deadline: Instant, mut call: F) -> Result<T, RpcError>
where where
F: FnMut() -> Fut, F: FnMut() -> Fut,
@@ -708,4 +714,95 @@ mod tests {
assert!(Audience::Standard.admits(&subscription("ios_apns", false))); assert!(Audience::Standard.admits(&subscription("ios_apns", false)));
assert!(Audience::Standard.admits(&subscription("ios_apns", true))); assert!(Audience::Standard.admits(&subscription("ios_apns", true)));
} }
async fn fake_api(
subscribed: &'static [&'static str],
) -> (RpcClient, Arc<std::sync::Mutex<Vec<Value>>>) {
let calls = Arc::new(std::sync::Mutex::new(Vec::new()));
let recorded = Arc::clone(&calls);
let app = axum::Router::new().route(
"/",
axum::routing::post(move |body: axum::body::Bytes| {
let recorded = Arc::clone(&recorded);
async move {
let request: Value = serde_json::from_slice(&body).unwrap();
recorded.lock().unwrap().push(request.clone());
let user_ids = request["user_ids"].as_array().cloned().unwrap_or_default();
let data = match request["type"].as_str().unwrap() {
"get_push_subscriptions" => serde_json::json!(
subscribed
.iter()
.map(|user_id| {
(
(*user_id).to_owned(),
serde_json::json!([{
"subscription_id": "sub",
"endpoint": "token",
"p256dh_key": null,
"auth_key": null,
"platform": "android_fcm",
"app_id": null,
"provider_environment": null
}]),
)
})
.collect::<serde_json::Map<_, _>>()
),
"get_badge_counts" => serde_json::json!({
"badge_counts": user_ids
.iter()
.map(|user_id| (user_id.as_str().unwrap().to_owned(), serde_json::json!(3)))
.collect::<serde_json::Map<_, _>>()
}),
other => panic!("unexpected rpc {other}"),
};
serde_json::json!({ "data": data }).to_string()
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let rpc = RpcClient::new(
&crate::config::RpcConfig {
url,
auth_token: fluxer_common::secret::SecretString::new("token".to_owned()),
},
reqwest::Client::new(),
Arc::new(crate::metrics::Metrics::new()),
);
(rpc, calls)
}
fn badge_requests(calls: &std::sync::Mutex<Vec<Value>>) -> Vec<Value> {
calls
.lock()
.unwrap()
.iter()
.filter(|call| call["type"] == "get_badge_counts")
.map(|call| call["user_ids"].clone())
.collect()
}
#[tokio::test]
async fn badge_counts_are_read_only_for_recipients_with_a_device() {
let (rpc, calls) = fake_api(&["2"]).await;
let user_ids: Vec<String> = (1..=500).map(|id| id.to_string()).collect();
let deadline = Instant::now() + Duration::from_secs(5);
let (badges, subscriptions) = lookup_recipients(&rpc, &user_ids, deadline).await.unwrap();
assert_eq!(badge_requests(&calls), vec![serde_json::json!(["2"])]);
assert_eq!(badges, HashMap::from([("2".to_owned(), 3)]));
assert!(subscribed(&subscriptions, "2"));
}
#[tokio::test]
async fn no_badge_counts_are_read_when_nobody_has_a_device() {
let (rpc, calls) = fake_api(&[]).await;
let user_ids: Vec<String> = (1..=500).map(|id| id.to_string()).collect();
let deadline = Instant::now() + Duration::from_secs(5);
let (badges, _subscriptions) = lookup_recipients(&rpc, &user_ids, deadline).await.unwrap();
assert!(badge_requests(&calls).is_empty());
assert!(badges.is_empty());
assert_eq!(calls.lock().unwrap().len(), 1);
}
} }
@@ -329,17 +329,53 @@ function withEndpointsOnServingOrigin(url: string, document: InstanceDiscoveryDo
return document; return document;
} }
const api = `${servedBy}${INSTANCE_DISCOVERY_CONVENTIONAL_ENDPOINT_PATH}`; const api = `${servedBy}${INSTANCE_DISCOVERY_CONVENTIONAL_ENDPOINT_PATH}`;
const declaredApiHost = hostOfEndpoint(document.endpoints.api);
const moveToServingOrigin = <T extends string | null>(endpoint: T): T =>
(endpoint == null ? endpoint : (onServingOrigin(endpoint, declaredApiHost, servedBy) ?? endpoint)) as T;
return { return {
...document, ...document,
endpoints: { endpoints: {
...document.endpoints, ...document.endpoints,
api, api,
api_client: document.endpoints.api_client == null ? null : api, api_client: document.endpoints.api_client == null ? null : api,
gateway: moveToServingOrigin(document.endpoints.gateway),
media: moveToServingOrigin(document.endpoints.media),
upload_relay: moveToServingOrigin(document.endpoints.upload_relay),
static_cdn: moveToServingOrigin(document.endpoints.static_cdn),
webapp: document.endpoints.webapp == null ? null : servedBy, webapp: document.endpoints.webapp == null ? null : servedBy,
}, },
}; };
} }
function hostOfEndpoint(value: string): string | null {
try {
return new URL(value).host;
} catch {
return null;
}
}
function onServingOrigin(endpoint: string, declaredApiHost: string | null, servedBy: string): string | null {
if (declaredApiHost == null) {
return null;
}
let url: URL;
let serving: URL;
try {
url = new URL(endpoint);
serving = new URL(servedBy);
} catch {
return null;
}
if (url.host !== declaredApiHost) {
return null;
}
const secure = serving.protocol === 'https:';
const socket = url.protocol === 'ws:' || url.protocol === 'wss:';
const path = url.pathname === '/' && !endpoint.endsWith('/') ? '' : url.pathname;
return `${socket ? (secure ? 'wss:' : 'ws:') : serving.protocol}//${serving.host}${path}${url.search}`;
}
export function requireDiscoveryDeclaresItsOwnOrigin(url: string, document: InstanceDiscoveryDocument): void { export function requireDiscoveryDeclaresItsOwnOrigin(url: string, document: InstanceDiscoveryDocument): void {
const servedBy = originOfEndpoint(url); const servedBy = originOfEndpoint(url);
if (servedBy == null) { if (servedBy == null) {
@@ -951,14 +951,12 @@ const LimitRuleSchema = z.object({
filters: LimitFilterSchema.optional().describe('Optional filters that scope the rule'), filters: LimitFilterSchema.optional().describe('Optional filters that scope the rule'),
limits: z limits: z
.record(z.string(), NonNegativeSafeIntegerType) .record(z.string(), NonNegativeSafeIntegerType)
.refine(
(limits) => {
const limitKeys = Object.keys(limits);
return limitKeys.every((key) => (LIMIT_KEYS as ReadonlyArray<string>).includes(key));
},
{error: 'Invalid limit key detected'},
)
.superRefine((limits, ctx) => { .superRefine((limits, ctx) => {
for (const key of Object.keys(limits)) {
if (!(LIMIT_KEYS as ReadonlyArray<string>).includes(key)) {
ctx.addIssue({code: 'custom', message: ValidationErrorCodes.INVALID_FORMAT, path: [key]});
}
}
for (const key of LIMIT_KEYS) { for (const key of LIMIT_KEYS) {
const value = limits[key]; const value = limits[key];
const {min = 0, max} = LIMIT_KEY_METADATA[key]; const {min = 0, max} = LIMIT_KEY_METADATA[key];