From 28d135de065cd573cb84ead6b1f4cf6b1cc29312 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sun, 17 May 2026 19:02:45 +0400 Subject: [PATCH] fix: track gecko_id for extension groups --- src-tauri/src/camoufox_manager.rs | 29 ++++-- src-tauri/src/extension_manager.rs | 136 +++++++++++++++++++++-------- src-tauri/src/profile/manager.rs | 9 +- 3 files changed, 129 insertions(+), 45 deletions(-) diff --git a/src-tauri/src/camoufox_manager.rs b/src-tauri/src/camoufox_manager.rs index 22eea7e..e5cdd9a 100644 --- a/src-tauri/src/camoufox_manager.rs +++ b/src-tauri/src/camoufox_manager.rs @@ -662,24 +662,39 @@ impl CamoufoxManager { } } - // Write explicit proxy prefs to user.js so Firefox always uses the local - // donut-proxy and never falls back to stale proxy settings baked into prefs.js - // from a previous session. user.js values override prefs.js on every launch. + // Write explicit proxy + extension prefs to user.js so Camoufox always + // uses the local donut-proxy and picks up sideloaded extensions. user.js + // values override prefs.js on every launch, so this is always canonical. if let Some(proxy_str) = &config.proxy { let user_js_path = profile_path.join("user.js"); let mut prefs = String::new(); - // Preserve existing user.js content (ephemeral prefs, etc.) + // Preserve existing user.js lines, but strip any keys we're about to + // re-emit so they never duplicate. + let managed_keys = [ + "network.proxy.", + "xpinstall.signatures.required", + "extensions.startupScanScopes", + ]; if let Ok(existing) = std::fs::read_to_string(&user_js_path) { - // Strip old proxy prefs so we don't duplicate for line in existing.lines() { - if !line.contains("network.proxy.") { + if !managed_keys.iter().any(|k| line.contains(k)) { prefs.push_str(line); prefs.push('\n'); } } } + // Required for sideloaded extensions: + // - signatures.required=false accepts unsigned .xpi (Camoufox is built + // without MOZ_REQUIRE_SIGNING so this is honored). + // - startupScanScopes=1 rescans SCOPE_PROFILE on each launch so newly + // dropped .xpi files in /extensions/ get registered. + prefs.push_str( + "user_pref(\"xpinstall.signatures.required\", false);\n\ + user_pref(\"extensions.startupScanScopes\", 1);\n", + ); + if let Ok(parsed) = url::Url::parse(proxy_str) { let host = parsed.host_str().unwrap_or("127.0.0.1"); let port = parsed.port().unwrap_or(8080); @@ -707,7 +722,7 @@ impl CamoufoxManager { } if let Err(e) = std::fs::write(&user_js_path, prefs) { - log::warn!("Failed to write proxy prefs to user.js: {e}"); + log::warn!("Failed to write user.js: {e}"); } } } diff --git a/src-tauri/src/extension_manager.rs b/src-tauri/src/extension_manager.rs index 6d12b2c..39a2d2d 100644 --- a/src-tauri/src/extension_manager.rs +++ b/src-tauri/src/extension_manager.rs @@ -27,6 +27,11 @@ pub struct Extension { pub author: Option, #[serde(default)] pub homepage_url: Option, + /// Firefox extension ID from `browser_specific_settings.gecko.id` (or + /// `applications.gecko.id` in old manifests). Firefox refuses to load a + /// sideloaded .xpi unless the filename matches this value. + #[serde(default)] + pub gecko_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -157,6 +162,32 @@ fn extract_manifest_metadata( (name, version, description, author, homepage_url) } +/// Read `browser_specific_settings.gecko.id` (or the legacy +/// `applications.gecko.id`) from the extension's manifest.json. Firefox uses +/// this value as the canonical add-on ID; sideloaded .xpi files must be named +/// `.xpi` to be picked up. +fn extract_gecko_id(file_data: &[u8], file_type: &str) -> Option { + let zip_start = if file_type == "crx" { + find_zip_start(file_data) + } else { + 0 + }; + let cursor = std::io::Cursor::new(&file_data[zip_start..]); + let mut archive = zip::ZipArchive::new(cursor).ok()?; + let mut manifest_content = String::new(); + std::io::Read::read_to_string( + &mut archive.by_name("manifest.json").ok()?, + &mut manifest_content, + ) + .ok()?; + let manifest: serde_json::Value = serde_json::from_str(&manifest_content).ok()?; + manifest + .pointer("/browser_specific_settings/gecko/id") + .or_else(|| manifest.pointer("/applications/gecko/id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec, String)> { let zip_start = if file_type == "crx" { find_zip_start(file_data) @@ -285,6 +316,7 @@ impl ExtensionManager { name }; + let gecko_id = extract_gecko_id(&file_data, &file_type); let ext = Extension { id: uuid::Uuid::new_v4().to_string(), name: final_name, @@ -299,6 +331,7 @@ impl ExtensionManager { description, author, homepage_url, + gecko_id, }; let file_dir = self.get_file_dir(&ext.id); @@ -415,6 +448,7 @@ impl ExtensionManager { ext.name = mn; } } + ext.gecko_id = extract_gecko_id(&data, &new_file_type); if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&data, &new_file_type) { let icon_path = self.get_extension_dir(id).join(format!("icon.{icon_ext}")); @@ -893,24 +927,33 @@ impl ExtensionManager { continue; } let src_file = self.get_file_dir(ext_id).join(&ext.file_name); - if src_file.exists() { - // Firefox expects .xpi files in extensions dir - let dest_name = if ext.file_type == "zip" { - format!( - "{}.xpi", - ext - .file_name - .rsplit('.') - .next_back() - .unwrap_or(&ext.file_name) - ) - } else { - ext.file_name.clone() - }; - let dest = extensions_dir.join(&dest_name); - fs::copy(&src_file, &dest)?; - extension_paths.push(dest.to_string_lossy().to_string()); + if !src_file.exists() { + continue; } + + // Firefox/Camoufox only loads sideloaded .xpi files whose filename + // matches `browser_specific_settings.gecko.id` from the manifest. + // Prefer the cached value; fall back to reading the manifest now + // for extensions added before the field existed. + let gecko_id = if let Some(ref id) = ext.gecko_id { + Some(id.clone()) + } else if let Ok(data) = fs::read(&src_file) { + extract_gecko_id(&data, &ext.file_type) + } else { + None + }; + + let Some(gecko_id) = gecko_id else { + log::warn!( + "Skipping Firefox extension '{}': could not determine gecko id from manifest.json", + ext.name + ); + continue; + }; + + let dest = extensions_dir.join(format!("{gecko_id}.xpi")); + fs::copy(&src_file, &dest)?; + extension_paths.push(dest.to_string_lossy().to_string()); } } } @@ -1022,30 +1065,49 @@ impl ExtensionManager { } } - if ext.version.is_none() && ext.description.is_none() { + let needs_meta_backfill = ext.version.is_none() && ext.description.is_none(); + let needs_gecko_backfill = + ext.gecko_id.is_none() && ext.browser_compatibility.iter().any(|b| b == "firefox"); + + if needs_meta_backfill || needs_gecko_backfill { let file_path = file_dir.join(&ext.file_name); if let Ok(file_data) = fs::read(&file_path) { - let (manifest_name, version, description, author, homepage_url) = - extract_manifest_metadata(&file_data, &ext.file_type); - if version.is_some() - || description.is_some() - || author.is_some() - || homepage_url.is_some() - || manifest_name.is_some() - { - let mut updated_ext = ext.clone(); - if let Some(v) = version { - updated_ext.version = Some(v); + let mut updated_ext = ext.clone(); + let mut changed = false; + + if needs_meta_backfill { + let (manifest_name, version, description, author, homepage_url) = + extract_manifest_metadata(&file_data, &ext.file_type); + if version.is_some() + || description.is_some() + || author.is_some() + || homepage_url.is_some() + || manifest_name.is_some() + { + if let Some(v) = version { + updated_ext.version = Some(v); + } + if let Some(d) = description { + updated_ext.description = Some(d); + } + if let Some(a) = author { + updated_ext.author = Some(a); + } + if let Some(h) = homepage_url { + updated_ext.homepage_url = Some(h); + } + changed = true; } - if let Some(d) = description { - updated_ext.description = Some(d); - } - if let Some(a) = author { - updated_ext.author = Some(a); - } - if let Some(h) = homepage_url { - updated_ext.homepage_url = Some(h); + } + + if needs_gecko_backfill { + if let Some(gid) = extract_gecko_id(&file_data, &ext.file_type) { + updated_ext.gecko_id = Some(gid); + changed = true; } + } + + if changed { let metadata_path = self.get_metadata_path(&ext.id); if let Ok(json) = serde_json::to_string_pretty(&updated_ext) { let _ = fs::write(metadata_path, json); diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 577ce97..ff7d2a0 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -1799,10 +1799,17 @@ impl ProfileManager { "user_pref(\"startup.homepage_welcome_url\", \"\");".to_string(), "user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(), "user_pref(\"startup.homepage_override_url\", \"\");".to_string(), - // Keep extension updates enabled and allow sideloaded extensions + // Keep extension updates enabled and allow sideloaded extensions. + // - autoDisableScopes=0: profile-installed extensions are enabled by default. + // - startupScanScopes=1: rescan SCOPE_PROFILE on each launch so freshly + // dropped .xpi files in /extensions/ get registered. + // - signatures.required=false: accept unsigned/dev .xpi files. Camoufox + // is built without MOZ_REQUIRE_SIGNING so this is honored. "user_pref(\"extensions.update.enabled\", true);".to_string(), "user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(), "user_pref(\"extensions.autoDisableScopes\", 0);".to_string(), + "user_pref(\"extensions.startupScanScopes\", 1);".to_string(), + "user_pref(\"xpinstall.signatures.required\", false);".to_string(), // Completely disable browser update checking "user_pref(\"app.update.enabled\", false);".to_string(), "user_pref(\"app.update.auto\", false);".to_string(),