Add native self-hosted instance connection to fluxer_desktop

Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 +
tools/ci) with a "Connect to a Different Server" menu item and popout
that lets the desktop app switch to any self-hosted Fluxer instance,
plus fixes for well-known discovery on single-domain self-hosted
deployments and a false-positive ERR_ABORTED on same-origin client
redirects during the switch. Defaults to chat.fluxr.chat and uses an
isolated userData directory from the official build.
This commit is contained in:
2026-07-01 18:22:43 -04:00
commit 682afacd30
1763 changed files with 613720 additions and 0 deletions
+266
View File
@@ -0,0 +1,266 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::common::{
S3UploadPlanItem, collect_files, delete_s3_objects, list_s3_keys, path_to_s3_key,
replace_s3_object_metadata, s3_client, s3_content_type_for_key, upload_s3_plan_sync,
};
use anyhow::{Context, Result, ensure};
use clap::Args;
use std::collections::BTreeMap;
use std::env;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
const DEFAULT_SOURCE: &str = "fluxer_static";
const DEFAULT_STATIC_BUCKET: &str = "fluxer-static";
const DEFAULT_S3_ENDPOINT: &str = "https://ewr1.vultrobjects.com";
const DEFAULT_ASSET_PREFIX: &str = "assets/";
const IMMUTABLE_ASSET_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
const DEFAULT_REPAIR_CONCURRENCY: usize = 8;
#[derive(Debug, Args, Clone)]
pub struct SyncStaticBucketArgs {
#[arg(long, default_value = DEFAULT_SOURCE)]
source: PathBuf,
#[arg(long, default_value = DEFAULT_STATIC_BUCKET)]
bucket: String,
}
#[derive(Debug, Args, Clone)]
pub struct RepairStaticAssetMetadataArgs {
#[arg(long, default_value = DEFAULT_STATIC_BUCKET)]
bucket: String,
#[arg(long, default_value = DEFAULT_ASSET_PREFIX)]
prefix: String,
}
pub async fn run(args: SyncStaticBucketArgs) -> Result<()> {
ensure!(
args.source.is_dir(),
"Static source directory is missing: {}",
args.source.display()
);
let upload_plan = static_upload_plan(&args.source)?;
let client = s3_client(Some(DEFAULT_S3_ENDPOINT)).await?;
let plan = upload_plan
.into_iter()
.map(|(key, path)| S3UploadPlanItem::new(path, key).with_detected_content_type())
.collect::<Vec<_>>();
let stats = upload_s3_plan_sync(&client, &args.bucket, plan).await?;
println!(
"Static bucket sync complete: uploaded {} file(s), skipped existing {}",
stats.uploaded, stats.skipped_existing
);
let remote_keys = list_s3_keys(&client, &args.bucket, "").await?;
let markdown_keys = remote_keys
.into_iter()
.filter(|key| key.to_ascii_lowercase().ends_with(".md"))
.collect::<Vec<_>>();
let removed = delete_s3_objects(&client, &args.bucket, &markdown_keys).await?;
println!("Static bucket sync removed {removed} stray .md object(s)");
Ok(())
}
pub async fn repair_asset_metadata(args: RepairStaticAssetMetadataArgs) -> Result<()> {
let client = s3_client(Some(DEFAULT_S3_ENDPOINT)).await?;
let keys = list_s3_keys(&client, &args.bucket, &args.prefix).await?;
let mut skipped = 0_usize;
let mut tasks = JoinSet::new();
let semaphore = Arc::new(Semaphore::new(static_asset_repair_concurrency()));
for key in keys {
let Some(content_type) = s3_content_type_for_key(&key) else {
skipped += 1;
continue;
};
let permit = semaphore
.clone()
.acquire_owned()
.await
.context("S3 repair semaphore closed")?;
let client = client.clone();
let bucket = args.bucket.clone();
tasks.spawn(async move {
let _permit = permit;
replace_s3_object_metadata(
&client,
&bucket,
&key,
Some(content_type),
Some(IMMUTABLE_ASSET_CACHE_CONTROL),
)
.await?;
Ok::<_, anyhow::Error>(())
});
}
let mut repaired = 0_usize;
while let Some(result) = tasks.join_next().await {
result.context("S3 metadata repair task failed")??;
repaired += 1;
}
println!(
"Static asset metadata repair complete: repaired {repaired} file(s), skipped {skipped}"
);
Ok(())
}
fn static_asset_repair_concurrency() -> usize {
env::var("S3_WRITE_CONCURRENCY")
.ok()
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| *value > 0)
.unwrap_or(DEFAULT_REPAIR_CONCURRENCY)
}
fn static_upload_plan(source: &Path) -> Result<BTreeMap<String, PathBuf>> {
let mut plan = BTreeMap::new();
for file in collect_files(source)? {
let relative = file
.strip_prefix(source)
.with_context(|| format!("Failed to relativize {}", file.display()))?;
if !should_sync_static_path(relative) {
continue;
}
let key = path_to_s3_key(relative);
if !key.is_empty() {
plan.insert(key, file);
}
}
Ok(plan)
}
fn should_sync_static_path(relative: &Path) -> bool {
if relative
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
{
return false;
}
let Some(first) = relative.components().next() else {
return false;
};
match first {
std::path::Component::Normal(value) => {
let value = value.to_string_lossy();
value != ".github" && value != "assets"
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap();
}
#[test]
fn upload_plan_excludes_github_and_assets_roots() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path();
write_file(&source.join("index.html"), "html");
write_file(&source.join("docs/install.html"), "docs");
write_file(&source.join(".github/workflows/ignored.yaml"), "workflow");
write_file(&source.join("assets/app.js"), "asset");
let keys = static_upload_plan(source)
.unwrap()
.keys()
.cloned()
.collect::<Vec<_>>();
assert_eq!(keys, vec!["docs/install.html", "index.html"]);
}
#[test]
fn upload_plan_is_deterministic_and_keeps_non_root_assets_paths() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path();
write_file(&source.join("z.html"), "z");
write_file(&source.join("docs/assets/keep.js"), "keep");
write_file(&source.join("a.html"), "a");
let keys = static_upload_plan(source)
.unwrap()
.keys()
.cloned()
.collect::<Vec<_>>();
assert_eq!(keys, vec!["a.html", "docs/assets/keep.js", "z.html"]);
}
#[test]
fn static_key_filter_matches_workflow_excludes() {
assert!(should_sync_static_path(Path::new("index.html")));
assert!(should_sync_static_path(
Path::new("docs").join("install.html").as_path()
));
assert!(!should_sync_static_path(
Path::new("assets").join("app.js").as_path()
));
assert!(!should_sync_static_path(
Path::new(".github")
.join("workflows")
.join("sync.yaml")
.as_path()
));
assert!(!should_sync_static_path(Path::new("")));
}
#[test]
fn static_key_filter_excludes_markdown() {
assert!(!should_sync_static_path(Path::new(
"THIRD_PARTY_LICENSES.md"
)));
assert!(!should_sync_static_path(
Path::new("fonts").join("NOTICE.md").as_path()
));
assert!(!should_sync_static_path(
Path::new("emoji").join("README.MD").as_path()
));
assert!(should_sync_static_path(Path::new("index.html")));
}
#[test]
fn upload_plan_excludes_markdown_files() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path();
write_file(&source.join("index.html"), "html");
write_file(&source.join("THIRD_PARTY_LICENSES.md"), "licenses");
write_file(&source.join("fonts/NOTICE.md"), "notice");
let keys = static_upload_plan(source)
.unwrap()
.keys()
.cloned()
.collect::<Vec<_>>();
assert_eq!(keys, vec!["index.html"]);
}
#[test]
fn repair_metadata_args_default_to_app_assets_prefix() {
let args = RepairStaticAssetMetadataArgs {
bucket: DEFAULT_STATIC_BUCKET.to_string(),
prefix: DEFAULT_ASSET_PREFIX.to_string(),
};
assert_eq!(args.bucket, "fluxer-static");
assert_eq!(args.prefix, "assets/");
}
}