1use super::HandlerResult;
8use crate::cli::arg::{
9 CheckOutputFormat, PluginArgs, PluginCommand, PluginImportArgs, PluginLaunchArgs,
10 PluginListArgs, PluginPlanArgs, PluginStatusArgs, PluginToggleArgs,
11};
12use foch::game::eu4::Eu4;
13use foch::game::eu4::base::snapshot::{data_root, detect_game_version, resolve_game_root};
14use foch::input::Config;
15use foch::plugin::manifest::Manifest;
16use foch::plugin::{self, GameIdentity, Selections, planner, store};
17use semver::Version;
18use serde_json::json;
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22pub fn handle_plugin(args: &PluginArgs, config: Config) -> HandlerResult {
23 match &args.command {
24 PluginCommand::List(list) => list_plugins(list, &config),
25 PluginCommand::Import(import) => import_plugin(import, &config),
26 PluginCommand::Enable(toggle) => set_enabled(toggle, true),
27 PluginCommand::Disable(toggle) => set_enabled(toggle, false),
28 PluginCommand::Plan(plan) => plan_launch(plan, &config),
29 PluginCommand::Launch(launch) => launch_plugins(launch, &config),
30 PluginCommand::Status(status) => show_status(status),
31 }
32}
33
34fn catalog_with_builtins(
37 store_root: &Path,
38) -> (BTreeMap<String, BTreeMap<Version, Manifest>>, Vec<String>) {
39 store::catalog_with_defaults(store_root, plugin::builtin::adapters())
40}
41
42fn load_selections() -> Result<(PathBuf, Selections), Box<dyn std::error::Error>> {
43 let path = plugin::paths::selections_path()?;
44 let selections = Selections::load(&path)?;
45 Ok((path, selections))
46}
47
48fn list_plugins(args: &PluginListArgs, _config: &Config) -> HandlerResult {
49 let store_root = plugin::paths::checked_store_root(&data_root())?;
50 let (catalog, problems) = catalog_with_builtins(&store_root);
51 let (_, selections) = load_selections()?;
52 let chosen = selections.for_playset(&args.playset);
53
54 match args.format {
55 CheckOutputFormat::Json => {
56 let plugins: Vec<_> = catalog
57 .iter()
58 .map(|(id, versions)| {
59 let choice = chosen.plugins.get(id);
60 json!({
61 "id": id,
62 "versions": versions.keys().map(|v| v.to_string()).collect::<Vec<_>>(),
63 "selected": choice.map(|c| json!({
64 "version": c.version.to_string(),
65 "enabled": c.enabled,
66 })),
67 })
68 })
69 .collect();
70 print_json(
71 &json!({ "playset": args.playset, "plugins": plugins, "problems": problems }),
72 )?;
73 }
74 CheckOutputFormat::Text => {
75 if catalog.is_empty() {
76 println!("No plugins installed.");
77 }
78 for (id, versions) in &catalog {
79 let versions: Vec<String> = versions.keys().map(|v| v.to_string()).collect();
80 let mark = match chosen.plugins.get(id) {
81 Some(choice) if choice.enabled => format!(" [enabled {}]", choice.version),
82 Some(choice) => format!(" [disabled {}]", choice.version),
83 None => String::new(),
84 };
85 println!("{id} ({}){mark}", versions.join(", "));
86 }
87 for problem in &problems {
88 eprintln!("warning: {problem}");
89 }
90 }
91 }
92 Ok(0)
93}
94
95fn import_plugin(args: &PluginImportArgs, config: &Config) -> HandlerResult {
96 if !args.path.is_dir() {
97 return Err(format!(
98 "{} is not a directory; extract the plugin package first",
99 args.path.display()
100 )
101 .into());
102 }
103 let entries = read_package_dir(&args.path)?;
104 let candidate = match &args.adapter {
105 Some(id) => store::adapt(
106 plugin::builtin::adapter(id).ok_or_else(|| format!("unknown built-in adapter {id}"))?,
107 entries,
108 ),
109 None => plugin::validate(entries),
110 };
111 let validated = match candidate {
112 Ok(package) => package,
113 Err(problems) => {
114 let mut message = String::from("package failed validation:");
115 for problem in problems {
116 message.push_str(&format!("\n - {problem}"));
117 }
118 return Err(message.into());
119 }
120 };
121 let data_root = data_root();
122 let destination = store::version_dir(&data_root.join("plugins/store"), &validated);
123 let game_root = resolve_game_root(config, &Eu4);
124 for source in std::iter::once(args.path.as_path()).chain(game_root.as_deref()) {
125 plugin::deployment::ensure_outside_game(source, &data_root)?;
126 plugin::deployment::ensure_outside_game(source, &destination)?;
127 }
128 let store_root = plugin::paths::prepare_store_root(&data_root)?;
129 match plugin::install(&store_root, &validated)? {
130 plugin::Installed::New(path) => {
131 println!(
132 "Imported {} {} into {}",
133 validated.manifest.plugin.id,
134 validated.manifest.plugin.version,
135 path.display()
136 );
137 }
138 plugin::Installed::AlreadyPresent(path) => {
139 println!(
140 "{} {} is already in the store ({})",
141 validated.manifest.plugin.id,
142 validated.manifest.plugin.version,
143 path.display()
144 );
145 }
146 }
147 Ok(0)
148}
149
150fn read_package_dir(root: &Path) -> std::io::Result<Vec<store::ArchiveEntry>> {
153 let mut entries = Vec::new();
154 for entry in walkdir::WalkDir::new(root).follow_links(false) {
155 let entry = entry?;
156 let metadata = std::fs::symlink_metadata(entry.path())?;
157 let linked = metadata.file_type().is_symlink();
158 #[cfg(windows)]
159 let linked = {
160 use std::os::windows::fs::MetadataExt;
161 linked || metadata.file_attributes() & 0x400 != 0
162 };
163 if linked {
164 return Err(std::io::Error::new(
165 std::io::ErrorKind::InvalidInput,
166 format!(
167 "linked package entry is unsupported: {}",
168 entry.path().display()
169 ),
170 ));
171 }
172 if !entry.file_type().is_file() {
173 continue;
174 }
175 let relative = entry
176 .path()
177 .strip_prefix(root)
178 .map_err(std::io::Error::other)?;
179 let path = relative.to_string_lossy().replace('\\', "/");
180 entries.push(store::ArchiveEntry {
181 path,
182 data: store::read_package_file(entry.path())?,
183 });
184 }
185 Ok(entries)
186}
187
188fn set_enabled(args: &PluginToggleArgs, enabled: bool) -> HandlerResult {
189 let store_root = plugin::paths::checked_store_root(&data_root())?;
190 let (catalog, _) = catalog_with_builtins(&store_root);
191
192 let path = plugin::paths::selections_path()?;
193 let requested = args.version.as_deref().map(Version::parse).transpose()?;
194 let version = Selections::update(&path, |selections| {
195 let mut chosen = selections.for_playset(&args.playset);
196 if !enabled
199 && requested.is_none()
200 && let Some(choice) = chosen.plugins.get_mut(&args.id)
201 {
202 choice.enabled = false;
203 let version = choice.version.clone();
204 selections.set_playset(&args.playset, chosen);
205 return Ok(version);
206 }
207 let versions = catalog.get(&args.id).ok_or_else(|| {
208 std::io::Error::other(format!("no plugin with id {} is installed", args.id))
209 })?;
210 let version = match &requested {
211 Some(version) => version.clone(),
212 None => match chosen.plugins.get(&args.id) {
213 Some(choice) if versions.contains_key(&choice.version) => choice.version.clone(),
215 _ => versions.keys().next_back().cloned().ok_or_else(|| {
217 std::io::Error::other(format!("no installed version of {} to pin", args.id))
218 })?,
219 },
220 };
221 if !versions.contains_key(&version) {
222 return Err(std::io::Error::other(format!(
223 "{} {version} is not installed",
224 args.id
225 )));
226 }
227
228 let entry =
229 chosen
230 .plugins
231 .entry(args.id.clone())
232 .or_insert_with(|| plugin::selection::Choice {
233 version: version.clone(),
234 enabled,
235 config: BTreeMap::new(),
236 });
237 entry.version = version.clone();
238 entry.enabled = enabled;
239
240 selections.set_playset(&args.playset, chosen);
241 Ok(version)
242 })?;
243 println!(
244 "{} {} {version} for playset '{}'",
245 if enabled { "Enabled" } else { "Disabled" },
246 args.id,
247 args.playset
248 );
249 Ok(0)
250}
251
252fn plan_launch(args: &PluginPlanArgs, config: &Config) -> HandlerResult {
253 let game_root = args
254 .game_path
255 .clone()
256 .or_else(|| resolve_game_root(config, &Eu4))
257 .ok_or("could not locate the EU4 install; pass --game-path")?;
258 let raw_version = detect_game_version(&game_root)
259 .ok_or("could not detect the EU4 version from the install")?;
260 let version = planner::parse_game_version(&raw_version)
261 .ok_or_else(|| format!("could not parse game version '{raw_version}'"))?;
262 let game = GameIdentity {
263 game: "eu4".into(),
264 version,
265 platform: planner::WINDOWS_X64.into(),
266 };
267
268 let store_root = plugin::paths::checked_store_root(&data_root())?;
269 let (catalog, _) = catalog_with_builtins(&store_root);
270 let (_, selections) = load_selections()?;
271 let chosen = selections.for_playset(&args.playset);
272 let selections = chosen.to_selections();
273 let resolution = match plugin::deployment::resolve(&game, &store_root, &selections) {
274 Ok(deployment) => deployment.resolution().clone(),
275 Err(reason) => {
276 let mut resolution = plugin::plan(&game, &catalog, &selections);
277 resolution.errors.push(planner::Diagnostic::Incompatible {
278 id: "deployment".into(),
279 reason,
280 });
281 resolution
282 }
283 };
284
285 match args.format {
286 CheckOutputFormat::Json => {
287 print_json(&json!({
288 "playset": args.playset,
289 "game_version": game.version.to_string(),
290 "launchable": resolution.is_launchable(),
291 "order": resolution.order.iter().map(|r| json!({
292 "id": r.id,
293 "version": r.version.to_string(),
294 "phase": format!("{:?}", r.phase).to_lowercase(),
295 })).collect::<Vec<_>>(),
296 "errors": resolution.errors.iter().map(|e| format!("{e:?}")).collect::<Vec<_>>(),
297 "warnings": resolution.warnings.iter().map(|w| format!("{w:?}")).collect::<Vec<_>>(),
298 }))?;
299 }
300 CheckOutputFormat::Text => {
301 println!(
302 "Playset '{}' on EU4 {}: {}",
303 args.playset,
304 game.version,
305 if resolution.is_launchable() {
306 "launchable"
307 } else {
308 "not launchable"
309 }
310 );
311 if !resolution.order.is_empty() {
312 println!("Load order:");
313 for (index, resolved) in resolution.order.iter().enumerate() {
314 println!(
315 " {}. {} {} ({:?})",
316 index + 1,
317 resolved.id,
318 resolved.version,
319 resolved.phase
320 );
321 }
322 }
323 for error in &resolution.errors {
324 println!("error: {error:?}");
325 }
326 for warning in &resolution.warnings {
327 println!("warning: {warning:?}");
328 }
329 }
330 }
331 Ok(if resolution.is_launchable() { 0 } else { 1 })
332}
333
334fn print_json(value: &serde_json::Value) -> Result<(), Box<dyn std::error::Error>> {
335 println!("{}", serde_json::to_string_pretty(value)?);
336 Ok(())
337}
338
339fn launch_plugins(args: &PluginLaunchArgs, config: &Config) -> HandlerResult {
340 if !cfg!(all(windows, target_arch = "x86_64")) {
341 return Err("plugin launch requires Windows x64".into());
342 }
343 let game_root = foch_runner::locate_game(config, args.plan.game_path.as_deref())?;
344 let raw_version = detect_game_version(&game_root).ok_or("could not detect the EU4 version")?;
345 let game = GameIdentity {
346 game: "eu4".into(),
347 version: planner::parse_game_version(&raw_version).ok_or("invalid EU4 version")?,
348 platform: planner::WINDOWS_X64.into(),
349 };
350 let (_, selections) = load_selections()?;
351 let selected = selections.for_playset(&args.plan.playset).to_selections();
352 let store_root = plugin::paths::checked_store_root(&data_root())?;
353 let deployment = plugin::deployment::resolve(&game, &store_root, &selected)?;
354 let host = args.host_dll.clone().unwrap_or(
355 std::env::current_exe()?
356 .parent()
357 .unwrap()
358 .join("foch_eu4_host.dll"),
359 );
360 let host_bytes = std::fs::read(&host).map_err(|error| {
361 format!(
362 "cannot read host {}: {error}; build foch-eu4-host and pass --host-dll",
363 host.display()
364 )
365 })?;
366 if store::pe_machine(&host_bytes) != Some(store::MACHINE_AMD64) {
367 return Err("host DLL is not an AMD64 PE image".into());
368 }
369 let runtime_base = args
370 .runtime_base
371 .clone()
372 .unwrap_or(std::env::temp_dir().join("foch-rt"));
373 let run_id = format!(
374 "p{:x}",
375 std::time::SystemTime::now()
376 .duration_since(std::time::UNIX_EPOCH)?
377 .as_nanos()
378 );
379 let mut layer =
380 foch_runner::runtime::RuntimeLayer::prepare(&game_root, &runtime_base, &run_id)?;
381 let directory = layer.directory.clone();
382 write_host_dll(&directory, &host_bytes)?;
383 let data_root = foch::game::eu4::base::snapshot::data_root();
384 foch_runner::runtime::ensure_outside_game(&game_root, &data_root)?;
385 let prepared = deployment.stage(&directory, &data_root, &run_id, &game_root)?;
386 if let Some(cache) =
387 deployment.prepare_font_cache(&data_root, &layer.fonts_sha256, &game_root)?
388 {
389 layer.redirect_unicode_cache(&cache)?;
390 }
391 let user_dir = args
392 .user_dir
393 .clone()
394 .or_else(|| {
395 dirs::document_dir().map(|path| path.join("Paradox Interactive/Europa Universalis IV"))
396 })
397 .ok_or("could not locate the EU4 user directory; pass --user-dir")?;
398 foch_runner::runtime::ensure_outside_game(&game_root, &user_dir)?;
399 std::fs::create_dir_all(&user_dir)?;
400 let user_dir = PathBuf::from(plugin::deployment::path_text(&user_dir.canonicalize()?)?);
401 plugin::store::replace_package_file(
402 &directory.join("userdir.txt"),
403 user_dir.to_string_lossy().as_bytes(),
404 )?;
405 let mut record = json!({
406 "format":1, "run_id":run_id, "playset":args.plan.playset, "runtime":directory,
407 "user_dir":user_dir, "game_version":raw_version,
408 "game_exe_sha256":plugin::deployment::hash(&plugin::store::read_package_file(&directory.join("eu4.exe"))?),
409 "host_sha256":plugin::deployment::hash(&host_bytes), "plan_sha256":prepared.plan_sha256,
410 "artifacts":prepared.artifacts, "excluded":layer.excluded,
411 "events":prepared.plan.events, "pid":null, "prepared_only":args.prepare_only,
412 });
413 plugin::store::create_package_file(
414 &directory.join("foch-host/run.json"),
415 &serde_json::to_vec_pretty(&record)?,
416 )?;
417 let child = layer.retain_after(|directory| {
418 if args.prepare_only {
419 return Ok(None);
420 }
421 let mut child = foch_runner::runtime::spawn_player(directory, &user_dir, &args.game_args)?;
422 record["pid"] = child.id().into();
423 let recorded = serde_json::to_vec_pretty(&record)
424 .map_err(std::io::Error::other)
425 .and_then(|bytes| {
426 plugin::store::replace_package_file(&directory.join("foch-host/run.json"), &bytes)
427 });
428 if let Err(error) = recorded {
429 let _ = child.kill();
430 let _ = child.wait();
431 return Err(error);
432 }
433 Ok(Some(child))
434 })?;
435 match args.plan.format {
436 CheckOutputFormat::Json => print_json(&record)?,
437 CheckOutputFormat::Text => {
438 println!(
439 "{}: {}",
440 if args.prepare_only {
441 "Prepared runtime"
442 } else {
443 "Started EU4"
444 },
445 directory.display()
446 );
447 if let Some(child) = &child {
448 println!("PID: {}", child.id());
449 }
450 println!(
451 "Read states: foch plugin status --run-dir \"{}\"",
452 directory.display()
453 );
454 }
455 }
456 Ok(0)
457}
458
459fn write_host_dll(directory: &Path, bytes: &[u8]) -> std::io::Result<()> {
461 plugin::store::create_package_file(&directory.join("VERSION.dll"), bytes)
462}
463
464fn show_status(args: &PluginStatusArgs) -> HandlerResult {
465 let plan = plugin::deployment::read_plan(&args.run_dir)?;
466 let states = plugin::deployment::states(&plan, &args.run_dir)?;
467 match args.format {
468 CheckOutputFormat::Json => print_json(&json!({"run_id":plan.run_id,"plugins":states}))?,
469 CheckOutputFormat::Text => {
470 for (id, state) in states {
471 println!(
472 "{id}: {}{}",
473 state["state"].as_str().unwrap_or("unknown"),
474 state["reason"]
475 .as_str()
476 .map(|reason| format!(" ({reason})"))
477 .unwrap_or_default()
478 );
479 }
480 }
481 }
482 Ok(0)
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 #[test]
490 fn host_staging_refuses_existing_entries_and_preserves_external_targets() {
491 let scratch = tempfile::tempdir().unwrap();
492 let game = scratch.path().join("game");
493 std::fs::create_dir(&game).unwrap();
494 let external = scratch.path().join("external.dll");
495 std::fs::write(&external, b"external bytes").unwrap();
496 let layer = foch_runner::runtime::RuntimeLayer::prepare(
497 &game,
498 &scratch.path().join("runtimes"),
499 "host-staging",
500 )
501 .unwrap();
502 let runtime = layer.directory.clone();
503 let host = runtime.join("VERSION.dll");
504 std::fs::hard_link(&external, &host).unwrap();
505 assert_eq!(
506 write_host_dll(&runtime, b"host bytes").unwrap_err().kind(),
507 std::io::ErrorKind::AlreadyExists
508 );
509 assert_eq!(std::fs::read(&external).unwrap(), b"external bytes");
510 std::fs::remove_file(&host).unwrap();
511 std::fs::write(&host, b"existing bytes").unwrap();
512 assert_eq!(
513 write_host_dll(&runtime, b"host bytes").unwrap_err().kind(),
514 std::io::ErrorKind::AlreadyExists
515 );
516 assert_eq!(std::fs::read(&host).unwrap(), b"existing bytes");
517 std::fs::remove_file(&host).unwrap();
518 write_host_dll(&runtime, b"host bytes").unwrap();
519 assert_eq!(std::fs::read(&host).unwrap(), b"host bytes");
520 drop(layer);
521 assert!(!runtime.exists());
522 assert_eq!(std::fs::read(&external).unwrap(), b"external bytes");
523 assert_eq!(std::fs::read_dir(&game).unwrap().count(), 0);
524 }
525
526 #[test]
527 fn package_links_are_rejected_instead_of_losing_optional_resources() {
528 let scratch = tempfile::tempdir().unwrap();
529 let package = scratch.path().join("package");
530 std::fs::create_dir_all(package.join("fonts")).unwrap();
531 std::fs::write(package.join("fonts/optional.ttf"), "font bytes").unwrap();
532 assert_eq!(
533 read_package_dir(&package).unwrap()[0].path,
534 "fonts/optional.ttf"
535 );
536 let layer = foch_runner::runtime::RuntimeLayer::prepare(
539 &package,
540 &scratch.path().join("runtimes"),
541 "linked-package",
542 )
543 .unwrap();
544 let error = read_package_dir(&layer.directory).unwrap_err();
545 assert!(error.to_string().contains("linked package entry"));
546 assert_eq!(
547 std::fs::read(package.join("fonts/optional.ttf")).unwrap(),
548 b"font bytes"
549 );
550 }
551}