feat: complete Steam execution
This commit is contained in:
@@ -32,6 +32,8 @@ relevant schema version is advanced.
|
|||||||
- Read-only Steam library-folder and app-manifest discovery with stable App ID identity,
|
- Read-only Steam library-folder and app-manifest discovery with stable App ID identity,
|
||||||
normalized records, provider-scoped CLI list/search/info/scan commands, hostile fixtures,
|
normalized records, provider-scoped CLI list/search/info/scan commands, hostile fixtures,
|
||||||
and local installation acceptance evidence.
|
and local installation acceptance evidence.
|
||||||
|
- Typed native and Flatpak Steam launch plans, shell-free client submission, private logs,
|
||||||
|
structured missing-client failures, and real native/Proton launch acceptance evidence.
|
||||||
|
|
||||||
### Corrected
|
### Corrected
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,14 @@ promises.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Phases 0–3 are complete: repository gates, shared contracts, native discovery/execution,
|
Phases 0–4 are complete: repository gates, shared contracts, native discovery/execution,
|
||||||
and read-only Steam discovery. Phase 4 adds Steam launch planning and representative
|
Steam discovery, typed Steam launch planning, and representative native/Proton execution.
|
||||||
native/Proton execution. The Steam commands below currently read metadata only; they do
|
Phase 5 proves the minimal graphical session and recovery path.
|
||||||
not start Steam or launch games.
|
|
||||||
|
|
||||||
## Current proof
|
## Current proof
|
||||||
|
|
||||||
The workspace contains the shared Rust core, native and read-only Steam adapters, and
|
The workspace contains the shared Rust core, native and Steam adapters, and CLI. Local
|
||||||
CLI. Local development, verification, and the minimum supported compiler are pinned to
|
development, verification, and the minimum supported compiler are pinned to
|
||||||
Rust 1.97.1:
|
Rust 1.97.1:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -28,6 +27,8 @@ kiln list --provider steam
|
|||||||
kiln search <query> --provider steam
|
kiln search <query> --provider steam
|
||||||
kiln scan --provider steam
|
kiln scan --provider steam
|
||||||
kiln info steam:<app-id>
|
kiln info steam:<app-id>
|
||||||
|
kiln launch steam:<app-id> --dry-run
|
||||||
|
kiln launch steam:<app-id>
|
||||||
kiln config defaults
|
kiln config defaults
|
||||||
kiln config validate
|
kiln config validate
|
||||||
kiln config explain --json
|
kiln config explain --json
|
||||||
@@ -47,4 +48,4 @@ The active specification is under `docs/spec/`. Start with
|
|||||||
|
|
||||||
Local Codex CLI work starts with `AGENTS.md`. Native manifest setup is documented in
|
Local Codex CLI work starts with `AGENTS.md`. Native manifest setup is documented in
|
||||||
`docs/phase-2/native-manifests.md`; completed Steam discovery evidence is under
|
`docs/phase-2/native-manifests.md`; completed Steam discovery evidence is under
|
||||||
`docs/phase-3/`.
|
`docs/phase-3/`; completed Steam execution evidence is under `docs/phase-4/`.
|
||||||
|
|||||||
@@ -347,27 +347,91 @@ pub struct LaunchOutcome {
|
|||||||
pub success: bool,
|
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
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns a structured error when the log cannot be secured, the command cannot start,
|
/// Returns a structured error when the log cannot be secured, the command cannot start,
|
||||||
/// or the child exits unsuccessfully.
|
/// or the child exits unsuccessfully.
|
||||||
pub fn execute(plan: &LaunchPlanV1, log_path: &Path) -> Result<LaunchOutcome, ContractErrorV1> {
|
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
|
let parent = log_path
|
||||||
.parent()
|
.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()
|
if !log_path.is_absolute()
|
||||||
|| fs::symlink_metadata(log_path).is_ok_and(|m| m.file_type().is_symlink())
|
|| 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)?;
|
reject_symlink_components(parent)?;
|
||||||
fs::create_dir_all(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)?;
|
reject_symlink_components(parent)?;
|
||||||
fs::set_permissions(parent, fs::Permissions::from_mode(DIRECTORY_MODE))
|
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()
|
let mut log = OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
.append(true)
|
.append(true)
|
||||||
@@ -388,23 +452,7 @@ pub fn execute(plan: &LaunchPlanV1, log_path: &Path) -> Result<LaunchOutcome, Co
|
|||||||
}
|
}
|
||||||
writeln!(log, "launch {}", plan.game_id())
|
writeln!(log, "launch {}", plan.game_id())
|
||||||
.map_err(|error| unavailable(format!("write native launch log: {error}")))?;
|
.map_err(|error| unavailable(format!("write native launch log: {error}")))?;
|
||||||
let (status, stdout, stderr) = spawn(plan)?;
|
Ok(log)
|
||||||
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"),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn(
|
fn spawn(
|
||||||
@@ -412,7 +460,7 @@ fn spawn(
|
|||||||
) -> Result<(ExitStatus, CapturedOutput, CapturedOutput), ContractErrorV1> {
|
) -> Result<(ExitStatus, CapturedOutput, CapturedOutput), ContractErrorV1> {
|
||||||
if !plan.wrappers().is_empty() {
|
if !plan.wrappers().is_empty() {
|
||||||
return Err(ContractErrorV1::unsupported_capability(
|
return Err(ContractErrorV1::unsupported_capability(
|
||||||
text("native wrapper execution is not implemented"),
|
text("wrapper execution is not implemented"),
|
||||||
name("wrappers"),
|
name("wrappers"),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -432,28 +480,28 @@ fn spawn(
|
|||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
let mut child = command
|
let mut child = command
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|error| launch_failed(format!("start native process: {error}")))?;
|
.map_err(|error| launch_failed(format!("start process: {error}")))?;
|
||||||
let stdout = child
|
let stdout = child
|
||||||
.stdout
|
.stdout
|
||||||
.take()
|
.take()
|
||||||
.ok_or_else(|| launch_failed("capture native stdout"))?;
|
.ok_or_else(|| launch_failed("capture process stdout"))?;
|
||||||
let stderr = child
|
let stderr = child
|
||||||
.stderr
|
.stderr
|
||||||
.take()
|
.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 stdout_reader = thread::spawn(move || capture_bounded(stdout));
|
||||||
let stderr_reader = thread::spawn(move || capture_bounded(stderr));
|
let stderr_reader = thread::spawn(move || capture_bounded(stderr));
|
||||||
let status = child
|
let status = child
|
||||||
.wait()
|
.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
|
let stdout = stdout_reader
|
||||||
.join()
|
.join()
|
||||||
.map_err(|_| launch_failed("native stdout capture failed"))?
|
.map_err(|_| launch_failed("stdout capture failed"))?
|
||||||
.map_err(|error| launch_failed(format!("read native stdout: {error}")))?;
|
.map_err(|error| launch_failed(format!("read stdout: {error}")))?;
|
||||||
let stderr = stderr_reader
|
let stderr = stderr_reader
|
||||||
.join()
|
.join()
|
||||||
.map_err(|_| launch_failed("native stderr capture failed"))?
|
.map_err(|_| launch_failed("stderr capture failed"))?
|
||||||
.map_err(|error| launch_failed(format!("read native stderr: {error}")))?;
|
.map_err(|error| launch_failed(format!("read stderr: {error}")))?;
|
||||||
Ok((status, stdout, stderr))
|
Ok((status, stdout, stderr))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,11 +575,11 @@ fn reject_symlink_components(path: &Path) -> Result<(), ContractErrorV1> {
|
|||||||
current.push(component);
|
current.push(component);
|
||||||
match fs::symlink_metadata(¤t) {
|
match fs::symlink_metadata(¤t) {
|
||||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
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(_) => {}
|
Ok(_) => {}
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
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(())
|
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.
|
/// Returns a structured error when no absolute XDG state or home path is available.
|
||||||
pub fn current_log_path() -> Result<PathBuf, ContractErrorV1> {
|
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 xdg = env::var_os("XDG_STATE_HOME");
|
||||||
let home = env::var_os("HOME");
|
let home = env::var_os("HOME");
|
||||||
xdg.as_deref()
|
xdg.as_deref()
|
||||||
@@ -827,7 +884,7 @@ pub fn current_log_path() -> Result<PathBuf, ContractErrorV1> {
|
|||||||
.filter(|path| path.is_absolute())
|
.filter(|path| path.is_absolute())
|
||||||
.map(|path| path.join(".local/state"))
|
.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"))
|
.ok_or_else(|| unavailable("no absolute XDG state or home directory is available"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,15 @@ use std::io::Read;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use kiln_core::adapter::ProviderAdapter;
|
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::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::{
|
use kiln_core::{
|
||||||
Capability, ContractName, ContractText, GameId, GameRecordInput, GameRecordV1, InstallPath,
|
Capability, ContractName, ContractText, GameId, GameRecordInput, GameRecordV1, InstallPath,
|
||||||
InstallationId,
|
InstallationId,
|
||||||
@@ -19,10 +26,11 @@ use uuid::Uuid;
|
|||||||
const MAX_VDF_BYTES: u64 = 1024 * 1024;
|
const MAX_VDF_BYTES: u64 = 1024 * 1024;
|
||||||
const MAX_LIBRARIES: usize = 128;
|
const MAX_LIBRARIES: usize = 128;
|
||||||
const MAX_MANIFESTS: usize = 8192;
|
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] = &[
|
const RECORD_CAPABILITIES: &[Capability] = &[
|
||||||
Capability::Discover,
|
Capability::Discover,
|
||||||
Capability::Info,
|
Capability::Info,
|
||||||
|
Capability::Launch,
|
||||||
Capability::ClientRequired,
|
Capability::ClientRequired,
|
||||||
];
|
];
|
||||||
const INSTALLATION_NAMESPACE: u128 = 0x72d8_017f_93a9_47b7_0000_0000_0000_0000;
|
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 {
|
pub struct SteamAdapter {
|
||||||
name: ContractName,
|
name: ContractName,
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
|
client: SteamClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
enum SteamClient {
|
||||||
|
Native,
|
||||||
|
Flatpak,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SteamAdapter {
|
impl SteamAdapter {
|
||||||
@@ -41,6 +56,7 @@ impl SteamAdapter {
|
|||||||
Self {
|
Self {
|
||||||
name: name("steam"),
|
name: name("steam"),
|
||||||
root: root.into(),
|
root: root.into(),
|
||||||
|
client: SteamClient::Native,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,8 +72,15 @@ impl SteamAdapter {
|
|||||||
.ok_or_else(|| unavailable("no absolute home directory is available"))?;
|
.ok_or_else(|| unavailable("no absolute home directory is available"))?;
|
||||||
let native = home.join(".local/share/Steam");
|
let native = home.join(".local/share/Steam");
|
||||||
let flatpak = home.join(".var/app/com.valvesoftware.Steam/data/Steam");
|
let flatpak = home.join(".var/app/com.valvesoftware.Steam/data/Steam");
|
||||||
let root = if native.is_dir() { native } else { flatpak };
|
if native.is_dir() {
|
||||||
Ok(Self::at(root))
|
Ok(Self::at(native))
|
||||||
|
} else {
|
||||||
|
Ok(Self {
|
||||||
|
name: name("steam"),
|
||||||
|
root: flatpak,
|
||||||
|
client: SteamClient::Flatpak,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns matching records in deterministic App ID order.
|
/// Returns matching records in deterministic App ID order.
|
||||||
@@ -223,12 +246,108 @@ impl ProviderAdapter for SteamAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn plan_launch(&self, _game_id: &GameId) -> Result<LaunchPlanV1, ContractErrorV1> {
|
fn plan_launch(&self, game_id: &GameId) -> Result<LaunchPlanV1, ContractErrorV1> {
|
||||||
Err(ContractErrorV1::unsupported_capability(
|
let record = self
|
||||||
text("Steam launch planning begins in Phase 4"),
|
.records()?
|
||||||
name("launch"),
|
.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> {
|
fn load_manifest(path: &Path, steamapps: &Path) -> Result<Option<GameRecordV1>, ContractErrorV1> {
|
||||||
@@ -518,9 +637,10 @@ mod tests {
|
|||||||
use kiln_core::Capability;
|
use kiln_core::Capability;
|
||||||
use kiln_core::adapter::ProviderAdapter;
|
use kiln_core::adapter::ProviderAdapter;
|
||||||
use kiln_core::contract_error::ContractErrorCode;
|
use kiln_core::contract_error::ContractErrorCode;
|
||||||
|
use kiln_core::launch::{CommandArgument, Executable};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::SteamAdapter;
|
use super::{SteamAdapter, SteamClient};
|
||||||
|
|
||||||
struct TestDirectory(PathBuf);
|
struct TestDirectory(PathBuf);
|
||||||
|
|
||||||
@@ -597,14 +717,50 @@ mod tests {
|
|||||||
[
|
[
|
||||||
Capability::Discover,
|
Capability::Discover,
|
||||||
Capability::Info,
|
Capability::Info,
|
||||||
|
Capability::Launch,
|
||||||
Capability::ClientRequired
|
Capability::ClientRequired
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(adapter.search("portal").unwrap().len(), 1);
|
assert_eq!(adapter.search("portal").unwrap().len(), 1);
|
||||||
assert_eq!(adapter.resolve("Portal 2").unwrap().id(), record.id());
|
assert_eq!(adapter.resolve("Portal 2").unwrap().id(), record.id());
|
||||||
|
let plan = adapter.plan_launch(record.id()).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
adapter.plan_launch(record.id()).unwrap_err().code(),
|
plan.lifecycle_confidence(),
|
||||||
ContractErrorCode::UnsupportedCapability
|
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"
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+48
-12
@@ -3,7 +3,7 @@
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::process::ExitCode;
|
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_adapter_steam::SteamAdapter;
|
||||||
use kiln_core::adapter::ProviderAdapter;
|
use kiln_core::adapter::ProviderAdapter;
|
||||||
use kiln_core::config::{
|
use kiln_core::config::{
|
||||||
@@ -27,7 +27,7 @@ Usage:
|
|||||||
kiln search <query> [--provider native|steam] [--json]
|
kiln search <query> [--provider native|steam] [--json]
|
||||||
kiln info <id-or-name> [--provider native|steam] [--json]
|
kiln info <id-or-name> [--provider native|steam] [--json]
|
||||||
kiln scan [--provider native|steam] [--no-cache] [--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 defaults
|
||||||
kiln config validate [--json]
|
kiln config validate [--json]
|
||||||
kiln config explain [--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 {
|
fn launch(mut arguments: Vec<String>, json_output: bool) -> ExitCode {
|
||||||
let dry_run = take_flag(&mut arguments, "--dry-run");
|
let dry_run = take_flag(&mut arguments, "--dry-run");
|
||||||
let _ = take_flag(&mut arguments, "--no-cache");
|
let _ = take_flag(&mut arguments, "--no-cache");
|
||||||
let [command, selector] = arguments.as_slice() else {
|
let (selector, provider) = match arguments.as_slice() {
|
||||||
return invalid_arguments(json_output);
|
[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);
|
return invalid_arguments(json_output);
|
||||||
}
|
}
|
||||||
|
if steam {
|
||||||
|
with_steam(json_output, |adapter| {
|
||||||
|
launch_with(adapter, selector, "steam", dry_run, json_output)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
with_adapter(json_output, |adapter| {
|
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 record = adapter.resolve(selector)?;
|
||||||
let plan = adapter.plan_launch(record.id())?;
|
let plan = adapter.plan_launch(record.id())?;
|
||||||
if dry_run {
|
if dry_run {
|
||||||
|
let output = if json_output {
|
||||||
|
serde_json::to_string(&plan)
|
||||||
|
} else {
|
||||||
|
serde_json::to_string_pretty(&plan)
|
||||||
|
}
|
||||||
|
.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 {
|
if json_output {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
serde_json::to_string(&plan).expect("launch plan serializes")
|
json!({"game_id": record.id(), "status": "submitted", "process_id": outcome.process_id, "log": log})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{} submitted successfully; log: {}",
|
||||||
serde_json::to_string_pretty(&plan).expect("launch plan serializes")
|
record.id(),
|
||||||
|
log.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Ok(());
|
} else {
|
||||||
}
|
|
||||||
let log = current_log_path()?;
|
|
||||||
let outcome = execute(&plan, &log)?;
|
let outcome = execute(&plan, &log)?;
|
||||||
if json_output {
|
if json_output {
|
||||||
println!(
|
println!(
|
||||||
@@ -369,8 +405,8 @@ fn launch(mut arguments: Vec<String>, json_output: bool) -> ExitCode {
|
|||||||
log.display()
|
log.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_adapter(
|
fn with_adapter(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Isolated CLI contract tests for Phase 1 machine output.
|
//! Isolated CLI contract tests for Phase 1 machine output.
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
@@ -28,11 +29,23 @@ impl TestDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run(&self, arguments: &[&str]) -> std::process::Output {
|
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"))
|
Command::new(env!("CARGO_BIN_EXE_kiln"))
|
||||||
.args(arguments)
|
.args(arguments)
|
||||||
.env("XDG_CONFIG_HOME", self.0.join("config"))
|
.env("XDG_CONFIG_HOME", self.0.join("config"))
|
||||||
.env("XDG_STATE_HOME", self.0.join("state"))
|
.env("XDG_STATE_HOME", self.0.join("state"))
|
||||||
.env("HOME", self.0.join("home"))
|
.env("HOME", self.0.join("home"))
|
||||||
|
.env("PATH", path)
|
||||||
.output()
|
.output()
|
||||||
.expect("kiln runs")
|
.expect("kiln runs")
|
||||||
}
|
}
|
||||||
@@ -66,6 +79,11 @@ impl TestDirectory {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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(
|
fs::write(
|
||||||
steamapps.join(format!("appmanifest_{app_id}.acf")),
|
steamapps.join(format!("appmanifest_{app_id}.acf")),
|
||||||
format!(
|
format!(
|
||||||
@@ -230,7 +248,7 @@ fn invalid_configuration_and_failed_launch_are_structured() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let directory = TestDirectory::new();
|
||||||
directory.add_steam_game("620", "Portal 2");
|
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]["id"], "steam:620");
|
||||||
assert_eq!(records[0]["runtime"], "steam-managed");
|
assert_eq!(records[0]["runtime"], "steam-managed");
|
||||||
assert_eq!(records[0]["client_required"], true);
|
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"]);
|
let search = directory.run(&["search", "portal", "--provider", "steam", "--json"]);
|
||||||
assert!(search.status.success());
|
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();
|
let result: Value = serde_json::from_slice(&scan.stdout).unwrap();
|
||||||
assert_eq!(result["provider"], "steam");
|
assert_eq!(result["provider"], "steam");
|
||||||
assert_eq!(result["discovered"], 1);
|
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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ R-014,12,Pegasus adapter tests,post-v1
|
|||||||
R-015,8,Lean and Ready policy tests,planned
|
R-015,8,Lean and Ready policy tests,planned
|
||||||
R-016,7,Phase 1 contracts Phase 2 native and Phase 3 Steam capability tests,in-progress
|
R-016,7,Phase 1 contracts Phase 2 native and Phase 3 Steam capability tests,in-progress
|
||||||
R-017,8,window-switching acceptance tests,planned
|
R-017,8,window-switching acceptance tests,planned
|
||||||
R-018,4,Phase 1 policy and Phase 2 owned native-process lifecycle tests,in-progress
|
R-018,4,Phase 1 policy Phase 2 owned native-process lifecycle tests and Phase 4 provider-handoff evidence,in-progress
|
||||||
R-019,15,vendor launcher tests,post-v1
|
R-019,15,vendor launcher tests,post-v1
|
||||||
R-020,6,Bluetooth CLI and TUI tests,planned
|
R-020,6,Bluetooth CLI and TUI tests,planned
|
||||||
R-021,11,installer destructive VM tests,planned
|
R-021,11,installer destructive VM tests,planned
|
||||||
|
|||||||
|
@@ -0,0 +1,28 @@
|
|||||||
|
# Phase 4 Steam execution evidence
|
||||||
|
|
||||||
|
- Date: 2026-07-20
|
||||||
|
- Requirement IDs: R-005, R-016, R-018, R-024
|
||||||
|
- Phase/gate: phase 4 / Steam execution
|
||||||
|
- Scope: typed native and Flatpak Steam launch plans, configuration provenance,
|
||||||
|
argument-safe client submission, private logs, structured errors, and representative
|
||||||
|
native plus Proton execution
|
||||||
|
- Exclusions: direct executable launch, provider-client ownership, strong game-lifetime
|
||||||
|
tracking, automatic Steam shutdown, and minimal-session behavior
|
||||||
|
- Host: Linux 7.1.4-arch1-1; NVIDIA GeForce RTX 5070 using the `nvidia` driver;
|
||||||
|
Steam 1.0.0.87-1
|
||||||
|
- Commands: `kiln launch steam:105600 --dry-run --json`; `kiln launch steam:105600
|
||||||
|
--json`; `kiln launch steam:12810 --dry-run --json`; `kiln launch steam:12810
|
||||||
|
--json`; `make check`
|
||||||
|
- Expected result: Kiln emits a typed `steam -silent -applaunch <appid>` plan, submits
|
||||||
|
it without shell interpolation, returns promptly with unknown lifecycle confidence,
|
||||||
|
launches one native and one Proton title through Steam, and reports missing clients
|
||||||
|
as structured failures
|
||||||
|
- Observed result: Terraria App ID 105600 loaded through Steam Linux Runtime and its
|
||||||
|
native `Terraria.bin.x86_64`; Overlord II App ID 12810 loaded through
|
||||||
|
`GE-Proton11-1` and `Overlord2.exe`; both returned cleanly after user exit
|
||||||
|
- Safety and cleanup: Steam was stopped before the temporary compatibility change and
|
||||||
|
final restore. The original `config.vdf` was restored byte-for-byte with SHA-256
|
||||||
|
`9251c6dbf08602857399916808208bd58dd415b5d5b000acc01f1c9044d89c37`, mode and
|
||||||
|
ownership unchanged, and Steam restarted
|
||||||
|
- Automated result: 61 tests passed with the complete local gate
|
||||||
|
- Result: pass
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Phase 4 status
|
||||||
|
|
||||||
|
Phase 4 is complete.
|
||||||
|
|
||||||
|
| Exit condition | Evidence | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| Steam launch plans | Typed native and Flatpak unit tests | Passed |
|
||||||
|
| Safe client submission | Shell-free CLI integration test with private logs | Passed |
|
||||||
|
| Failure handling | Structured missing-client integration assertion | Passed |
|
||||||
|
| Native representative | Terraria App ID 105600 through Steam Linux Runtime | Passed |
|
||||||
|
| Proton representative | Overlord II App ID 12810 through GE-Proton11-1 | Passed |
|
||||||
|
| Host cleanup | Original Steam config restored byte-for-byte; Steam restarted | Passed |
|
||||||
|
| Repository gates | `make check` | Passed |
|
||||||
|
|
||||||
|
Full provider-client lifecycle tracking remains later session-hardening work. Phase 5
|
||||||
|
can now prove the minimal compositor, terminal, login/session, and recovery path.
|
||||||
@@ -33,4 +33,4 @@ This directory is the starting point for project planning and later implementati
|
|||||||
|
|
||||||
## Integrity note
|
## Integrity note
|
||||||
|
|
||||||
The numbered topic files are the current v0.22 source of truth. `99_FULL_SPEC_ARCHIVE.md` preserves the complete monolithic v0.4 specification for historical and integrity checks, so it no longer matches the evolving numbered files byte-for-byte.
|
The numbered topic files are the current v0.23 source of truth. `99_FULL_SPEC_ARCHIVE.md` preserves the complete monolithic v0.4 specification for historical and integrity checks, so it no longer matches the evolving numbered files byte-for-byte.
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
> Performance-first Linux gaming appliance with a unified launcher backend
|
> Performance-first Linux gaming appliance with a unified launcher backend
|
||||||
|
|
||||||
| **Status** | Implementation — phases 0–3 complete; phase 4 ready |
|
| **Status** | Implementation — phases 0–4 complete; phase 5 ready |
|
||||||
|--------------------|----------------------------------------------------|
|
|--------------------|----------------------------------------------------|
|
||||||
| **Version** | 0.22 |
|
| **Version** | 0.23 |
|
||||||
| **Created** | July 17, 2026 |
|
| **Created** | July 17, 2026 |
|
||||||
| **Document type** | Living project specification |
|
| **Document type** | Living project specification |
|
||||||
| **Current target** | Purpose-built, performance-focused gaming distribution |
|
| **Current target** | Purpose-built, performance-focused gaming distribution |
|
||||||
|
|||||||
@@ -145,6 +145,7 @@
|
|||||||
| 0.20 | 2026-07-18 | Replaced the unused hosted-CI plan with mandatory local verification, made Rust 1.97.1 the minimum and reviewed compiler, and scheduled closure of the remaining configuration and adversarial-test gaps before phase 3. |
|
| 0.20 | 2026-07-18 | Replaced the unused hosted-CI plan with mandatory local verification, made Rust 1.97.1 the minimum and reviewed compiler, and scheduled closure of the remaining configuration and adversarial-test gaps before phase 3. |
|
||||||
| 0.21 | 2026-07-18 | Closed the pre-phase-3 gaps with local configuration defaults, validation, explanation and safe edits; native launch-plan configuration integration; removal of the obsolete file-lock dependency; bounded redacted process logs; and expanded adversarial tests. |
|
| 0.21 | 2026-07-18 | Closed the pre-phase-3 gaps with local configuration defaults, validation, explanation and safe edits; native launch-plan configuration integration; removal of the obsolete file-lock dependency; bounded redacted process logs; and expanded adversarial tests. |
|
||||||
| 0.22 | 2026-07-18 | Completed Phase 3 with bounded read-only Steam library and app-manifest parsing, stable App ID identities, provider-scoped CLI discovery, adversarial fixtures, and a non-launching check against the local Steam installation. |
|
| 0.22 | 2026-07-18 | Completed Phase 3 with bounded read-only Steam library and app-manifest parsing, stable App ID identities, provider-scoped CLI discovery, adversarial fixtures, and a non-launching check against the local Steam installation. |
|
||||||
|
| 0.23 | 2026-07-20 | Completed Phase 4 with typed native and Flatpak Steam launch plans, shell-free client submission, structured failures, and real native Terraria plus GE-Proton Overlord II acceptance tests. |
|
||||||
|
|
||||||
## 21. Reference notes
|
## 21. Reference notes
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ def check_spec() -> set[str]:
|
|||||||
if duplicates:
|
if duplicates:
|
||||||
fail(f"duplicate {label} IDs: {duplicates}")
|
fail(f"duplicate {label} IDs: {duplicates}")
|
||||||
versions = re.findall(r"^\| \*\*Version\*\*\s+\| ([0-9.]+)", combined, re.MULTILINE)
|
versions = re.findall(r"^\| \*\*Version\*\*\s+\| ([0-9.]+)", combined, re.MULTILINE)
|
||||||
if versions != ["0.22"]:
|
if versions != ["0.23"]:
|
||||||
fail(f"active specification version markers are stale or ambiguous: {versions}")
|
fail(f"active specification version markers are stale or ambiguous: {versions}")
|
||||||
return set(requirements)
|
return set(requirements)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user