Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ as a game launcher.
installation, preserving files needed by paused or unfinished transfers.
- Native macOS games and Windows games through pinned NotProton integration
with a separately installed CrossOver or CrossOver Preview.
- Native launch plans use Steam option IDs and installed bundle/binary/script
metadata. Auto-Cloud save paths are resolved without per-game AppID rules;
unsupported paths and launch formats produce specific diagnostics.
- Per-game launch arguments and compatibility settings, launch/stop controls,
installation removal and redacted launch diagnostics.
- Steam store and profile pages, friends and text chat, with invisible presence
Expand Down
81 changes: 68 additions & 13 deletions crates/client-core/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,17 @@ pub struct Game {
pub installed_platform: Option<String>,
pub header_url: String,
pub launch: Vec<LaunchEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cloud_config: Option<Value>,
/// False for pre-UFS caches, where None does not mean verified absence.
#[serde(default)]
pub launch_metadata_known: bool,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LaunchEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<u32>,
pub executable: String,
pub arguments: Option<String>,
pub workingdir: Option<String>,
Expand All @@ -71,6 +78,21 @@ pub struct LaunchEntry {
pub betakey: Option<String>,
}

impl From<steam_cm_protocol::LaunchEntry> for LaunchEntry {
fn from(entry: steam_cm_protocol::LaunchEntry) -> Self {
Self {
id: entry.id,
executable: entry.executable,
arguments: entry.arguments,
workingdir: entry.workingdir,
launch_type: entry.launch_type,
oslist: entry.oslist,
osarch: entry.osarch,
betakey: entry.betakey,
}
}
}

impl From<steam_cm_protocol::ProtocolGame> for Game {
fn from(game: steam_cm_protocol::ProtocolGame) -> Self {
let public_default = |entry: &&steam_cm_protocol::LaunchEntry| {
Expand Down Expand Up @@ -115,26 +137,58 @@ impl From<steam_cm_protocol::ProtocolGame> for Game {
"https://cdn.akamai.steamstatic.com/steam/apps/{}/header.jpg",
game.appid
),
launch: game
.launch
.into_iter()
.map(|entry| LaunchEntry {
executable: entry.executable,
arguments: entry.arguments,
workingdir: entry.workingdir,
launch_type: entry.launch_type,
oslist: entry.oslist,
osarch: entry.osarch,
betakey: entry.betakey,
})
.collect(),
launch: game.launch.into_iter().map(LaunchEntry::from).collect(),
cloud_config: game.cloud_config,
launch_metadata_known: true,
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn legacy_cached_game_omits_launch_ids_and_cloud_config() {
let game: Game = serde_json::from_value(serde_json::json!({
"appid": 42, "name": "Legacy", "playtime_minutes": 0, "last_played": 0,
"app_type": "game", "installdir": "Legacy", "installed_dir": null,
"macos_supported": true, "header_url": "",
"launch": [{"executable": "Legacy.app"}]
}))
.unwrap();
assert!(game.cloud_config.is_none());
assert!(!game.launch_metadata_known);
assert!(game.launch[0].id.is_none());
}

#[test]
fn game_conversion_preserves_cloud_and_original_launch_option_id() {
let ufs = serde_json::json!({"savefiles": {"8": {
"root": "MacAppSupport", "path": "Publisher/Game", "pattern": "*.sav"
}}});
let protocol = steam_cm_protocol::ProtocolGame {
appid: 42,
name: "Game".into(),
playtime_forever: 0,
rtime_last_played: 0,
img_icon_url: None,
app_type: None,
installdir: None,
cloud_config: Some(ufs.clone()),
launch: vec![steam_cm_protocol::LaunchEntry {
id: Some(19),
executable: "Game.app".into(),
..Default::default()
}],
};
let serialized = serde_json::to_value(Game::from(protocol)).unwrap();
assert_eq!(serialized["cloud_config"], ufs);
assert_eq!(serialized["launch_metadata_known"], true);
let restored: Game = serde_json::from_value(serialized.clone()).unwrap();
assert!(restored.launch_metadata_known);
assert_eq!(serialized["launch"][0]["id"], 19);
}
#[test]
fn request_password_is_never_in_response_schema() {
let req: Request = serde_json::from_str(
Expand All @@ -154,6 +208,7 @@ mod tests {
img_icon_url: None,
app_type: None,
installdir: None,
cloud_config: None,
launch: vec![steam_cm_protocol::LaunchEntry {
oslist: Some("windows, macos".into()),
..Default::default()
Expand Down
193 changes: 191 additions & 2 deletions crates/client-core/src/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,11 +405,15 @@ fn launch_entry<'a>(
dir: &Path,
windows: bool,
) -> Result<&'a crate::model::LaunchEntry> {
ensure!(
windows || game.launch_metadata_known,
"Данные запуска и сохранений игры устарели. Обновите библиотеку перед запуском macOS-версии"
);
game.launch
.iter()
.filter(|e| is_default_public(e))
.filter(|e| if windows { is_windows(e) } else { is_macos(e) })
.find(|e| {
.filter(|e| {
relative_game_path(dir, &e.executable).is_ok_and(|p| {
if windows {
p.is_file()
Expand All @@ -418,6 +422,9 @@ fn launch_entry<'a>(
}
})
})
// PICS option numbers are stable identifiers, not array positions.
// Old library caches omit them; retain their existing order until refresh.
.min_by_key(|entry| (entry.id.is_none(), entry.id.unwrap_or_default()))
.with_context(|| {
if windows {
"Не найдены установленные Windows-файлы. Установите Windows-версию через NotProton"
Expand All @@ -427,6 +434,49 @@ fn launch_entry<'a>(
})
}

fn game_with_fresh_launch_metadata(
game: &Game,
metadata: steam_cm_protocol::pics::AppCatalogInfo,
) -> Result<Game> {
ensure!(
metadata.appid == game.appid,
"Steam returned launch metadata for another game"
);
ensure!(
!metadata.name.trim().is_empty(),
"Steam returned launch metadata without a game identity"
);
let mut refreshed = game.clone();
refreshed.name = metadata.name;
refreshed.installdir = metadata.installdir;
refreshed.launch = metadata
.launch
.into_iter()
.map(crate::model::LaunchEntry::from)
.collect();
refreshed.cloud_config = metadata.cloud_config;
refreshed.launch_metadata_known = true;
Ok(refreshed)
}

async fn game_for_launch<'a>(
game: &'a Game,
windows: bool,
metadata: impl std::future::Future<Output = Result<steam_cm_protocol::pics::AppCatalogInfo>>,
) -> Result<std::borrow::Cow<'a, Game>> {
if windows {
return Ok(std::borrow::Cow::Borrowed(game));
}
// `known` distinguishes legacy caches from an explicitly empty UFS object;
// it does not prove that launch options or save paths survived a game update.
// Native launch already requires a live connection, so resolve its current
// PICS metadata each time before selecting an executable or save policy.
let metadata = metadata.await.context(
"Не удалось обновить данные запуска и сохранений. Обновите библиотеку и повторите запуск",
)?;
game_with_fresh_launch_metadata(game, metadata).map(std::borrow::Cow::Owned)
}

fn launch_arguments(
entry: &crate::model::LaunchEntry,
options: &skinos_compat::GameSettings,
Expand Down Expand Up @@ -794,6 +844,13 @@ async fn launch(
record.appid == game.appid,
"Installed game identity mismatch"
);
let game = game_for_launch(
game,
windows,
skinos_downloader::application_metadata(&context.connection, game.appid),
)
.await?;
let game = game.as_ref();
let entry = launch_entry(game, destination, windows)?;
let executable = relative_game_path(destination, &entry.executable)?;
let working_dir = match entry.workingdir.as_deref().filter(|v| !v.is_empty()) {
Expand Down Expand Up @@ -875,6 +932,7 @@ async fn launch(
let mut launch = json!({"protocol_version":1,"command":"launch","appid":game.appid,"steamid":context.steamid,
"account_name":account.account_name,"refresh_token":token.expose_secret(),"install_dir":destination,
"executable":executable,"arguments":arguments,"working_dir":working_dir,
"launch_option_id":entry.id,"cloud_config":game.cloud_config,
"name":game.name,"build_id":record.build_id,"depots":record.depots,"compatibility":compatibility,"persona_state":initial_persona});
launch["input_diagnostic"] = json!(input_diagnostic);
// A requested diagnostic temporarily enables the same input path without
Expand Down Expand Up @@ -1039,6 +1097,7 @@ mod tests {
}
fn entry(exe: &str, os: &str) -> crate::model::LaunchEntry {
crate::model::LaunchEntry {
id: None,
executable: exe.into(),
arguments: Some("--base 'a b'".into()),
workingdir: None,
Expand All @@ -1050,7 +1109,137 @@ mod tests {
}
fn game(launch: Vec<crate::model::LaunchEntry>) -> Game {
serde_json::from_value(json!({"appid":42,"name":"Game","playtime_minutes":0,"last_played":0,"app_type":"game",
"installdir":"Game","installed_dir":null,"macos_supported":true,"windows_supported":true,"header_url":"","launch":launch})).unwrap()
"installdir":"Game","installed_dir":null,"macos_supported":true,"windows_supported":true,"header_url":"","launch_metadata_known":true,"launch":launch})).unwrap()
}
#[test]
fn launch_selection_uses_original_numeric_id_after_platform_and_file_checks() {
let dir = tempfile::tempdir().unwrap();
for filename in ["high.app", "low.app", "private.app", "windows.exe"] {
std::fs::write(dir.path().join(filename), b"fixture").unwrap();
}
let mut high = entry("high.app", "macos");
high.id = Some(19);
let mut low = entry("low.app", "macos");
low.id = Some(2);
let mut missing = entry("missing.app", "macos");
missing.id = Some(0);
let mut private = entry("private.app", "macos");
private.id = Some(1);
private.betakey = Some("private".into());
let mut windows = entry("windows.exe", "windows");
windows.id = Some(0);
let g = game(vec![high, private, missing, windows, low]);
assert_eq!(launch_entry(&g, dir.path(), false).unwrap().id, Some(2));
assert_eq!(launch_entry(&g, dir.path(), true).unwrap().id, Some(0));
}

#[test]
fn legacy_launch_selection_keeps_first_matching_entry() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("first"), b"fixture").unwrap();
std::fs::write(dir.path().join("second"), b"fixture").unwrap();
let g = game(vec![entry("first", "macos"), entry("second", "macos")]);
assert_eq!(
launch_entry(&g, dir.path(), false).unwrap().executable,
"first"
);
}

#[test]
fn native_launch_rejects_legacy_unknown_cloud_metadata() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("game"), b"fixture").unwrap();
let mut g = game(vec![entry("game", "macos")]);
g.launch_metadata_known = false;
assert!(launch_entry(&g, dir.path(), false).is_err());
}

#[test]
fn fresh_single_app_metadata_resolves_legacy_unknown_and_known_no_cloud() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("game"), b"fixture").unwrap();
let mut legacy = game(vec![entry("old", "macos")]);
legacy.launch_metadata_known = false;
legacy.playtime_minutes = 123;
let metadata = || steam_cm_protocol::pics::AppCatalogInfo {
appid: legacy.appid,
name: "Fresh identity".into(),
img_icon_url: None,
app_type: Some("game".into()),
installdir: Some("Fresh folder".into()),
launch: vec![steam_cm_protocol::LaunchEntry {
id: Some(19),
executable: "game".into(),
oslist: Some("macos".into()),
..Default::default()
}],
cloud_config: None,
};
let refreshed = game_with_fresh_launch_metadata(&legacy, metadata()).unwrap();
assert!(refreshed.launch_metadata_known);
assert!(refreshed.cloud_config.is_none(), "known absence is valid");
assert_eq!(refreshed.name, "Fresh identity");
assert_eq!(refreshed.playtime_minutes, 123);
assert_eq!(
launch_entry(&refreshed, dir.path(), false).unwrap().id,
Some(19)
);
let mut wrong = metadata();
wrong.appid += 1;
assert!(game_with_fresh_launch_metadata(&legacy, wrong).is_err());
let mut with_cloud = metadata();
with_cloud.cloud_config =
Some(json!({"savefiles":{"0":{"root":"MacDocuments","path":"Fresh identity"}}}));
assert!(
game_with_fresh_launch_metadata(&legacy, with_cloud)
.unwrap()
.cloud_config
.is_some()
);
}

#[tokio::test]
async fn known_native_cache_refreshes_changed_launch_and_cloud_but_windows_stays_cached() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("new-game"), b"fixture").unwrap();
let mut stale_entry = entry("old-game", "macos");
stale_entry.id = Some(2);
let mut cached = game(vec![stale_entry]);
cached.launch_metadata_known = true;
cached.cloud_config =
Some(json!({"savefiles":{"0":{"root":"MacDocuments","path":"OldGame"}}}));
let current_cloud = json!({"savefiles":{"0":{"root":"GameInstall","path":"saves"}}});
let refreshed = game_for_launch(&cached, false, async {
Ok(steam_cm_protocol::pics::AppCatalogInfo {
appid: cached.appid,
name: cached.name.clone(),
img_icon_url: None,
app_type: Some("game".into()),
installdir: cached.installdir.clone(),
launch: vec![steam_cm_protocol::LaunchEntry {
id: Some(19),
executable: "new-game".into(),
oslist: Some("macos".into()),
..Default::default()
}],
cloud_config: Some(current_cloud.clone()),
})
})
.await
.unwrap();
let selected = launch_entry(refreshed.as_ref(), dir.path(), false).unwrap();
assert_eq!(selected.id, Some(19));
assert_eq!(selected.executable, "new-game");
assert_eq!(refreshed.cloud_config.as_ref(), Some(&current_cloud));

let windows = game_for_launch(&cached, true, async {
bail!("Windows metadata refresh must not be polled")
})
.await
.unwrap();
assert!(matches!(windows, std::borrow::Cow::Borrowed(_)));
assert_eq!(windows.launch[0].id, Some(2));
assert_eq!(windows.cloud_config, cached.cloud_config);
}
#[test]
fn detects_actual_installed_platform_and_ignores_beta_tools() {
Expand Down
14 changes: 14 additions & 0 deletions crates/client-core/vendor/steam-cm-protocol/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## SkinOS local changes - 2026-10-15

These changes apply to the vendored SkinOS copy, not an upstream release. See
[SKINOS-PATCHES.md](SKINOS-PATCHES.md) for provenance and the other local patches.

### Added

- `LaunchEntry.id` preserves the numeric PICS launch-option key. Parsed entries
are sorted by ID, retaining sparse option IDs rather than treating them as indices.
- `AppCatalogInfo.cloud_config` and `ProtocolGame.cloud_config` preserve appinfo
`ufs` save rules and root overrides, with matching text/binary scalar normalization.
- `pics::parse_app_info_kv` decodes already-fetched appinfo. Authentication and
requested-AppID validation remain the caller's responsibility.


## [v0.4.1] - 2026-06-09

Expand Down
Loading
Loading