fix(plugins): ajoute les headers CORS sur le protocole idea-plugin:// (#120)
plugin_asset_response ne posait aucun header Access-Control-Allow-Origin, ce qui bloquait l'import() dynamique du bundle plugin (origine distincte du document) sous WebKitGTK — cause racine confirmee de la 4e rechute #120 (bandeau « Cross-origin script load denied »). Ajoute Access-Control-Allow- Origin/-Methods sur toutes les reponses du protocole (succes et erreurs) et un test backend dedie pour eviter une rechute silencieuse. QA 2026-08-01 : PASS avec reserve — cargo test -p app-tauri (test CORS cible + plugin_install_load) et tests frontend runtime/plugins verts, mais pas de preuve visuelle e2e AppImage dans cet environnement (fuse absent, AppImage non montable ici). Validation AppImage reelle a faire avant cloture definitive du ticket #120. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -15,6 +15,9 @@ use tauri::{AppHandle, Manager, State};
|
|||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
const PLUGIN_ASSET_CORS_ORIGIN: &str = "*";
|
||||||
|
const PLUGIN_ASSET_CORS_METHODS: &str = "GET";
|
||||||
|
|
||||||
/// Lists installed plugins for the admin surface.
|
/// Lists installed plugins for the admin surface.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn plugin_list_plugins(
|
pub async fn plugin_list_plugins(
|
||||||
@ -192,8 +195,7 @@ pub fn plugin_asset_protocol(
|
|||||||
application::diag!(
|
application::diag!(
|
||||||
"[plugins] asset failed uri={uri} status={status} message={message}"
|
"[plugins] asset failed uri={uri} status={status} message={message}"
|
||||||
);
|
);
|
||||||
Response::builder()
|
plugin_asset_response_builder(status)
|
||||||
.status(status)
|
|
||||||
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||||
.body(message.into_bytes())
|
.body(message.into_bytes())
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
@ -207,6 +209,27 @@ pub fn plugin_asset_protocol(
|
|||||||
fn plugin_asset_response(
|
fn plugin_asset_response(
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
request: http::Request<Vec<u8>>,
|
request: http::Request<Vec<u8>>,
|
||||||
|
) -> Result<Response<Vec<u8>>, (StatusCode, String)> {
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
let app_data = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
plugin_asset_response_with_stores(
|
||||||
|
&app_data,
|
||||||
|
request,
|
||||||
|
state.plugin_registry_store.as_ref(),
|
||||||
|
state.plugin_package_store.as_ref(),
|
||||||
|
state.plugin_manifest_validator.as_ref(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plugin_asset_response_with_stores(
|
||||||
|
app_data: &Path,
|
||||||
|
request: http::Request<Vec<u8>>,
|
||||||
|
registry_store: &dyn PluginRegistryStore,
|
||||||
|
package_store: &dyn PluginPackageStore,
|
||||||
|
validator: &dyn PluginManifestValidator,
|
||||||
) -> Result<Response<Vec<u8>>, (StatusCode, String)> {
|
) -> Result<Response<Vec<u8>>, (StatusCode, String)> {
|
||||||
let uri = request.uri();
|
let uri = request.uri();
|
||||||
let plugin_id = uri
|
let plugin_id = uri
|
||||||
@ -227,14 +250,13 @@ fn plugin_asset_response(
|
|||||||
}
|
}
|
||||||
let rel =
|
let rel =
|
||||||
RelativePath::new(rel.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
RelativePath::new(rel.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
||||||
let state = app.state::<AppState>();
|
|
||||||
let allowed = block_on_protocol_future(asset_allowed(
|
let allowed = block_on_protocol_future(asset_allowed(
|
||||||
&plugin_id,
|
&plugin_id,
|
||||||
hash,
|
hash,
|
||||||
&rel,
|
&rel,
|
||||||
state.plugin_registry_store.as_ref(),
|
registry_store,
|
||||||
state.plugin_package_store.as_ref(),
|
package_store,
|
||||||
state.plugin_manifest_validator.as_ref(),
|
validator,
|
||||||
))?;
|
))?;
|
||||||
if !allowed {
|
if !allowed {
|
||||||
return Err((
|
return Err((
|
||||||
@ -242,10 +264,6 @@ fn plugin_asset_response(
|
|||||||
"plugin asset is not active".to_owned(),
|
"plugin asset is not active".to_owned(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let app_data = app
|
|
||||||
.path()
|
|
||||||
.app_data_dir()
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
let root = app_data
|
let root = app_data
|
||||||
.join("plugins")
|
.join("plugins")
|
||||||
.join("installed")
|
.join("installed")
|
||||||
@ -264,13 +282,25 @@ fn plugin_asset_response(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let bytes = std::fs::read(&target).map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
let bytes = std::fs::read(&target).map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||||
Response::builder()
|
plugin_asset_response_builder(StatusCode::OK)
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header(header::CONTENT_TYPE, content_type(&target))
|
.header(header::CONTENT_TYPE, content_type(&target))
|
||||||
.body(bytes)
|
.body(bytes)
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn plugin_asset_response_builder(status: StatusCode) -> http::response::Builder {
|
||||||
|
Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header(
|
||||||
|
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||||
|
PLUGIN_ASSET_CORS_ORIGIN,
|
||||||
|
)
|
||||||
|
.header(
|
||||||
|
header::ACCESS_CONTROL_ALLOW_METHODS,
|
||||||
|
PLUGIN_ASSET_CORS_METHODS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async fn asset_allowed(
|
async fn asset_allowed(
|
||||||
plugin_id: &PluginId,
|
plugin_id: &PluginId,
|
||||||
hash: &str,
|
hash: &str,
|
||||||
@ -351,8 +381,12 @@ fn open_folder(path: &PathBuf) -> Result<(), ErrorDto> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{asset_allowed, block_on_protocol_future};
|
use super::{
|
||||||
|
asset_allowed, block_on_protocol_future, plugin_asset_response_with_stores,
|
||||||
|
PLUGIN_ASSET_CORS_METHODS, PLUGIN_ASSET_CORS_ORIGIN,
|
||||||
|
};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
@ -360,10 +394,11 @@ mod tests {
|
|||||||
PluginRegistryStore, PluginStoreError,
|
PluginRegistryStore, PluginStoreError,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
ContentHash, LocalPath, PluginId, PluginInstallSource, PluginLifecycleState,
|
ContentHash, LocalPath, PluginContributionSet, PluginId, PluginInstallSource,
|
||||||
PluginRegistry, PluginRegistryEntry, RelativePath, RemovalOutcome, StagedPluginPackage,
|
PluginLifecycleState, PluginManifest, PluginRegistry, PluginRegistryEntry,
|
||||||
|
PluginTrustLevel, PluginVersion, RelativePath, RemovalOutcome, StagedPluginPackage,
|
||||||
};
|
};
|
||||||
use http::StatusCode;
|
use http::{header, StatusCode};
|
||||||
|
|
||||||
struct FakeRegistry {
|
struct FakeRegistry {
|
||||||
registry: Mutex<PluginRegistry>,
|
registry: Mutex<PluginRegistry>,
|
||||||
@ -459,6 +494,34 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AcceptingValidator {
|
||||||
|
plugin_id: PluginId,
|
||||||
|
main: RelativePath,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl domain::ports::PluginManifestValidator for AcceptingValidator {
|
||||||
|
fn validate(
|
||||||
|
&self,
|
||||||
|
_bytes: &[u8],
|
||||||
|
_package: &domain::PluginPackageRef,
|
||||||
|
) -> Result<PluginManifest, PluginManifestError> {
|
||||||
|
Ok(PluginManifest {
|
||||||
|
idea_plugin_manifest_version: 1,
|
||||||
|
id: self.plugin_id.clone(),
|
||||||
|
display_name: "Git Graph".to_owned(),
|
||||||
|
publisher: None,
|
||||||
|
version: PluginVersion::new("1.0.0").unwrap(),
|
||||||
|
description: None,
|
||||||
|
engine_idea: None,
|
||||||
|
main: self.main.clone(),
|
||||||
|
icon: None,
|
||||||
|
trust_level: PluginTrustLevel::Full,
|
||||||
|
capabilities: Vec::new(),
|
||||||
|
contributes: PluginContributionSet::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn protocol_future_can_be_waited_inside_tauri_runtime() {
|
async fn protocol_future_can_be_waited_inside_tauri_runtime() {
|
||||||
let value = block_on_protocol_future(async { 42 });
|
let value = block_on_protocol_future(async { 42 });
|
||||||
@ -504,4 +567,80 @@ mod tests {
|
|||||||
assert_eq!(err.0, StatusCode::FORBIDDEN);
|
assert_eq!(err.0, StatusCode::FORBIDDEN);
|
||||||
assert!(err.1.contains("broken manifest"));
|
assert!(err.1.contains("broken manifest"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_asset_response_includes_cors_headers_for_dynamic_import() {
|
||||||
|
let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
|
||||||
|
let hash = ContentHash::new("abc123").unwrap();
|
||||||
|
let rel = RelativePath::new("dist/index.js").unwrap();
|
||||||
|
let registry = FakeRegistry {
|
||||||
|
registry: Mutex::new(PluginRegistry {
|
||||||
|
version: 1,
|
||||||
|
plugins: vec![PluginRegistryEntry {
|
||||||
|
id: plugin_id.clone(),
|
||||||
|
lifecycle_state: PluginLifecycleState::Enabled,
|
||||||
|
source: PluginInstallSource::Directory {
|
||||||
|
path_label: "/source/plugin".to_owned(),
|
||||||
|
},
|
||||||
|
content_hash: hash.clone(),
|
||||||
|
restart_required: false,
|
||||||
|
error: None,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let packages = FakePackages {
|
||||||
|
manifest: br#"{"ideaPluginManifestVersion":1}"#.to_vec(),
|
||||||
|
};
|
||||||
|
let validator = AcceptingValidator {
|
||||||
|
plugin_id: plugin_id.clone(),
|
||||||
|
main: rel.clone(),
|
||||||
|
};
|
||||||
|
let app_data = test_app_data_dir("plugin-asset-cors");
|
||||||
|
let target = app_data
|
||||||
|
.join("plugins")
|
||||||
|
.join("installed")
|
||||||
|
.join(plugin_id.as_str())
|
||||||
|
.join(rel.as_str());
|
||||||
|
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(&target, "export default 42;").unwrap();
|
||||||
|
let request = http::Request::builder()
|
||||||
|
.uri(format!(
|
||||||
|
"idea-plugin://{}/current/{}/{}",
|
||||||
|
plugin_id.as_str(),
|
||||||
|
hash.as_str(),
|
||||||
|
rel.as_str()
|
||||||
|
))
|
||||||
|
.body(Vec::new())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let response =
|
||||||
|
plugin_asset_response_with_stores(&app_data, request, ®istry, &packages, &validator)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
|
||||||
|
Some(&http::HeaderValue::from_static(PLUGIN_ASSET_CORS_ORIGIN))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers().get(header::ACCESS_CONTROL_ALLOW_METHODS),
|
||||||
|
Some(&http::HeaderValue::from_static(PLUGIN_ASSET_CORS_METHODS))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers().get(header::CONTENT_TYPE),
|
||||||
|
Some(&http::HeaderValue::from_static(
|
||||||
|
"text/javascript; charset=utf-8"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(app_data).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_app_data_dir(label: &str) -> std::path::PathBuf {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("idea-{label}-{}-{nanos}", std::process::id()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user