Skip to main content

foch_cli/cli/handler/
plugin.rs

1//! `foch plugin`: install plugins, choose them per playset, and plan a launch.
2//!
3//! The CLI drives the shared management module in `foch::plugin`; the desktop
4//! application uses the same module. Launch stages a frozen plan into an
5//! isolated game layer; only the in-game host loads DLLs.
6
7use 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
34/// The store catalog, including Foch's built-in adapter manifests as available
35/// versions so they can be selected without a separate import step.
36fn 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
150/// Read a package directory into the store's archive-entry list, with paths
151/// relative to the directory root and normalized to forward slashes.
152fn 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		// Disabling an existing choice must remain possible after its installed
197		// version becomes missing or ambiguous. Preserve its pin and config.
198		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				// Keep the already-pinned version when only toggling state.
214				Some(choice) if versions.contains_key(&choice.version) => choice.version.clone(),
215				// Otherwise pin the highest installed version.
216				_ => 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
459/// Create the proxy host exclusively and write through the new handle.
460fn 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		// Preparing this empty fixture creates an ordinary directory link
537		// (a native junction on Windows) without launching any executable.
538		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}