feat: complete Steam execution

This commit is contained in:
2026-07-20 15:51:11 -04:00
parent bc0090c63d
commit 7d1077c0cb
13 changed files with 438 additions and 83 deletions
+91 -34
View File
@@ -347,27 +347,91 @@ pub struct LaunchOutcome {
pub success: bool,
}
/// Executes a validated native launch plan and captures stdout/stderr in a private log.
/// Result of submitting work to a provider-owned client.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SubmissionOutcome {
/// Process ID of the submitted client command.
pub process_id: u32,
}
/// Submits a validated launch plan without equating the client process with the game.
///
/// # Errors
///
/// Returns a structured error when the log cannot be secured or the command cannot start.
pub fn submit(plan: &LaunchPlanV1, log_path: &Path) -> Result<SubmissionOutcome, ContractErrorV1> {
if !plan.wrappers().is_empty() {
return Err(ContractErrorV1::unsupported_capability(
text("wrapper execution is not implemented"),
name("wrappers"),
));
}
let mut log = open_log(plan, log_path)?;
let executable = executable_path(&plan.command().executable);
let child = Command::new(executable)
.args(plan.command().arguments.iter().map(CommandArgument::as_str))
.env_clear()
.envs(
plan.environment()
.iter()
.map(|(key, value)| (key.as_str(), value.as_str())),
)
.stdin(Stdio::null())
.current_dir(plan.working_directory().as_path())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|error| launch_failed(format!("submit process: {error}")))?;
let outcome = SubmissionOutcome {
process_id: child.id(),
};
writeln!(log, "submitted {}", outcome.process_id)
.map_err(|error| unavailable(format!("write launch log: {error}")))?;
Ok(outcome)
}
/// Executes a validated launch plan and captures stdout/stderr in a private log.
///
/// # Errors
///
/// Returns a structured error when the log cannot be secured, the command cannot start,
/// or the child exits unsuccessfully.
pub fn execute(plan: &LaunchPlanV1, log_path: &Path) -> Result<LaunchOutcome, ContractErrorV1> {
let mut log = open_log(plan, log_path)?;
let (status, stdout, stderr) = spawn(plan)?;
write_redacted_output(&mut log, plan, "stdout", &stdout)?;
write_redacted_output(&mut log, plan, "stderr", &stderr)?;
writeln!(log, "exit {:?}", status.code())
.map_err(|error| unavailable(format!("write native launch log: {error}")))?;
let outcome = LaunchOutcome {
exit_code: status.code(),
success: status.success(),
};
if outcome.success {
Ok(outcome)
} else {
Err(ContractErrorV1::new(
ContractErrorCode::LaunchFailed,
text("process exited unsuccessfully; see launch log"),
))
}
}
fn open_log(plan: &LaunchPlanV1, log_path: &Path) -> Result<File, ContractErrorV1> {
let parent = log_path
.parent()
.ok_or_else(|| invalid("native log path has no parent"))?;
.ok_or_else(|| invalid("launch log path has no parent"))?;
if !log_path.is_absolute()
|| fs::symlink_metadata(log_path).is_ok_and(|m| m.file_type().is_symlink())
{
return Err(invalid("native log path is unsafe"));
return Err(invalid("launch log path is unsafe"));
}
reject_symlink_components(parent)?;
fs::create_dir_all(parent)
.map_err(|error| unavailable(format!("create native log directory: {error}")))?;
.map_err(|error| unavailable(format!("create launch log directory: {error}")))?;
reject_symlink_components(parent)?;
fs::set_permissions(parent, fs::Permissions::from_mode(DIRECTORY_MODE))
.map_err(|error| unavailable(format!("secure native log directory: {error}")))?;
.map_err(|error| unavailable(format!("secure launch log directory: {error}")))?;
let mut log = OpenOptions::new()
.create(true)
.append(true)
@@ -388,23 +452,7 @@ pub fn execute(plan: &LaunchPlanV1, log_path: &Path) -> Result<LaunchOutcome, Co
}
writeln!(log, "launch {}", plan.game_id())
.map_err(|error| unavailable(format!("write native launch log: {error}")))?;
let (status, stdout, stderr) = spawn(plan)?;
write_redacted_output(&mut log, plan, "stdout", &stdout)?;
write_redacted_output(&mut log, plan, "stderr", &stderr)?;
writeln!(log, "exit {:?}", status.code())
.map_err(|error| unavailable(format!("write native launch log: {error}")))?;
let outcome = LaunchOutcome {
exit_code: status.code(),
success: status.success(),
};
if outcome.success {
Ok(outcome)
} else {
Err(ContractErrorV1::new(
ContractErrorCode::LaunchFailed,
text("native process exited unsuccessfully; see launch log"),
))
}
Ok(log)
}
fn spawn(
@@ -412,7 +460,7 @@ fn spawn(
) -> Result<(ExitStatus, CapturedOutput, CapturedOutput), ContractErrorV1> {
if !plan.wrappers().is_empty() {
return Err(ContractErrorV1::unsupported_capability(
text("native wrapper execution is not implemented"),
text("wrapper execution is not implemented"),
name("wrappers"),
));
}
@@ -432,28 +480,28 @@ fn spawn(
.stderr(Stdio::piped());
let mut child = command
.spawn()
.map_err(|error| launch_failed(format!("start native process: {error}")))?;
.map_err(|error| launch_failed(format!("start process: {error}")))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| launch_failed("capture native stdout"))?;
.ok_or_else(|| launch_failed("capture process stdout"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| launch_failed("capture native stderr"))?;
.ok_or_else(|| launch_failed("capture process stderr"))?;
let stdout_reader = thread::spawn(move || capture_bounded(stdout));
let stderr_reader = thread::spawn(move || capture_bounded(stderr));
let status = child
.wait()
.map_err(|error| launch_failed(format!("wait for native process: {error}")))?;
.map_err(|error| launch_failed(format!("wait for process: {error}")))?;
let stdout = stdout_reader
.join()
.map_err(|_| launch_failed("native stdout capture failed"))?
.map_err(|error| launch_failed(format!("read native stdout: {error}")))?;
.map_err(|_| launch_failed("stdout capture failed"))?
.map_err(|error| launch_failed(format!("read stdout: {error}")))?;
let stderr = stderr_reader
.join()
.map_err(|_| launch_failed("native stderr capture failed"))?
.map_err(|error| launch_failed(format!("read native stderr: {error}")))?;
.map_err(|_| launch_failed("stderr capture failed"))?
.map_err(|error| launch_failed(format!("read stderr: {error}")))?;
Ok((status, stdout, stderr))
}
@@ -527,11 +575,11 @@ fn reject_symlink_components(path: &Path) -> Result<(), ContractErrorV1> {
current.push(component);
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(invalid("native log path contains a symlink"));
return Err(invalid("launch log path contains a symlink"));
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(unavailable(format!("inspect native log path: {error}"))),
Err(error) => return Err(unavailable(format!("inspect launch log path: {error}"))),
}
}
Ok(())
@@ -815,6 +863,15 @@ fn config_home(xdg: Option<&OsStr>, home: Option<&OsStr>) -> Result<PathBuf, Con
///
/// Returns a structured error when no absolute XDG state or home path is available.
pub fn current_log_path() -> Result<PathBuf, ContractErrorV1> {
current_log_path_for(&name("native"))
}
/// Resolves a provider-named private per-user launch log.
///
/// # Errors
///
/// Returns a structured error when no absolute XDG state or home path is available.
pub fn current_log_path_for(provider: &ContractName) -> Result<PathBuf, ContractErrorV1> {
let xdg = env::var_os("XDG_STATE_HOME");
let home = env::var_os("HOME");
xdg.as_deref()
@@ -827,7 +884,7 @@ pub fn current_log_path() -> Result<PathBuf, ContractErrorV1> {
.filter(|path| path.is_absolute())
.map(|path| path.join(".local/state"))
})
.map(|path| path.join("kiln/logs/native.log"))
.map(|path| path.join(format!("kiln/logs/{}.log", provider.as_str())))
.ok_or_else(|| unavailable("no absolute XDG state or home directory is available"))
}
+168 -12
View File
@@ -8,8 +8,15 @@ use std::io::Read;
use std::path::{Path, PathBuf};
use kiln_core::adapter::ProviderAdapter;
use kiln_core::config::{
ConfigurationLayerV1, ConfigurationSource, ConfigurationTier, built_in_configuration,
configuration_path_for_current_user, load_configuration, resolve_layers,
};
use kiln_core::contract_error::{AmbiguousGameCandidate, ContractErrorCode, ContractErrorV1};
use kiln_core::launch::LaunchPlanV1;
use kiln_core::launch::{
CommandArgument, CommandSpec, ControllerState, EnvironmentName, EnvironmentValue, Executable,
LaunchPlanInput, LaunchPlanV1, LifecycleConfidence,
};
use kiln_core::{
Capability, ContractName, ContractText, GameId, GameRecordInput, GameRecordV1, InstallPath,
InstallationId,
@@ -19,10 +26,11 @@ use uuid::Uuid;
const MAX_VDF_BYTES: u64 = 1024 * 1024;
const MAX_LIBRARIES: usize = 128;
const MAX_MANIFESTS: usize = 8192;
const CAPABILITIES: &[Capability] = &[Capability::Discover, Capability::Info];
const CAPABILITIES: &[Capability] = &[Capability::Discover, Capability::Info, Capability::Launch];
const RECORD_CAPABILITIES: &[Capability] = &[
Capability::Discover,
Capability::Info,
Capability::Launch,
Capability::ClientRequired,
];
const INSTALLATION_NAMESPACE: u128 = 0x72d8_017f_93a9_47b7_0000_0000_0000_0000;
@@ -32,6 +40,13 @@ const INSTALLATION_NAMESPACE: u128 = 0x72d8_017f_93a9_47b7_0000_0000_0000_0000;
pub struct SteamAdapter {
name: ContractName,
root: PathBuf,
client: SteamClient,
}
#[derive(Clone, Copy, Debug)]
enum SteamClient {
Native,
Flatpak,
}
impl SteamAdapter {
@@ -41,6 +56,7 @@ impl SteamAdapter {
Self {
name: name("steam"),
root: root.into(),
client: SteamClient::Native,
}
}
@@ -56,8 +72,15 @@ impl SteamAdapter {
.ok_or_else(|| unavailable("no absolute home directory is available"))?;
let native = home.join(".local/share/Steam");
let flatpak = home.join(".var/app/com.valvesoftware.Steam/data/Steam");
let root = if native.is_dir() { native } else { flatpak };
Ok(Self::at(root))
if native.is_dir() {
Ok(Self::at(native))
} else {
Ok(Self {
name: name("steam"),
root: flatpak,
client: SteamClient::Flatpak,
})
}
}
/// Returns matching records in deterministic App ID order.
@@ -223,14 +246,110 @@ impl ProviderAdapter for SteamAdapter {
}
}
fn plan_launch(&self, _game_id: &GameId) -> Result<LaunchPlanV1, ContractErrorV1> {
Err(ContractErrorV1::unsupported_capability(
text("Steam launch planning begins in Phase 4"),
name("launch"),
))
fn plan_launch(&self, game_id: &GameId) -> Result<LaunchPlanV1, ContractErrorV1> {
let record = self
.records()?
.into_iter()
.find(|record| record.id() == game_id)
.ok_or_else(|| not_found("Steam game was not found"))?;
let configuration_path = configuration_path_for_current_user()
.map_err(|error| invalid(format!("resolve configuration path: {error}")))?;
let mut layers = vec![built_in_configuration()];
if let Some(user) = load_configuration(
&configuration_path,
ConfigurationSource {
tier: ConfigurationTier::GlobalUser,
label: text(&configuration_path.to_string_lossy()),
},
)
.map_err(|error| invalid(format!("invalid user configuration: {error}")))?
{
layers.push(user);
}
layers.push(
ConfigurationLayerV1::parse_toml(
ConfigurationSource {
tier: ConfigurationTier::Provider,
label: text("Steam-managed runtime"),
},
"schema_version = 1\n[launch]\nruntime = \"steam-managed\"\n",
)
.expect("static Steam configuration"),
);
let resolved = resolve_layers(&layers)
.map_err(|error| invalid(format!("resolve Steam configuration: {error}")))?;
let mut arguments = match self.client {
SteamClient::Native => Vec::new(),
SteamClient::Flatpak => vec![argument("run"), argument("com.valvesoftware.Steam")],
};
arguments.extend([
argument("-silent"),
argument("-applaunch"),
argument(game_id.value()),
]);
let executable = match self.client {
SteamClient::Native => "steam",
SteamClient::Flatpak => "flatpak",
};
let mut environment = minimal_environment();
environment.extend(
resolved
.environment
.iter()
.map(|(key, value)| (key.clone(), value.value.clone())),
);
LaunchPlanV1::new(LaunchPlanInput {
game_id: record.id().clone(),
installation_id: record.installation_id(),
store: name("steam"),
adapter: name("steam"),
configuration_owner: name("steam"),
runtime: resolved.runtime.value,
session_backend: resolved.session_backend.value,
display_mode: resolved.display_mode.map(|mode| mode.value),
controller_state: ControllerState::Unknown,
working_directory: record.install_path().clone(),
environment,
wrappers: resolved
.wrappers
.into_iter()
.map(|wrapper| wrapper.value)
.collect(),
provenance: resolved.provenance,
lifecycle_confidence: LifecycleConfidence::Unknown,
command: CommandSpec {
executable: Executable::Program(name(executable)),
arguments,
},
})
.map_err(|_| invalid("Steam launch plan violated its contract"))
}
}
fn argument(raw: &str) -> CommandArgument {
raw.to_owned().try_into().expect("validated Steam argument")
}
fn minimal_environment() -> BTreeMap<EnvironmentName, EnvironmentValue> {
[
"DBUS_SESSION_BUS_ADDRESS",
"DISPLAY",
"HOME",
"LANG",
"LC_ALL",
"PATH",
"WAYLAND_DISPLAY",
"XAUTHORITY",
"XDG_RUNTIME_DIR",
]
.into_iter()
.filter_map(|key| {
let value = env::var(key).ok()?;
Some((key.parse().ok()?, value.try_into().ok()?))
})
.collect()
}
fn load_manifest(path: &Path, steamapps: &Path) -> Result<Option<GameRecordV1>, ContractErrorV1> {
let input = read_text(path, "app manifest")?;
let document = parse_vdf(&input)?;
@@ -518,9 +637,10 @@ mod tests {
use kiln_core::Capability;
use kiln_core::adapter::ProviderAdapter;
use kiln_core::contract_error::ContractErrorCode;
use kiln_core::launch::{CommandArgument, Executable};
use uuid::Uuid;
use super::SteamAdapter;
use super::{SteamAdapter, SteamClient};
struct TestDirectory(PathBuf);
@@ -597,14 +717,50 @@ mod tests {
[
Capability::Discover,
Capability::Info,
Capability::Launch,
Capability::ClientRequired
]
);
assert_eq!(adapter.search("portal").unwrap().len(), 1);
assert_eq!(adapter.resolve("Portal 2").unwrap().id(), record.id());
let plan = adapter.plan_launch(record.id()).unwrap();
assert_eq!(
adapter.plan_launch(record.id()).unwrap_err().code(),
ContractErrorCode::UnsupportedCapability
plan.lifecycle_confidence(),
kiln_core::launch::LifecycleConfidence::Unknown
);
assert!(matches!(
&plan.command().executable,
Executable::Program(program) if program.as_str() == "steam"
));
assert_eq!(
plan.command()
.arguments
.iter()
.map(CommandArgument::as_str)
.collect::<Vec<_>>(),
["-silent", "-applaunch", "620"]
);
let mut flatpak = adapter.clone();
flatpak.client = SteamClient::Flatpak;
let plan = flatpak.plan_launch(record.id()).unwrap();
assert!(matches!(
&plan.command().executable,
Executable::Program(program) if program.as_str() == "flatpak"
));
assert_eq!(
plan.command()
.arguments
.iter()
.map(CommandArgument::as_str)
.collect::<Vec<_>>(),
[
"run",
"com.valvesoftware.Steam",
"-silent",
"-applaunch",
"620"
]
);
}
+60 -24
View File
@@ -3,7 +3,7 @@
use std::env;
use std::process::ExitCode;
use kiln_adapter_native::{NativeAdapter, current_log_path, execute};
use kiln_adapter_native::{NativeAdapter, current_log_path_for, execute, submit};
use kiln_adapter_steam::SteamAdapter;
use kiln_core::adapter::ProviderAdapter;
use kiln_core::config::{
@@ -27,7 +27,7 @@ Usage:
kiln search <query> [--provider native|steam] [--json]
kiln info <id-or-name> [--provider native|steam] [--json]
kiln scan [--provider native|steam] [--no-cache] [--json]
kiln launch <id-or-name> [--dry-run] [--json]
kiln launch <id-or-name> [--provider native|steam] [--dry-run] [--json]
kiln config defaults
kiln config validate [--json]
kiln config explain [--json]
@@ -332,30 +332,66 @@ fn scan_steam(json_output: bool) -> ExitCode {
fn launch(mut arguments: Vec<String>, json_output: bool) -> ExitCode {
let dry_run = take_flag(&mut arguments, "--dry-run");
let _ = take_flag(&mut arguments, "--no-cache");
let [command, selector] = arguments.as_slice() else {
return invalid_arguments(json_output);
let (selector, provider) = match arguments.as_slice() {
[command, selector] if command == "launch" => (selector, None),
[command, selector, flag, provider] if command == "launch" && flag == "--provider" => {
(selector, Some(provider.as_str()))
}
_ => return invalid_arguments(json_output),
};
if command != "launch" {
let steam = provider == Some("steam") || provider.is_none() && selector.starts_with("steam:");
if provider.is_some_and(|value| !matches!(value, "native" | "steam"))
|| provider == Some("native") && selector.starts_with("steam:")
{
return invalid_arguments(json_output);
}
with_adapter(json_output, |adapter| {
let record = adapter.resolve(selector)?;
let plan = adapter.plan_launch(record.id())?;
if dry_run {
if json_output {
println!(
"{}",
serde_json::to_string(&plan).expect("launch plan serializes")
);
} else {
println!(
"{}",
serde_json::to_string_pretty(&plan).expect("launch plan serializes")
);
}
return Ok(());
if steam {
with_steam(json_output, |adapter| {
launch_with(adapter, selector, "steam", dry_run, json_output)
})
} else {
with_adapter(json_output, |adapter| {
launch_with(adapter, selector, "native", dry_run, json_output)
})
}
}
fn launch_with(
adapter: &impl ProviderAdapter,
selector: &str,
provider: &str,
dry_run: bool,
json_output: bool,
) -> Result<(), ContractErrorV1> {
let record = adapter.resolve(selector)?;
let plan = adapter.plan_launch(record.id())?;
if dry_run {
let output = if json_output {
serde_json::to_string(&plan)
} else {
serde_json::to_string_pretty(&plan)
}
let log = current_log_path()?;
.expect("launch plan serializes");
println!("{output}");
return Ok(());
}
let provider_name = name(provider);
let log = current_log_path_for(&provider_name)?;
if provider == "steam" {
let outcome = submit(&plan, &log)?;
if json_output {
println!(
"{}",
json!({"game_id": record.id(), "status": "submitted", "process_id": outcome.process_id, "log": log})
);
} else {
println!(
"{} submitted successfully; log: {}",
record.id(),
log.display()
);
}
} else {
let outcome = execute(&plan, &log)?;
if json_output {
println!(
@@ -369,8 +405,8 @@ fn launch(mut arguments: Vec<String>, json_output: bool) -> ExitCode {
log.display()
);
}
Ok(())
})
}
Ok(())
}
fn with_adapter(
+59 -1
View File
@@ -1,6 +1,7 @@
//! Isolated CLI contract tests for Phase 1 machine output.
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -28,11 +29,23 @@ impl TestDirectory {
}
fn run(&self, arguments: &[&str]) -> std::process::Output {
self.run_with_path(
arguments,
&format!(
"{}:{}",
self.0.join("bin").display(),
std::env::var("PATH").unwrap_or_default()
),
)
}
fn run_with_path(&self, arguments: &[&str], path: &str) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_kiln"))
.args(arguments)
.env("XDG_CONFIG_HOME", self.0.join("config"))
.env("XDG_STATE_HOME", self.0.join("state"))
.env("HOME", self.0.join("home"))
.env("PATH", path)
.output()
.expect("kiln runs")
}
@@ -66,6 +79,11 @@ impl TestDirectory {
),
)
.unwrap();
let bin = self.0.join("bin");
fs::create_dir_all(&bin).unwrap();
let steam = bin.join("steam");
fs::write(&steam, "#!/bin/sh\nprintf 'steam %s\\n' \"$*\"\n").unwrap();
fs::set_permissions(steam, fs::Permissions::from_mode(0o755)).unwrap();
fs::write(
steamapps.join(format!("appmanifest_{app_id}.acf")),
format!(
@@ -230,7 +248,7 @@ fn invalid_configuration_and_failed_launch_are_structured() {
}
#[test]
fn steam_provider_cli_discovers_searches_and_resolves_without_launching() {
fn steam_provider_cli_discovers_plans_and_submits_without_a_shell() {
let directory = TestDirectory::new();
directory.add_steam_game("620", "Portal 2");
@@ -240,6 +258,12 @@ fn steam_provider_cli_discovers_searches_and_resolves_without_launching() {
assert_eq!(records[0]["id"], "steam:620");
assert_eq!(records[0]["runtime"], "steam-managed");
assert_eq!(records[0]["client_required"], true);
assert!(
records[0]["capabilities"]
.as_array()
.unwrap()
.contains(&Value::String("launch".into()))
);
let search = directory.run(&["search", "portal", "--provider", "steam", "--json"]);
assert!(search.status.success());
@@ -260,4 +284,38 @@ fn steam_provider_cli_discovers_searches_and_resolves_without_launching() {
let result: Value = serde_json::from_slice(&scan.stdout).unwrap();
assert_eq!(result["provider"], "steam");
assert_eq!(result["discovered"], 1);
let dry_run = directory.run(&[
"launch",
"Portal 2",
"--provider",
"steam",
"--dry-run",
"--json",
]);
assert!(dry_run.status.success());
let plan: Value = serde_json::from_slice(&dry_run.stdout).unwrap();
assert_eq!(plan["runtime"], "steam-managed");
assert_eq!(plan["lifecycle_confidence"], "unknown");
assert_eq!(plan["command"]["arguments"][2], "620");
let launch = directory.run(&["launch", "steam:620", "--json"]);
assert!(launch.status.success());
let outcome: Value = serde_json::from_slice(&launch.stdout).unwrap();
assert_eq!(outcome["status"], "submitted");
assert!(outcome["process_id"].as_u64().unwrap() > 0);
assert!(
fs::read_to_string(directory.0.join("state/kiln/logs/steam.log"))
.unwrap()
.contains("submitted")
);
fs::remove_file(directory.0.join("bin/steam")).unwrap();
let failed = directory.run_with_path(
&["launch", "steam:620", "--json"],
directory.0.join("bin").to_str().unwrap(),
);
assert_eq!(failed.status.code(), Some(2));
let error: Value = serde_json::from_slice(&failed.stderr).unwrap();
assert_eq!(error["code"], "launch-failed");
}