From ee957ede52c57fece78d3e95e8cb993e17262164 Mon Sep 17 00:00:00 2001 From: Brekit <155304101+Brekit@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:36:55 +0300 Subject: [PATCH 1/5] Let users choose macOS or Windows before downloading games --- README.md | 2 + apps/macos/Sources/SkinOS/AppModel.swift | 20 +- apps/macos/Sources/SkinOS/DownloadQueue.swift | 2 + apps/macos/Sources/SkinOS/DownloadsView.swift | 4 + .../Sources/SkinOS/InstallPlatform.swift | 174 ++++++++++++++ apps/macos/Sources/SkinOS/Models.swift | 1 + apps/macos/Sources/SkinOS/SettingsView.swift | 14 +- apps/macos/Sources/SkinOS/SkinOSApp.swift | 12 +- .../SkinOSTests/InstallPlatformTests.swift | 167 ++++++++++++++ crates/client-core/src/download_queue.rs | 119 +++++++++- crates/client-core/src/operations.rs | 214 +++++++++++++++--- crates/downloader/src/lib.rs | 14 +- crates/downloader/src/library.rs | 16 +- crates/downloader/src/library/uninstall.rs | 1 + scripts/build-app.sh | 4 +- 15 files changed, 694 insertions(+), 70 deletions(-) create mode 100644 apps/macos/Sources/SkinOS/InstallPlatform.swift create mode 100644 apps/macos/Tests/SkinOSTests/InstallPlatformTests.swift diff --git a/README.md b/README.md index fe478c8..fe11272 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ as a game launcher. - Steam sign-in and saved accounts, library search, filters and favorites. - Parallel game downloads and updates, pause/resume, cancellation and queue ordering, with transfer speed, progress and estimated time remaining. +- Explicit macOS/Windows selection before downloading, with macOS preselected + when available. The chosen version is retained across pauses and updates. - Searchable Steam download regions with preferred servers and fallbacks. - Native macOS games and Windows games through pinned NotProton integration with a separately installed CrossOver or CrossOver Preview. diff --git a/apps/macos/Sources/SkinOS/AppModel.swift b/apps/macos/Sources/SkinOS/AppModel.swift index dc1749e..ad4b8e6 100644 --- a/apps/macos/Sources/SkinOS/AppModel.swift +++ b/apps/macos/Sources/SkinOS/AppModel.swift @@ -71,6 +71,7 @@ final class AppModel: ObservableObject { @Published var downloadQueueError: String? @Published var pendingDownloadControls: Set = [] @Published var pendingEnqueueGames: Set = [] + @Published var installationRequest: GameInstallationRequest? @Published var downloadLimitPending = false var downloadQueueRevision: Int64 = -1 var downloadControlRequests: [String: Int] = [:] @@ -373,24 +374,6 @@ final class AppModel: ObservableObject { } } - func install(_ game: SteamGame, update: Bool = false) { - guard account != nil else { isShowingLogin = true; return } - guard coreReady else { errorMessage = "Нет соединения с SkinOS Core. Перезапустите приложение."; return } - guard !pendingEnqueueGames.contains(game.id), !uninstallingGames.contains(game.id) else { return } - errorMessage = nil - pendingEnqueueGames.insert(game.id) - if !downloadQueueReady { - downloads[game.id] = GameDownload(id: game.id, phase: "queued", isUpdate: update) - if !downloadOrder.contains(game.id) { downloadOrder.append(game.id) } - } - if let id = send(update ? "update_game" : "install_game", payload: ["appid": game.id]) { - gameRequests[id] = game.id - } else { - pendingEnqueueGames.remove(game.id) - if !downloadQueueReady { downloads[game.id]?.failed = true } - } - } - func launch(_ game: SteamGame) { guard !launchIsActive(game) else { return } guard !steamInputBusy else { errorMessage = "Дождитесь завершения проверки Steam Input."; return } @@ -618,6 +601,7 @@ final class AppModel: ObservableObject { if !downloadQueueReady { downloads[appID] = download } if let index = games.firstIndex(where: { $0.id == appID }) { games[index].installedDirectory = payload["install_dir"] as? String ?? games[index].installedDirectory + games[index].installedPlatform = payload["platform"] as? String ?? download.platform?.rawValue ?? games[index].installedPlatform games[index].updateAvailable = false } case "launch_status": diff --git a/apps/macos/Sources/SkinOS/DownloadQueue.swift b/apps/macos/Sources/SkinOS/DownloadQueue.swift index c0d645c..ed42828 100644 --- a/apps/macos/Sources/SkinOS/DownloadQueue.swift +++ b/apps/macos/Sources/SkinOS/DownloadQueue.swift @@ -8,6 +8,7 @@ extension GameDownload { phase = state queuePosition = max(0, json["queue_position"] as? Int ?? 0) isUpdate = json["is_update"] as? Bool ?? false + platform = (json["platform"] as? String).flatMap(InstallPlatform.init(rawValue:)) progress = (json["progress"] as? NSNumber)?.doubleValue downloadedBytes = max(0, (json["downloaded_bytes"] as? NSNumber)?.int64Value ?? 0) totalBytes = max(0, (json["total_bytes"] as? NSNumber)?.int64Value ?? 0) @@ -42,6 +43,7 @@ extension AppModel { downloadQueueRevision = -1; downloadQueueError = nil; pendingDownloadControls = []; pendingEnqueueGames = [] downloadControlRequests = [:]; downloadQueueRequests = []; downloadLimitRequests = []; downloadLimitPending = false maxConcurrentDownloads = 2 + installationRequest = nil } func refreshDownloadQueue() { diff --git a/apps/macos/Sources/SkinOS/DownloadsView.swift b/apps/macos/Sources/SkinOS/DownloadsView.swift index d18eb7f..eff32eb 100644 --- a/apps/macos/Sources/SkinOS/DownloadsView.swift +++ b/apps/macos/Sources/SkinOS/DownloadsView.swift @@ -148,6 +148,10 @@ struct DownloadMeter: View { let download: GameDownload var body: some View { VStack(alignment: .leading, spacing: 8) { + if let platform = download.platform { + Label(platform.downloadLabel, systemImage: platform.symbol) + .font(.system(size: 10)).foregroundStyle(Theme.muted) + } HStack { Text(download.failed ? "Ошибка загрузки" : download.complete ? "Готово" : download.phaseDescription).lineLimit(2) Spacer() diff --git a/apps/macos/Sources/SkinOS/InstallPlatform.swift b/apps/macos/Sources/SkinOS/InstallPlatform.swift new file mode 100644 index 0000000..d0dc5c4 --- /dev/null +++ b/apps/macos/Sources/SkinOS/InstallPlatform.swift @@ -0,0 +1,174 @@ +import SwiftUI + +enum InstallPlatform: String, CaseIterable, Identifiable { + case macOS = "macos" + case windows + + var id: Self { self } + var title: String { self == .macOS ? "macOS" : "Windows" } + var symbol: String { self == .macOS ? "apple.logo" : "desktopcomputer" } + var downloadLabel: String { self == .macOS ? "Версия для macOS" : "Windows · NotProton" } + + func isSupported(by game: SteamGame) -> Bool { + self == .macOS ? game.supportsMacOS : game.supportsWindows + } +} + +extension SteamGame { + var availableInstallPlatforms: [InstallPlatform] { InstallPlatform.allCases.filter { $0.isSupported(by: self) } } + var installedVersion: InstallPlatform? { + guard isInstalled, let installedPlatform else { return nil } + return InstallPlatform(rawValue: installedPlatform) + } +} + +struct GameInstallationRequest: Identifiable { + let id = UUID() + let game: SteamGame + let accountID: String + let defaultPlatform: InstallPlatform + + init?(game: SteamGame, accountID: String) { + // Download selection is independent of saved launch/compatibility settings. + guard let platform = game.availableInstallPlatforms.first else { return nil } + self.game = game + self.accountID = accountID + defaultPlatform = platform + } +} + +@MainActor +extension AppModel { + func install(_ game: SteamGame, update: Bool = false) { + guard let account else { isShowingLogin = true; return } + guard coreReady else { errorMessage = "Нет соединения с SkinOS Core. Перезапустите приложение."; return } + guard !pendingEnqueueGames.contains(game.id), !uninstallingGames.contains(game.id), + downloads[game.id]?.isActive != true, !launchIsActive(game) else { return } + let currentGame = games.first { $0.id == game.id } ?? game + errorMessage = nil + if update { + guard currentGame.isInstalled else { return } + // The backend resolves the installed platform. Never turn an update + // into a conversion based on platform support or launch preferences. + _ = enqueueInstallation(currentGame, update: true, platform: currentGame.installedVersion) + } else { + guard !currentGame.isInstalled else { return } + installationRequest = GameInstallationRequest(game: currentGame, accountID: account.id) + if installationRequest == nil { errorMessage = "У этой игры нет версии для macOS или Windows." } + } + } + + func cancelInstallation() { installationRequest = nil } + + @discardableResult + func confirmInstallation(_ platform: InstallPlatform, requestID: UUID) -> Bool { + guard let request = installationRequest, request.id == requestID, + account?.id == request.accountID, coreReady else { return false } + let game = games.first { $0.id == request.game.id } ?? request.game + guard platform.isSupported(by: game), !game.isInstalled, + !pendingEnqueueGames.contains(game.id), !uninstallingGames.contains(game.id), + downloads[game.id]?.isActive != true, !launchIsActive(game) else { return false } + guard enqueueInstallation(game, update: false, platform: platform) else { return false } + installationRequest = nil + return true + } + + private func enqueueInstallation(_ game: SteamGame, update: Bool, platform: InstallPlatform?) -> Bool { + var payload: [String: Any] = ["appid": game.id] + if !update { + guard let platform else { return false } + payload["platform"] = platform.rawValue + } + errorMessage = nil + guard let id = send(update ? "update_game" : "install_game", payload: payload) else { return false } + pendingEnqueueGames.insert(game.id) + gameRequests[id] = game.id + if !downloadQueueReady { + downloads[game.id] = GameDownload(id: game.id, phase: "queued", isUpdate: update, platform: platform) + if !downloadOrder.contains(game.id) { downloadOrder.append(game.id) } + } + return true + } +} + +struct InstallPlatformView: View { + @ObservedObject var model: AppModel + let request: GameInstallationRequest + @State private var selectedPlatform: InstallPlatform + + init(model: AppModel, request: GameInstallationRequest) { + self.model = model + self.request = request + _selectedPlatform = State(initialValue: request.defaultPlatform) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top, spacing: 16) { + GameArtwork(url: request.game.headerURL).frame(width: 112, height: 53) + .clipShape(RoundedRectangle(cornerRadius: 8)) + VStack(alignment: .leading, spacing: 6) { + Text("УСТАНОВКА ИГРЫ").font(.system(size: 9, weight: .semibold)).tracking(1.4).foregroundStyle(Theme.muted) + Text(request.game.name).font(.system(size: 22, weight: .bold, design: .rounded)).lineLimit(2) + } + Spacer(minLength: 0) + Button { model.cancelInstallation() } label: { Image(systemName: "xmark").font(.system(size: 12)) } + .buttonStyle(.plain).foregroundStyle(Theme.muted).accessibilityLabel("Отменить установку") + }.padding(25) + Divider().overlay(Theme.divider) + VStack(alignment: .leading, spacing: 13) { + Text("Какую версию скачать?").font(.system(size: 16, weight: .semibold)) + ForEach(InstallPlatform.allCases) { platform in + platformOption(platform) + } + if selectedPlatform == .windows { + Text("Для запуска Windows-версии нужен установленный CrossOver или CrossOver Preview. NotProton уже встроен в SkinOS.") + .font(.system(size: 11)).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true) + } + Text("Папка установки: \(model.settings.steamappsDirectory)") + .font(.system(size: 10)).foregroundStyle(Theme.muted).lineLimit(2).textSelection(.enabled) + if let error = model.errorMessage { + Text(error).font(.system(size: 11)).foregroundStyle(.orange).fixedSize(horizontal: false, vertical: true) + } + }.padding(25) + Divider().overlay(Theme.divider) + HStack(spacing: 13) { + Spacer() + Button("Отмена") { model.cancelInstallation() }.keyboardShortcut(.cancelAction) + Button { model.confirmInstallation(selectedPlatform, requestID: request.id) } label: { + Label("Скачать для \(selectedPlatform.title)", systemImage: "arrow.down") + .font(.system(size: 12, weight: .semibold)).padding(.horizontal, 14).padding(.vertical, 9) + }.buttonStyle(AccentButtonStyle()).keyboardShortcut(.defaultAction) + .disabled(!model.coreReady || model.account?.id != request.accountID || !selectedPlatform.isSupported(by: request.game)) + }.padding(20) + }.frame(width: 540).background(Theme.background).preferredColorScheme(.dark) + } + + private func platformOption(_ platform: InstallPlatform) -> some View { + let supported = platform.isSupported(by: request.game) + let selected = selectedPlatform == platform + return Button { selectedPlatform = platform } label: { + HStack(spacing: 14) { + Image(systemName: platform.symbol).font(.system(size: 20)).frame(width: 28) + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + Text(platform.title).font(.system(size: 14, weight: .semibold)) + if platform == .macOS, supported { + Text("РЕКОМЕНДУЕТСЯ").font(.system(size: 8, weight: .semibold)).tracking(0.6).foregroundStyle(Theme.accent) + } + } + Text(!supported ? "Нет версии для этой платформы" : platform == .macOS ? "Версия для Mac · без CrossOver" : "Запуск через NotProton и CrossOver") + .font(.system(size: 11)).foregroundStyle(Theme.muted) + } + Spacer() + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 20)).foregroundStyle(selected ? Theme.accent : Theme.muted) + }.foregroundStyle(supported ? Color.white : Theme.muted).padding(17).frame(maxWidth: .infinity, alignment: .leading) + .background(selected ? Theme.accent.opacity(0.06) : Theme.surface, in: RoundedRectangle(cornerRadius: 11)) + .overlay(RoundedRectangle(cornerRadius: 11).stroke(selected ? Theme.accent.opacity(0.7) : Theme.divider, lineWidth: 1)) + .opacity(supported ? 1 : 0.45).contentShape(RoundedRectangle(cornerRadius: 11)) + }.buttonStyle(.plain).disabled(!supported) + .accessibilityLabel("\(platform.title), \(supported ? selected ? "выбрана" : "доступна" : "недоступна")") + .accessibilityAddTraits(selected ? .isSelected : []) + } +} diff --git a/apps/macos/Sources/SkinOS/Models.swift b/apps/macos/Sources/SkinOS/Models.swift index dd9c29e..dc00032 100644 --- a/apps/macos/Sources/SkinOS/Models.swift +++ b/apps/macos/Sources/SkinOS/Models.swift @@ -70,6 +70,7 @@ struct GameDownload: Identifiable { var complete = false var failed = false var isUpdate = false + var platform: InstallPlatform? var cancelled: Bool { phase == "cancelled" } var isActive: Bool { !complete && !failed && !cancelled } diff --git a/apps/macos/Sources/SkinOS/SettingsView.swift b/apps/macos/Sources/SkinOS/SettingsView.swift index 7127495..3ca3d70 100644 --- a/apps/macos/Sources/SkinOS/SettingsView.swift +++ b/apps/macos/Sources/SkinOS/SettingsView.swift @@ -128,7 +128,8 @@ struct GameSettingsView: View { @State private var draft = GameSettings() private var windowsSelected: Bool { - draft.compatibility == "notproton" || (draft.compatibility == "auto" && (!game.supportsMacOS || game.installedPlatform == "windows")) + if let installed = game.installedVersion { return installed == .windows } + return game.supportsWindows } var body: some View { @@ -148,14 +149,13 @@ struct GameSettingsView: View { SettingsCard(title: "Запуск", symbol: "play.circle") { VStack(alignment: .leading, spacing: 16) { HStack { - Text("Версия игры").font(.system(size: 12)) + Text(game.isInstalled ? "Установленная версия" : "Версия игры").font(.system(size: 12)) Spacer() - Picker("Версия игры", selection: $draft.compatibility) { - Text("Автоматически").tag("auto") - if game.supportsMacOS { Text("Нативная macOS").tag("native") } - if game.supportsWindows || game.installedPlatform == "windows" { Text("Windows · NotProton").tag("notproton") } - }.labelsHidden().frame(width: 225) + Text(game.installedVersion?.downloadLabel ?? (game.isInstalled ? "Определяется по файлам игры" : "Выбирается при установке")) + .font(.system(size: 12)).foregroundStyle(Theme.muted) } + Text(game.isInstalled ? "Обновления сохраняют установленную версию. Чтобы выбрать другую платформу, удалите игру и установите её заново." : "При установке можно выбрать macOS или Windows, если у игры есть обе версии.") + .font(.system(size: 10)).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true) VStack(alignment: .leading, spacing: 9) { Text("Параметры запуска").font(.system(size: 12)) TextField("Например: -windowed", text: $draft.launchArguments) diff --git a/apps/macos/Sources/SkinOS/SkinOSApp.swift b/apps/macos/Sources/SkinOS/SkinOSApp.swift index 605a390..2c1778a 100644 --- a/apps/macos/Sources/SkinOS/SkinOSApp.swift +++ b/apps/macos/Sources/SkinOS/SkinOSApp.swift @@ -137,6 +137,9 @@ struct RootView: View { .sheet(item: $model.gameForLog) { game in LaunchLogView(model: model, game: game) } + .sheet(item: $model.installationRequest) { request in + InstallPlatformView(model: model, request: request) + } } } @@ -348,7 +351,7 @@ struct GameCard: View { .overlay(RoundedRectangle(cornerRadius: 11).stroke(selected ? Theme.accent : .white.opacity(hovered ? 0.18 : 0.04), lineWidth: selected ? 2 : 1)) .overlay(alignment: .bottomLeading) { if game.isInstalled { - Label(game.updateAvailable ? "Есть обновление" : "Установлена", systemImage: game.updateAvailable ? "arrow.down.circle.fill" : "checkmark.circle.fill") + Label(game.updateAvailable ? "Есть обновление" : game.installedVersion.map { "Установлена · \($0.title)" } ?? "Установлена", systemImage: game.updateAvailable ? "arrow.down.circle.fill" : "checkmark.circle.fill") .font(.system(size: 8, weight: .semibold)) .padding(.horizontal, 7).padding(.vertical, 5).background(.black.opacity(0.65), in: Capsule()).padding(8) } @@ -358,10 +361,12 @@ struct GameCard: View { HStack { Text(game.playtime).font(.system(size: 10)).foregroundStyle(Theme.muted) Spacer() - if game.supportsMacOS { Image(systemName: "apple.logo").font(.system(size: 10)).foregroundStyle(Theme.muted) } + if let platform = game.installedVersion { + Image(systemName: platform.symbol).font(.system(size: 10)).foregroundStyle(Theme.muted).help("Установлена версия \(platform.title)") + } else if game.supportsMacOS { Image(systemName: "apple.logo").font(.system(size: 10)).foregroundStyle(Theme.muted) } } if let download, download.isActive { - Text(download.phaseDescription).font(.system(size: 9, weight: .medium)).foregroundStyle(download.phase == "paused" ? Theme.muted : Theme.accent) + Text(download.phaseDescription + (download.platform.map { " · \($0.title)" } ?? "")).font(.system(size: 9, weight: .medium)).foregroundStyle(download.phase == "paused" ? Theme.muted : Theme.accent) if let progress = download.displayProgress { ProgressView(value: progress).tint(Theme.accent).controlSize(.small) } @@ -478,6 +483,7 @@ struct GameDetailView: View { } private var platformLabel: String { + if game.installedVersion == .macOS { return "Установлена версия для macOS" } if game.installedPlatform == "windows" { return "Windows · NotProton" } if game.supportsMacOS { return "Есть версия для macOS" } return game.supportsWindows ? "Windows · NotProton" : "Нет версии для Mac или Windows" diff --git a/apps/macos/Tests/SkinOSTests/InstallPlatformTests.swift b/apps/macos/Tests/SkinOSTests/InstallPlatformTests.swift new file mode 100644 index 0000000..e90288b --- /dev/null +++ b/apps/macos/Tests/SkinOSTests/InstallPlatformTests.swift @@ -0,0 +1,167 @@ +import Foundation +import Testing +@testable import SkinOS + +@MainActor +struct InstallPlatformTests { + private func game(macOS: Bool = true, windows: Bool = true, installed: String? = nil) -> SteamGame { + var payload: [String: Any] = ["appid": 570, "name": "Fixture", "macos_supported": macOS, "windows_supported": windows] + if let installed { + payload["installed_dir"] = "/fixture/game" + payload["installed_platform"] = installed + } + return SteamGame(json: payload)! + } + + private func model(_ game: SteamGame) -> AppModel { + let model = AppModel() + model.account = SteamAccount(id: "100", accountName: "fixture", displayName: "Fixture", avatarURL: nil) + model.coreReady = true + model.games = [game] + return model + } + + @Test func macOSIsDefaultEvenWhenLaunchSettingsPreferNotProton() throws { + let game = game() + let model = model(game) + var settings = GameSettings() + settings.compatibility = "notproton" + model.gameSettings[game.id] = settings + var commands: [String] = [] + model.commandSink = { command, _ in commands.append(command); return "fixture" } + + model.install(game) + + let request = try #require(model.installationRequest) + #expect(request.defaultPlatform == .macOS) + #expect(request.game.availableInstallPlatforms == [.macOS, .windows]) + #expect(commands.isEmpty) + #expect(model.pendingEnqueueGames.isEmpty) + #expect(model.downloads.isEmpty) + } + + @Test func onlySupportedPlatformsAreSelectable() throws { + for (mac, windows, expected): (Bool, Bool, InstallPlatform) in [(true, false, .macOS), (false, true, .windows)] { + let game = game(macOS: mac, windows: windows) + let model = model(game) + var commands: [String] = [] + model.commandSink = { command, _ in commands.append(command); return "fixture" } + model.install(game) + let request = try #require(model.installationRequest) + #expect(request.defaultPlatform == expected) + #expect(game.availableInstallPlatforms == [expected]) + let unsupported: InstallPlatform = expected == .macOS ? .windows : .macOS + #expect(!model.confirmInstallation(unsupported, requestID: request.id)) + #expect(commands.isEmpty) + #expect(model.downloads.isEmpty) + } + + let unsupported = game(macOS: false, windows: false) + let model = model(unsupported) + model.install(unsupported) + #expect(model.installationRequest == nil) + #expect(model.errorMessage != nil) + } + + @Test func cancellingPickerNeverEnqueuesAndStaleConfirmationIsIgnored() throws { + let game = game() + let model = model(game) + var commands: [String] = [] + model.commandSink = { command, _ in commands.append(command); return "fixture" } + model.install(game) + let first = try #require(model.installationRequest) + model.cancelInstallation() + #expect(!model.confirmInstallation(.windows, requestID: first.id)) + model.install(game) + #expect(!model.confirmInstallation(.macOS, requestID: first.id)) + #expect(commands.isEmpty) + #expect(model.downloads.isEmpty) + #expect(model.pendingEnqueueGames.isEmpty) + } + + @Test func confirmationSendsExactChosenPlatformAndOptimisticRowMatchesIt() throws { + for platform in InstallPlatform.allCases { + let game = game() + let model = model(game) + var commands: [(String, [String: Any])] = [] + model.commandSink = { command, payload in commands.append((command, payload)); return "fixture" } + model.install(game) + let request = try #require(model.installationRequest) + + #expect(model.confirmInstallation(platform, requestID: request.id)) + + #expect(commands.count == 1) + #expect(commands.first?.0 == "install_game") + #expect(commands.first?.1["appid"] as? Int == game.id) + #expect(commands.first?.1["platform"] as? String == platform.rawValue) + #expect(model.downloads[game.id]?.platform == platform) + #expect(model.pendingEnqueueGames.contains(game.id)) + #expect(model.installationRequest == nil) + #expect(!model.confirmInstallation(platform, requestID: request.id)) + #expect(commands.count == 1) + } + } + + @Test func updatePreservesInstalledVersionAndDoesNotPresentPicker() { + for platform in InstallPlatform.allCases { + let game = game(installed: platform.rawValue) + let model = model(game) + var settings = GameSettings() + settings.compatibility = platform == .macOS ? "notproton" : "native" + model.gameSettings[game.id] = settings + var commands: [(String, [String: Any])] = [] + model.commandSink = { command, payload in commands.append((command, payload)); return "fixture" } + + model.install(game, update: true) + + #expect(model.installationRequest == nil) + #expect(commands.count == 1) + #expect(commands.first?.0 == "update_game") + #expect(commands.first?.1["platform"] == nil) + #expect(model.downloads[game.id]?.platform == platform) + #expect(model.downloads[game.id]?.isUpdate == true) + } + } + + @Test func accountChangeOrNewInstalledVersionInvalidatesOpenPicker() throws { + let game = game() + let model = model(game) + var commands: [String] = [] + model.commandSink = { command, _ in commands.append(command); return "fixture" } + model.install(game) + let request = try #require(model.installationRequest) + model.account = SteamAccount(id: "200", accountName: "other", displayName: "Other", avatarURL: nil) + #expect(!model.confirmInstallation(.macOS, requestID: request.id)) + model.account = SteamAccount(id: "100", accountName: "fixture", displayName: "Fixture", avatarURL: nil) + model.games[0].installedDirectory = "/fixture/game" + model.games[0].installedPlatform = "windows" + #expect(!model.confirmInstallation(.macOS, requestID: request.id)) + #expect(commands.isEmpty) + } + + @Test func queueAndCompletionShowActualVersionInsteadOfStoreAvailability() throws { + let game = game() + let model = model(game) + model.receive(["event": "download_queue", "payload": [ + "steamid": "100", "revision": 1, "items": [["appid": 570, "state": "downloading", "platform": "windows"]] + ]]) + #expect(model.downloads[570]?.platform == .windows) + model.receive(["event": "install_complete", "payload": ["appid": 570, "install_dir": "/fixture/game"]]) + #expect(model.games[0].installedVersion == .windows) + #expect(model.games[0].supportsMacOS) + let legacy = try #require(GameDownload(queueJSON: ["appid": 570, "state": "paused"])) + #expect(legacy.platform == nil) + } + + @Test func failedSendLeavesPickerAvailableAndDoesNotInventQueuedDownload() throws { + let game = game() + let model = model(game) + model.commandSink = { _, _ in nil } + model.install(game) + let request = try #require(model.installationRequest) + #expect(!model.confirmInstallation(.macOS, requestID: request.id)) + #expect(model.installationRequest?.id == request.id) + #expect(model.downloads.isEmpty) + #expect(model.pendingEnqueueGames.isEmpty) + } +} diff --git a/crates/client-core/src/download_queue.rs b/crates/client-core/src/download_queue.rs index b339764..50e6ac1 100644 --- a/crates/client-core/src/download_queue.rs +++ b/crates/client-core/src/download_queue.rs @@ -3,7 +3,7 @@ use crate::model::{Event, Request}; use anyhow::{Context, Result, bail, ensure}; use serde::{Deserialize, Serialize}; use serde_json::json; -use skinos_downloader::{InstallControl, InstallInterrupted}; +use skinos_downloader::{InstallControl, InstallInterrupted, InstallPlatform}; use std::{ collections::HashMap, future::Future, path::PathBuf, pin::Pin, sync::Arc, time::Duration, }; @@ -18,10 +18,11 @@ pub struct DownloadJob { pub steamid: String, pub appid: u32, pub is_update: bool, + pub platform: Option, pub request_id: Option, } pub trait DownloadExecutor: Send + Sync + 'static { - fn validate(&self, job: &DownloadJob) -> Result<()>; + fn validate(&self, job: &DownloadJob) -> Result; fn can_start(&self, job: &DownloadJob) -> bool; fn resource_key(&self, job: &DownloadJob) -> Result; fn run( @@ -68,6 +69,7 @@ pub struct DownloadItem { pub state: DownloadState, pub queue_position: usize, pub is_update: bool, + pub platform: Option, pub progress: Option, pub downloaded_bytes: u64, pub total_bytes: u64, @@ -97,6 +99,7 @@ impl DownloadItem { state, queue_position: 0, is_update, + platform: None, progress: None, downloaded_bytes: 0, total_bytes: 0, @@ -122,6 +125,7 @@ impl DownloadItem { steamid: steamid.into(), appid: self.appid, is_update: self.is_update, + platform: self.platform, request_id: self.request_id.clone(), } } @@ -191,6 +195,8 @@ impl DownloadItem { struct Intent { appid: u32, is_update: bool, + #[serde(default)] + platform: Option, state: DownloadState, #[serde(default)] downloaded_bytes: u64, @@ -376,6 +382,16 @@ impl Actor { intent.state }; let mut row = DownloadItem::new(intent.appid, intent.is_update, state); + row.platform = intent.platform; + // Old queues did not record a platform. Resolve once using game + // evidence/defaults, never compatibility preferences, and keep + // interrupted jobs paused until the user resumes them. + if row.platform.is_none() { + match actor.executor.validate(&row.job(&actor.steamid)) { + Ok(platform) => row.platform = Some(platform), + Err(error) => row.error = Some(error.to_string()), + } + } row.downloaded_bytes = intent.downloaded_bytes; row.total_bytes = intent.total_bytes; if row.total_bytes > 0 { @@ -427,6 +443,7 @@ impl Actor { .map(|row| Intent { appid: row.appid, is_update: row.is_update, + platform: row.platform, state: row.state, downloaded_bytes: row.downloaded_bytes, total_bytes: row.total_bytes, @@ -570,7 +587,15 @@ impl Actor { DownloadState::Queued, ); row.request_id = Some(request.id.clone()); - self.executor.validate(&row.job(&self.steamid))?; + row.platform = request + .payload + .get("platform") + .map(|value| { + serde_json::from_value(value.clone()) + .context("Версия игры должна быть macos или windows") + }) + .transpose()?; + row.platform = Some(self.executor.validate(&row.job(&self.steamid))?); if let Some(index) = position { self.items.remove(index); } @@ -579,7 +604,7 @@ impl Actor { let _ = self.events.send(Event::new( Some(&request.id), "download_enqueued", - json!({"appid":appid,"queue_id":format!("{}-{appid}",self.steamid)}), + json!({"appid":appid,"queue_id":format!("{}-{appid}",self.steamid),"platform":self.items.last().and_then(|row| row.platform)}), )); } "pause_download" | "cancel_download" => { @@ -629,8 +654,10 @@ impl Actor { ), "Эта загрузка уже выполняется или ожидает запуска" ); - self.executor - .validate(&self.items[index].job(&self.steamid))?; + self.items[index].platform = Some( + self.executor + .validate(&self.items[index].job(&self.steamid))?, + ); self.items[index].state = DownloadState::Queued; self.items[index].error = None; self.items[index].message = Some("Ожидание запуска".into()); @@ -935,9 +962,9 @@ mod tests { resources: HashMap, } impl DownloadExecutor for Mock { - fn validate(&self, job: &DownloadJob) -> Result<()> { + fn validate(&self, job: &DownloadJob) -> Result { ensure!(job.steamid == self.account, "wrong account"); - Ok(()) + Ok(job.platform.unwrap_or(InstallPlatform::Macos)) } fn can_start(&self, _: &DownloadJob) -> bool { true @@ -1292,6 +1319,7 @@ mod tests { Intent { appid: 1, is_update: false, + platform: None, state: DownloadState::Cancelling, downloaded_bytes: 12, total_bytes: 50, @@ -1300,6 +1328,7 @@ mod tests { Intent { appid: 2, is_update: false, + platform: None, state: DownloadState::Pausing, downloaded_bytes: 20, total_bytes: 70, @@ -1316,6 +1345,76 @@ mod tests { assert_eq!(actor.items[0].downloaded_bytes, 12); } #[test] + fn explicit_platform_survives_queue_restart_and_resume() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("queue.json"); + let (executor, _) = mock(ACCOUNT, HashMap::new()); + let (tx, _) = mpsc::unbounded_channel(); + let mut actor = + Actor::load(ACCOUNT.into(), path.clone(), executor.clone(), tx.clone()).unwrap(); + actor + .apply(&request( + "enqueue", + "install_game", + json!({"appid":1,"platform":"windows"}), + )) + .unwrap(); + assert_eq!(actor.items[0].platform, Some(InstallPlatform::Windows)); + let saved: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved["items"][0]["platform"], "windows"); + drop(actor); + // The executor's automatic default is macOS. A restart/resume must + // retain the explicit Windows selection rather than recompute it. + let mut actor = Actor::load(ACCOUNT.into(), path, executor, tx).unwrap(); + assert_eq!(actor.items[0].state, DownloadState::Paused); + assert_eq!(actor.items[0].platform, Some(InstallPlatform::Windows)); + actor + .apply(&request( + "resume", + "resume_download", + json!({"appid":1,"platform":"macos"}), + )) + .unwrap(); + let job = actor.items[0].job(ACCOUNT); + assert_eq!(job.platform, Some(InstallPlatform::Windows)); + assert_eq!(actor.items[0].state, DownloadState::Queued); + } + #[test] + fn legacy_queue_freezes_default_while_paused_and_invalid_platform_cannot_enqueue() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("queue.json"); + std::fs::write( + &path, + serde_json::to_vec(&json!({ + "steamid": ACCOUNT, "max_concurrent": 2, "revision": 1, + "items": [{"appid":1,"is_update":false,"state":"downloading"}] + })) + .unwrap(), + ) + .unwrap(); + let (executor, _) = mock(ACCOUNT, HashMap::new()); + let (tx, _) = mpsc::unbounded_channel(); + let mut actor = Actor::load(ACCOUNT.into(), path.clone(), executor, tx).unwrap(); + assert_eq!(actor.items[0].state, DownloadState::Paused); + assert_eq!(actor.items[0].platform, Some(InstallPlatform::Macos)); + let saved: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved["items"][0]["platform"], "macos"); + for invalid in [json!("linux"), json!("unknown"), json!(null), json!(42)] { + assert!( + actor + .apply(&request( + "invalid", + "install_game", + json!({"appid":2,"platform":invalid}) + )) + .is_err() + ); + assert_eq!(actor.items.len(), 1); + } + } + #[test] fn failed_persistence_rolls_back_enqueue_resume_reorder_and_limit() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("queue.json"); @@ -1422,8 +1521,8 @@ mod tests { active: Arc, } impl DownloadExecutor for DrainExecutor { - fn validate(&self, _: &DownloadJob) -> Result<()> { - Ok(()) + fn validate(&self, job: &DownloadJob) -> Result { + Ok(job.platform.unwrap_or(InstallPlatform::Macos)) } fn can_start(&self, _: &DownloadJob) -> bool { true diff --git a/crates/client-core/src/operations.rs b/crates/client-core/src/operations.rs index a916772..16bf113 100644 --- a/crates/client-core/src/operations.rs +++ b/crates/client-core/src/operations.rs @@ -72,7 +72,10 @@ impl MvpDownloader { } } impl crate::download_queue::DownloadExecutor for MvpDownloader { - fn validate(&self, job: &crate::download_queue::DownloadJob) -> Result<()> { + fn validate( + &self, + job: &crate::download_queue::DownloadJob, + ) -> Result { ensure!( job.steamid == self.context.steamid, "Download account changed" @@ -88,9 +91,9 @@ impl crate::download_queue::DownloadExecutor for MvpDownloader { if job.is_update { ensure!(game.installed_dir.is_some(), "Сначала установите игру"); } - platform(&game, &settings::game(job.appid)?)?; + let platform = download_platform(&game, job.platform, job.is_update)?; self.destination(job.appid)?; - Ok(()) + Ok(platform) } fn can_start(&self, job: &crate::download_queue::DownloadJob) -> bool { if self @@ -152,7 +155,13 @@ impl crate::download_queue::DownloadExecutor for MvpDownloader { ensure!(game.installed_dir.is_some(), "Сначала установите игру"); } let config = settings::load()?; - let platform = platform(&game, &settings::game(job.appid)?)?; + let platform = job + .platform + .context("Выберите версию игры перед загрузкой")?; + ensure!( + download_platform(&game, Some(platform), job.is_update)? == platform, + "Версия установленной игры изменилась; обновите очередь" + ); let destination = skinos_downloader::library::installation_directory( &config.steamapps_dir, job.appid, @@ -182,7 +191,7 @@ impl crate::download_queue::DownloadExecutor for MvpDownloader { control, ) .await?; - let _=events.send(Event::new(job.request_id.as_deref(),"install_complete",json!({"appid":job.appid,"install_dir":record.install_dir,"build_id":record.build_id}))); + let _=events.send(Event::new(job.request_id.as_deref(),"install_complete",json!({"appid":job.appid,"install_dir":record.install_dir,"build_id":record.build_id,"platform":record.platform}))); if let Some(game) = context .games .iter_mut() @@ -287,36 +296,54 @@ fn is_default_public(entry: &crate::model::LaunchEntry) -> bool { .is_none_or(|v| v.is_empty() || v == "default") && entry.betakey.as_deref().is_none_or(str::is_empty) } +fn is_macho_file(path: &Path) -> bool { + use std::io::Read; + let mut magic = [0u8; 4]; + if !std::fs::File::open(path).is_ok_and(|mut file| file.read_exact(&mut magic).is_ok()) { + return false; + } + matches!( + u32::from_be_bytes(magic), + 0xfeedface + | 0xcefaedfe + | 0xfeedfacf + | 0xcffaedfe + | 0xcafebabe + | 0xbebafeca + | 0xcafebabf + | 0xbfbafeca + ) +} + fn installed_platform(game: &Game, dir: &Path) -> Option { - // Ignore tools/editors, beta branches and Linux launch entries. A native app - // bundle is unambiguous even when PICS lists several OSes on one entry. - let available = game - .launch + // Common depots may include macOS wrapper scripts in a Windows install. + // Prefer actual native app/binary evidence, then a Windows executable, and + // use a wrapper only when neither platform has a more specific artifact. + game.launch .iter() .filter(|e| is_default_public(e)) .filter_map(|e| { let path = relative_game_path(dir, &e.executable).ok()?; if is_macos(e) && path.extension().is_some_and(|v| v == "app") && path.is_dir() { - Some("macos") + Some((3, "macos")) + } else if !path.is_file() { + None + } else if is_macos(e) && is_macho_file(&path) { + Some((3, "macos")) } else if is_windows(e) - && path.is_file() && path .extension() .is_some_and(|v| v.eq_ignore_ascii_case("exe")) { - Some("windows") - } else if is_macos(e) && path.is_file() { - Some("macos") + Some((2, "windows")) + } else if is_macos(e) { + Some((1, "macos")) } else { None } }) - .collect::>(); - if available.contains(&"macos") { - Some("macos".into()) - } else { - available.first().map(|v| (*v).into()) - } + .max_by_key(|(specificity, _)| *specificity) + .map(|(_, platform)| platform.to_owned()) } fn launch_entry<'a>( @@ -389,9 +416,11 @@ pub fn annotate_installed(games: &mut [Game]) { .as_ref() .map(|r| r.install_dir.to_string_lossy().into_owned()); game.installed_build_id = record.as_ref().map(|r| r.build_id.clone()); - game.installed_platform = record - .as_ref() - .and_then(|r| installed_platform(game, &r.install_dir)); + game.installed_platform = record.as_ref().and_then(|r| { + r.platform + .map(|p| p.os().to_owned()) + .or_else(|| installed_platform(game, &r.install_dir)) + }); game.update_available = game .available_build_id .as_ref() @@ -400,11 +429,59 @@ pub fn annotate_installed(games: &mut [Game]) { } } +/// The download version is an installation choice, independent of launcher +/// preferences. An installed variant cannot be converted by an update or retry. +fn download_platform( + game: &Game, + requested: Option, + is_update: bool, +) -> Result { + use skinos_downloader::InstallPlatform; + let installed = match game.installed_platform.as_deref() { + Some("macos") => Some(InstallPlatform::Macos), + Some("windows") => Some(InstallPlatform::Windows), + Some(_) => bail!("Неизвестная версия установленной игры"), + None => None, + }; + let selected = if game.installed_dir.is_some() || is_update { + let installed = installed.context( + "Не удалось определить версию установленной игры. Обновите библиотеку перед загрузкой", + )?; + ensure!( + requested.is_none_or(|requested| requested == installed), + "Установлена другая версия игры. Сначала удалите её с устройства" + ); + installed + } else { + requested.unwrap_or(if game.macos_supported { + InstallPlatform::Macos + } else { + InstallPlatform::Windows + }) + }; + ensure!( + match selected { + InstallPlatform::Macos => game.macos_supported, + InstallPlatform::Windows => game.windows_supported, + }, + "У игры нет версии для выбранной платформы" + ); + Ok(selected) +} + fn platform( game: &Game, settings: &skinos_compat::GameSettings, ) -> Result { use skinos_downloader::InstallPlatform; + // There is one installed variant per app. A stale launch preference must + // never reinterpret native files as Windows content (or vice versa). + match game.installed_platform.as_deref() { + Some("macos") => return Ok(InstallPlatform::Macos), + Some("windows") => return Ok(InstallPlatform::Windows), + Some(_) => bail!("Неизвестная версия установленной игры"), + None => {} + } match settings.compatibility.as_str() { "notproton" => { ensure!(game.windows_supported, "У игры нет Windows-варианта"); @@ -414,9 +491,7 @@ fn platform( ensure!(game.macos_supported, "У игры нет macOS-варианта"); Ok(InstallPlatform::Macos) } - "auto" - if game.installed_platform.as_deref() == Some("windows") || !game.macos_supported => - { + "auto" if !game.macos_supported => { ensure!(game.windows_supported, "Нет совместимого варианта запуска"); Ok(InstallPlatform::Windows) } @@ -463,11 +538,10 @@ impl OperationAdapter for MvpOperations { g.installed_dir.is_some() && only.is_none_or(|id| id == u64::from(g.appid)) }) { let installed = installed(game).context("Установленная игра недоступна")?; - let options = settings::game(game.appid)?; let build = skinos_downloader::latest_build( &context.connection, game.appid, - platform(game, &options)?, + download_platform(game, None, true)?, ) .await?; updates.push(json!({"appid":game.appid,"installed_build_id":installed.build_id,"available_build_id":build,"update_available":installed.build_id!=build})); @@ -957,6 +1031,32 @@ mod tests { ); } #[test] + fn legacy_common_mac_wrapper_does_not_override_actual_windows_install() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("game.sh"), b"#!/bin/sh\nexec game_osx\n").unwrap(); + std::fs::write(dir.path().join("game.exe"), b"MZ").unwrap(); + let g = game(vec![ + entry("game.sh", "macos"), + entry("game.exe", "windows"), + entry("game_osx", "macos"), + entry("Game.app", "macos"), + ]); + // A shared wrapper alone is not evidence that native game files exist. + assert_eq!( + installed_platform(&g, dir.path()).as_deref(), + Some("windows") + ); + std::fs::write(dir.path().join("game_osx"), [0xcf, 0xfa, 0xed, 0xfe]).unwrap(); + assert_eq!(installed_platform(&g, dir.path()).as_deref(), Some("macos")); + std::fs::remove_file(dir.path().join("game_osx")).unwrap(); + std::fs::create_dir(dir.path().join("Game.app")).unwrap(); + assert_eq!(installed_platform(&g, dir.path()).as_deref(), Some("macos")); + std::fs::remove_dir(dir.path().join("Game.app")).unwrap(); + std::fs::remove_file(dir.path().join("game.exe")).unwrap(); + // Some games expose only their wrapper in PICS; keep that fallback. + assert_eq!(installed_platform(&g, dir.path()).as_deref(), Some("macos")); + } + #[test] fn native_arguments_append_once_and_windows_custom_is_left_to_notproton() { let options = skinos_compat::GameSettings { launch_arguments: "--custom 'c d'".into(), @@ -982,6 +1082,7 @@ mod tests { InstallPlatform::Windows ); g.macos_supported = false; + g.installed_platform = None; let native = skinos_compat::GameSettings { compatibility: "native".into(), ..Default::default() @@ -989,6 +1090,63 @@ mod tests { assert!(platform(&g, &native).is_err()); } #[test] + fn install_choice_is_native_first_independent_of_launch_preferences() { + use skinos_downloader::InstallPlatform::{Macos, Windows}; + let mut g = game(vec![ + entry("Game.app", "macos"), + entry("game.exe", "windows"), + ]); + let notproton = skinos_compat::GameSettings { + compatibility: "notproton".into(), + ..Default::default() + }; + assert_eq!(platform(&g, ¬proton).unwrap(), Windows); + assert_eq!(download_platform(&g, None, false).unwrap(), Macos); + assert_eq!( + download_platform(&g, Some(Windows), false).unwrap(), + Windows + ); + assert_eq!(download_platform(&g, Some(Macos), false).unwrap(), Macos); + g.windows_supported = false; + assert!(download_platform(&g, Some(Windows), false).is_err()); + g.windows_supported = true; + g.macos_supported = false; + assert_eq!(download_platform(&g, None, false).unwrap(), Windows); + assert!(download_platform(&g, Some(Macos), false).is_err()); + g.windows_supported = false; + assert!(download_platform(&g, None, false).is_err()); + } + #[test] + fn updates_and_launch_follow_installed_variant_without_rewriting_preferences() { + use skinos_downloader::InstallPlatform::{Macos, Windows}; + let mut g = game(vec![ + entry("Game.app", "macos"), + entry("game.exe", "windows"), + ]); + g.installed_dir = Some("/unused/test/Game".into()); + for (installed, other, stale_preference) in + [(Macos, Windows, "notproton"), (Windows, Macos, "native")] + { + g.installed_platform = Some(installed.os().into()); + let settings = skinos_compat::GameSettings { + compatibility: stale_preference.into(), + ..Default::default() + }; + assert_eq!(platform(&g, &settings).unwrap(), installed); + assert_eq!(download_platform(&g, None, true).unwrap(), installed); + assert_eq!( + download_platform(&g, Some(installed), true).unwrap(), + installed + ); + assert!(download_platform(&g, Some(other), true).is_err()); + assert!(download_platform(&g, Some(other), false).is_err()); + assert_eq!(settings.compatibility, stale_preference); + } + g.installed_platform = None; + assert!(download_platform(&g, None, true).is_err()); + assert!(download_platform(&g, Some(Macos), true).is_err()); + } + #[test] fn selected_executable_cannot_escape_game_through_symlink() { let root = tempfile::tempdir().unwrap(); let outside = tempfile::tempdir().unwrap(); diff --git a/crates/downloader/src/lib.rs b/crates/downloader/src/lib.rs index 0a94c7e..5bd8c32 100644 --- a/crates/downloader/src/lib.rs +++ b/crates/downloader/src/lib.rs @@ -41,7 +41,7 @@ pub enum InstallPlatform { Windows, } impl InstallPlatform { - fn os(self) -> &'static str { + pub fn os(self) -> &'static str { match self { Self::Macos => "macos", Self::Windows => "windows", @@ -74,6 +74,9 @@ pub struct InstalledGame { pub steamid: String, pub install_dir: PathBuf, pub build_id: String, + /// Absent on imports/older installations; callers may infer from files. + #[serde(default)] + pub platform: Option, pub depots: Vec, } @@ -300,8 +303,16 @@ pub async fn install( && record.install_dir.canonicalize()? == destination.canonicalize()?, "Destination belongs to another game" ); + ensure!( + record + .platform + .is_none_or(|installed| installed == request.platform), + "Установлена другая версия игры. Сначала удалите её с устройства" + ); } + // Keep the stable cache name so upgrades resume existing partial content. + // Every reused chunk is verified against the selected platform's manifest. let staging = parent.join(format!(".skinos-{}-{}.partial", request.appid, build_id)); ensure!( !staging.is_symlink(), @@ -427,6 +438,7 @@ pub async fn install( steamid: request.steamid, install_dir: destination.clone(), build_id, + platform: Some(request.platform), depots: selections .iter() .map(|d| InstalledDepot { diff --git a/crates/downloader/src/library.rs b/crates/downloader/src/library.rs index 9ae2c71..a86e609 100644 --- a/crates/downloader/src/library.rs +++ b/crates/downloader/src/library.rs @@ -1,6 +1,6 @@ //! Interoperable Steam library discovery and atomic appmanifest updates. mod uninstall; -use crate::{InstalledDepot, InstalledGame, safe_relative}; +use crate::{InstallPlatform, InstalledDepot, InstalledGame, safe_relative}; use anyhow::{Context, Result, ensure}; use keyvalues_parser::{Obj, Value, Vdf}; use std::{ @@ -141,6 +141,11 @@ pub fn read_appmanifest(steamapps: &Path, appid: u32) -> Result { steamid: text(obj, "LastOwner").unwrap_or_default().into(), install_dir, build_id: text(obj, "buildid").unwrap_or("0").into(), + platform: match text(obj, "SkinOSInstallPlatform") { + Some("macos") => Some(InstallPlatform::Macos), + Some("windows") => Some(InstallPlatform::Windows), + _ => None, + }, depots, }) } @@ -210,6 +215,9 @@ pub(crate) fn write_appmanifest( ] { set_text(obj, key, val); } + if let Some(platform) = record.platform { + set_text(obj, "SkinOSInstallPlatform", platform.os()); + } let mut depots = Obj::new(); for depot in &record.depots { let mut entry = Obj::new(); @@ -259,11 +267,17 @@ mod tests { std::fs::write(manifest_path(dir.path(), 42), old).unwrap(); let mut record = read_appmanifest(dir.path(), 42).unwrap(); assert_eq!(record.depots[0].manifest_id, "11"); + assert_eq!(record.platform, None); record.build_id = "2".into(); + record.platform = Some(InstallPlatform::Windows); write_appmanifest(dir.path(), &record, "Game", Some(old), 10, 4, &[(43, 10)]).unwrap(); let updated = std::fs::read_to_string(manifest_path(dir.path(), 42)).unwrap(); assert!(updated.contains("russian")); assert_eq!(read_appmanifest(dir.path(), 42).unwrap().build_id, "2"); + assert_eq!( + read_appmanifest(dir.path(), 42).unwrap().platform, + Some(InstallPlatform::Windows) + ); assert!(write_appmanifest(dir.path(), &record, "Game", Some(old), 10, 4, &[]).is_err()); assert_eq!( std::fs::read_dir(dir.path().join("skinos-backups")) diff --git a/crates/downloader/src/library/uninstall.rs b/crates/downloader/src/library/uninstall.rs index 52f07c6..cfe34c5 100644 --- a/crates/downloader/src/library/uninstall.rs +++ b/crates/downloader/src/library/uninstall.rs @@ -596,6 +596,7 @@ mod tests { fs::create_dir_all(&directory).unwrap(); let record = crate::InstalledGame { appid, + platform: None, steamid: "1".into(), install_dir: directory.canonicalize().unwrap(), build_id: "1".into(), diff --git a/scripts/build-app.sh b/scripts/build-app.sh index bbbc80a..483e580 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -229,8 +229,8 @@ cat > "$SKINOS_OUTPUT/Contents/Info.plist" <<'PLIST' CFBundleExecutableSkinOS CFBundleIconFileSkinOS CFBundlePackageTypeAPPL - CFBundleShortVersionString0.1.3 - CFBundleVersion4 + CFBundleShortVersionString0.1.4 + CFBundleVersion5 LSMinimumSystemVersion15.0 NSHighResolutionCapable NSPrincipalClassNSApplication From 12622425aea1732ea4e02e32cbbacd67ed2ab441 Mon Sep 17 00:00:00 2001 From: Brekit <155304101+Brekit@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:07:38 +0300 Subject: [PATCH 2/5] Clean obsolete staging after completed game installations --- README.md | 2 + crates/client-core/src/daemon.rs | 2 +- crates/client-core/src/download_queue.rs | 242 ++++++++++++++++++++++- crates/client-core/src/operations.rs | 54 +++++ crates/downloader/src/cache_cleanup.rs | 218 ++++++++++++++++++++ crates/downloader/src/lib.rs | 2 + 6 files changed, 518 insertions(+), 2 deletions(-) create mode 100644 crates/downloader/src/cache_cleanup.rs diff --git a/README.md b/README.md index fe11272..19b57ee 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ as a game launcher. - Explicit macOS/Windows selection before downloading, with macOS preselected when available. The chosen version is retained across pauses and updates. - Searchable Steam download regions with preferred servers and fallbacks. +- Automatic cleanup of obsolete download staging after a verified completed + 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. - Per-game launch arguments and compatibility settings, launch/stop controls, diff --git a/crates/client-core/src/daemon.rs b/crates/client-core/src/daemon.rs index 1240523..98550e4 100644 --- a/crates/client-core/src/daemon.rs +++ b/crates/client-core/src/daemon.rs @@ -339,7 +339,7 @@ pub async fn serve_with_adapter(adapter: Arc) -> Result<() operations.spawn(relay_operation(future,operation_rx,worker_events.clone(),epochs.account,id)); } else {emit(&events,Event::error(Some(&id),"not_authenticated","Sign in before opening Steam pages",true));} } - "install_game" | "update_game" | "get_download_queue" | "pause_download" | "resume_download" | "cancel_download" | "remove_download" | "move_download" | "set_download_limit" => { + "install_game" | "update_game" | "get_download_queue" | "pause_download" | "resume_download" | "cancel_download" | "remove_download" | "move_download" | "set_download_limit" | "cleanup_download_cache" => { if let Some(queue)=&downloads { if let Err(error)=queue.request(request){emit(&events,Event::error(Some(&id),"download_queue_failed",&error.to_string(),true));} }else{emit(&events,Event::error(Some(&id),"not_authenticated","Войдите в аккаунт перед управлением загрузками",true));} diff --git a/crates/client-core/src/download_queue.rs b/crates/client-core/src/download_queue.rs index 50e6ac1..60dfe76 100644 --- a/crates/client-core/src/download_queue.rs +++ b/crates/client-core/src/download_queue.rs @@ -25,6 +25,13 @@ pub trait DownloadExecutor: Send + Sync + 'static { fn validate(&self, job: &DownloadJob) -> Result; fn can_start(&self, job: &DownloadJob) -> bool; fn resource_key(&self, job: &DownloadJob) -> Result; + fn cleanup_completed( + &self, + _job: DownloadJob, + _events: mpsc::UnboundedSender, + ) -> DownloadFuture { + Box::pin(async { Ok(()) }) + } fn run( &self, job: DownloadJob, @@ -212,6 +219,44 @@ struct SavedQueue { revision: u64, items: Vec, } + +/// All persisted account intents participate in the cache reservation. An older +/// paused/cancelled/failed attempt must not lose resumable data during another +/// account's cleanup. Malformed queue state fails closed. +pub(crate) fn completed_cache_cleanup_allowed( + data_root: &std::path::Path, + appid: u32, +) -> Result { + let directory = data_root.join("download-queues"); + ensure!( + !directory.is_symlink(), + "Download queue directory cannot be a symlink" + ); + let mut completed = false; + for entry in std::fs::read_dir(directory).context("Cannot verify download queue states")? { + let entry = entry?; + let path = entry.path(); + if path.extension().is_none_or(|extension| extension != "json") { + continue; + } + let metadata = std::fs::symlink_metadata(&path)?; + ensure!( + metadata.is_file() + && !metadata.file_type().is_symlink() + && metadata.len() <= 4 * 1024 * 1024, + "Cannot verify download queue file" + ); + let queue: SavedQueue = serde_json::from_slice(&std::fs::read(&path)?) + .context("Cannot verify download queue contents")?; + for item in queue.items.iter().filter(|item| item.appid == appid) { + if item.state != DownloadState::Completed { + return Ok(false); + } + completed = true; + } + } + Ok(completed) +} enum Command { Request(Request), Executor(Arc), @@ -780,11 +825,55 @@ impl Actor { } } } + async fn cleanup_completed(&self, appid: u32, request_id: Option<&str>) -> Result<()> { + ensure!( + !self.active.contains_key(&appid), + "Загрузка ещё выполняется" + ); + let row = self + .items + .iter() + .find(|row| row.appid == appid) + .context("Игра отсутствует в очереди")?; + ensure!( + row.state == DownloadState::Completed, + "Очистка доступна только после успешной установки" + ); + // A failed save must never turn a transient in-memory success into + // permission to delete resumable on-disk state. + self.persist()?; + let mut job = row.job(&self.steamid); + job.request_id = request_id.map(str::to_owned); + self.executor + .cleanup_completed(job, self.events.clone()) + .await + } + async fn cleanup_completed_best_effort(&self, appid: u32) { + if let Err(error) = self.cleanup_completed(appid, None).await { + let _ = self.events.send(Event::new( + None, + "download_cache_cleanup_warning", + json!({"appid":appid,"message":error.to_string()}), + )); + } + } async fn run(mut self, mut commands: mpsc::UnboundedReceiver) { let (sink, mut incoming) = mpsc::unbounded_channel(); let mut tick = tokio::time::interval(Duration::from_millis(250)); tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); self.snapshot(None); + // Catch up only explicitly completed jobs after an upgrade/restart. + // No worker starts and no new queue command is accepted until this + // completed-state reservation is released. + let completed: Vec = self + .items + .iter() + .filter(|row| row.state == DownloadState::Completed) + .map(|row| row.appid) + .collect(); + for appid in completed { + self.cleanup_completed_best_effort(appid).await; + } loop { self.start_ready(&sink); if self.stopping && self.active.is_empty() { @@ -794,6 +883,14 @@ impl Actor { tokio::select! { command=commands.recv(),if !self.stopping=>match command { Some(Command::Request(request))=>{ + if request.command == "cleanup_download_cache" { + let result = match request.payload["appid"].as_u64().and_then(|id| u32::try_from(id).ok()).filter(|id| *id > 0) { + Some(appid) => self.cleanup_completed(appid, Some(&request.id)).await, + None => Err(anyhow::anyhow!("Invalid AppID")), + }; + if let Err(error) = result { let _=self.events.send(Event::error(Some(&request.id), "download_cache_cleanup_failed", &error.to_string(), true)); } + continue; + } if matches!(request.command.as_str(), "pause_download" | "cancel_download" | "remove_download") && let Some(appid)=request.payload["appid"].as_u64().and_then(|value|u32::try_from(value).ok()) { self.resume_after_connection.remove(&appid); @@ -909,6 +1006,13 @@ impl Actor { self.snapshot(None); } if succeeded { + if self + .items + .iter() + .any(|row| row.appid == appid && row.state == DownloadState::Completed) + { + self.cleanup_completed_best_effort(appid).await; + } let _ = self.events.send(Event::new( request_id.as_deref(), "operation_complete", @@ -948,7 +1052,7 @@ mod tests { use super::*; use std::sync::{ Mutex, - atomic::{AtomicUsize, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, }; use tokio::sync::Semaphore; const ACCOUNT: &str = "76561198000000001"; @@ -959,6 +1063,8 @@ mod tests { started: mpsc::UnboundedSender, gates: Arc>>>, completed: Arc>>, + cleanup_calls: Arc>>, + cleanup_fails: Arc, resources: HashMap, } impl DownloadExecutor for Mock { @@ -976,6 +1082,22 @@ mod tests { .cloned() .unwrap_or_else(|| job.appid.to_string())) } + fn cleanup_completed( + &self, + job: DownloadJob, + _: mpsc::UnboundedSender, + ) -> DownloadFuture { + let calls = self.cleanup_calls.clone(); + let fails = self.cleanup_fails.clone(); + Box::pin(async move { + calls.lock().unwrap().push(job.appid); + ensure!( + !fails.load(Ordering::SeqCst), + "fixture cache cleanup failure" + ); + Ok(()) + }) + } fn run( &self, job: DownloadJob, @@ -1039,6 +1161,8 @@ mod tests { started, gates: Default::default(), completed: Default::default(), + cleanup_calls: Default::default(), + cleanup_fails: Default::default(), resources, }), rx, @@ -1345,6 +1469,122 @@ mod tests { assert_eq!(actor.items[0].downloaded_bytes, 12); } #[test] + fn cleanup_gate_checks_all_accounts_and_preserves_every_resumable_state() { + let root = tempfile::tempdir().unwrap(); + let queues = root.path().join("download-queues"); + std::fs::create_dir(&queues).unwrap(); + let write = |name: &str, appid: u32, state: DownloadState| { + std::fs::write( + queues.join(name), + serde_json::to_vec(&json!({ + "steamid":ACCOUNT,"max_concurrent":2,"revision":1, + "items":[{"appid":appid,"is_update":false,"state":state}] + })) + .unwrap(), + ) + .unwrap(); + }; + assert!(!completed_cache_cleanup_allowed(root.path(), 1).unwrap()); + write("first.json", 1, DownloadState::Completed); + assert!(completed_cache_cleanup_allowed(root.path(), 1).unwrap()); + for state in [ + DownloadState::Queued, + DownloadState::Downloading, + DownloadState::Preparing, + DownloadState::Verifying, + DownloadState::Installing, + DownloadState::Pausing, + DownloadState::Paused, + DownloadState::Cancelling, + DownloadState::Cancelled, + DownloadState::Failed, + ] { + write("second.json", 1, state); + assert!(!completed_cache_cleanup_allowed(root.path(), 1).unwrap()); + } + write("second.json", 2, DownloadState::Paused); + assert!(completed_cache_cleanup_allowed(root.path(), 1).unwrap()); + std::fs::write(queues.join("second.json"), b"malformed").unwrap(); + assert!(completed_cache_cleanup_allowed(root.path(), 1).is_err()); + } + #[tokio::test] + async fn cleanup_requires_completed_state_and_failure_keeps_install_successful() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("queue.json"); + let (executor, mut started_rx) = mock(ACCOUNT, HashMap::new()); + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut actor = + Actor::load(ACCOUNT.into(), path.clone(), executor.clone(), tx.clone()).unwrap(); + for state in [ + DownloadState::Paused, + DownloadState::Failed, + DownloadState::Cancelled, + DownloadState::Queued, + DownloadState::Downloading, + ] { + actor.items = vec![DownloadItem::new(1, false, state)]; + assert!(actor.cleanup_completed(1, None).await.is_err()); + } + assert!(executor.cleanup_calls.lock().unwrap().is_empty()); + actor.items.clear(); + actor.persist().unwrap(); + drop(actor); + executor.cleanup_fails.store(true, Ordering::SeqCst); + let queue = + DownloadQueue::start(ACCOUNT.into(), path.clone(), executor.clone(), tx).unwrap(); + send(&queue, "install", "install_game", 1); + assert_eq!(started(&mut started_rx).await, 1); + finish(&executor, 1); + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let event = rx.recv().await.unwrap(); + if event.event == "operation_complete" { + break; + } + } + }) + .await + .unwrap(); + assert_eq!(*executor.cleanup_calls.lock().unwrap(), [1]); + let saved: SavedQueue = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(saved.items[0].state, DownloadState::Completed); + queue.shutdown().await; + } + #[tokio::test] + async fn startup_cleanup_targets_only_previously_completed_rows() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("queue.json"); + std::fs::write( + &path, + serde_json::to_vec(&json!({ + "steamid":ACCOUNT,"max_concurrent":2,"revision":1,"items":[ + {"appid":1,"is_update":false,"state":"completed"}, + {"appid":2,"is_update":false,"state":"paused"}, + {"appid":3,"is_update":false,"state":"failed"}, + {"appid":4,"is_update":false,"state":"cancelled"} + ] + })) + .unwrap(), + ) + .unwrap(); + let (executor, _) = mock(ACCOUNT, HashMap::new()); + let (tx, mut rx) = mpsc::unbounded_channel(); + let queue = DownloadQueue::start(ACCOUNT.into(), path, executor.clone(), tx).unwrap(); + // A correlated snapshot is handled only after startup cleanup finishes. + send(&queue, "ready", "get_download_queue", 1); + tokio::time::timeout(Duration::from_secs(3), async { + while let Some(event) = rx.recv().await { + if event.id.as_deref() == Some("ready") { + break; + } + } + }) + .await + .unwrap(); + assert_eq!(*executor.cleanup_calls.lock().unwrap(), [1]); + queue.shutdown().await; + } + #[test] fn explicit_platform_survives_queue_restart_and_resume() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("queue.json"); diff --git a/crates/client-core/src/operations.rs b/crates/client-core/src/operations.rs index 16bf113..0da9876 100644 --- a/crates/client-core/src/operations.rs +++ b/crates/client-core/src/operations.rs @@ -119,6 +119,60 @@ impl crate::download_queue::DownloadExecutor for MvpDownloader { .to_string_lossy() .into_owned()) } + fn cleanup_completed( + &self, + job: crate::download_queue::DownloadJob, + events: mpsc::UnboundedSender, + ) -> crate::download_queue::DownloadFuture { + let account = self.context.steamid.clone(); + let apps = self.apps.clone(); + let destinations = self.destinations.clone(); + Box::pin(async move { + ensure!(job.steamid == account, "Download account changed"); + let appid = job.appid; + let outcome = tokio::task::spawn_blocking( + move || -> Result> { + if !apps + .lock() + .map_err(|_| anyhow::anyhow!("Operation lock unavailable"))? + .insert(appid) + { + return Ok(None); + } + let _app = AppOperation { appid, apps }; + let config = settings::load()?; + let installed = + skinos_downloader::library::read_appmanifest(&config.steamapps_dir, appid)?; + let _destination = + DestinationOperation::acquire(destinations, installed.install_dir)?; + if !crate::download_queue::completed_cache_cleanup_allowed( + &session::data_dir()?, + appid, + )? { + return Ok(None); + } + skinos_downloader::cleanup_completed_download_cache( + &config.steamapps_dir, + appid, + ) + .map(Some) + }, + ) + .await??; + let payload = match outcome { + Some(outcome) => serde_json::to_value(outcome)?, + None => { + json!({"appid":appid,"skipped":true,"removed_directories":0,"removed_bytes":0}) + } + }; + let _ = events.send(Event::new( + job.request_id.as_deref(), + "download_cache_cleaned", + payload, + )); + Ok(()) + }) + } fn run( &self, job: crate::download_queue::DownloadJob, diff --git a/crates/downloader/src/cache_cleanup.rs b/crates/downloader/src/cache_cleanup.rs new file mode 100644 index 0000000..42ded1d --- /dev/null +++ b/crates/downloader/src/cache_cleanup.rs @@ -0,0 +1,218 @@ +//! Remove obsolete SteamPipe staging only for an explicitly completed game. +//! Queue ownership/lifecycle is checked by the caller; filesystem locks and +//! committed installation evidence are checked again here before any removal. +use crate::{library, read_install}; +use anyhow::{Context, Result, ensure}; +use serde::Serialize; +use std::{fs, path::Path}; + +#[derive(Debug, Default, Serialize)] +pub struct CacheCleanup { + pub appid: u32, + pub removed_directories: usize, + pub removed_bytes: u64, + pub retained_directories: usize, +} + +fn numeric_build(value: &str) -> Option { + (!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())) + .then(|| value.parse().ok()) + .flatten() + .filter(|build| *build > 0) +} + +/// The caller must hold its queue's completed-state reservation. Active, +/// paused, cancelled and failed download intents must never call this API. +pub fn cleanup_completed_download_cache(steamapps: &Path, appid: u32) -> Result { + ensure!(appid > 0, "Invalid AppID for download cache cleanup"); + ensure!(!steamapps.is_symlink(), "Library root cannot be a symlink"); + let common = steamapps.join("common"); + ensure!( + !common.is_symlink(), + "Library common directory cannot be a symlink" + ); + let initial = library::read_appmanifest(steamapps, appid)?; + let _installation = library::installation_lock(&initial.install_dir)?; + let _operation = library::operation_lock(&common, appid)?; + // Re-read after acquiring both locks; another installer may have committed + // between discovery and lock acquisition. + let committed = library::read_appmanifest(steamapps, appid)?; + ensure!( + committed.install_dir == initial.install_dir, + "Installation directory changed" + ); + ensure!( + !committed + .install_dir + .join(".skinos-install.json") + .is_symlink(), + "Installation record cannot be a symlink" + ); + let owned = read_install(&committed.install_dir)?; + ensure!( + owned.appid == appid && owned.build_id == committed.build_id, + "Download cache cleanup requires a committed SkinOS installation" + ); + let completed_build = numeric_build(&committed.build_id) + .context("Invalid completed build for download cache cleanup")?; + let root = common.canonicalize()?; + let prefix = format!(".skinos-{appid}-"); + let mut outcome = CacheCleanup { + appid, + ..Default::default() + }; + for entry in fs::read_dir(&common)? { + let entry = entry?; + let name = entry.file_name(); + let Some(build) = name + .to_str() + .and_then(|name| name.strip_prefix(&prefix)) + .and_then(|name| name.strip_suffix(".partial")) + .and_then(numeric_build) + else { + continue; + }; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path)?; + // Keep same/newer builds: those can still belong to a resumable attempt. + // A linked top-level cache is not an owned staging directory. + if build >= completed_build || !metadata.is_dir() || metadata.file_type().is_symlink() { + outcome.retained_directories += 1; + continue; + } + ensure!( + path.canonicalize()?.parent() == Some(root.as_path()), + "Staging directory escapes library" + ); + let bytes = regular_bytes(&path)?; + // Never traverse links when estimating or removing. Rust's remove_dir_all + // removes nested symlinks themselves and leaves their targets untouched. + let current = fs::symlink_metadata(&path)?; + use std::os::unix::fs::MetadataExt; + ensure!( + current.is_dir() + && !current.file_type().is_symlink() + && current.dev() == metadata.dev() + && current.ino() == metadata.ino(), + "Staging directory changed before cleanup" + ); + fs::remove_dir_all(&path)?; + outcome.removed_directories += 1; + outcome.removed_bytes = outcome.removed_bytes.saturating_add(bytes); + } + Ok(outcome) +} + +fn regular_bytes(root: &Path) -> Result { + let mut bytes = 0u64; + for entry in fs::read_dir(root)? { + let entry = entry?; + let metadata = fs::symlink_metadata(entry.path())?; + if metadata.file_type().is_symlink() { + continue; + } + bytes = bytes.saturating_add(if metadata.is_dir() { + regular_bytes(&entry.path())? + } else if metadata.is_file() { + metadata.len() + } else { + 0 + }); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture() -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + let game = root.path().join("common/Game"); + fs::create_dir_all(&game).unwrap(); + fs::write(game.join("game"), b"installed").unwrap(); + fs::write( + library::manifest_path(root.path(), 42), + br#""AppState" { "appid" "42" "StateFlags" "4" "installdir" "Game" "buildid" "20" }"#, + ) + .unwrap(); + fs::write( + game.join(".skinos-install.json"), + serde_json::to_vec(&crate::InstalledGame { + appid: 42, + steamid: "1".into(), + install_dir: game.canonicalize().unwrap(), + build_id: "20".into(), + platform: None, + depots: vec![], + }) + .unwrap(), + ) + .unwrap(); + root + } + fn cache(root: &Path, name: &str) -> std::path::PathBuf { + let path = root.join("common").join(name); + fs::create_dir(&path).unwrap(); + fs::write(path.join("content"), b"cache").unwrap(); + path + } + #[test] + fn removes_only_older_owned_partial_after_successful_commit() { + let root = fixture(); + let old = cache(root.path(), ".skinos-42-19.partial"); + let preserved = [ + ".skinos-42-20.partial", + ".skinos-42-21.partial", + ".skinos-420-19.partial", + ".skinos-42-19.previous", + ".skinos-42-other.partial", + ] + .map(|name| cache(root.path(), name)); + let outcome = cleanup_completed_download_cache(root.path(), 42).unwrap(); + assert_eq!(outcome.removed_directories, 1); + assert_eq!(outcome.removed_bytes, 5); + assert!(!old.exists()); + for path in preserved { + assert!(path.join("content").exists()); + } + assert_eq!( + fs::read(root.path().join("common/Game/game")).unwrap(), + b"installed" + ); + } + #[test] + fn missing_or_incomplete_manifest_and_active_writer_preserve_cache() { + let root = fixture(); + let old = cache(root.path(), ".skinos-42-19.partial"); + let lock = library::operation_lock(&root.path().join("common"), 42).unwrap(); + assert!(cleanup_completed_download_cache(root.path(), 42).is_err()); + drop(lock); + let manifest = library::manifest_path(root.path(), 42); + fs::write( + &manifest, + br#""AppState" { "appid" "42" "StateFlags" "2" "installdir" "Game" "buildid" "20" }"#, + ) + .unwrap(); + assert!(cleanup_completed_download_cache(root.path(), 42).is_err()); + fs::remove_file(manifest).unwrap(); + assert!(cleanup_completed_download_cache(root.path(), 42).is_err()); + assert!(old.join("content").exists()); + } + #[test] + fn symlinks_never_follow_targets_and_uncommitted_record_preserves_cache() { + let root = fixture(); + let outside = tempfile::tempdir().unwrap(); + fs::write(outside.path().join("keep"), b"outside").unwrap(); + let link = root.path().join("common/.skinos-42-18.partial"); + std::os::unix::fs::symlink(outside.path(), &link).unwrap(); + let old = cache(root.path(), ".skinos-42-19.partial"); + std::os::unix::fs::symlink(outside.path(), old.join("outside")).unwrap(); + cleanup_completed_download_cache(root.path(), 42).unwrap(); + assert!(link.is_symlink()); + assert_eq!(fs::read(outside.path().join("keep")).unwrap(), b"outside"); + let old = cache(root.path(), ".skinos-42-19.partial"); + fs::remove_file(root.path().join("common/Game/.skinos-install.json")).unwrap(); + assert!(cleanup_completed_download_cache(root.path(), 42).is_err()); + assert!(old.join("content").exists()); + } +} diff --git a/crates/downloader/src/lib.rs b/crates/downloader/src/lib.rs index 5bd8c32..402f8a6 100644 --- a/crates/downloader/src/lib.rs +++ b/crates/downloader/src/lib.rs @@ -1,5 +1,7 @@ //! SteamPipe downloads over an existing authenticated Steam CM connection. +mod cache_cleanup; mod content; +pub use cache_cleanup::{CacheCleanup, cleanup_completed_download_cache}; mod control; pub use control::{InstallControl, InstallInterrupted}; pub mod library; From b5206f0dd3c8c347e0532d62de14d2c1afb8b647 Mon Sep 17 00:00:00 2001 From: Brekit <155304101+Brekit@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:59:28 +0300 Subject: [PATCH 3/5] Resolve native game launches from Steam metadata --- README.md | 3 + crates/client-core/src/model.rs | 53 ++ crates/client-core/src/operations.rs | 142 ++- .../vendor/steam-cm-protocol/src/friends.rs | 4 + .../vendor/steam-cm-protocol/src/library.rs | 4 + .../vendor/steam-cm-protocol/src/pics.rs | 198 +++- crates/downloader/src/lib.rs | 12 + crates/runtime-host/native/launch.m | 168 ++-- .../native/platform_guard_tests.m | 221 ++++- crates/runtime-host/src/installation.rs | 51 +- .../src/installation/save_roots.rs | 855 ++++++++++++++++++ crates/runtime-host/src/main.rs | 85 +- crates/runtime-host/src/native_launch.rs | 559 ++++++++++++ crates/runtime-host/src/native_lock_files.rs | 107 +++ crates/runtime-host/src/worker.rs | 146 ++- .../src/worker/launch_selection.rs | 56 ++ .../runtime-host/src/worker/native_process.rs | 669 ++++++++++++++ scripts/build-app.sh | 4 +- 18 files changed, 3207 insertions(+), 130 deletions(-) create mode 100644 crates/runtime-host/src/installation/save_roots.rs create mode 100644 crates/runtime-host/src/native_launch.rs create mode 100644 crates/runtime-host/src/native_lock_files.rs create mode 100644 crates/runtime-host/src/worker/launch_selection.rs create mode 100644 crates/runtime-host/src/worker/native_process.rs diff --git a/README.md b/README.md index 19b57ee..43f0517 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/client-core/src/model.rs b/crates/client-core/src/model.rs index 24df317..03c9310 100644 --- a/crates/client-core/src/model.rs +++ b/crates/client-core/src/model.rs @@ -58,10 +58,17 @@ pub struct Game { pub installed_platform: Option, pub header_url: String, pub launch: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cloud_config: Option, + /// 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, pub executable: String, pub arguments: Option, pub workingdir: Option, @@ -119,6 +126,7 @@ impl From for Game { .launch .into_iter() .map(|entry| LaunchEntry { + id: entry.id, executable: entry.executable, arguments: entry.arguments, workingdir: entry.workingdir, @@ -128,6 +136,8 @@ impl From for Game { betakey: entry.betakey, }) .collect(), + cloud_config: game.cloud_config, + launch_metadata_known: true, } } } @@ -135,6 +145,48 @@ impl From for Game { #[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( @@ -154,6 +206,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() diff --git a/crates/client-core/src/operations.rs b/crates/client-core/src/operations.rs index 0da9876..65f1999 100644 --- a/crates/client-core/src/operations.rs +++ b/crates/client-core/src/operations.rs @@ -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() @@ -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" @@ -427,6 +434,40 @@ fn launch_entry<'a>( }) } +fn game_with_fresh_launch_metadata( + game: &Game, + metadata: steam_cm_protocol::pics::AppCatalogInfo, +) -> Result { + 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(|entry| crate::model::LaunchEntry { + 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, + }) + .collect(); + refreshed.cloud_config = metadata.cloud_config; + refreshed.launch_metadata_known = true; + Ok(refreshed) +} + fn launch_arguments( entry: &crate::model::LaunchEntry, options: &skinos_compat::GameSettings, @@ -794,6 +835,15 @@ async fn launch( record.appid == game.appid, "Installed game identity mismatch" ); + let refreshed; + let game = if !windows && !game.launch_metadata_known { + let metadata = skinos_downloader::application_metadata(&context.connection, game.appid) + .await.context("Не удалось обновить данные запуска и сохранений. Обновите библиотеку и повторите запуск")?; + refreshed = game_with_fresh_launch_metadata(game, metadata)?; + &refreshed + } else { + game + }; 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()) { @@ -875,6 +925,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 @@ -1039,6 +1090,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, @@ -1050,7 +1102,93 @@ mod tests { } fn game(launch: Vec) -> 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() + ); } #[test] fn detects_actual_installed_platform_and_ignores_beta_tools() { diff --git a/crates/client-core/vendor/steam-cm-protocol/src/friends.rs b/crates/client-core/vendor/steam-cm-protocol/src/friends.rs index e53b65a..52a5b8e 100644 --- a/crates/client-core/vendor/steam-cm-protocol/src/friends.rs +++ b/crates/client-core/vendor/steam-cm-protocol/src/friends.rs @@ -76,6 +76,8 @@ pub struct Persona { /// Used by the direct (no-Steam) launch path to resolve the executable without the Steam client. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct LaunchEntry { + /// Numeric child key in `config/launch`; this is not the entry's vector index. + pub id: Option, /// Executable path, relative to the game's install directory (e.g. `bin/game.exe`). pub executable: String, /// Launch arguments, if any. @@ -107,6 +109,8 @@ pub struct ProtocolGame { pub installdir: Option, /// Appinfo `config/launch` entries (how Steam would launch it). Empty when unresolved. pub launch: Vec, + /// Raw appinfo `ufs` object. Scalar leaves use strings in both PICS encodings. + pub cloud_config: Option, } #[derive(Debug, Clone)] diff --git a/crates/client-core/vendor/steam-cm-protocol/src/library.rs b/crates/client-core/vendor/steam-cm-protocol/src/library.rs index f4b2c5b..0f8583e 100644 --- a/crates/client-core/vendor/steam-cm-protocol/src/library.rs +++ b/crates/client-core/vendor/steam-cm-protocol/src/library.rs @@ -59,6 +59,7 @@ pub fn recently_played_games(playtimes: &HashMap) -> Vec, /// Appinfo `config/launch` entries — used by the direct (no-Steam) launch path. pub launch: Vec, + /// Raw appinfo `ufs` object, retaining numbered savefiles and root overrides. + pub cloud_config: Option, } /// Library entries we surface: real games plus owned software/tools. DLC, soundtracks (music), @@ -250,6 +252,7 @@ async fn resolve_app_infos( app_type: None, installdir: None, launch: Vec::new(), + cloud_config: None, }, ) }) @@ -455,6 +458,12 @@ fn hex_prefix(buffer: &[u8], limit: usize) -> String { fn parse_binary_app_info(appid: u32, buffer: &[u8]) -> Option { let root = kv::parse_binary_kv(buffer)?; + parse_app_info_kv(appid, &root) +} + +/// Decode already-fetched appinfo with the same launch/UFS interpretation as +/// the library parser. Callers are responsible for the authenticated fetch. +pub fn parse_app_info_kv(appid: u32, root: &KVValue) -> Option { let common = root .get("appinfo") .and_then(|appinfo| appinfo.get("common")) @@ -488,6 +497,12 @@ fn parse_binary_app_info(appid: u32, buffer: &[u8]) -> Option { .and_then(|config| config.get("launch")) .map(launch_entries_from_kv) .unwrap_or_default(); + let cloud_config = root + .get("appinfo") + .and_then(|appinfo| appinfo.get("ufs")) + .or_else(|| root.get("ufs")) + .filter(|ufs| ufs.as_nested().is_some()) + .map(kv_json); Some(AppCatalogInfo { appid, @@ -496,6 +511,7 @@ fn parse_binary_app_info(appid: u32, buffer: &[u8]) -> Option { app_type, installdir, launch, + cloud_config, }) } @@ -504,9 +520,10 @@ fn launch_entries_from_kv(launch: &KVValue) -> Vec { let Some(children) = launch.as_nested() else { return Vec::new(); }; - children + let mut entries: Vec<_> = children .iter() - .filter_map(|(_, entry)| { + .filter_map(|(key, entry)| { + let id = key.parse::().ok()?; let executable = entry .get("executable") .and_then(|value| value.as_str()) @@ -520,6 +537,7 @@ fn launch_entries_from_kv(launch: &KVValue) -> Vec { .map(str::to_owned) }; Some(LaunchEntry { + id: Some(id), executable, arguments: str_field(Some(entry), "arguments"), workingdir: str_field(Some(entry), "workingdir"), @@ -529,6 +547,36 @@ fn launch_entries_from_kv(launch: &KVValue) -> Vec { betakey: str_field(cfg, "betakey"), }) }) + .collect(); + entries.sort_by_key(|entry| entry.id); + entries +} + +// UFS originates in KeyValues; normalize typed binary scalars to the string +// representation of text appinfo so consumers do not depend on the transport. +fn kv_json(value: &KVValue) -> serde_json::Value { + match value { + KVValue::Nested(children) => children + .iter() + .map(|(key, value)| (key.clone(), kv_json(value))) + .collect(), + KVValue::Str(value) => serde_json::Value::String(value.clone()), + KVValue::Int(value) => serde_json::Value::String(value.to_string()), + KVValue::Uint64(value) => serde_json::Value::String(value.to_string()), + KVValue::Other => serde_json::Value::Null, + } +} + +fn vdf_json(node: &VdfNode) -> serde_json::Value { + node.values + .iter() + .map(|(key, value)| { + let value = match value { + VdfValue::Node(node) => vdf_json(node), + VdfValue::Str(value) => serde_json::Value::String(value.clone()), + }; + (key.clone(), value) + }) .collect() } @@ -564,6 +612,11 @@ fn parse_text_app_info(appid: u32, buffer: &[u8]) -> Option { .and_then(|config| config.get_node("launch")) .map(launch_entries_from_vdf) .unwrap_or_default(); + let cloud_config = root + .get_node("appinfo") + .and_then(|appinfo| appinfo.get_node("ufs")) + .or_else(|| root.get_node("ufs")) + .map(vdf_json); Some(AppCatalogInfo { appid, @@ -572,16 +625,18 @@ fn parse_text_app_info(appid: u32, buffer: &[u8]) -> Option { app_type, installdir, launch, + cloud_config, }) } /// Collect `config/launch` entries from a text-VDF `launch` node (numbered child nodes). fn launch_entries_from_vdf(launch: &VdfNode) -> Vec { - launch + let mut entries: Vec<_> = launch .values .iter() - .filter_map(|(_, value)| match value { + .filter_map(|(key, value)| match value { VdfValue::Node(entry) => { + let id = key.parse::().ok()?; let executable = entry .get_str("executable") .filter(|value| !value.is_empty())? @@ -593,6 +648,7 @@ fn launch_entries_from_vdf(launch: &VdfNode) -> Vec { .map(str::to_owned) }; Some(LaunchEntry { + id: Some(id), executable, arguments: str_field(Some(entry), "arguments"), workingdir: str_field(Some(entry), "workingdir"), @@ -604,7 +660,9 @@ fn launch_entries_from_vdf(launch: &VdfNode) -> Vec { } VdfValue::Str(_) => None, }) - .collect() + .collect(); + entries.sort_by_key(|entry| entry.id); + entries } #[derive(Debug, Default, Clone, PartialEq, Eq)] @@ -771,6 +829,135 @@ fn parse_vdf_node(lexer: &mut VdfLexer<'_>, stop_on_close: bool) -> Option>(), + ["first.app", "second.app"] + ); + assert_eq!( + app.launch.iter().map(|entry| entry.id).collect::>(), + [Some(2), Some(19)] + ); + } + + fn binary_fixture(node: &KVValue) -> Vec { + fn append(out: &mut Vec, key: &str, value: &KVValue) { + out.push(match value { + KVValue::Nested(_) => 0, + KVValue::Str(_) => 1, + KVValue::Int(_) => 2, + KVValue::Uint64(_) => 7, + KVValue::Other => panic!("unsupported fixture"), + }); + out.extend_from_slice(key.as_bytes()); + out.push(0); + match value { + KVValue::Nested(children) => { + for (key, value) in children { + append(out, key, value); + } + out.push(8); + } + KVValue::Str(value) => { + out.extend_from_slice(value.as_bytes()); + out.push(0); + } + KVValue::Int(value) => out.extend_from_slice(&value.to_le_bytes()), + KVValue::Uint64(value) => out.extend_from_slice(&value.to_le_bytes()), + KVValue::Other => unreachable!(), + } + } + let mut bytes = Vec::new(); + append(&mut bytes, "appinfo", node); + bytes + } + + #[test] + fn text_and_binary_preserve_same_cloud_metadata_and_sparse_launch_ids() { + let text = br#""appinfo" { + "common" { "name" "Game" } + "config" { "launch" { + "19" { "executable" "other.app" } + "2" { "executable" "Game.app" "config" { "oslist" "macos" } } + } } + "ufs" { + "quota" "1000" "maxnumfiles" "20" + "savefiles" { "7" { + "root" "MacAppSupport" "path" "Studio/Game/{64BitSteamID}" + "pattern" "*.sav" "recursive" "1" "platforms" { "1" "MacOS" } + } } + "rootoverrides" { "5" { + "root" "WinAppDataRoaming" "os" "MacOS" "oscompare" "=" + "useinstead" "MacAppSupport" "addpath" "Studio" + } } + } + }"#; + let vdf = parse_vdf(std::str::from_utf8(text).unwrap()).unwrap(); + fn as_kv(node: &VdfNode) -> KVValue { + KVValue::Nested( + node.values + .iter() + .map(|(key, value)| { + let value = match value { + VdfValue::Node(node) => as_kv(node), + VdfValue::Str(value) if key == "quota" => { + KVValue::Uint64(value.parse().unwrap()) + } + VdfValue::Str(value) if key == "maxnumfiles" || key == "recursive" => { + KVValue::Int(value.parse().unwrap()) + } + VdfValue::Str(value) => KVValue::Str(value.clone()), + }; + (key.clone(), value) + }) + .collect(), + ) + } + let binary = binary_fixture(&as_kv(vdf.get_node("appinfo").unwrap())); + let text_app = parse_app_info(42, text).unwrap(); + let binary_app = parse_app_info(42, &binary).unwrap(); + let fetched_app = parse_app_info_kv(42, &kv::parse_binary_kv(&binary).unwrap()).unwrap(); + assert_eq!(text_app, binary_app); + assert_eq!(binary_app, fetched_app); + assert_eq!( + binary_app + .launch + .iter() + .map(|entry| entry.id) + .collect::>(), + [Some(2), Some(19)] + ); + let cloud = binary_app.cloud_config.unwrap(); + assert_eq!(cloud["quota"], "1000"); + assert_eq!(cloud["maxnumfiles"], "20"); + assert_eq!(cloud["savefiles"]["7"]["recursive"], "1"); + assert_eq!( + cloud["savefiles"]["7"]["path"], + "Studio/Game/{64BitSteamID}" + ); + assert_eq!(cloud["savefiles"]["7"]["platforms"]["1"], "MacOS"); + assert_eq!(cloud["rootoverrides"]["5"]["useinstead"], "MacAppSupport"); + } + fn catalog_app(appid: u32, name: &str, app_type: Option<&str>) -> AppCatalogInfo { AppCatalogInfo { appid, @@ -779,6 +966,7 @@ mod tests { app_type: app_type.map(str::to_owned), installdir: None, launch: Vec::new(), + cloud_config: None, } } diff --git a/crates/downloader/src/lib.rs b/crates/downloader/src/lib.rs index 402f8a6..1e295f5 100644 --- a/crates/downloader/src/lib.rs +++ b/crates/downloader/src/lib.rs @@ -24,6 +24,18 @@ use tokio::sync::Mutex; pub type SharedConnection = Arc>; pub type ProgressCallback = Arc; +/// Refresh one app's launch and Cloud settings over the current authenticated +/// CM connection. This does not acquire a license or change installed files. +pub async fn application_metadata( + connection: &SharedConnection, + appid: u32, +) -> Result { + ensure!(appid != 0, "Invalid application metadata identity"); + let appinfo = network::app_info(connection, appid).await?; + steam_cm_protocol::pics::parse_app_info_kv(appid, &appinfo) + .context("Steam returned incomplete application metadata") +} + #[derive(Debug, Clone)] pub struct InstallRequest { pub appid: u32, diff --git a/crates/runtime-host/native/launch.m b/crates/runtime-host/native/launch.m index 31a21e1..ddbaab0 100644 --- a/crates/runtime-host/native/launch.m +++ b/crates/runtime-host/native/launch.m @@ -1,7 +1,5 @@ -// Mac platform launch adapter: real Steam engine still owns authentication, -// ownership, launch metadata and process lifetime. Only NSWorkspace's remote -// LaunchServices handoff is replaced with a direct, confined child spawn so the -// child retains the intended environment and owned IPC broker namespace. +// Steam owns authentication, entitlement and the selected launch option. This +// adapter confines its platform handoff to the parent's validated native plan. #import #import #include @@ -12,16 +10,19 @@ #include #include #include +#include #include "launch_guard.h" extern char **environ; static _Atomic(pid_t) last_game_pid=0; +static _Atomic(pid_t) last_game_group=0; +static _Atomic(uint64_t) last_game_started_microseconds=0; static NSString *last_game_executable; pid_t skinos_last_game_pid(void) { return atomic_load(&last_game_pid); } +pid_t skinos_last_game_group(void) { return atomic_load(&last_game_group); } +uint64_t skinos_last_game_started_microseconds(void) { return atomic_load(&last_game_started_microseconds); } const char *skinos_last_game_executable(void) { return last_game_executable.fileSystemRepresentation; } -// Steam's reviewed Mac platform wrapper reads only processIdentifier from its -// successful launch result. This object represents the actual posix_spawn PID. @interface SkinOsChildProcess : NSObject @property(nonatomic) pid_t processIdentifier; @end @@ -33,30 +34,86 @@ static id rejected(NSError **error,const char *reason) { if (error) *error=[NSError errorWithDomain:@"dev.skinos.launch" code:EPERM userInfo:@{NSLocalizedDescriptionKey:@"Launch context was rejected"}]; return nil; } +static NSString *configured(const char *name) { + const char *value=getenv(name); + return value ? [NSString stringWithUTF8String:value] : nil; +} +static NSString *resolved(NSString *path) { + if (!path) return nil; + char canonical[PATH_MAX]; + return realpath(path.fileSystemRepresentation,canonical) ? [NSString stringWithUTF8String:canonical] : nil; +} +static bool contained(NSString *path,NSString *root) { + return path && root && [path hasPrefix:[root stringByAppendingString:@"/"]]; +} +static bool regular_macho(NSString *path) { + NSFileHandle *file=[NSFileHandle fileHandleForReadingAtPath:path]; + NSData *header=[file readDataOfLength:4]; + [file closeFile]; + if (header.length!=4) return false; + const unsigned char *b=header.bytes; + uint32_t magic=((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3]; + return magic==0xfeedface || magic==0xfeedfacf || magic==0xcefaedfe || magic==0xcffaedfe || + magic==0xcafebabe || magic==0xbebafeca || magic==0xcafebabf || magic==0xbfbafeca; +} +static bool string_arguments(id value) { + if (![value isKindOfClass:[NSArray class]] || [value count]>128) return false; + for (id argument in value) { + if (![argument isKindOfClass:[NSString class]] || [argument length]>16384 || + strlen([argument UTF8String])!=[argument lengthOfBytesUsingEncoding:NSUTF8StringEncoding]) return false; + } + return true; +} +// This fixed code restores only our adapter after the protected system shell +// strips DYLD_* on entry. The published script remains the launcher, with its +// original $0, positional arguments and cwd. No path is interpolated as code. +static NSString *const scriptBootstrap=@"export DYLD_INSERT_LIBRARIES=\"$SKINOS_ADAPTER_PATH\"; . \"$0\""; static id native_launch(id object,SEL selector,NSURL *url,NSWorkspaceLaunchOptions options,NSDictionary *configuration,NSError **error) { (void)object;(void)selector;(void)options; const char *expected=getenv("SKINOS_LAUNCH_APP_ID"); - const char *selected=getenv("SKINOS_GAME_BUNDLE"); + NSString *selected=configured("SKINOS_GAME_BUNDLE"); if (!url.isFileURL || !selected || !expected) return rejected(error,"non_file_or_unconfigured_target"); - NSString *target=url.path.stringByResolvingSymlinksInPath; - NSString *allowed=[[NSString stringWithUTF8String:selected] stringByResolvingSymlinksInPath]; - if (![target isEqualToString:allowed]) return rejected(error,"different_application"); - NSDictionary *provided=configuration[NSWorkspaceLaunchConfigurationEnvironment]; + NSString *target=resolved(url.path); + NSString *allowed=resolved(selected); + NSString *root=resolved(configured("SKINOS_GAME_INSTALL_ROOT")); + if (!target || ![target isEqualToString:allowed]) return rejected(error,"different_application"); + if (!contained(target,root)) return rejected(error,"target_outside_installation"); + id provided=configuration[NSWorkspaceLaunchConfigurationEnvironment]; + if (![provided isKindOfClass:[NSDictionary class]]) return rejected(error,"missing_steam_environment"); + for (id key in provided) if (![key isKindOfClass:[NSString class]] || ![provided[key] isKindOfClass:[NSString class]]) return rejected(error,"invalid_steam_environment"); NSString *appid=provided[@"SteamAppId"]; NSString *gameid=provided[@"SteamGameId"]; - NSString *launched=provided[@"SteamClientLaunch"]; bool valid=skinos_launch_context_valid(expected,appid.UTF8String,gameid.UTF8String); - fprintf(stderr,"SKINOS_LAUNCH context appid_matches=%d gameid_matches=%d client_launch_present=%d valid=%d\n",appid && strcmp(expected,appid.UTF8String)==0,gameid && strcmp(expected,gameid.UTF8String)==0,launched!=nil,valid);fflush(stderr); + fprintf(stderr,"SKINOS_LAUNCH context appid_matches=%d gameid_matches=%d client_launch_present=%d valid=%d\n",appid && strcmp(expected,appid.UTF8String)==0,gameid && strcmp(expected,gameid.UTF8String)==0,provided[@"SteamClientLaunch"]!=nil,valid);fflush(stderr); if (!valid) return rejected(error,"missing_or_wrong_steam_context"); - NSBundle *bundle=[NSBundle bundleWithPath:target]; - NSString *executable=bundle.executablePath; - if (!executable || ![executable.stringByResolvingSymlinksInPath hasPrefix:[allowed stringByAppendingString:@"/"]]) return rejected(error,"invalid_bundle_executable"); + NSString *kind=configured("SKINOS_NATIVE_TARGET_KIND"); + NSString *executable=nil; + NSMutableArray *prefix=[NSMutableArray array]; + if ([kind isEqualToString:@"bundle"]) { + executable=resolved([NSBundle bundleWithPath:target].executablePath); + if (!contained(executable,target) || !regular_macho(executable)) return rejected(error,"invalid_bundle_executable"); + } else if ([kind isEqualToString:@"binary"]) { + executable=target; + if (!regular_macho(executable)) return rejected(error,"invalid_native_binary"); + } else if ([kind isEqualToString:@"script"]) { + executable=configured("SKINOS_NATIVE_INTERPRETER"); + if (!([executable isEqualToString:@"/bin/sh"] || [executable isEqualToString:@"/bin/bash"])) return rejected(error,"unsupported_script_interpreter"); + NSString *option=configured("SKINOS_NATIVE_INTERPRETER_ARGUMENT"); + if (option) { + NSCharacterSet *flags=[NSCharacterSet characterSetWithCharactersInString:@"eufvx"]; + if (![option hasPrefix:@"-"] || option.length<2 || [[option substringFromIndex:1] rangeOfCharacterFromSet:flags.invertedSet].location!=NSNotFound) return rejected(error,"unsupported_script_option"); + [prefix addObject:option]; + } + [prefix addObjectsFromArray:@[@"-c",scriptBootstrap,target]]; + } else return rejected(error,"unknown_native_target_kind"); + if (![resolved(configured("SKINOS_NATIVE_EXECUTABLE")) isEqualToString:([kind isEqualToString:@"script"] ? target : executable)]) return rejected(error,"selected_executable_changed"); + NSString *cwd=resolved(configured("SKINOS_GAME_WORKING_DIR")); + BOOL cwdDirectory=NO; + if (!cwd || !([cwd isEqualToString:root] || contained(cwd,root)) || ![[NSFileManager defaultManager] fileExistsAtPath:cwd isDirectory:&cwdDirectory] || !cwdDirectory) return rejected(error,"invalid_working_directory"); const char *adapter=getenv("SKINOS_ADAPTER_PATH"); const char *service=getenv("SKINOS_IPC_SERVICE_NAME"); if (!adapter || !service || strncmp(service,"dev.skinos.ipctool.",19)!=0) return rejected(error,"owned_broker_missing"); - // The parent worker is the sole lock owner. Valve/Wine may clear CLOEXEC, - // so require an explicit close action for this descriptor in the game. const char *lockValue=getenv("SKINOS_PROFILE_LOCK_FD"); char *lockEnd=NULL; errno=0; @@ -65,76 +122,83 @@ static id native_launch(id object,SEL selector,NSURL *url,NSWorkspaceLaunchOptio NSMutableDictionary *environment=[NSMutableDictionary dictionary]; for (char **entry=environ;entry && *entry;entry++) { NSString *line=[NSString stringWithUTF8String:*entry]; + if (!line) continue; NSRange equal=[line rangeOfString:@"="]; if (equal.location!=NSNotFound) environment[[line substringToIndex:equal.location]]=[line substringFromIndex:equal.location+1]; } [environment addEntriesFromDictionary:provided]; [environment removeObjectForKey:@"SKINOS_PROFILE_LOCK_FD"]; - // Deliberately inject only our documented IPC/directory adapter into this - // selected game. The game's Steam SDK and all authorization stay original. environment[@"DYLD_INSERT_LIBRARIES"]=[NSString stringWithUTF8String:adapter]; + environment[@"SKINOS_ADAPTER_PATH"]=[NSString stringWithUTF8String:adapter]; + environment[@"SKINOS_LAUNCH_APP_ID"]=[NSString stringWithUTF8String:expected]; environment[@"SKINOS_COMPONENT_ROLE"]=@"game"; environment[@"SKINOS_IPC_SERVICE_NAME"]=[NSString stringWithUTF8String:service]; - NSArray *arguments=configuration[NSWorkspaceLaunchConfigurationArguments] ?: @[]; + id arguments=configuration[NSWorkspaceLaunchConfigurationArguments] ?: @[]; const char *requestedArguments=getenv("SKINOS_GAME_ARGUMENTS_JSON"); if (requestedArguments) { NSData *data=[[NSString stringWithUTF8String:requestedArguments] dataUsingEncoding:NSUTF8StringEncoding]; - id parsed=[NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - if (![parsed isKindOfClass:[NSArray class]]) return rejected(error,"invalid_arguments"); - for (id argument in parsed) if (![argument isKindOfClass:[NSString class]]) return rejected(error,"invalid_argument_type"); - arguments=parsed; + arguments=[NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; } - if (arguments.count>128) return rejected(error,"argument_limit"); - char **argv=calloc(arguments.count+2,sizeof(char *)); + if (!string_arguments(arguments)) return rejected(error,"invalid_arguments"); + [prefix addObjectsFromArray:arguments]; + char **argv=calloc(prefix.count+2,sizeof(char *)); char **envp=calloc(environment.count+1,sizeof(char *)); if (!argv || !envp) { free(argv);free(envp);return rejected(error,"allocation_failure"); } argv[0]=strdup(executable.fileSystemRepresentation); - for (NSUInteger i=0;i +#include #include -int main(void) { +#include +#include +#include +static NSArray *observedArguments; +static NSDictionary *observedEnvironment; +static NSString *observedExecutable; +static NSString *observedCwd; +static int observedClose=-1; +static short observedFlags=0; +static pid_t observedGroup=-1; +static int spawnCalls=0; +static int failAttributeStep=0; +static int spawnError=0; +static int initializedAttributes=0; +static int destroyedAttributes=0; +static int fixture_spawn(pid_t *pid,const char *path,const posix_spawn_file_actions_t *actions,const posix_spawnattr_t *attributes,char *const argv[],char *const envp[]) { + (void)actions; + spawnCalls++; + observedFlags=0;observedGroup=-1; + if (attributes) { + assert(posix_spawnattr_getflags(attributes,&observedFlags)==0); + assert(posix_spawnattr_getpgroup(attributes,&observedGroup)==0); + } + if (spawnError) return spawnError; + observedExecutable=[NSString stringWithUTF8String:path]; + NSMutableArray *args=[NSMutableArray array]; + for (int i=0;argv[i];i++) [args addObject:[NSString stringWithUTF8String:argv[i]]]; + observedArguments=args; + NSMutableDictionary *env=[NSMutableDictionary dictionary]; + for (int i=0;envp[i];i++) { + NSString *line=[NSString stringWithUTF8String:envp[i]]; + NSRange equal=[line rangeOfString:@"="]; + env[[line substringToIndex:equal.location]]=[line substringFromIndex:equal.location+1]; + } + observedEnvironment=env; + *pid=4242; + return 0; +} +static int fixture_chdir(posix_spawn_file_actions_t *actions,const char *path) { + observedCwd=[NSString stringWithUTF8String:path]; + return posix_spawn_file_actions_addchdir_np(actions,path); +} +static int fixture_close(posix_spawn_file_actions_t *actions,int fd) { + observedClose=fd; + return posix_spawn_file_actions_addclose(actions,fd); +} +static int fixture_attr_init(posix_spawnattr_t *attributes) { + if (failAttributeStep==1) return EINVAL; + int result=posix_spawnattr_init(attributes); + if (!result) initializedAttributes++; + return result; +} +static int fixture_attr_group(posix_spawnattr_t *attributes,pid_t group) { + if (failAttributeStep==2) return EINVAL; + return posix_spawnattr_setpgroup(attributes,group); +} +static int fixture_attr_flags(posix_spawnattr_t *attributes,short flags) { + if (failAttributeStep==3) return EINVAL; + return posix_spawnattr_setflags(attributes,flags); +} +static int fixture_attr_destroy(posix_spawnattr_t *attributes) { + destroyedAttributes++; + return posix_spawnattr_destroy(attributes); +} +#define posix_spawnattr_init fixture_attr_init +#define posix_spawnattr_setpgroup fixture_attr_group +#define posix_spawnattr_setflags fixture_attr_flags +#define posix_spawnattr_destroy fixture_attr_destroy +#define posix_spawn fixture_spawn +#define posix_spawn_file_actions_addchdir_np fixture_chdir +#define posix_spawn_file_actions_addclose fixture_close +#import "launch.m" +#undef posix_spawn +#undef posix_spawnattr_init +#undef posix_spawnattr_setpgroup +#undef posix_spawnattr_setflags +#undef posix_spawnattr_destroy +#undef posix_spawn_file_actions_addchdir_np +#undef posix_spawn_file_actions_addclose + +static void write_macho(NSString *path) { + [[NSFileManager defaultManager] createDirectoryAtPath:path.stringByDeletingLastPathComponent withIntermediateDirectories:YES attributes:nil error:NULL]; + const unsigned char bytes[]={0xcf,0xfa,0xed,0xfe,7,0,0,1,0,0,0,0}; + assert([[NSData dataWithBytes:bytes length:sizeof(bytes)] writeToFile:path atomically:YES]); +} +static void select_target(NSString *target,NSString *executable,const char *kind) { + setenv("SKINOS_GAME_BUNDLE",target.fileSystemRepresentation,1); + setenv("SKINOS_NATIVE_EXECUTABLE",executable.fileSystemRepresentation,1); + setenv("SKINOS_NATIVE_TARGET_KIND",kind,1); +} +int main(int argc,const char *argv[]) { @autoreleasepool { + if (argc>1 && strcmp(argv[1],"--script-child")==0) { + NSMutableArray *values=[NSMutableArray arrayWithObject:[[NSFileManager defaultManager] currentDirectoryPath]]; + for (int i=2;i2); + char fd[32];snprintf(fd,sizeof(fd),"%d",lock);setenv("SKINOS_PROFILE_LOCK_FD",fd,1); + NSArray *arguments=@[@"two words",@"quote'\"",@"$(must-not-run)"]; + NSData *encoded=[NSJSONSerialization dataWithJSONObject:arguments options:0 error:NULL]; + setenv("SKINOS_GAME_ARGUMENTS_JSON",[[[NSString alloc] initWithData:encoded encoding:NSUTF8StringEncoding] UTF8String],1); + NSDictionary *context=@{NSWorkspaceLaunchConfigurationEnvironment:@{@"SteamAppId":@"123",@"SteamGameId":@"123",@"SteamClientLaunch":@"1"},NSWorkspaceLaunchConfigurationArguments:@[@"ignored-runtime-default"]}; + select_target(binary,binary,"binary"); + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:binary],0,@{},&error)==nil); + NSDictionary *wrong=@{NSWorkspaceLaunchConfigurationEnvironment:@{@"SteamAppId":@"124",@"SteamGameId":@"123"}}; + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:binary],0,wrong,&error)==nil); + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:binary],0,context,&error)!=nil); + assert([observedExecutable isEqualToString:binary]); + assert([observedArguments isEqualToArray:[@[binary] arrayByAddingObjectsFromArray:arguments]]); + assert([observedCwd isEqualToString:root]); + assert(observedClose==lock); + assert(!observedEnvironment[@"SKINOS_PROFILE_LOCK_FD"]); + assert([observedEnvironment[@"SteamAppId"] isEqualToString:@"123"]); + assert([observedEnvironment[@"DYLD_INSERT_LIBRARIES"] isEqualToString:@"/fixture/our-adapter.dylib"]); + assert(skinos_last_game_pid()==4242); + assert(skinos_last_game_group()==0 && observedGroup==-1 && observedFlags==0); + assert(skinos_last_game_started_microseconds()>0); + NSString *bundle=[root stringByAppendingPathComponent:@"Demo.app"]; + NSString *bundleBinary=[bundle stringByAppendingPathComponent:@"Contents/MacOS/Demo"]; + write_macho(bundleBinary); + NSDictionary *info=@{@"CFBundleExecutable":@"Demo",@"CFBundleIdentifier":@"dev.skinos.fixture",@"CFBundlePackageType":@"APPL"}; + assert([info writeToFile:[bundle stringByAppendingPathComponent:@"Contents/Info.plist"] atomically:YES]); + select_target(bundle,bundleBinary,"bundle"); + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:bundle],0,context,&error)!=nil); + assert([observedExecutable isEqualToString:bundleBinary]); + assert(skinos_last_game_group()==0 && observedGroup==-1 && observedFlags==0); + assert(native_open_urls(nil,NULL,@[[NSURL fileURLWithPath:binary]],[NSURL fileURLWithPath:bundle],0,context,&error)==nil); + NSString *script=[root stringByAppendingPathComponent:@"launcher with 'quote.sh"]; + NSString *source=@"#!/bin/sh\n[ \"${DYLD_INSERT_LIBRARIES+x}\" = x ] || exit 80\nexec \"$SKINOS_NATIVE_FIXTURE_CHILD\" --script-child \"$0\" \"$@\"\n"; + assert([source writeToFile:script atomically:YES encoding:NSUTF8StringEncoding error:NULL]); + select_target(script,script,"script"); + setenv("SKINOS_NATIVE_INTERPRETER","/bin/sh",1); + assert(native_open_urls(nil,NULL,@[[NSURL fileURLWithPath:script]],[NSURL fileURLWithPath:@"/Applications/TextEdit.app"],0,context,&error)!=nil); + assert([observedExecutable isEqualToString:@"/bin/sh"]); + assert(([observedArguments isEqualToArray:[@[@"/bin/sh",@"-c",scriptBootstrap,script] arrayByAddingObjectsFromArray:arguments]])); + assert(strcmp(skinos_last_game_executable(),"/bin/sh")==0); + assert(observedGroup==0 && observedFlags==POSIX_SPAWN_SETPGROUP); + assert(skinos_last_game_group()==4242); + assert(initializedAttributes==destroyedAttributes); + uint64_t publishedStart=skinos_last_game_started_microseconds(); + int callsBeforeFailure=spawnCalls; + for (int step=1;step<=3;step++) { + failAttributeStep=step; + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:script],0,context,&error)==nil); + assert(spawnCalls==callsBeforeFailure); + assert(skinos_last_game_started_microseconds()==publishedStart); + assert(skinos_last_game_group()==4242); + assert(initializedAttributes==destroyedAttributes); + } + failAttributeStep=0; + spawnError=EAGAIN; + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:script],0,context,&error)==nil); + assert(skinos_last_game_started_microseconds()==publishedStart); + assert(initializedAttributes==destroyedAttributes); + spawnError=0; + assert(native_open_urls(nil,NULL,@[[NSURL fileURLWithPath:script],[NSURL fileURLWithPath:binary]],nil,0,context,&error)==nil); + assert(native_open_urls(nil,NULL,@[[NSURL fileURLWithPath:binary]],nil,0,context,&error)==nil); + NSString *escape=[root stringByAppendingPathComponent:@"escaped.sh"]; + assert([[NSFileManager defaultManager] createSymbolicLinkAtPath:escape withDestinationPath:@"/bin/sh" error:NULL]); + select_target(escape,escape,"script"); + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:escape],0,context,&error)==nil); + select_target(script,script,"script"); + setenv("SKINOS_GAME_WORKING_DIR","/Applications",1); + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:script],0,context,&error)==nil); + setenv("SKINOS_GAME_WORKING_DIR",root.fileSystemRepresentation,1); + setenv("SKINOS_NATIVE_INTERPRETER","/usr/bin/env",1); + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:script],0,context,&error)==nil); + unsetenv("SKINOS_NATIVE_INTERPRETER_ARGUMENT"); + // Prove the bootstrap preserves the script's observable invocation. It + // executes only this fixture binary, with no Steam runtime or game. + for (NSString *shell in @[@"/bin/sh",@"/bin/bash"]) { + NSTask *task=[NSTask new]; + task.executableURL=[NSURL fileURLWithPath:shell]; + task.currentDirectoryURL=[NSURL fileURLWithPath:root]; + task.arguments=[@[@"-c",scriptBootstrap,script] arrayByAddingObjectsFromArray:arguments]; + task.environment=@{@"SKINOS_COMPONENT_ROLE":@"fixture",@"SKINOS_ADAPTER_PATH":@"",@"SKINOS_NATIVE_FIXTURE_CHILD":resolved([NSString stringWithUTF8String:argv[0]])}; + NSPipe *pipe=[NSPipe pipe];task.standardOutput=pipe; + assert([task launchAndReturnError:&error]); + NSData *output=[pipe.fileHandleForReading readDataToEndOfFile]; + [task waitUntilExit]; + assert(task.terminationStatus==0); + id result=[NSJSONSerialization JSONObjectWithData:output options:0 error:NULL]; + assert(([result isEqualToArray:[@[root,script] arrayByAddingObjectsFromArray:arguments]])); + } + close(lock); + assert([[NSFileManager defaultManager] removeItemAtPath:root error:NULL]); + puts("Native bundle, Mach-O and script plans preserve context/args/cwd; escapes, arbitrary URLs and invalid interpreters rejected; shell exec fixture passed"); } } diff --git a/crates/runtime-host/src/installation.rs b/crates/runtime-host/src/installation.rs index c68d2c2..b57158c 100644 --- a/crates/runtime-host/src/installation.rs +++ b/crates/runtime-host/src/installation.rs @@ -2,35 +2,20 @@ use serde_json::Value; use std::fs; use std::path::{Path, PathBuf}; -/// Verified from Brotato's shipped Godot project config and Steam UFS metadata. -/// Never derive a permission grant from arbitrary title text or allow all HOME. -pub fn native_save_root(request: &Value) -> Result, String> { - if request["command"] != "launch" || !request["compatibility"].is_null() { - return Ok(None); - } - if request["appid"] != 1942280 || request["name"] != "Brotato" { - return Err("This game's native save-directory policy has not been verified yet".into()); - } - let executable = Path::new(request["executable"].as_str().ok_or("Executable missing")?); - if executable.file_name() != Some(std::ffi::OsStr::new("Brotato.app")) { - return Err("Save policy does not match the native bundle".into()); +mod save_roots; +pub use save_roots::native_save_roots; + +/// Create only paths accepted by the read-only resolver, then re-resolve them +/// before the caller constructs its normal sandbox permissions. +pub fn prepare_native_save_roots(request: &Value) -> Result, String> { + let roots = native_save_roots(request)?; + for root in &roots { + save_roots::create_checked_directory(root)?; } - let base = PathBuf::from(std::env::var_os("HOME").ok_or("User home unavailable")?) - .join("Library/Application Support"); - let saves = base.join("Brotato"); - fs::create_dir_all(&saves).map_err(|e| e.to_string())?; - let canonical = saves.canonicalize().map_err(|e| e.to_string())?; - if canonical - != base - .canonicalize() - .map_err(|e| e.to_string())? - .join("Brotato") - { - return Err( - "Native save root is unexpectedly redirected; no broad access was granted".into(), - ); + if native_save_roots(request)? != roots { + return Err("Native save paths changed while preparing the launch".into()); } - Ok(Some(canonical)) + Ok(roots) } /// Register only a completed, caller-verified SkinOs installation in a private @@ -109,3 +94,15 @@ pub fn stage(request: &Value, state: &Path) -> Result, String> { .map_err(|e| e.to_string())?; Ok(Some(install)) } + +#[cfg(test)] +mod save_policy_regression_tests { + use super::*; + use serde_json::json; + + #[test] + fn unknown_native_game_without_cloud_metadata_has_no_extra_save_roots() { + let request = json!({"command": "launch", "appid": 424242, "name": "Fixture"}); + assert!(native_save_roots(&request).unwrap().is_empty()); + } +} diff --git a/crates/runtime-host/src/installation/save_roots.rs b/crates/runtime-host/src/installation/save_roots.rs new file mode 100644 index 0000000..ae7b4d4 --- /dev/null +++ b/crates/runtime-host/src/installation/save_roots.rs @@ -0,0 +1,855 @@ +//! Resolve Steam Auto-Cloud locations, not a game's complete write policy. +//! +//! Root meanings and user placeholders are documented by Valve at +//! https://partner.steamgames.com/doc/features/cloud#steam_auto-cloud . +//! Metadata is untrusted: only narrow descendants of supported save containers +//! are accepted. There is no AppID/title exception and no fallback to all HOME. + +use serde_json::Value; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +const MAX_RULES: usize = 128; +const MAX_PATH_BYTES: usize = 2048; + +/// Read-only resolution. Missing Auto-Cloud settings means no additional roots; +/// games using the Cloud API do not necessarily have Auto-Cloud settings. +pub fn native_save_roots(request: &Value) -> Result, String> { + if !is_native_launch(request) || request["cloud_config"].is_null() { + return Ok(Vec::new()); + } + let home = PathBuf::from(std::env::var_os("HOME").ok_or("User home unavailable")?); + resolve(request, &home) +} + +fn is_native_launch(request: &Value) -> bool { + request["command"] == "launch" && request["compatibility"].is_null() +} + +fn resolve(request: &Value, home: &Path) -> Result, String> { + if !is_native_launch(request) || request["cloud_config"].is_null() { + return Ok(Vec::new()); + } + let config = &request["cloud_config"]; + if !config.is_object() { + return Err("Steam Auto-Cloud configuration is not an object".into()); + } + let home = home + .canonicalize() + .map_err(|_| "Cannot resolve the native save home directory")?; + let files = numbered_entries(&config["savefiles"], "savefiles")?; + let overrides = numbered_entries(&config["rootoverrides"], "rootoverrides")?; + let mut roots = BTreeSet::new(); + for file in files { + if !applies_to_mac(file)? { + continue; + } + let mut root = required_string(file, "root")?.to_owned(); + let mut path = file["path"].as_str().unwrap_or("").to_owned(); + let matching: Vec<_> = overrides + .iter() + .copied() + .filter(|entry| { + entry["root"] + .as_str() + .is_some_and(|s| s.eq_ignore_ascii_case(&root)) + }) + .filter(|entry| entry["os"].as_str().is_some_and(is_mac)) + .collect(); + if matching.len() > 1 { + return Err("Steam Auto-Cloud has ambiguous macOS root overrides".into()); + } + if let Some(over) = matching.first() { + if over["oscompare"].as_str().is_some_and(|s| s != "=") { + return Err("Unsupported Steam Auto-Cloud OS comparison".into()); + } + root = required_string(over, "useinstead")?.to_owned(); + path = override_path(&path, over)?; + } + let Some(base) = root_base(&root, request, &home)? else { + continue; + }; + let relative = relative_path(&expand_user_path(&path, request)?)?; + let target = base.join(relative); + require_narrow_save_path(&target, &base, &home)?; + if !matches!( + root.to_ascii_lowercase().as_str(), + "gameinstall" | "appinstalldir" + ) { + require_game_identity(&target, &home, request)?; + } + check_existing_components(&target)?; + roots.insert(target); + } + Ok(roots.into_iter().collect()) +} + +fn numbered_entries<'a>(value: &'a Value, field: &str) -> Result, String> { + if value.is_null() { + return Ok(Vec::new()); + } + let entries = match value { + Value::Object(object) => { + if object.keys().any(|key| key.parse::().is_err()) { + return Err(format!("Unsupported Steam Auto-Cloud {field} entries")); + } + let mut entries: Vec<_> = object.iter().collect(); + entries.sort_by_key(|(key, _)| key.parse::().unwrap_or(u32::MAX)); + entries + .into_iter() + .map(|(_, value)| value) + .collect::>() + } + Value::Array(array) => array.iter().collect(), + _ => return Err(format!("Malformed Steam Auto-Cloud {field}")), + }; + if entries.len() > MAX_RULES || entries.iter().any(|entry| !entry.is_object()) { + return Err(format!( + "Unsupported Steam Auto-Cloud {field} size or shape" + )); + } + Ok(entries) +} + +fn required_string<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> { + value[key] + .as_str() + .ok_or_else(|| format!("Steam Auto-Cloud rule has no {key}")) +} + +fn is_mac(os: &str) -> bool { + matches!( + os.trim().to_ascii_lowercase().as_str(), + "macos" | "mac" | "osx" + ) +} + +fn applies_to_mac(rule: &Value) -> Result { + let field = if !rule["platforms"].is_null() { + &rule["platforms"] + } else if !rule["oslist"].is_null() { + &rule["oslist"] + } else { + &rule["os"] + }; + let values: Vec<&str> = match field { + Value::Null => return Ok(true), + Value::String(value) => value.split([',', ';']).collect(), + Value::Object(object) => object + .values() + .map(|value| { + value + .as_str() + .ok_or("Malformed Auto-Cloud platform restriction") + }) + .collect::>()?, + Value::Array(array) => array + .iter() + .map(|value| { + value + .as_str() + .ok_or("Malformed Auto-Cloud platform restriction") + }) + .collect::>()?, + _ => return Err("Unsupported Auto-Cloud platform restriction".into()), + }; + Ok(values.is_empty() + || values.iter().any(|value| { + is_mac(value) + || matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "all" | "cross-platform" + ) + })) +} + +fn override_path(original: &str, over: &Value) -> Result { + // Validate every supplied path, including replaced portions, so an override + // cannot hide traversal in otherwise discarded input. + relative_path(original)?; + let add = over["addpath"].as_str().unwrap_or(""); + relative_path(add)?; + let replace = match &over["replacepath"] { + Value::Null => false, + Value::Bool(value) => *value, + Value::String(value) if value == "1" => true, + Value::String(value) if value == "0" || value.is_empty() => false, + _ => return Err("Unsupported Auto-Cloud replace-path flag".into()), + }; + let mut path = if replace { + add.to_owned() + } else { + [add, original] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("/") + }; + for transform in numbered_entries(&over["pathtransforms"], "pathtransforms")? { + let find = required_string(transform, "find")?.replace('\\', "/"); + let replace = required_string(transform, "replace")?.replace('\\', "/"); + let find_path = relative_path(&find)?; + relative_path(&replace)?; + if find_path.as_os_str().is_empty() { + return Err("Empty Auto-Cloud path transform is unsupported".into()); + } + path = path.replace('\\', "/"); + // A transform may replace a directory prefix, never an arbitrary piece + // of a component. Reject ambiguous substitutions instead of guessing. + let original_path = relative_path(&path)?; + if let Ok(suffix) = original_path.strip_prefix(&find_path) { + path = relative_path(&replace)? + .join(suffix) + .to_string_lossy() + .into_owned(); + } else if path.starts_with(&find) { + return Err("Auto-Cloud transform does not end at a directory boundary".into()); + } + } + Ok(path) +} + +fn expand_user_path(path: &str, request: &Value) -> Result { + let mut expanded = path.to_owned(); + if path.contains("{64BitSteamID}") || path.contains("{Steam3AccountID}") { + let id = request["steamid"] + .as_str() + .and_then(|s| s.parse::().ok()) + .filter(|id| *id != 0) + .ok_or("Auto-Cloud user placeholder requires a valid SteamID")?; + expanded = expanded + .replace("{64BitSteamID}", &id.to_string()) + .replace("{Steam3AccountID}", &(id as u32).to_string()); + } + if expanded.contains(['{', '}', '%', '$', '~']) { + return Err("Unsupported or unresolved Steam Auto-Cloud path placeholder".into()); + } + Ok(expanded) +} + +fn relative_path(value: &str) -> Result { + if value.len() > MAX_PATH_BYTES + || value.chars().any(|c| c.is_control()) + || value.contains([':', '*', '?', '[', ']']) + { + return Err("Unsafe Steam Auto-Cloud directory path".into()); + } + let normalized = value.replace('\\', "/"); + let path = Path::new(&normalized); + if path.is_absolute() || path.components().any(|c| c == Component::ParentDir) { + return Err("Steam Auto-Cloud directory escapes its root".into()); + } + Ok(path + .components() + .filter(|c| *c != Component::CurDir) + .collect()) +} + +fn root_base(root: &str, request: &Value, home: &Path) -> Result, String> { + Ok(match root.to_ascii_lowercase().as_str() { + "machome" => Some(home.to_path_buf()), + "macappsupport" => Some(home.join("Library/Application Support")), + "macdocuments" => Some(home.join("Documents")), + "gameinstall" | "appinstalldir" => { + let install = request["install_dir"] + .as_str() + .ok_or("Missing verified installation")?; + let install = Path::new(install) + .canonicalize() + .map_err(|_| "Cannot resolve the verified installation for Auto-Cloud")?; + if !install.is_dir() { + return Err("Auto-Cloud installation root is not a directory".into()); + } + Some(install) + } + "winmydocuments" | "winappdatalocal" | "winappdatalocallow" | "winappdataroaming" + | "winsavedgames" | "windowshome" | "linuxhome" | "linuxxdgdatahome" => None, + "steamclouddocuments" => { + return Err( + "SteamCloudDocuments has no verified macOS mapping; no extra access was granted" + .into(), + ); + } + _ => return Err("Unsupported Steam Auto-Cloud root; no extra access was granted".into()), + }) +} + +fn require_narrow_save_path(target: &Path, base: &Path, home: &Path) -> Result<(), String> { + if target == base { + return Err("Auto-Cloud requires a specific save subdirectory, not a whole root".into()); + } + if base == home { + let support = home.join("Library/Application Support"); + let documents = home.join("Documents"); + let container = [&support, &documents] + .into_iter() + .find(|container| target.starts_with(container)) + .ok_or("MacHome Auto-Cloud path is outside supported save containers")?; + return require_narrow_save_path(target, container, home); + } + if base == home.join("Library/Application Support") { + let first = target.strip_prefix(base).ok().and_then(|p| p.iter().next()); + let first = first + .and_then(|p| p.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if matches!( + first.as_str(), + "steam" + | "skinos" + | "codex" + | "google" + | "mozilla" + | "firefox" + | "chromium" + | "bravesoftware" + | "1password" + | "keychains" + ) || first.starts_with("com.apple.") + || first.starts_with("dev.skinos") + { + return Err("Auto-Cloud path names a protected application data directory".into()); + } + } + if target.strip_prefix(base).ok().is_none_or(|p| { + p.components().any(|component| { + component + .as_os_str() + .to_str() + .is_none_or(|s| s.starts_with('.')) + }) + }) { + return Err("Auto-Cloud path is not a supported visible save subdirectory".into()); + } + Ok(()) +} + +fn identity_component(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +/// UFS may propose a directory, but its spelling alone is not a grant. Match a +/// complete path component to identity from the catalog/verified installation, +/// never to a value taken from the UFS rule itself or to a substring. +fn require_game_identity(target: &Path, home: &Path, request: &Value) -> Result<(), String> { + let containers = [ + home.join("Library/Application Support"), + home.join("Documents"), + ]; + let relative = containers + .iter() + .find_map(|base| target.strip_prefix(base).ok()) + .ok_or("Native save path is outside supported external containers")?; + let mut identities = BTreeSet::new(); + if let Some(name) = request["name"].as_str() { + identities.insert(identity_component(name)); + } + if let Some(install) = request["install_dir"].as_str() { + let install = Path::new(install) + .canonicalize() + .map_err(|_| "Cannot verify installation identity for native saves")?; + if !install.is_dir() { + return Err("Native save identity requires an installed directory".into()); + } + if let Some(name) = install.file_name().and_then(|name| name.to_str()) { + identities.insert(identity_component(name)); + } + if let Some(executable) = request["executable"].as_str() { + let executable = Path::new(executable) + .canonicalize() + .map_err(|_| "Cannot verify executable identity for native saves")?; + if executable == install || !executable.starts_with(&install) { + return Err("Native save identity executable escapes its installation".into()); + } + if let Some(name) = executable.file_stem().and_then(|name| name.to_str()) { + identities.insert(identity_component(name)); + } + for bundle in executable + .ancestors() + .take_while(|path| path.starts_with(&install) && *path != install) + { + if bundle + .extension() + .is_some_and(|extension| extension == "app") + && bundle.is_dir() + { + add_bundle_identities(bundle, &mut identities)?; + break; + } + } + } + } + identities.remove(""); + if relative + .iter() + .filter_map(|part| part.to_str()) + .any(|part| identities.contains(&identity_component(part))) + { + return Ok(()); + } + Err(format!( + "Steam Auto-Cloud directory '{}' does not match the selected game's identity; no extra save access was granted", + relative.display() + )) +} + +fn add_bundle_identities(bundle: &Path, identities: &mut BTreeSet) -> Result<(), String> { + let plist = bundle.join("Contents/Info.plist"); + if !plist.exists() { + return Ok(()); + } + let plist = plist + .canonicalize() + .map_err(|_| "Cannot verify native bundle save identity")?; + if !plist.starts_with(bundle) + || !plist.is_file() + || fs::metadata(&plist) + .map_err(|_| "Cannot inspect native bundle save identity")? + .len() + > 1024 * 1024 + { + return Err( + "Native bundle save identity metadata is outside the bundle or too large".into(), + ); + } + for key in ["CFBundleIdentifier", "CFBundleName"] { + let output = std::process::Command::new("/usr/bin/plutil") + .args(["-extract", key, "raw", "-o", "-", "--"]) + .arg(&plist) + .output() + .map_err(|_| "Cannot inspect native bundle save identity")?; + if output.status.success() && output.stdout.len() <= 4096 { + if let Ok(value) = std::str::from_utf8(&output.stdout) { + identities.insert(identity_component(value.trim())); + } + } + } + Ok(()) +} + +fn check_existing_components(path: &Path) -> Result<(), String> { + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err("Native save directory is redirected by a symlink".into()); + } + Ok(meta) if !meta.is_dir() => { + return Err("Native save path contains a non-directory".into()); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, + Err(_) => return Err("Cannot inspect native save directory safely".into()), + } + } + Ok(()) +} + +/// Check every existing component for redirection before and after creation. +/// Errors must never be converted to a grant on a broader parent directory. +pub(super) fn create_checked_directory(path: &Path) -> Result<(), String> { + check_existing_components(path)?; + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component); + if let Err(error) = fs::create_dir(¤t) { + if error.kind() != std::io::ErrorKind::AlreadyExists { + return Err("Cannot create native save directory".into()); + } + } + check_existing_components(¤t)?; + } + if fs::canonicalize(path).ok().as_deref() != Some(path) { + return Err("Native save directory changed while being created".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::sync::atomic::{AtomicU64, Ordering}; + + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "skinos-save-policy-{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(path.join("Library/Application Support")).unwrap(); + fs::create_dir(path.join("Documents")).unwrap(); + let bundle = path.join("Installed Fixture/Game.app/Contents"); + fs::create_dir_all(&bundle).unwrap(); + fs::write(bundle.join("Info.plist"), br#"CFBundleIdentifiercom.studio.gameCFBundleNameMacGame"#).unwrap(); + Self(path.canonicalize().unwrap()) + } + fn resolve(&self, cloud: Value) -> Result, String> { + self.resolve_as(cloud, "Game") + } + fn resolve_as(&self, cloud: Value, name: &str) -> Result, String> { + resolve( + &json!({"command":"launch", "appid":424242, "name":name,"install_dir":self.0.join("Installed Fixture"),"executable":self.0.join("Installed Fixture/Game.app"), "steamid":"76561197960265770", "cloud_config":cloud}), + &self.0, + ) + } + fn rule(&self, root: &str, path: &str) -> Result, String> { + self.resolve(json!({"savefiles":{"0":{"root":root,"path":path,"pattern":"*"}}})) + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn mac_saves_follow_metadata_without_an_appid_allowlist() { + let f = Fixture::new(); + let roots = f + .resolve_as( + json!({"savefiles":{"0":{"root":"MacAppSupport","path":"Brotato"}}}), + "Brotato", + ) + .unwrap(); + assert_eq!(roots, vec![f.0.join("Library/Application Support/Brotato")]); + assert!( + !roots[0].exists(), + "read-only resolution must not create directories" + ); + } + + #[test] + fn multiple_save_locations_are_deduplicated() { + let f = Fixture::new(); + let roots = f + .resolve(json!({"savefiles":{ + "0":{"root":"MacAppSupport","path":"Studio/Game","pattern":"*.sav"}, + "1":{"root":"MacAppSupport","path":"Studio/Game","pattern":"*.cfg"}, + "2":{"root":"MacDocuments","path":"My Games/Game"} + }})) + .unwrap(); + assert_eq!(roots.len(), 2); + assert!(roots.contains(&f.0.join("Library/Application Support/Studio/Game"))); + assert!(roots.contains(&f.0.join("Documents/My Games/Game"))); + } + + #[test] + fn unrelated_documents_are_not_authorized_by_cloud_metadata() { + let f = Fixture::new(); + assert!(f.rule("MacDocuments", "Taxes2026").is_err()); + assert!(f.rule("MacAppSupport", "AnotherGame/Backups").is_err()); + assert!( + f.rule("MacDocuments", "GameSaves").is_err(), + "substring identity is not proof" + ); + assert!(f.rule("MacDocuments", "SomeGame").is_err()); + assert!(f.rule("MacDocuments", "GAME/Saves").is_ok()); + } + + #[test] + fn external_paths_match_catalog_install_executable_or_bundle_identity() { + let f = Fixture::new(); + for path in [ + "Game/Saves", + "Installed Fixture/Saves", + "com.studio.game/Saves", + "MacGame/Saves", + ] { + assert!( + f.rule("MacDocuments", path).is_ok(), + "identity not recognized: {path}" + ); + } + assert!( + f.resolve_as( + json!({"savefiles":{"0":{"root":"MacAppSupport","path":"Space Adventure/Saves"}}}), + "Space: Adventure" + ) + .is_ok() + ); + } + + #[test] + fn external_paths_do_not_take_identity_from_an_escaping_executable() { + let f = Fixture::new(); + let outside = Fixture::new(); + let request = json!({"command":"launch","name":"Other","install_dir":f.0.join("Installed Fixture"),"executable":outside.0.join("Installed Fixture/Game.app"), + "cloud_config":{"savefiles":{"0":{"root":"MacDocuments","path":"Game"}}}}); + assert!(resolve(&request, &f.0).is_err()); + } + + #[test] + fn user_placeholders_use_the_selected_account() { + let f = Fixture::new(); + assert_eq!( + f.rule("MacAppSupport", "Game/{64BitSteamID}/{Steam3AccountID}") + .unwrap(), + vec![f.0.join("Library/Application Support/Game/76561197960265770/42")] + ); + } + + #[test] + fn windows_paths_require_a_mac_override() { + let f = Fixture::new(); + assert!( + f.rule("WinAppDataLocalLow", "Studio/Game") + .unwrap() + .is_empty() + ); + let roots = f.resolve(json!({ + "savefiles":{"0":{"root":"WinAppDataLocalLow","path":"Studio\\Game"}}, + "rootoverrides":{"0":{"root":"WinAppDataLocalLow","os":"MacOS","oscompare":"=","useinstead":"MacAppSupport","pathtransforms":{"0":{"find":"Studio/Game","replace":"com.studio.game"}}}} + })).unwrap(); + assert_eq!( + roots, + vec![f.0.join("Library/Application Support/com.studio.game")] + ); + } + + #[test] + fn override_add_and_replace_paths_are_applied() { + let f = Fixture::new(); + for (replace, expected) in [("0", "Studio/Game/Saves"), ("1", "Studio/Game")] { + let roots = f.resolve(json!({ + "savefiles":{"0":{"root":"WinMyDocuments","path":"Saves"}}, + "rootoverrides":{"0":{"root":"WinMyDocuments","os":"MacOS","useinstead":"MacDocuments","addpath":"Studio/Game","replacepath":replace}} + })).unwrap(); + assert_eq!(roots, vec![f.0.join("Documents").join(expected)]); + } + } + + #[test] + fn non_mac_platform_restrictions_are_respected() { + let f = Fixture::new(); + let roots = f + .resolve(json!({"savefiles":{ + "0":{"root":"MacAppSupport","path":"WindowsOnly","platforms":{"1":"Windows"}}, + "1":{"root":"MacAppSupport","path":"MacGame","platforms":{"1":"MacOS","2":"Linux"}}, + "2":{"root":"MacAppSupport","path":"Other","oslist":"windows,linux"} + }})) + .unwrap(); + assert_eq!(roots, vec![f.0.join("Library/Application Support/MacGame")]); + } + + #[test] + fn mac_home_only_maps_into_narrow_supported_save_containers() { + let f = Fixture::new(); + assert_eq!( + f.rule("MacHome", "Library/Application Support/Studio/Game") + .unwrap(), + vec![f.0.join("Library/Application Support/Studio/Game")] + ); + assert_eq!( + f.rule("MacHome", "Documents/Game").unwrap(), + vec![f.0.join("Documents/Game")] + ); + for path in [ + "", + ".", + "Library", + "Documents", + "Library/Application Support", + ".ssh", + "Desktop", + "Library/Keychains", + ] { + assert!(f.rule("MacHome", path).is_err(), "accepted {path}"); + } + } + + #[test] + fn broad_and_injected_paths_are_rejected() { + let f = Fixture::new(); + for root in ["MacAppSupport", "MacDocuments"] { + for path in [ + "", + ".", + "../escape", + "Game/../../escape", + "/tmp/escape", + "C:\\escape", + "\\escape", + "Game/*", + "Game\nOther", + "~", + "Game/{Unknown}", + "$HOME/Game", + "%USERPROFILE%/Game", + ] { + assert!(f.rule(root, path).is_err(), "accepted {root} {path}"); + } + } + } + + #[test] + fn sensitive_application_roots_are_not_granted_by_cloud_metadata() { + let f = Fixture::new(); + for path in [ + "Steam", + "Steam/config", + "SkinOS/accounts", + "Google/Chrome", + "com.apple.TCC", + ".secrets", + ] { + assert!(f.rule("MacAppSupport", path).is_err(), "accepted {path}"); + } + } + + #[test] + fn directory_symlinks_do_not_expand_the_save_grant() { + let f = Fixture::new(); + let outside = Fixture::new(); + std::os::unix::fs::symlink(&outside.0, f.0.join("Documents/Game")).unwrap(); + assert!( + f.rule("MacDocuments", "Game/Saves") + .unwrap_err() + .contains("symlink") + ); + assert!(!outside.0.join("Saves").exists()); + } + + #[test] + fn a_file_cannot_be_used_as_a_save_directory() { + let f = Fixture::new(); + fs::write(f.0.join("Documents/Game"), b"fixture").unwrap(); + assert!( + f.rule("MacDocuments", "Game") + .unwrap_err() + .contains("non-directory") + ); + } + + #[test] + fn unsupported_roots_and_ambiguous_overrides_have_diagnostics() { + let f = Fixture::new(); + assert!( + f.rule("SteamCloudDocuments", "Game") + .unwrap_err() + .contains("verified macOS mapping") + ); + assert!( + f.rule("UnexpectedRoot", "Game") + .unwrap_err() + .contains("Unsupported") + ); + assert!( + f.resolve(json!({ + "savefiles":{"0":{"root":"WinMyDocuments","path":"Game"}}, + "rootoverrides":{ + "0":{"root":"WinMyDocuments","os":"MacOS","useinstead":"MacDocuments"}, + "1":{"root":"WinMyDocuments","os":"MacOS","useinstead":"MacAppSupport"} + } + })) + .unwrap_err() + .contains("ambiguous") + ); + } + + #[test] + fn malformed_metadata_does_not_silently_expand_access() { + let f = Fixture::new(); + for cloud in [ + json!("bad"), + json!({"savefiles":true}), + json!({"savefiles":{"bad":{}}}), + json!({"savefiles":{"0":{"path":"Game"}}}), + ] { + assert!(f.resolve(cloud).is_err()); + } + let files: serde_json::Map = (0..=MAX_RULES) + .map(|i| (i.to_string(), json!({"root":"MacDocuments","path":"Game"}))) + .collect(); + assert!(f.resolve(json!({"savefiles":files})).is_err()); + } + + #[test] + fn invalid_path_transforms_are_rejected() { + let f = Fixture::new(); + for (find, replacement) in [ + ("", "Game"), + (".", "Game"), + ("Studio", "../escape"), + ("Stu", "Game"), + ] { + assert!(f.resolve(json!({ + "savefiles":{"0":{"root":"WinMyDocuments","path":"Studio/Game"}}, + "rootoverrides":{"0":{"root":"WinMyDocuments","os":"MacOS","useinstead":"MacDocuments","pathtransforms":{"0":{"find":find,"replace":replacement}}}} + })).is_err()); + } + } + + #[test] + fn path_transforms_preserve_directory_boundaries_and_subdirectories() { + let f = Fixture::new(); + let roots = f.resolve(json!({ + "savefiles":{"0":{"root":"WinMyDocuments","path":"Studio/Game/Saves"}}, + "rootoverrides":{"0":{"root":"WinMyDocuments","os":"MacOS","useinstead":"MacDocuments","pathtransforms":{"0":{"find":"Studio/Game/","replace":"com.studio.game"}}}} + })).unwrap(); + assert_eq!(roots, vec![f.0.join("Documents/com.studio.game/Saves")]); + } + + #[test] + fn game_install_rules_are_confined_to_specific_children() { + let f = Fixture::new(); + let install = f.0.join("Installed Game"); + fs::create_dir(&install).unwrap(); + let request = |path| { + json!({ + "command":"launch","install_dir":install, + "cloud_config":{"savefiles":{"0":{"root":"gameinstall","path":path}}} + }) + }; + assert_eq!( + resolve(&request("game/cfg"), &f.0).unwrap(), + vec![install.join("game/cfg")] + ); + for path in [".", "../Documents", "/tmp/Game"] { + assert!(resolve(&request(path), &f.0).is_err()); + } + std::os::unix::fs::symlink(f.0.join("Documents"), install.join("game")).unwrap(); + assert!(resolve(&request("game/cfg"), &f.0).is_err()); + } + + #[test] + fn preparation_creates_only_fixture_subdirectories_and_rechecks_redirects() { + let f = Fixture::new(); + let roots = f.rule("MacDocuments", "Studio/Game").unwrap(); + create_checked_directory(&roots[0]).unwrap(); + assert!(roots[0].is_dir()); + assert_eq!(f.rule("MacDocuments", "Studio/Game").unwrap(), roots); + fs::remove_dir(&roots[0]).unwrap(); + let outside = Fixture::new(); + std::os::unix::fs::symlink(&outside.0, &roots[0]).unwrap(); + assert!(create_checked_directory(&roots[0]).is_err()); + } + + #[test] + fn cloud_absence_and_non_native_commands_need_no_home_or_appid_rules() { + for request in [ + json!({"command":"launch"}), + json!({"command":"probe","cloud_config":true}), + json!({"command":"launch","compatibility":{},"cloud_config":true}), + ] { + assert!( + resolve(&request, Path::new("/fixture-does-not-exist")) + .unwrap() + .is_empty() + ); + } + } +} diff --git a/crates/runtime-host/src/main.rs b/crates/runtime-host/src/main.rs index 2a8cc81..3f218f2 100644 --- a/crates/runtime-host/src/main.rs +++ b/crates/runtime-host/src/main.rs @@ -14,6 +14,8 @@ mod input_focus; mod input_readiness; mod input_settings; mod installation; +mod native_launch; +mod native_lock_files; mod profile; mod runtime_bundle; mod wine_input_output; @@ -120,6 +122,36 @@ fn main() -> ExitCode { } fn run(args: &[std::ffi::OsString]) -> Result<()> { + if args + .first() + .is_some_and(|arg| arg == "inspect-native-launch") + { + use std::io::Read; + let mut bytes = Vec::new(); + std::io::stdin() + .lock() + .take(65537) + .read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + if bytes.len() > 65536 { + return Err("Launch description exceeds protocol limit".into()); + } + let request: Value = + serde_json::from_slice(&bytes).map_err(|_| "Invalid launch description")?; + let plan = native_launch::NativeLaunchPlan::from_request(&request)?; + let save_roots = installation::native_save_roots(&request)?; + println!( + "{}", + serde_json::json!({ + "kind":plan.kind,"target":plan.target,"executable":plan.executable, + "working_directory":plan.cwd,"interpreter":plan.interpreter, + "architectures":plan.target_architectures,"sdk":plan.sdk, + "sdk_probe_reason":plan.sdk_probe_reason,"save_roots":save_roots, + "game_started":false,"directories_created":false + }) + ); + return Ok(()); + } if args .first() .is_some_and(|arg| arg == "--prepare-bundled-runtime") @@ -205,11 +237,16 @@ fn run(args: &[std::ffi::OsString]) -> Result<()> { .filter(|v| !v.is_null()) .map(|value| serde_json::from_value(value.clone()).map_err(|e| e.to_string())) .transpose()?; + let native_plan = initial_command + .as_ref() + .filter(|(_, request)| request["command"] == "launch" && compatibility.is_none()) + .map(|(_, request)| native_launch::NativeLaunchPlan::from_request(request)) + .transpose()?; let game_data = initial_command .as_ref() - .map(|(_, request)| installation::native_save_root(request)) + .map(|(_, request)| installation::prepare_native_save_roots(request)) .transpose()? - .flatten(); + .unwrap_or_default(); let runtime = runtime.map(Ok).unwrap_or_else(owned_runtime)?; if !cfg!(target_arch = "aarch64") { return Err("The current dynamic adapter is audited for arm64 only.".into()); @@ -386,7 +423,7 @@ fn run(args: &[std::ffi::OsString]) -> Result<()> { &mut write_paths, ); } - if let Some(path) = &game_data { + for path in &game_data { grant(path.clone(), "SAVE_READ", &mut read_paths); grant(path.clone(), "SAVE_WRITE", &mut write_paths); } @@ -435,6 +472,38 @@ fn run(args: &[std::ffi::OsString]) -> Result<()> { // The compatibility runner is launched directly; it never hands off to Steam.app. profile.push_str("\n(deny process-exec (literal \"/usr/bin/open\"))\n"); } + if let Some(plan) = &native_plan { + read_paths.push(native_lock_files::SOURCE2_LOCK_FILES.into()); + write_paths.push(native_lock_files::SOURCE2_LOCK_FILES.into()); + // A script uses its declared interpreter and a small set of system + // path/platform utilities. Every grant names one executable, never a + // general bin directory or a path supplied by launch arguments. + let mut tools = Vec::new(); + if let Some(interpreter) = &plan.interpreter { + tools.push(interpreter.clone()); + tools.extend( + ["/usr/bin/basename", "/usr/bin/dirname", "/usr/bin/uname"].map(PathBuf::from), + ); + } + for path in tools { + let key = format!("NATIVE_TOOL_{}", parameters.len()); + read_paths.push(format!("(literal (param \"{key}\"))")); + exec_paths.push(format!("(literal (param \"{key}\"))")); + parameters.push((key, path)); + } + if plan.kind == "script" + || plan + .target_architectures + .iter() + .any(|arch| arch == "x86_64") + { + // Rosetta's system runtime is outside /System/Library. Intel game + // executables require this read even though no user-data access is needed. + read_paths + .push("(literal \"/Library/Apple/usr/libexec/oah/libRosettaRuntime\")".into()); + } + profile.push_str("\n(deny process-exec (literal \"/usr/bin/open\"))\n"); + } profile = profile .replace("; SKINOS_EXTRA_READ", &read_paths.join(" ")) .replace("; SKINOS_EXTRA_WRITE", &write_paths.join(" ")) @@ -444,9 +513,7 @@ fn run(args: &[std::ffi::OsString]) -> Result<()> { for (name, path) in parameters { command.arg("-D").arg(format!("{name}={}", path.display())); } - if let Some(path) = &game_data { - command.env("SKINOS_GAME_DATA_ROOT", path); - } + command.env_remove("SKINOS_GAME_DATA_ROOT"); command .arg("-D") .arg(format!("STATE_ROOT={}", state.display())) @@ -524,6 +591,12 @@ fn run(args: &[std::ffi::OsString]) -> Result<()> { ); } } + if let Some(plan) = &native_plan { + plan.configure(&mut command); + if plan.kind == "script" { + command.env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin"); + } + } if let Some(profile) = &account_profile { let lock_fd = profile.lock_fd(); command.env("SKINOS_PROFILE_LOCK_FD", lock_fd.to_string()); diff --git a/crates/runtime-host/src/native_launch.rs b/crates/runtime-host/src/native_launch.rs new file mode 100644 index 0000000..13d0967 --- /dev/null +++ b/crates/runtime-host/src/native_launch.rs @@ -0,0 +1,559 @@ +//! A native launch is selected by Steam metadata; filesystem discovery only +//! validates that selection and locates a uniquely associated Steamworks SDK. +use serde_json::Value; +use std::collections::BTreeSet; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Debug, Clone)] +pub struct NativeLaunchPlan { + pub install_root: PathBuf, + pub target: PathBuf, + pub executable: PathBuf, + pub kind: &'static str, + pub cwd: PathBuf, + pub interpreter: Option, + pub interpreter_argument: Option, + pub target_architectures: Vec, + pub sdk: Option, + pub sdk_architectures: Vec, + pub sdk_probe_reason: &'static str, +} + +impl NativeLaunchPlan { + pub fn from_request(request: &Value) -> Result { + let install_root = canonical_directory(request, "install_dir")?; + let target = canonical_member(&install_root, field(request, "executable")?)?; + let cwd = fs::canonicalize(field(request, "working_dir")?) + .map_err(|e| format!("Native working directory is unavailable: {e}"))?; + if !cwd.starts_with(&install_root) { + return Err("Native working directory escapes the selected installation".into()); + } + if !cwd.is_dir() { + return Err("Native working directory is not a directory".into()); + } + let arguments = request["arguments"] + .as_array() + .ok_or("Native launch arguments must be an array")?; + if arguments.len() > 128 + || arguments.iter().any(|arg| { + arg.as_str() + .is_none_or(|text| text.contains('\0') || text.len() > 16 * 1024) + }) + { + return Err("Native launch arguments are invalid or too large".into()); + } + let (kind, executable, interpreter, interpreter_argument) = if target.is_dir() { + if target.extension().is_none_or(|ext| ext != "app") { + return Err("Selected native directory is not an application bundle".into()); + } + let plist = canonical_member(&target, &target.join("Contents/Info.plist"))?; + if fs::metadata(&plist).map_err(|e| e.to_string())?.len() > 1024 * 1024 { + return Err("Native bundle Info.plist exceeds the metadata limit".into()); + } + let output = Command::new("/usr/bin/plutil") + .args(["-extract", "CFBundleExecutable", "raw", "-o", "-", "--"]) + .arg(plist) + .output() + .map_err(|e| format!("Cannot inspect native bundle executable: {e}"))?; + if !output.status.success() { + return Err("Native bundle has no valid CFBundleExecutable".into()); + } + let name = String::from_utf8(output.stdout) + .map_err(|_| "Native bundle executable name is not UTF-8")?; + let name = name.trim_end_matches(['\n', '\r']); + if name.is_empty() || name.contains(['/', '\\', '\0']) || matches!(name, "." | "..") { + return Err("Native bundle executable name is invalid".into()); + } + let executable = canonical_member(&target, &target.join("Contents/MacOS").join(name))?; + if !executable.is_file() || macho_architectures(&executable)?.is_empty() { + return Err("Native bundle executable is not a Mach-O file".into()); + } + ("bundle", executable, None, None) + } else if target.is_file() { + if !macho_architectures(&target)?.is_empty() { + ("binary", target.clone(), None, None) + } else { + let (interpreter, argument) = script_interpreter(&target)?; + ("script", target.clone(), Some(interpreter), argument) + } + } else { + return Err( + "Selected native target is not an ordinary file or application bundle".into(), + ); + }; + let target_architectures = if kind == "script" { + vec![] + } else { + macho_architectures(&executable)? + }; + let (candidate, mut sdk_probe_reason) = + resolve_sdk(&install_root, &target, &executable, kind); + let sdk_architectures = candidate + .as_ref() + .and_then(|path| macho_architectures(path).ok()) + .unwrap_or_default(); + let sdk = if candidate.is_some() && !sdk_architectures.iter().any(|arch| arch == "arm64") { + sdk_probe_reason = "incompatible"; + None + } else { + candidate + }; + Ok(Self { + install_root, + target, + executable, + kind, + cwd, + interpreter, + interpreter_argument, + target_architectures, + sdk, + sdk_architectures, + sdk_probe_reason, + }) + } + + pub fn configure(&self, command: &mut Command) { + command + .env("SKINOS_GAME_INSTALL_ROOT", &self.install_root) + .env("SKINOS_GAME_BUNDLE", &self.target) + .env("SKINOS_GAME_WORKING_DIR", &self.cwd) + .env("SKINOS_NATIVE_TARGET_KIND", self.kind) + .env("SKINOS_NATIVE_EXECUTABLE", &self.executable) + .env_remove("SKINOS_NATIVE_INTERPRETER") + .env_remove("SKINOS_NATIVE_INTERPRETER_ARGUMENT") + .env_remove("SKINOS_NATIVE_SDK_PATH") + .env("SKINOS_NATIVE_SDK_PROBE_SUPPORTED", "0") + .env("SKINOS_NATIVE_SDK_PROBE_REASON", self.sdk_probe_reason); + if let Some(sdk) = &self.sdk { + command.env("SKINOS_NATIVE_SDK_PATH", sdk); + if self.sdk_architectures.iter().any(|arch| arch == "arm64") { + command.env("SKINOS_NATIVE_SDK_PROBE_SUPPORTED", "1"); + } + } + if let Some(interpreter) = &self.interpreter { + command.env("SKINOS_NATIVE_INTERPRETER", interpreter); + } + if let Some(argument) = &self.interpreter_argument { + command.env("SKINOS_NATIVE_INTERPRETER_ARGUMENT", argument); + } + } +} + +fn field<'a>(request: &'a Value, name: &str) -> Result<&'a Path, String> { + request[name] + .as_str() + .map(Path::new) + .ok_or_else(|| format!("Native launch is missing {name}")) +} + +fn canonical_directory(request: &Value, name: &str) -> Result { + let path = fs::canonicalize(field(request, name)?) + .map_err(|e| format!("Native {name} is unavailable: {e}"))?; + if !path.is_dir() { + return Err(format!("Native {name} must be a directory")); + } + Ok(path) +} + +fn canonical_member(root: &Path, path: &Path) -> Result { + let path = + fs::canonicalize(path).map_err(|e| format!("Native launch file is unavailable: {e}"))?; + if !path.starts_with(root) || path == root { + return Err("Native launch path escapes the selected installation or bundle".into()); + } + Ok(path) +} + +fn script_interpreter(path: &Path) -> Result<(PathBuf, Option), String> { + let mut head = [0_u8; 512]; + let len = File::open(path) + .and_then(|mut file| file.read(&mut head)) + .map_err(|e| format!("Cannot inspect native script: {e}"))?; + let head = &head[..len]; + let end = head.iter().position(|byte| *byte == b'\n').unwrap_or(len); + if !head.starts_with(b"#!") || end == 512 { + return Err( + "Selected native file is neither Mach-O nor a script with a bounded shebang".into(), + ); + } + let line = std::str::from_utf8(&head[2..end]) + .map_err(|_| "Native script shebang is not UTF-8")? + .trim(); + let mut fields = line.split_whitespace(); + let interpreter = fields + .next() + .ok_or("Native script interpreter is missing")?; + let rest: Vec<&str> = fields.collect(); + let (interpreter, argument) = if interpreter == "/usr/bin/env" { + // Resolve only a single explicit, known interpreter. No PATH lookup, + // assignments, env -S splitting, or command-language evaluation. + let [name] = rest.as_slice() else { + return Err( + "Native env shebang must name one supported interpreter without options".into(), + ); + }; + let path = match *name { + "sh" => "/bin/sh", + "bash" => "/bin/bash", + _ => return Err("Native env shebang names an unsupported interpreter".into()), + }; + (path, None) + } else { + if rest.len() > 1 { + return Err("Native script shebang has ambiguous interpreter options".into()); + } + (interpreter, rest.first().map(|value| (*value).to_string())) + }; + if !matches!(interpreter, "/bin/sh" | "/bin/bash") { + return Err("Native script must declare /bin/sh or /bin/bash; other interpreters are not supported yet".into()); + } + if argument.as_ref().is_some_and(|option| { + !option.starts_with('-') + || option.len() < 2 + || !option[1..].bytes().all(|flag| b"eufvx".contains(&flag)) + }) { + return Err("Native shell shebang option is not supported".into()); + } + let interpreter = fs::canonicalize(interpreter) + .map_err(|e| format!("Native script interpreter is unavailable: {e}"))?; + Ok((interpreter, argument)) +} + +/// Read only the executable header, never load a game's code to classify it. +pub fn macho_architectures(path: &Path) -> Result, String> { + let mut bytes = [0_u8; 4096]; + let count = File::open(path) + .and_then(|mut file| file.read(&mut bytes)) + .map_err(|e| format!("Cannot inspect native binary header: {e}"))?; + let bytes = &bytes[..count]; + if bytes.len() < 8 { + return Ok(vec![]); + } + let be = |offset: usize| u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()); + let le = |offset: usize| u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); + let mut cpus = vec![]; + match be(0) { + 0xfeedface | 0xfeedfacf => cpus.push(be(4)), + 0xcefaedfe | 0xcffaedfe => cpus.push(le(4)), + magic @ (0xcafebabe | 0xbebafeca | 0xcafebabf | 0xbfbafeca) => { + let little = matches!(magic, 0xbebafeca | 0xbfbafeca); + let read = |offset| if little { le(offset) } else { be(offset) }; + let entries = read(4) as usize; + let width = if matches!(magic, 0xcafebabf | 0xbfbafeca) { + 32 + } else { + 20 + }; + if entries == 0 || entries > 64 || 8 + entries * width > bytes.len() { + return Err("Native universal binary has an invalid architecture table".into()); + } + for index in 0..entries { + cpus.push(read(8 + index * width)); + } + } + _ => return Ok(vec![]), + } + let mut result = BTreeSet::new(); + for cpu in cpus { + result.insert(match cpu { + 0x01000007 => "x86_64".into(), + 0x0100000c => "arm64".into(), + 7 => "i386".into(), + 12 => "arm".into(), + other => format!("cpu-{other}"), + }); + } + Ok(result.into_iter().collect()) +} + +fn resolve_sdk( + root: &Path, + target: &Path, + executable: &Path, + kind: &str, +) -> (Option, &'static str) { + resolve_sdk_with_limits(root, target, executable, kind, 100_000, 12) +} + +fn resolve_sdk_with_limits( + root: &Path, + target: &Path, + executable: &Path, + kind: &str, + max_entries: usize, + max_depth: u8, +) -> (Option, &'static str) { + let Some(parent) = executable.parent() else { + return (None, "invalid_sdk"); + }; + let nearby = parent.join("libsteam_api.dylib"); + if fs::symlink_metadata(&nearby).is_ok() { + return match validate_sdk(root, &nearby) { + Ok(path) => (Some(path), "ready"), + Err(_) => (None, "invalid_sdk"), + }; + } + // Optional SDK verification never blocks a valid Steam launch. Incomplete + // discovery cannot authorize an arbitrary library, so report why it skips. + let scan_root = if kind == "bundle" { target } else { root }; + let mut stack = vec![(scan_root.to_path_buf(), 0_u8)]; + let mut candidates = BTreeSet::new(); + let mut visited = 0_usize; + while let Some((directory, depth)) = stack.pop() { + let Ok(entries) = fs::read_dir(&directory) else { + return (None, "unavailable"); + }; + for entry in entries { + visited += 1; + if visited > max_entries { + return (None, "bounded_scan"); + } + let Ok(entry) = entry else { + return (None, "unavailable"); + }; + let path = entry.path(); + let Ok(metadata) = fs::symlink_metadata(&path) else { + return (None, "unavailable"); + }; + if entry.file_name() == "libsteam_api.dylib" { + let Ok(path) = validate_sdk(root, &path) else { + return (None, "invalid_sdk"); + }; + candidates.insert(path); + if candidates.len() > 1 { + return (None, "ambiguous"); + } + } else if metadata.is_dir() { + if depth >= max_depth { + return (None, "bounded_scan"); + } + stack.push((path, depth + 1)); + } + } + } + match candidates.len() { + 0 => (None, "missing"), + 1 => (candidates.into_iter().next(), "ready"), + _ => (None, "ambiguous"), + } +} + +fn validate_sdk(root: &Path, candidate: &Path) -> Result { + let path = canonical_member(root, candidate)?; + if !path.is_file() || macho_architectures(&path)?.is_empty() { + return Err("Native Steamworks SDK is not a Mach-O library".into()); + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "skinos-native-plan-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + Self(path.canonicalize().unwrap()) + } + fn binary(&self, relative: &str) -> PathBuf { + let path = self.0.join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, [0xcf, 0xfa, 0xed, 0xfe, 12, 0, 0, 1, 0, 0, 0, 0]).unwrap(); + path + } + fn request(&self, target: &Path) -> Value { + json!({"install_dir":self.0,"executable":target,"working_dir":self.0,"arguments":["-test","two words"]}) + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + #[test] + fn binary_with_nearby_sdk_preserves_architecture() { + let f = Fixture::new(); + let binary = f.binary("bin/game"); + let sdk = f.binary("bin/libsteam_api.dylib"); + let plan = NativeLaunchPlan::from_request(&f.request(&binary)).unwrap(); + assert_eq!(plan.kind, "binary"); + assert_eq!(plan.sdk, Some(sdk)); + assert_eq!(plan.sdk_architectures, ["arm64"]); + } + #[test] + fn script_keeps_declared_launcher_and_discovers_unique_nested_sdk() { + let f = Fixture::new(); + let script = f.0.join("launch.sh"); + fs::write(&script, b"#!/bin/sh\nexec ./bin/game \"$@\"\n").unwrap(); + let sdk = f.binary("bin/osx64/libsteam_api.dylib"); + let plan = NativeLaunchPlan::from_request(&f.request(&script)).unwrap(); + assert_eq!(plan.kind, "script"); + assert_eq!(plan.target, script); + assert_eq!(plan.interpreter, Some(fs::canonicalize("/bin/sh").unwrap())); + assert_eq!(plan.sdk, Some(sdk)); + } + #[test] + fn bundle_uses_info_plist_and_framework_sdk() { + let f = Fixture::new(); + let app = f.0.join("Game.app"); + let executable = f.binary("Game.app/Contents/MacOS/Game"); + fs::write(app.join("Contents/Info.plist"),br#"CFBundleExecutableGame"#).unwrap(); + let sdk = f.binary("Game.app/Contents/Frameworks/libsteam_api.dylib"); + let plan = NativeLaunchPlan::from_request(&f.request(&app)).unwrap(); + assert_eq!(plan.kind, "bundle"); + assert_eq!(plan.executable, executable); + assert_eq!(plan.sdk, Some(sdk)); + } + #[test] + fn no_sdk_is_a_valid_standalone_game() { + let f = Fixture::new(); + let target = f.binary("game"); + assert!( + NativeLaunchPlan::from_request(&f.request(&target)) + .unwrap() + .sdk + .is_none() + ); + } + #[test] + fn ambiguous_sdk_does_not_pick_arbitrary_file() { + let f = Fixture::new(); + let target = f.binary("game"); + f.binary("first/libsteam_api.dylib"); + f.binary("second/libsteam_api.dylib"); + let plan = NativeLaunchPlan::from_request(&f.request(&target)).unwrap(); + assert!(plan.sdk.is_none()); + assert_eq!(plan.sdk_probe_reason, "ambiguous"); + } + #[test] + fn executable_and_sdk_symlink_escapes_are_rejected() { + let f = Fixture::new(); + let outside = Fixture::new(); + let external = outside.binary("game"); + let link = f.0.join("game"); + std::os::unix::fs::symlink(&external, &link).unwrap(); + assert!( + NativeLaunchPlan::from_request(&f.request(&link)) + .unwrap_err() + .contains("escapes") + ); + let target = f.binary("actual-game"); + std::os::unix::fs::symlink(external, f.0.join("libsteam_api.dylib")).unwrap(); + let plan = NativeLaunchPlan::from_request(&f.request(&target)).unwrap(); + assert!(plan.sdk.is_none()); + assert_eq!(plan.sdk_probe_reason, "invalid_sdk"); + } + #[test] + fn cwd_outside_install_and_nonstring_arguments_are_rejected() { + let f = Fixture::new(); + let target = f.binary("game"); + let mut request = f.request(&target); + request["working_dir"] = json!("/Applications"); + assert!(NativeLaunchPlan::from_request(&request).is_err()); + request = f.request(&target); + request["arguments"] = json!([1]); + assert!(NativeLaunchPlan::from_request(&request).is_err()); + } + #[test] + fn sdk_scan_bounds_are_diagnostic_not_launch_failures() { + let f = Fixture::new(); + let binary = f.binary("bin/game"); + f.binary("deeper/nested/libsteam_api.dylib"); + for (entries, depth) in [(1, 12), (100_000, 0)] { + let (sdk, reason) = + resolve_sdk_with_limits(&f.0, &binary, &binary, "binary", entries, depth); + assert!(sdk.is_none()); + assert_eq!(reason, "bounded_scan"); + } + let deep = f.0.join(["nested"; 14].join("/")); + fs::create_dir_all(deep).unwrap(); + let plan = NativeLaunchPlan::from_request(&f.request(&binary)).unwrap(); + assert!(plan.sdk.is_none()); + assert_eq!(plan.sdk_probe_reason, "bounded_scan"); + } + #[test] + fn incompatible_sdk_does_not_block_native_target() { + let f = Fixture::new(); + let binary = f.binary("game"); + fs::write( + f.0.join("libsteam_api.dylib"), + [0xcf, 0xfa, 0xed, 0xfe, 7, 0, 0, 1], + ) + .unwrap(); + let plan = NativeLaunchPlan::from_request(&f.request(&binary)).unwrap(); + assert!(plan.sdk.is_none()); + assert_eq!(plan.sdk_probe_reason, "incompatible"); + assert_eq!(plan.sdk_architectures, ["x86_64"]); + } + #[test] + fn bundle_executable_cannot_escape_bundle() { + let f = Fixture::new(); + let binary = f.binary("outside/Game"); + let app = f.0.join("Game.app"); + fs::create_dir_all(app.join("Contents/MacOS")).unwrap(); + fs::write(app.join("Contents/Info.plist"), br#"CFBundleExecutableGame"#).unwrap(); + std::os::unix::fs::symlink(binary, app.join("Contents/MacOS/Game")).unwrap(); + assert!( + NativeLaunchPlan::from_request(&f.request(&app)) + .unwrap_err() + .contains("escapes") + ); + } + #[test] + fn universal_header_reports_both_probe_architectures() { + let f = Fixture::new(); + let path = f.0.join("universal"); + let mut bytes = vec![0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 2]; + for cpu in [0x01000007_u32, 0x0100000c] { + bytes.extend_from_slice(&cpu.to_be_bytes()); + bytes.extend_from_slice(&[0; 16]); + } + fs::write(&path, bytes).unwrap(); + assert_eq!(macho_architectures(&path).unwrap(), ["arm64", "x86_64"]); + } + #[test] + fn script_control_options_are_rejected_before_spawn() { + let f = Fixture::new(); + let path = f.0.join("launcher"); + fs::write(&path, b"#!/bin/bash -c\nexit 0\n").unwrap(); + assert!( + NativeLaunchPlan::from_request(&f.request(&path)) + .unwrap_err() + .contains("option") + ); + fs::write(&path, b"#!/bin/bash -eu\nexit 0\n").unwrap(); + assert_eq!( + NativeLaunchPlan::from_request(&f.request(&path)) + .unwrap() + .interpreter_argument + .as_deref(), + Some("-eu") + ); + } + #[test] + fn env_shebang_only_resolves_a_single_fixed_interpreter() { + let f = Fixture::new(); + let target = f.0.join("launch"); + fs::write(&target, b"#!/usr/bin/env bash\nexit 0\n").unwrap(); + assert_eq!( + NativeLaunchPlan::from_request(&f.request(&target)) + .unwrap() + .interpreter, + Some(fs::canonicalize("/bin/bash").unwrap()) + ); + fs::write(&target, b"#!/usr/bin/env PATH=/tmp bash\nexit 0\n").unwrap(); + assert!(NativeLaunchPlan::from_request(&f.request(&target)).is_err()); + } +} diff --git a/crates/runtime-host/src/native_lock_files.rs b/crates/runtime-host/src/native_lock_files.rs new file mode 100644 index 0000000..83f1c67 --- /dev/null +++ b/crates/runtime-host/src/native_lock_files.rs @@ -0,0 +1,107 @@ +//! Source 2 uses a numeric single-instance lock in the POSIX temporary root. +//! Permit that file family without allowing arbitrary temporary files or +//! changing the engine's kernel locking semantics. No existing lock is removed. +pub const SOURCE2_LOCK_FILES: &str = r#"(regex #"^(/private)?/tmp/source_engine_[0-9]+[.]lock$")"#; + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + process::Command, + time::{SystemTime, UNIX_EPOCH}, + }; + + #[test] + fn real_sandbox_allows_only_engine_locks_and_preserves_exclusion() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("skinos-lock-policy-test-{nonce}")); + let state = root.join("state"); + fs::create_dir_all(&state).unwrap(); + let state = state.canonicalize().unwrap(); + let canary = root.join("private-canary"); + fs::write(&canary, b"owned test data").unwrap(); + let lock = std::path::PathBuf::from(format!("/private/tmp/source_engine_{nonce}.lock")); + let unrelated = + std::path::PathBuf::from(format!("/private/tmp/source_engine_{nonce}Xlock")); + assert!(!lock.exists() && !unrelated.exists()); + let source = state.join("fixture.c"); + let executable = state.join("fixture"); + fs::write( + &source, + r#" +#include +#include +#include +#include +#include +int main(int argc, char **argv) { + if (argc != 5) return 10; + int expected = atoi(argv[4]); + int fd = open(argv[1], O_RDWR | O_CREAT | O_EXCL, 0600); + if (!expected) { + if (fd >= 0) { close(fd); return 11; } + return (errno == EPERM || errno == EACCES) ? 0 : 12; + } + if (fd < 0 || flock(fd, LOCK_EX | LOCK_NB)) return 13; + int second = open(argv[1], O_RDWR); + if (second < 0) return 14; + if (flock(second, LOCK_EX | LOCK_NB) == 0 || errno != EWOULDBLOCK) return 15; + close(second); + int other = open(argv[2], O_RDWR | O_CREAT | O_EXCL, 0600); + if (other >= 0) { close(other); return 16; } + if (errno != EPERM && errno != EACCES) return 17; + other = open(argv[3], O_RDONLY); + if (other >= 0) { close(other); return 18; } + if (errno != EPERM && errno != EACCES) return 19; + if (flock(fd, LOCK_UN)) return 20; + close(fd); + return 0; +} +"#, + ) + .unwrap(); + assert!( + Command::new("/usr/bin/clang") + .args(["-Wall", "-Wextra", "-Werror"]) + .arg(&source) + .arg("-o") + .arg(&executable) + .status() + .unwrap() + .success() + ); + for allowed in [false, true] { + let rule = if allowed { SOURCE2_LOCK_FILES } else { "" }; + let profile = crate::PROFILE + .replace("; SKINOS_EXTRA_READ", rule) + .replace("; SKINOS_EXTRA_WRITE", rule) + .replace("; SKINOS_EXTRA_EXEC", ""); + let file = state.join("profile.sb"); + fs::write(&file, profile).unwrap(); + let status = Command::new("/usr/bin/sandbox-exec") + .arg("-D") + .arg(format!("STATE_ROOT={}", state.display())) + .arg("-D") + .arg(format!("WORKER={}", executable.display())) + .arg("-f") + .arg(file) + .arg(&executable) + .arg(&lock) + .arg(&unrelated) + .arg(&canary) + .arg(if allowed { "1" } else { "0" }) + .status() + .unwrap(); + assert!(status.success(), "lock policy fixture failed: {status}"); + } + assert!(!unrelated.exists()); + // Only this test's freshly created, unique fixture is removed. The + // application never removes a game's real lock file. + fs::remove_file(lock).unwrap(); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/runtime-host/src/worker.rs b/crates/runtime-host/src/worker.rs index 10dd0e1..d2ac24e 100644 --- a/crates/runtime-host/src/worker.rs +++ b/crates/runtime-host/src/worker.rs @@ -14,6 +14,10 @@ use std::time::{Duration, Instant}; #[path = "worker/game_input_session.rs"] mod game_input_session; +#[path = "worker/launch_selection.rs"] +mod launch_selection; +#[path = "worker/native_process.rs"] +mod native_process; #[path = "worker/rumble_feedback_session.rs"] mod rumble_feedback_session; #[path = "worker/rumble_fixture_session.rs"] @@ -919,7 +923,26 @@ fn launch_game( let count = launch_options(apps, appid, options.as_mut_ptr(), options.len() as i32); let owned = subscribed(client_user, appid); if owned && (count > 0 || !request["compatibility"].is_null()) { - break if count > 0 { options[0] as u32 } else { 0 }; + let count = + usize::try_from(count.max(0)).map_err(|_| "Invalid launch option count")?; + let available = options + .get(..count) + .ok_or("Runtime launch option list exceeds its buffer")?; + let requested = request + .get("launch_option_id") + .filter(|value| !value.is_null()) + .map(|value| { + value + .as_u64() + .and_then(|id| u32::try_from(id).ok()) + .ok_or("Invalid selected launch option ID") + }) + .transpose()?; + break launch_selection::select( + requested, + available, + !request["compatibility"].is_null(), + )?; } if rerequests < 3 && Instant::now() > next_request { event( @@ -1001,10 +1024,29 @@ fn launch_game( let mut launch_result = false; let mut stop_attempt = None; let mut tracked: Option = None; + let script = std::env::var("SKINOS_NATIVE_TARGET_KIND").as_deref() == Ok("script"); + let mut script_tracker: Option = None; let mut persona = runtime.controls.persona.load(Ordering::SeqCst); apply_presence(runtime, persona, true)?; loop { refresh_presence(runtime, &mut persona)?; + if script + && script_tracker.is_none() + && let Some((pid, group, started)) = native_launcher_identity()? + { + script_tracker = Some(native_process::ScriptTracker::capture( + pid, + group, + u128::from(started), + Path::new( + request["install_dir"] + .as_str() + .ok_or("Installation missing")?, + ), + appid, + runtime.state, + )?); + } if seen_running && runtime.controls.stop.load(Ordering::SeqCst) && let Some(process) = tracked.as_ref() @@ -1058,19 +1100,29 @@ fn launch_game( } let active = running(client_user, &gameid); if active && launch_result && !seen_running { - let process = verify_game_process()?; - let pid = process.pid; - tracked = Some(process); - if !running(client_user, &gameid) { - return Err("Game exited during SDK verification".into()); + let candidate = if script { + script_tracker + .as_ref() + .map(|tracker| tracker.find_game()) + .transpose()? + .flatten() + } else { + Some(verify_game_process()?) + }; + if let Some(process) = candidate { + let pid = process.pid; + tracked = Some(process); + if !running(client_user, &gameid) { + return Err("Game exited during SDK verification".into()); + } + seen_running = true; + event( + "game_running", + true, + json!({"appid":appid,"steamid":steamid.to_string(),"pid":pid}), + ); + live_status(appid, "running", Some(steamid), None); } - seen_running = true; - event( - "game_running", - true, - json!({"appid":appid,"steamid":steamid.to_string(),"pid":pid}), - ); - live_status(appid, "running", Some(steamid), None); } if seen_running && !active { live_status(appid, "exited", Some(steamid), None); @@ -2806,6 +2858,34 @@ fn find_compat_game( Ok(None) } +fn native_launcher_identity() -> Result, String> { + unsafe { + let pointer = dlsym((-2_isize) as *mut c_void, c"skinos_last_game_pid".as_ptr()); + if pointer.is_null() { + return Err("Confined native launch adapter is unavailable".into()); + } + let function: unsafe extern "C" fn() -> i32 = std::mem::transmute(pointer); + let pid = function(); + if pid <= 0 { + return Ok(None); + } + let group = dlsym( + (-2_isize) as *mut c_void, + c"skinos_last_game_group".as_ptr(), + ); + let started = dlsym( + (-2_isize) as *mut c_void, + c"skinos_last_game_started_microseconds".as_ptr(), + ); + if group.is_null() || started.is_null() { + return Err("Native launch identity exports are missing".into()); + } + let group: unsafe extern "C" fn() -> i32 = std::mem::transmute(group); + let started: unsafe extern "C" fn() -> u64 = std::mem::transmute(started); + Ok(Some((pid, group(), started()))) + } +} + fn verify_game_process() -> Result { unsafe { let root = (-2_isize) as *mut c_void; @@ -3178,15 +3258,39 @@ fn verify_sdk( steamid: u64, ) -> Result<(), String> { use std::process::{Command, Stdio}; - let executable = Path::new(request["executable"].as_str().ok_or("Executable missing")?); - let sdk = if executable.is_dir() { - executable.join("Contents/MacOS/libsteam_api.dylib") - } else { - executable - .parent() - .ok_or("Executable parent missing")? - .join("libsteam_api.dylib") + let sdk = match std::env::var_os("SKINOS_NATIVE_SDK_PATH") { + Some(path) => std::path::PathBuf::from(path), + None => { + event( + "sdk_probe_skipped", + true, + json!({"reason":std::env::var("SKINOS_NATIVE_SDK_PROBE_REASON").unwrap_or_else(|_| "missing".into())}), + ); + return Ok(()); + } }; + let install = std::fs::canonicalize( + request["install_dir"] + .as_str() + .ok_or("Installation missing")?, + ) + .map_err(|error| error.to_string())?; + let sdk = sdk.canonicalize().map_err(|error| error.to_string())?; + if !sdk.starts_with(install) + || sdk + .file_name() + .is_none_or(|name| name != "libsteam_api.dylib") + { + return Err("Steamworks library escapes selected installation".into()); + } + if std::env::var("SKINOS_NATIVE_SDK_PROBE_SUPPORTED").as_deref() != Ok("1") { + event( + "sdk_probe_skipped", + true, + json!({"reason":"sdk_architecture_not_supported_by_arm64_probe"}), + ); + return Ok(()); + } let stderr = std::fs::File::create(runtime.state.join("sdk-probe.stderr.log")) .map_err(|e| e.to_string())?; let mut command = Command::new(runtime.state.join("skinos-sdk-probe")); diff --git a/crates/runtime-host/src/worker/launch_selection.rs b/crates/runtime-host/src/worker/launch_selection.rs new file mode 100644 index 0000000..8ef0148 --- /dev/null +++ b/crates/runtime-host/src/worker/launch_selection.rs @@ -0,0 +1,56 @@ +//! Keep the appinfo choice made by the client through the native runtime handoff. +pub(super) fn select( + requested: Option, + available: &[i32], + compatibility: bool, +) -> Result { + // Windows launches use the separately validated compatibility plan; the + // macOS runtime need not advertise those Windows-only option IDs. + if compatibility { + return Ok(requested.unwrap_or(0)); + } + if available.iter().any(|value| *value < 0) { + return Err("Runtime returned an invalid launch option ID".into()); + } + if let Some(requested) = requested { + return available + .iter() + .any(|value| *value as u32 == requested) + .then_some(requested) + .ok_or_else(|| { + "Выбранный вариант запуска изменился. Обновите библиотеку и повторите запуск".into() + }); + } + match available { + [only] => Ok(*only as u32), + [] => Err("No launch options available".into()), + _ => Err( + "В сохранённых данных нет ID варианта запуска. Обновите библиотеку перед запуском" + .into(), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_selected_sparse_option_instead_of_first_runtime_option() { + assert_eq!(select(Some(19), &[2, 19], false).unwrap(), 19); + } + + #[test] + fn mismatched_or_ambiguous_metadata_never_launches_another_option() { + assert!(select(Some(19), &[2, 7], false).is_err()); + assert!(select(None, &[2, 7], false).is_err()); + assert!(select(None, &[-1], false).is_err()); + } + + #[test] + fn legacy_single_option_and_windows_compatibility_remain_supported() { + assert_eq!(select(None, &[7], false).unwrap(), 7); + assert_eq!(select(Some(19), &[], true).unwrap(), 19); + assert!(select(None, &[], false).is_err()); + } +} diff --git a/crates/runtime-host/src/worker/native_process.rs b/crates/runtime-host/src/worker/native_process.rs new file mode 100644 index 0000000..1d4ff76 --- /dev/null +++ b/crates/runtime-host/src/worker/native_process.rs @@ -0,0 +1,669 @@ +//! Tracks the native executable reached by a confined launcher script. +//! Process arguments stay in the parent's private, zeroed buffer. Only the +//! selected launch's descendants are inspected, and no environment is logged. + +use super::{ProcBsdInfo, TrackedGameProcess, proc_listallpids, proc_pidinfo, process_arguments}; +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Read; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; + +const MAX_PROCESSES: usize = 65_536; +const MAX_ANCESTRY: usize = 64; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ProcessSnapshot { + pid: i32, + parent: i32, + group: i32, + start: (u64, u64), + zombie: bool, +} + +impl ProcessSnapshot { + fn capture(pid: i32) -> Option { + let mut info = ProcBsdInfo::default(); + let size = std::mem::size_of::() as i32; + // PROC_PIDTBSDINFO: the PID/parent/status fields are supplied by the + // kernel, not argv. The shared struct is checked against the SDK. + let actual = + unsafe { proc_pidinfo(pid, 3, 0, (&mut info as *mut ProcBsdInfo).cast(), size) }; + if actual != size || info.fields[3] != pid as u32 { + return None; + } + Some(Self { + pid, + parent: i32::try_from(info.fields[4]).ok()?, + // pbi_pgid follows pbi_nfiles in sys/proc_info.h. + group: i32::try_from(info.tail[1]).ok()?, + start: (info.start_seconds, info.start_microseconds), + zombie: info.fields[1] == 5, + }) + } + + fn start_micros(self) -> u128 { + u128::from(self.start.0) * 1_000_000 + u128::from(self.start.1) + } +} + +pub(super) struct ScriptTracker { + launcher: Option, + process_group: i32, + worker: ProcessSnapshot, + install_root: PathBuf, + appid: u32, + state_root: PathBuf, + spawned_after: u128, +} + +impl ScriptTracker { + pub(super) fn capture( + launcher_pid: i32, + process_group: i32, + spawned_after: u128, + install_root: &Path, + appid: u32, + state_root: &Path, + ) -> Result { + let worker = ProcessSnapshot::capture(std::process::id() as i32) + .ok_or("Cannot verify native launcher owner")?; + // These two values come from this worker's native adapter, which used + // POSIX_SPAWN_SETPGROUP with pgroup=0 for this successful script spawn. + // They must never come from the selected game's argv or public appid. + if process_group <= 0 || process_group != launcher_pid || spawned_after == 0 { + return Err("Native script has no proven isolated process group".into()); + } + let launcher = ProcessSnapshot::capture(launcher_pid); + if launcher.is_some_and(|launcher| !owned_launcher(launcher, worker, spawned_after)) { + return Err("Native launcher does not belong to this launch".into()); + } + let install_root = install_root + .canonicalize() + .map_err(|_| "Cannot resolve native installation directory")?; + if !install_root.is_dir() || !state_root.is_absolute() || appid == 0 { + return Err("Invalid native process tracking context".into()); + } + Ok(Self { + launcher, + process_group, + worker, + install_root, + appid, + state_root: state_root.to_owned(), + spawned_after, + }) + } + + pub(super) fn find_game(&self) -> Result, String> { + let snapshots = snapshots()?; + let mut candidates = Vec::new(); + for snapshot in snapshots.values() { + let Some(chain) = launch_origin( + snapshot.pid, + &snapshots, + self.launcher, + self.process_group, + self.worker, + self.spawned_after, + ) else { + continue; + }; + let Some(process) = TrackedGameProcess::capture(snapshot.pid) else { + continue; + }; + if process.start != snapshot.start { + continue; + } + let Ok(executable) = process.executable.canonicalize() else { + continue; + }; + if !native_game_path(&executable, &self.install_root) || !is_macho(&executable) { + continue; + } + // Only now read argv/environment: ownership, age, actual executable + // and containment have already excluded unrelated processes. + let Some(arguments) = process_arguments(process.pid) else { + continue; + }; + if !procargs_match_launch( + &arguments.0, + self.appid, + self.state_root.as_os_str().as_bytes(), + ) { + continue; + } + drop(arguments); + if !chain + .iter() + .all(|expected| ProcessSnapshot::capture(expected.pid) == Some(*expected)) + || ProcessSnapshot::capture(self.process_group) + != snapshots.get(&self.process_group).copied() + || !process.is_current() + { + continue; + } + candidates.push(process); + } + candidates.retain(TrackedGameProcess::is_current); + choose_candidate(candidates) + } +} + +fn owned_launcher(launcher: ProcessSnapshot, worker: ProcessSnapshot, spawned_after: u128) -> bool { + launcher.pid > 0 + && launcher.pid != worker.pid + && launcher.parent == worker.pid + && launcher.group == launcher.pid + && launcher.start >= worker.start + && launcher.start_micros() >= spawned_after +} + +fn launch_origin( + pid: i32, + snapshots: &BTreeMap, + launcher: Option, + process_group: i32, + worker: ProcessSnapshot, + spawned_after: u128, +) -> Option> { + if *snapshots.get(&worker.pid)? != worker { + return None; + } + let current_leader = snapshots.get(&process_group).copied(); + match (launcher, current_leader) { + (Some(expected), Some(actual)) + if expected.pid == actual.pid + && expected.start == actual.start + && owned_launcher(actual, worker, spawned_after) => {} + (_, None) => {} + // A leader that appeared after we captured an already-dead launcher, + // or whose start changed, is a reused PID/group rather than our spawn. + _ => return None, + } + let candidate = *snapshots.get(&pid)?; + if candidate.zombie || candidate.start_micros() < spawned_after { + return None; + } + if let Some(launcher) = launcher + && let Some(chain) = ancestry(pid, snapshots, launcher, worker) + { + return Some(chain); + } + // A fast script may background its child and exit before the first poll. + // Kernel group membership preserves the adapter's launch ownership even + // after reparenting. The caller additionally requires the private launch + // environment, exact current PID/start, and a contained native executable. + if candidate.group != process_group { + return None; + } + let mut proof = vec![candidate, worker]; + if let Some(leader) = current_leader { + proof.push(leader); + } + Some(proof) +} + +fn choose_candidate( + mut candidates: Vec, +) -> Result, String> { + match candidates.len() { + 0 => Ok(None), + 1 => Ok(candidates.pop()), + _ => Err("Launcher produced multiple possible game processes; refusing to guess".into()), + } +} + +fn snapshots() -> Result, String> { + let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) }; + if count <= 0 || count as usize > MAX_PROCESSES - 128 { + return Err("Cannot enumerate native game processes within the process limit".into()); + } + let mut pids = vec![0i32; count as usize + 128]; + let capacity = std::mem::size_of_val(pids.as_slice()) as i32; + let filled = unsafe { proc_listallpids(pids.as_mut_ptr().cast(), capacity) }; + if filled < 0 || filled as usize >= pids.len() { + return Err("Native process list changed during enumeration; retry verification".into()); + } + Ok(pids + .into_iter() + .take(filled as usize) + .filter(|pid| *pid > 0) + .filter_map(|pid| ProcessSnapshot::capture(pid).map(|snapshot| (pid, snapshot))) + .collect()) +} + +/// Every link must still be the captured instance and older than its child. +/// A reparented orphan cannot prove its former ancestry and is not adopted. +fn ancestry( + pid: i32, + snapshots: &BTreeMap, + launcher: ProcessSnapshot, + worker: ProcessSnapshot, +) -> Option> { + let actual_launcher = *snapshots.get(&launcher.pid)?; + let actual_worker = *snapshots.get(&worker.pid)?; + if actual_launcher != launcher || actual_worker != worker || launcher.parent != worker.pid { + return None; + } + let mut current = *snapshots.get(&pid)?; + let mut seen = BTreeSet::new(); + let mut chain = Vec::new(); + for _ in 0..MAX_ANCESTRY { + if current.zombie || !seen.insert(current.pid) || current.start < launcher.start { + return None; + } + chain.push(current); + if current.pid == launcher.pid { + chain.push(worker); + return Some(chain); + } + let parent = *snapshots.get(¤t.parent)?; + if parent.start > current.start { + return None; + } + current = parent; + } + None +} + +fn native_game_path(executable: &Path, install_root: &Path) -> bool { + let Ok(relative) = executable.strip_prefix(install_root) else { + return false; + }; + if relative.as_os_str().is_empty() { + return false; + } + if relative.components().any(|component| { + let name = component.as_os_str().to_string_lossy().to_ascii_lowercase(); + name.ends_with(".framework") || name.ends_with(".xpc") + }) { + return false; + } + let Some(name) = executable.file_name() else { + return false; + }; + let name = name.to_string_lossy().to_ascii_lowercase(); + !matches!( + name.as_str(), + "sh" | "bash" | "zsh" | "dash" | "fish" | "env" | "updater" | "launcher" | "bootstrap" + ) && !name.ends_with(" helper") + && !name.ends_with("_helper") + && !name.ends_with("-helper") + && ![ + "crashpad", + "crashhandler", + "crashreport", + "steamwebhelper", + "gameoverlayui", + ] + .iter() + .any(|helper| name.contains(helper)) +} + +fn is_macho(path: &Path) -> bool { + let mut magic = [0u8; 4]; + if !std::fs::File::open(path).is_ok_and(|mut file| file.read_exact(&mut magic).is_ok()) { + return false; + } + matches!( + u32::from_be_bytes(magic), + 0xfeedfacf | 0xcffaedfe | 0xcafebabe | 0xbebafeca | 0xcafebabf | 0xbfbafeca + ) +} + +fn take_string<'a>(bytes: &'a [u8], offset: &mut usize) -> Option<&'a [u8]> { + let tail = bytes.get(*offset..)?; + let end = tail.iter().position(|byte| *byte == 0)?; + *offset += end + 1; + Some(&tail[..end]) +} + +fn procargs_match_launch(bytes: &[u8], appid: u32, state_root: &[u8]) -> bool { + let Some(raw) = bytes.get(..4).and_then(|bytes| bytes.try_into().ok()) else { + return false; + }; + let argc = i32::from_ne_bytes(raw); + if !(1..=65_536).contains(&argc) { + return false; + } + let mut offset = 4; + if take_string(bytes, &mut offset).is_none_or(<[u8]>::is_empty) { + return false; + } + while bytes.get(offset) == Some(&0) { + offset += 1; + } + for _ in 0..argc { + let Some(argument) = take_string(bytes, &mut offset) else { + return false; + }; + // Chromium/Electron renderer and utility children are not the game. + if argument == b"--type" || argument.starts_with(b"--type=") { + return false; + } + } + // Preserve exact argc including empty arguments before skipping padding. + while bytes.get(offset) == Some(&0) { + offset += 1; + } + let expected_appid = appid.to_string(); + let mut appid_seen = false; + let mut state_seen = false; + while let Some(entry) = take_string(bytes, &mut offset) { + if entry.is_empty() { + return appid_seen && state_seen; + } + if let Some(value) = entry.strip_prefix(b"SteamAppId=") { + if appid_seen || value != expected_appid.as_bytes() { + return false; + } + appid_seen = true; + } else if let Some(value) = entry.strip_prefix(b"SKINOS_RUNTIME_STATE_ROOT=") { + if state_seen || value != state_root { + return false; + } + state_seen = true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kernel_tracks_own_background_fixture_after_script_exits() { + use std::os::unix::process::CommandExt; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + + struct Fixture { + root: PathBuf, + spawned: Option, + } + impl Drop for Fixture { + fn drop(&mut self) { + // The fixture exits itself after one second. Never signal a + // process from this test, including on an assertion failure. + if let Some(spawned) = self.spawned { + std::thread::sleep( + Duration::from_millis(1200).saturating_sub(spawned.elapsed()), + ); + } + let _ = std::fs::remove_dir_all(&self.root); + } + } + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let mut fixture = Fixture { + root: std::env::temp_dir().join(format!( + "skinos-script-group-{}-{nonce}", + std::process::id() + )), + spawned: None, + }; + let install = fixture.root.join("install"); + let state = fixture.root.join("private-state"); + std::fs::create_dir_all(&install).unwrap(); + std::fs::create_dir(&state).unwrap(); + let game = install.join("game"); + std::fs::copy("/bin/sleep", &game).unwrap(); + let script = install.join("launch.sh"); + let pid_file = fixture.root.join("fixture.pid"); + std::fs::write( + &script, + b"#!/bin/sh\n\"$1\" 1 &\nprintf '%s\\n' \"$!\" > \"$2\"\n", + ) + .unwrap(); + let spawned_after = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_micros(); + fixture.spawned = Some(Instant::now()); + let mut launcher = Command::new("/bin/sh") + .arg(&script) + .arg(&game) + .arg(&pid_file) + .env("SteamAppId", "424242") + .env("SKINOS_RUNTIME_STATE_ROOT", &state) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .process_group(0) + .spawn() + .unwrap(); + let launcher_pid = launcher.id() as i32; + assert!(launcher.wait().unwrap().success()); + let game_pid: i32 = std::fs::read_to_string(&pid_file) + .unwrap() + .trim() + .parse() + .unwrap(); + let tracker = ScriptTracker::capture( + launcher_pid, + launcher_pid, + spawned_after, + &install, + 424242, + &state, + ) + .unwrap(); + assert!( + tracker.launcher.is_none(), + "launcher must be reaped before this check" + ); + let selected = tracker + .find_game() + .unwrap() + .expect("background fixture must remain identifiable after shell exit"); + assert_eq!(selected.pid, game_pid); + assert_eq!( + selected.executable.canonicalize().unwrap(), + game.canonicalize().unwrap() + ); + } + + fn process(pid: i32, parent: i32, seconds: u64) -> ProcessSnapshot { + ProcessSnapshot { + pid, + parent, + group: pid, + start: (seconds, 0), + zombie: false, + } + } + + fn tree() -> BTreeMap { + [ + process(1, 0, 1), + process(10, 1, 10), + process(20, 10, 20), + process(30, 20, 21), + process(40, 30, 22), + process(50, 10, 23), + ] + .into_iter() + .map(|snapshot| (snapshot.pid, snapshot)) + .collect() + } + + #[test] + fn same_pid_exec_and_proven_descendants_keep_identity() { + let tree = tree(); + let launcher = tree[&20]; + let worker = tree[&10]; + assert!(ancestry(20, &tree, launcher, worker).is_some()); + assert!(ancestry(40, &tree, launcher, worker).is_some()); + assert!(ancestry(50, &tree, launcher, worker).is_none()); + } + + #[test] + fn launcher_must_be_created_after_request_by_current_worker() { + let worker = process(10, 1, 10); + let launcher = process(20, 10, 20); + assert!(owned_launcher(launcher, worker, 20_000_000)); + assert!(!owned_launcher(launcher, worker, 20_000_001)); + assert!(!owned_launcher(process(20, 99, 20), worker, 0)); + assert!(!owned_launcher(worker, worker, 0)); + } + + #[test] + fn multiple_real_game_candidates_are_ambiguous() { + fn candidate(pid: i32) -> TrackedGameProcess { + TrackedGameProcess { + pid, + start: (20, 0), + executable: PathBuf::from("/Games/Game/game"), + executable_identity: (1, 2), + } + } + assert!(choose_candidate(Vec::new()).unwrap().is_none()); + assert_eq!( + choose_candidate(vec![candidate(20)]).unwrap().unwrap().pid, + 20 + ); + assert!(choose_candidate(vec![candidate(20), candidate(30)]).is_err()); + } + + #[test] + fn reused_or_reparented_processes_cannot_be_adopted() { + let mut tree = tree(); + let launcher = tree[&20]; + let worker = tree[&10]; + tree.get_mut(&20).unwrap().start = (30, 0); + assert!(ancestry(20, &tree, launcher, worker).is_none()); + assert!(ancestry(40, &tree, launcher, worker).is_none()); + tree.insert(20, launcher); + tree.get_mut(&30).unwrap().start = (30, 0); + assert!(ancestry(40, &tree, launcher, worker).is_none()); + tree.insert(30, process(30, 1, 21)); + assert!(ancestry(40, &tree, launcher, worker).is_none()); + } + + #[test] + fn orphaned_child_is_found_only_in_the_proven_adapter_group() { + let mut tree = tree(); + let launcher = tree[&20]; + let worker = tree[&10]; + tree.remove(&20); + let orphan = tree.get_mut(&40).unwrap(); + orphan.parent = 1; + orphan.group = 20; + // Both cases are valid: we observed the launcher, or it had already + // exited before capture and only the adapter's group proof remains. + assert!(launch_origin(40, &tree, Some(launcher), 20, worker, 20_000_000).is_some()); + assert!(launch_origin(40, &tree, None, 20, worker, 20_000_000).is_some()); + tree.get_mut(&40).unwrap().group = 99; + assert!(launch_origin(40, &tree, None, 20, worker, 20_000_000).is_none()); + tree.get_mut(&40).unwrap().group = 20; + tree.get_mut(&40).unwrap().start = (19, 0); + assert!(launch_origin(40, &tree, None, 20, worker, 20_000_000).is_none()); + } + + #[test] + fn reused_group_leader_and_stale_worker_invalidate_group_proof() { + let mut tree = tree(); + let launcher = tree[&20]; + let worker = tree[&10]; + tree.get_mut(&40).unwrap().group = 20; + assert!(launch_origin(40, &tree, Some(launcher), 20, worker, 20_000_000).is_some()); + tree.get_mut(&20).unwrap().start = (30, 0); + assert!(launch_origin(40, &tree, Some(launcher), 20, worker, 20_000_000).is_none()); + assert!(launch_origin(40, &tree, None, 20, worker, 20_000_000).is_none()); + tree.remove(&20); + tree.get_mut(&10).unwrap().start = (30, 0); + assert!(launch_origin(40, &tree, None, 20, worker, 20_000_000).is_none()); + } + + #[test] + fn zombie_group_leader_does_not_hide_a_live_child() { + let mut tree = tree(); + let launcher = tree[&20]; + let worker = tree[&10]; + tree.get_mut(&20).unwrap().zombie = true; + tree.get_mut(&40).unwrap().group = 20; + assert!(launch_origin(40, &tree, Some(launcher), 20, worker, 20_000_000).is_some()); + assert!(launch_origin(20, &tree, Some(launcher), 20, worker, 20_000_000).is_none()); + } + + #[test] + fn zombies_and_ancestry_cycles_fail_closed() { + let mut tree = tree(); + let launcher = tree[&20]; + let worker = tree[&10]; + tree.get_mut(&40).unwrap().zombie = true; + assert!(ancestry(40, &tree, launcher, worker).is_none()); + tree.get_mut(&40).unwrap().zombie = false; + tree.get_mut(&30).unwrap().parent = 40; + tree.get_mut(&30).unwrap().start = (22, 0); + assert!(ancestry(40, &tree, launcher, worker).is_none()); + } + + fn procargs(arguments: &[&[u8]], environment: &[&[u8]]) -> Vec { + let mut bytes = (arguments.len() as i32).to_ne_bytes().to_vec(); + bytes.extend_from_slice(b"/Games/Game/game\0\0\0"); + for argument in arguments { + bytes.extend_from_slice(argument); + bytes.push(0); + } + bytes.extend_from_slice(&[0, 0]); + for entry in environment { + bytes.extend_from_slice(entry); + bytes.push(0); + } + bytes.push(0); + bytes + } + + #[test] + fn exact_environment_is_required_and_argv_cannot_spoof_it() { + let env: &[&[u8]] = &[ + b"SteamAppId=42", + b"SKINOS_RUNTIME_STATE_ROOT=/launch/unique", + ]; + let bytes = procargs(&[b"game", b""], env); + assert!(procargs_match_launch(&bytes, 42, b"/launch/unique")); + assert!(!procargs_match_launch(&bytes, 43, b"/launch/unique")); + assert!(!procargs_match_launch(&bytes, 42, b"/other/launch")); + assert!(!procargs_match_launch( + &procargs(env, &[]), + 42, + b"/launch/unique" + )); + assert!(!procargs_match_launch( + &procargs(&[b"game"], &[env[0], env[1], env[0]]), + 42, + b"/launch/unique" + )); + assert!(!procargs_match_launch( + &procargs(&[b"game", b"--type=renderer"], env), + 42, + b"/launch/unique" + )); + } + + #[test] + fn outside_install_shells_and_helpers_are_not_game_candidates() { + let root = Path::new("/Games/Game"); + assert!(native_game_path( + Path::new("/Games/Game/Game.app/Contents/MacOS/Game"), + root + )); + for path in [ + "/Games/Other/game", + "/Games/Game2/game", + "/Games/Game/sh", + "/Games/Game/UnityCrashHandler64", + "/Games/Game/Game Helper.app/Contents/MacOS/crashpad_handler", + "/Games/Game/Engine.framework/engine", + "/Games/Game/Renderer.xpc/renderer", + "/Games/Game/updater", + "/Games/Game/Game Helper", + ] { + assert!(!native_game_path(Path::new(path), root), "{path}"); + } + } +} diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 483e580..58cef4b 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -229,8 +229,8 @@ cat > "$SKINOS_OUTPUT/Contents/Info.plist" <<'PLIST' CFBundleExecutableSkinOS CFBundleIconFileSkinOS CFBundlePackageTypeAPPL - CFBundleShortVersionString0.1.4 - CFBundleVersion5 + CFBundleShortVersionString0.1.5 + CFBundleVersion6 LSMinimumSystemVersion15.0 NSHighResolutionCapable NSPrincipalClassNSApplication From 6eee09a86458327e653820c9c5dbaf60e9ace206 Mon Sep 17 00:00:00 2001 From: Brekit <155304101+Brekit@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:15:21 +0300 Subject: [PATCH 4/5] Fix native launch edge cases and stabilize process coverage --- crates/client-core/src/model.rs | 30 +++--- crates/client-core/src/operations.rs | 89 +++++++++++++---- .../vendor/steam-cm-protocol/CHANGELOG.md | 14 +++ .../vendor/steam-cm-protocol/README.md | 17 ++++ .../steam-cm-protocol/SKINOS-PATCHES.md | 3 + .../src/installation/save_roots.rs | 51 +++++++++- .../runtime-host/src/worker/native_process.rs | 95 ++++++++++++++++--- 7 files changed, 246 insertions(+), 53 deletions(-) diff --git a/crates/client-core/src/model.rs b/crates/client-core/src/model.rs index 03c9310..328ea2f 100644 --- a/crates/client-core/src/model.rs +++ b/crates/client-core/src/model.rs @@ -78,6 +78,21 @@ pub struct LaunchEntry { pub betakey: Option, } +impl From 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 for Game { fn from(game: steam_cm_protocol::ProtocolGame) -> Self { let public_default = |entry: &&steam_cm_protocol::LaunchEntry| { @@ -122,20 +137,7 @@ impl From for Game { "https://cdn.akamai.steamstatic.com/steam/apps/{}/header.jpg", game.appid ), - launch: game - .launch - .into_iter() - .map(|entry| LaunchEntry { - 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, - }) - .collect(), + launch: game.launch.into_iter().map(LaunchEntry::from).collect(), cloud_config: game.cloud_config, launch_metadata_known: true, } diff --git a/crates/client-core/src/operations.rs b/crates/client-core/src/operations.rs index 65f1999..eb4c2c9 100644 --- a/crates/client-core/src/operations.rs +++ b/crates/client-core/src/operations.rs @@ -452,22 +452,31 @@ fn game_with_fresh_launch_metadata( refreshed.launch = metadata .launch .into_iter() - .map(|entry| crate::model::LaunchEntry { - 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, - }) + .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>, +) -> Result> { + 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, @@ -835,15 +844,13 @@ async fn launch( record.appid == game.appid, "Installed game identity mismatch" ); - let refreshed; - let game = if !windows && !game.launch_metadata_known { - let metadata = skinos_downloader::application_metadata(&context.connection, game.appid) - .await.context("Не удалось обновить данные запуска и сохранений. Обновите библиотеку и повторите запуск")?; - refreshed = game_with_fresh_launch_metadata(game, metadata)?; - &refreshed - } else { - game - }; + 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()) { @@ -1190,6 +1197,50 @@ mod tests { .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(¤t_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() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/client-core/vendor/steam-cm-protocol/CHANGELOG.md b/crates/client-core/vendor/steam-cm-protocol/CHANGELOG.md index 5c2bacd..34ec132 100644 --- a/crates/client-core/vendor/steam-cm-protocol/CHANGELOG.md +++ b/crates/client-core/vendor/steam-cm-protocol/CHANGELOG.md @@ -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 diff --git a/crates/client-core/vendor/steam-cm-protocol/README.md b/crates/client-core/vendor/steam-cm-protocol/README.md index 872abbb..0bdf0c3 100644 --- a/crates/client-core/vendor/steam-cm-protocol/README.md +++ b/crates/client-core/vendor/steam-cm-protocol/README.md @@ -7,6 +7,23 @@ Rust APIs for client-style Steam features — authentication, friends, chat, and without depending on the Steam client or Web API key. It was built for, and is used by, [Steamie](https://github.com/LargeModGames/steamie), but it is a standalone crate you can use on its own. +## SkinOS vendored changes + +This copy includes local SkinOS patches; it is not an unmodified upstream release. +[SKINOS-PATCHES.md](SKINOS-PATCHES.md) records the upstream revision and local changes. + +Launch metadata preserves each numeric `config/launch` key as `LaunchEntry.id` and +sorts entries by that ID. IDs can be sparse and must not be replaced with vector indices. +`AppCatalogInfo` and `ProtocolGame` also expose the appinfo `ufs` object as +`cloud_config`, including numbered save rules and root overrides. Text and binary +PICS parsing normalize scalar values to strings. This supplies metadata only; +callers still validate paths and implement any Cloud synchronization separately. + +`pics::parse_app_info_kv` decodes already-fetched `KVValue` appinfo using the same +launch and UFS rules as library loading. It does not fetch metadata or verify an +account's entitlement; the caller must fetch through its authenticated connection +and validate the requested AppID. + ## Features - Steam authentication: QR, username/password (with Steam Guard), and refresh-token reuse. diff --git a/crates/client-core/vendor/steam-cm-protocol/SKINOS-PATCHES.md b/crates/client-core/vendor/steam-cm-protocol/SKINOS-PATCHES.md index 1f31131..e02fdb1 100644 --- a/crates/client-core/vendor/steam-cm-protocol/SKINOS-PATCHES.md +++ b/crates/client-core/vendor/steam-cm-protocol/SKINOS-PATCHES.md @@ -9,5 +9,8 @@ SkinOs changes: - Public binary KV parser for depot metadata. - Depot key EMsg 5438/5439 (SteamTracking/Protobufs steam/enums_clientserver.proto). - Device display name SkinOs; macOS sends EOSType::MacOSUnknown (-102) instead of Windows 11. Enum source: SteamRE/SteamKit SteamKit2/SteamKit2/Base/Generated/SteamLanguage.cs. +- Preserve numeric PICS launch-option IDs, sorting them numerically without replacing sparse keys with vector indices. +- Preserve raw appinfo UFS save rules and root overrides in catalog/library metadata, normalizing text and binary scalar values to strings. +- Expose `pics::parse_app_info_kv` for already-fetched appinfo; authenticated fetching and requested-AppID validation remain the caller's responsibility. Synthetic fixtures verify identical text/binary metadata and sparse option IDs. No bypass, credential import, or official Steam process launch is added. diff --git a/crates/runtime-host/src/installation/save_roots.rs b/crates/runtime-host/src/installation/save_roots.rs index ae7b4d4..817d30a 100644 --- a/crates/runtime-host/src/installation/save_roots.rs +++ b/crates/runtime-host/src/installation/save_roots.rs @@ -72,11 +72,17 @@ fn resolve(request: &Value, home: &Path) -> Result, String> { }; let relative = relative_path(&expand_user_path(&path, request)?)?; let target = base.join(relative); - require_narrow_save_path(&target, &base, &home)?; - if !matches!( + let installation_root = matches!( root.to_ascii_lowercase().as_str(), "gameinstall" | "appinstalldir" - ) { + ); + // Valve permits `.` when saves live directly in the game's install + // directory. That caller-verified installation is already game-scoped; + // the same spelling must never grant a whole user-data container. + if !installation_root || target != base { + require_narrow_save_path(&target, &base, &home)?; + } + if !installation_root { require_game_identity(&target, &home, request)?; } check_existing_components(&target)?; @@ -261,6 +267,15 @@ fn root_base(root: &str, request: &Value, home: &Path) -> Result if !install.is_dir() { return Err("Auto-Cloud installation root is not a directory".into()); } + if [ + home.to_path_buf(), + home.join("Documents"), + home.join("Library/Application Support"), + ] + .contains(&install) + { + return Err("Auto-Cloud installation cannot be a whole user-data container".into()); + } Some(install) } "winmydocuments" | "winappdatalocal" | "winappdatalocallow" | "winappdataroaming" @@ -695,6 +710,32 @@ mod tests { } } + #[test] + fn install_root_saves_accept_dot_without_granting_user_containers() { + let f = Fixture::new(); + for root in ["GameInstall", "AppInstallDir"] { + assert_eq!( + f.rule(root, ".").unwrap(), + vec![f.0.join("Installed Fixture")] + ); + assert!(f.rule(root, "../escape").is_err()); + for container in [ + f.0.clone(), + f.0.join("Documents"), + f.0.join("Library/Application Support"), + ] { + let request = json!({ + "command":"launch", "install_dir":container, + "cloud_config":{"savefiles":{"0":{"root":root,"path":".","pattern":"*.sav"}}} + }); + assert!(resolve(&request, &f.0).is_err()); + } + } + for root in ["MacHome", "MacDocuments", "MacAppSupport"] { + assert!(f.rule(root, ".").is_err()); + } + } + #[test] fn sensitive_application_roots_are_not_granted_by_cloud_metadata() { let f = Fixture::new(); @@ -804,7 +845,7 @@ mod tests { } #[test] - fn game_install_rules_are_confined_to_specific_children() { + fn game_install_rules_are_confined_to_the_installation() { let f = Fixture::new(); let install = f.0.join("Installed Game"); fs::create_dir(&install).unwrap(); @@ -818,7 +859,7 @@ mod tests { resolve(&request("game/cfg"), &f.0).unwrap(), vec![install.join("game/cfg")] ); - for path in [".", "../Documents", "/tmp/Game"] { + for path in ["../Documents", "/tmp/Game"] { assert!(resolve(&request(path), &f.0).is_err()); } std::os::unix::fs::symlink(f.0.join("Documents"), install.join("game")).unwrap(); diff --git a/crates/runtime-host/src/worker/native_process.rs b/crates/runtime-host/src/worker/native_process.rs index 1d4ff76..02770ad 100644 --- a/crates/runtime-host/src/worker/native_process.rs +++ b/crates/runtime-host/src/worker/native_process.rs @@ -380,16 +380,23 @@ mod tests { struct Fixture { root: PathBuf, - spawned: Option, + spawned: bool, } impl Drop for Fixture { fn drop(&mut self) { - // The fixture exits itself after one second. Never signal a - // process from this test, including on an assertion failure. - if let Some(spawned) = self.spawned { - std::thread::sleep( - Duration::from_millis(1200).saturating_sub(spawned.elapsed()), - ); + if self.spawned { + // Release only this fixture through its private marker; + // never signal a PID after a failed assertion. + let _ = std::fs::write(self.root.join("release"), b"done"); + let deadline = Instant::now() + Duration::from_secs(5); + while !self.root.join("finished").exists() { + if Instant::now() > deadline { + // The child has its own watchdog. Keep its files + // intact rather than unlinking a running fixture. + return; + } + std::thread::sleep(Duration::from_millis(10)); + } } let _ = std::fs::remove_dir_all(&self.root); } @@ -403,29 +410,68 @@ mod tests { "skinos-script-group-{}-{nonce}", std::process::id() )), - spawned: None, + spawned: false, }; let install = fixture.root.join("install"); let state = fixture.root.join("private-state"); std::fs::create_dir_all(&install).unwrap(); std::fs::create_dir(&state).unwrap(); let game = install.join("game"); - std::fs::copy("/bin/sleep", &game).unwrap(); + let source = fixture.root.join("game.c"); + // Use our own Mach-O, not an Apple platform binary whose environment + // inspection can differ under SIP between macOS versions. + std::fs::write( + &source, + r#" +#include +#include +static int mark(const char *path) { + int fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0600); + if (fd < 0) return 1; + return close(fd) != 0; +} +int main(int argc, char **argv) { + if (argc != 4 || mark(argv[1])) return 10; + for (int i = 0; i < 3000; ++i) { + if (access(argv[2], F_OK) == 0) return mark(argv[3]); + usleep(10000); + } + mark(argv[3]); + return 11; +} +"#, + ) + .unwrap(); + assert!( + Command::new("/usr/bin/clang") + .args(["-Wall", "-Wextra", "-Werror"]) + .arg(&source) + .arg("-o") + .arg(&game) + .status() + .unwrap() + .success() + ); + let ready = fixture.root.join("ready"); + let release = fixture.root.join("release"); + let finished = fixture.root.join("finished"); let script = install.join("launch.sh"); let pid_file = fixture.root.join("fixture.pid"); std::fs::write( &script, - b"#!/bin/sh\n\"$1\" 1 &\nprintf '%s\\n' \"$!\" > \"$2\"\n", + b"#!/bin/sh\n\"$1\" \"$2\" \"$3\" \"$4\" &\nprintf '%s\\n' \"$!\" > \"$5\"\n", ) .unwrap(); let spawned_after = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_micros(); - fixture.spawned = Some(Instant::now()); let mut launcher = Command::new("/bin/sh") .arg(&script) .arg(&game) + .arg(&ready) + .arg(&release) + .arg(&finished) .arg(&pid_file) .env("SteamAppId", "424242") .env("SKINOS_RUNTIME_STATE_ROOT", &state) @@ -435,6 +481,7 @@ mod tests { .process_group(0) .spawn() .unwrap(); + fixture.spawned = true; let launcher_pid = launcher.id() as i32; assert!(launcher.wait().unwrap().success()); let game_pid: i32 = std::fs::read_to_string(&pid_file) @@ -442,6 +489,14 @@ mod tests { .trim() .parse() .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while !ready.exists() { + assert!( + Instant::now() < deadline, + "native fixture did not enter main" + ); + std::thread::sleep(Duration::from_millis(10)); + } let tracker = ScriptTracker::capture( launcher_pid, launcher_pid, @@ -455,10 +510,20 @@ mod tests { tracker.launcher.is_none(), "launcher must be reaped before this check" ); - let selected = tracker - .find_game() - .unwrap() - .expect("background fixture must remain identifiable after shell exit"); + // Reproduce a scheduling delay longer than the old fixture's lifetime. + // Readiness and lifetime now have explicit handshakes, not a 1s race. + std::thread::sleep(Duration::from_millis(1300)); + let deadline = Instant::now() + Duration::from_secs(5); + let selected = loop { + if let Some(process) = tracker.find_game().unwrap() { + break process; + } + assert!( + Instant::now() < deadline, + "background fixture must remain identifiable after shell exit" + ); + std::thread::sleep(Duration::from_millis(10)); + }; assert_eq!(selected.pid, game_pid); assert_eq!( selected.executable.canonicalize().unwrap(), From 6f6173e9d52993f9162cb85dfb371d561cbeb25c Mon Sep 17 00:00:00 2001 From: Brekit <155304101+Brekit@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:18:30 +0300 Subject: [PATCH 5/5] Keep download controls responsive during cache cleanup --- crates/client-core/src/download_queue.rs | 323 +++++++++++++++++++++-- 1 file changed, 295 insertions(+), 28 deletions(-) diff --git a/crates/client-core/src/download_queue.rs b/crates/client-core/src/download_queue.rs index 60dfe76..ecd223a 100644 --- a/crates/client-core/src/download_queue.rs +++ b/crates/client-core/src/download_queue.rs @@ -5,7 +5,12 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use skinos_downloader::{InstallControl, InstallInterrupted, InstallPlatform}; use std::{ - collections::HashMap, future::Future, path::PathBuf, pin::Pin, sync::Arc, time::Duration, + collections::{HashMap, VecDeque}, + future::Future, + path::PathBuf, + pin::Pin, + sync::Arc, + time::Duration, }; use tokio::{ sync::{mpsc, oneshot}, @@ -278,6 +283,15 @@ struct Active { resource: String, task: JoinHandle<()>, } +struct CleanupReservation { + job: DownloadJob, + resource: String, +} +struct ActiveCleanup { + appid: u32, + task: JoinHandle>, + events: mpsc::UnboundedReceiver, +} #[derive(Clone)] pub struct DownloadQueueHandle { commands: mpsc::UnboundedSender, @@ -360,6 +374,9 @@ struct Actor { revision: u64, next_run: u64, active: HashMap, + cleanups: HashMap, + pending_cleanups: VecDeque, + active_cleanup: Option, pending_removals: HashMap, stopping: bool, connected: bool, @@ -394,6 +411,9 @@ impl Actor { revision: 0, next_run: 0, active: HashMap::new(), + cleanups: HashMap::new(), + pending_cleanups: VecDeque::new(), + active_cleanup: None, pending_removals: HashMap::new(), stopping: false, connected: true, @@ -524,6 +544,10 @@ impl Actor { } fn remove_if_idle(&mut self, appid: u32) -> Result<()> { ensure!(appid > 0, "Invalid AppID"); + ensure!( + !self.cleanups.contains_key(&appid), + "Дождитесь очистки временных файлов этой игры" + ); ensure!( !self.active.contains_key(&appid), "Дождитесь завершения или остановки загрузки этой игры" @@ -579,6 +603,12 @@ impl Actor { .and_then(|v| u32::try_from(v).ok()) .filter(|v| *v > 0) .context("Invalid AppID")?; + // Keep the persisted completed intent stable until its cleanup drains. + // Other apps (including their pause/cancel controls) remain independent. + ensure!( + !self.cleanups.contains_key(&appid), + "Дождитесь очистки временных файлов этой игры" + ); let position = self.items.iter().position(|row| row.appid == appid); if let Some(index) = position && self.items[index].remove_requested @@ -737,7 +767,9 @@ impl Actor { if self.active.len() >= self.max_concurrent { break; } - if self.items[index].state != DownloadState::Queued { + if self.items[index].state != DownloadState::Queued + || self.cleanups.contains_key(&self.items[index].appid) + { continue; } let job = self.items[index].job(&self.steamid); @@ -758,6 +790,10 @@ impl Actor { .active .values() .any(|active| active.resource == resource) + || self + .cleanups + .values() + .any(|cleanup| cleanup.resource == resource) { continue; } @@ -825,11 +861,15 @@ impl Actor { } } } - async fn cleanup_completed(&self, appid: u32, request_id: Option<&str>) -> Result<()> { + fn cleanup_completed(&mut self, appid: u32, request_id: Option<&str>) -> Result<()> { ensure!( !self.active.contains_key(&appid), "Загрузка ещё выполняется" ); + ensure!( + !self.cleanups.contains_key(&appid), + "Очистка временных файлов этой игры уже выполняется" + ); let row = self .items .iter() @@ -844,12 +884,14 @@ impl Actor { self.persist()?; let mut job = row.job(&self.steamid); job.request_id = request_id.map(str::to_owned); - self.executor - .cleanup_completed(job, self.events.clone()) - .await + let resource = self.executor.resource_key(&job)?; + self.cleanups + .insert(appid, CleanupReservation { job, resource }); + self.pending_cleanups.push_back(appid); + Ok(()) } - async fn cleanup_completed_best_effort(&self, appid: u32) { - if let Err(error) = self.cleanup_completed(appid, None).await { + fn cleanup_completed_best_effort(&mut self, appid: u32) { + if let Err(error) = self.cleanup_completed(appid, None) { let _ = self.events.send(Event::new( None, "download_cache_cleanup_warning", @@ -857,14 +899,73 @@ impl Actor { )); } } + fn start_cleanup(&mut self) { + if self.active_cleanup.is_some() { + return; + } + // One disk cleanup at a time; queued reservations also prevent a new + // writer from taking their destination before the cleanup task starts. + let ready = self.pending_cleanups.iter().position(|appid| { + let resource = &self.cleanups[appid].resource; + !self + .active + .values() + .any(|active| &active.resource == resource) + }); + let Some(appid) = ready.and_then(|index| self.pending_cleanups.remove(index)) else { + return; + }; + let executor = self.executor.clone(); + let job = self.cleanups[&appid].job.clone(); + let (events, received) = mpsc::unbounded_channel(); + self.active_cleanup = Some(ActiveCleanup { + appid, + task: tokio::spawn(async move { executor.cleanup_completed(job, events).await }), + events: received, + }); + } + fn finish_cleanup(&mut self, result: std::result::Result, tokio::task::JoinError>) { + let Some(mut active) = self.active_cleanup.take() else { + return; + }; + let Some(reservation) = self.cleanups.remove(&active.appid) else { + return; + }; + // A correlated success becomes observable only after releasing the + // completed-state reservation, so a follow-up request can proceed. + while let Ok(event) = active.events.try_recv() { + let _ = self.events.send(event); + } + let result = result.unwrap_or_else(|error| { + Err(anyhow::anyhow!( + "Download cache cleanup task failed: {error}" + )) + }); + if let Err(error) = result { + let event = match reservation.job.request_id.as_deref() { + Some(id) => Event::error( + Some(id), + "download_cache_cleanup_failed", + &error.to_string(), + true, + ), + None => Event::new( + None, + "download_cache_cleanup_warning", + json!({"appid":active.appid,"message":error.to_string()}), + ), + }; + let _ = self.events.send(event); + } + } async fn run(mut self, mut commands: mpsc::UnboundedReceiver) { let (sink, mut incoming) = mpsc::unbounded_channel(); let mut tick = tokio::time::interval(Duration::from_millis(250)); tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); self.snapshot(None); // Catch up only explicitly completed jobs after an upgrade/restart. - // No worker starts and no new queue command is accepted until this - // completed-state reservation is released. + // Reserve only each completed app/destination. Disk work runs outside + // the actor so unrelated downloads and controls remain responsive. let completed: Vec = self .items .iter() @@ -872,11 +973,12 @@ impl Actor { .map(|row| row.appid) .collect(); for appid in completed { - self.cleanup_completed_best_effort(appid).await; + self.cleanup_completed_best_effort(appid); } loop { + self.start_cleanup(); self.start_ready(&sink); - if self.stopping && self.active.is_empty() { + if self.stopping && self.active.is_empty() && self.cleanups.is_empty() { self.save_or_report(); break; } @@ -885,7 +987,7 @@ impl Actor { Some(Command::Request(request))=>{ if request.command == "cleanup_download_cache" { let result = match request.payload["appid"].as_u64().and_then(|id| u32::try_from(id).ok()).filter(|id| *id > 0) { - Some(appid) => self.cleanup_completed(appid, Some(&request.id)).await, + Some(appid) => self.cleanup_completed(appid, Some(&request.id)), None => Err(anyhow::anyhow!("Invalid AppID")), }; if let Err(error) = result { let _=self.events.send(Event::error(Some(&request.id), "download_cache_cleanup_failed", &error.to_string(), true)); } @@ -911,6 +1013,7 @@ impl Actor { } }, message=incoming.recv()=>if let Some(message)=message {self.handle_job(message).await;}, + result=async { (&mut self.active_cleanup.as_mut().expect("guarded cleanup task").task).await },if self.active_cleanup.is_some()=>{self.finish_cleanup(result);}, _=tick.tick(),if !self.stopping && self.items.iter().any(|row|row.state==DownloadState::Queued)=>{}, } } @@ -1011,7 +1114,7 @@ impl Actor { .iter() .any(|row| row.appid == appid && row.state == DownloadState::Completed) { - self.cleanup_completed_best_effort(appid).await; + self.cleanup_completed_best_effort(appid); } let _ = self.events.send(Event::new( request_id.as_deref(), @@ -1065,6 +1168,7 @@ mod tests { completed: Arc>>, cleanup_calls: Arc>>, cleanup_fails: Arc, + cleanup_gate: Arc>>>, resources: HashMap, } impl DownloadExecutor for Mock { @@ -1085,16 +1189,25 @@ mod tests { fn cleanup_completed( &self, job: DownloadJob, - _: mpsc::UnboundedSender, + events: mpsc::UnboundedSender, ) -> DownloadFuture { let calls = self.cleanup_calls.clone(); let fails = self.cleanup_fails.clone(); + let gate = self.cleanup_gate.lock().unwrap().clone(); Box::pin(async move { calls.lock().unwrap().push(job.appid); + if let Some(gate) = gate { + gate.acquire().await?.forget(); + } ensure!( !fails.load(Ordering::SeqCst), "fixture cache cleanup failure" ); + let _ = events.send(Event::new( + job.request_id.as_deref(), + "download_cache_cleaned", + json!({"appid":job.appid,"removed_directories":0}), + )); Ok(()) }) } @@ -1163,6 +1276,7 @@ mod tests { completed: Default::default(), cleanup_calls: Default::default(), cleanup_fails: Default::default(), + cleanup_gate: Default::default(), resources, }), rx, @@ -1201,6 +1315,21 @@ mod tests { .await .unwrap() } + async fn matching_event( + rx: &mut mpsc::UnboundedReceiver, + predicate: impl Fn(&Event) -> bool, + ) -> Event { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let event = rx.recv().await.unwrap(); + if predicate(&event) { + return event; + } + } + }) + .await + .unwrap() + } fn state(value: &serde_json::Value, appid: u32) -> Option<&str> { value["items"] .as_array()? @@ -1523,7 +1652,7 @@ mod tests { DownloadState::Downloading, ] { actor.items = vec![DownloadItem::new(1, false, state)]; - assert!(actor.cleanup_completed(1, None).await.is_err()); + assert!(actor.cleanup_completed(1, None).is_err()); } assert!(executor.cleanup_calls.lock().unwrap().is_empty()); actor.items.clear(); @@ -1536,9 +1665,13 @@ mod tests { assert_eq!(started(&mut started_rx).await, 1); finish(&executor, 1); tokio::time::timeout(Duration::from_secs(3), async { + let mut completed = false; + let mut cleanup_failed = false; loop { let event = rx.recv().await.unwrap(); - if event.event == "operation_complete" { + completed |= event.event == "operation_complete"; + cleanup_failed |= event.event == "download_cache_cleanup_warning"; + if completed && cleanup_failed { break; } } @@ -1551,7 +1684,54 @@ mod tests { queue.shutdown().await; } #[tokio::test] - async fn startup_cleanup_targets_only_previously_completed_rows() { + async fn slow_cleanup_keeps_other_downloads_and_controls_responsive() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("queue.json"); + std::fs::write( + &path, + serde_json::to_vec(&json!({ + "steamid":ACCOUNT,"max_concurrent":2,"revision":1,"items":[ + {"appid":1,"is_update":false,"state":"completed"} + ] + })) + .unwrap(), + ) + .unwrap(); + let (executor, mut starts) = mock(ACCOUNT, [(1, "same".into()), (3, "same".into())].into()); + let gate = Arc::new(Semaphore::new(0)); + *executor.cleanup_gate.lock().unwrap() = Some(gate.clone()); + let (tx, mut rx) = mpsc::unbounded_channel(); + let queue = DownloadQueue::start(ACCOUNT.into(), path, executor.clone(), tx).unwrap(); + send(&queue, "two", "install_game", 2); + assert_eq!(started(&mut starts).await, 2); + snapshot(&mut rx, |value| state(value, 2) == Some("downloading")).await; + send(&queue, "update-reserved", "update_game", 1); + let rejected = matching_event(&mut rx, |event| { + event.id.as_deref() == Some("update-reserved") + }) + .await; + assert_eq!(rejected.event, "error"); + assert!(queue.handle().remove_if_idle(1).await.is_err()); + send(&queue, "same-destination", "install_game", 3); + snapshot(&mut rx, |value| state(value, 3) == Some("queued")).await; + send(&queue, "pause", "pause_download", 2); + snapshot(&mut rx, |value| state(value, 2) == Some("paused")).await; + send(&queue, "inspect", "get_download_queue", 1); + let current = matching_event(&mut rx, |event| event.id.as_deref() == Some("inspect")).await; + assert_eq!(state(¤t.payload, 1), Some("completed")); + assert_eq!(state(¤t.payload, 3), Some("queued")); + assert!(starts.try_recv().is_err()); + assert_eq!(*executor.cleanup_calls.lock().unwrap(), [1]); + assert_eq!(gate.available_permits(), 0); + gate.add_permits(1); + assert_eq!(started(&mut starts).await, 3); + send(&queue, "cancel", "cancel_download", 3); + snapshot(&mut rx, |value| state(value, 3) == Some("cancelled")).await; + queue.shutdown().await; + } + + #[tokio::test] + async fn cleanup_tasks_are_bounded_and_shutdown_drains_pending_reservations() { let root = tempfile::tempdir().unwrap(); let path = root.path().join("queue.json"); std::fs::write( @@ -1559,28 +1739,115 @@ mod tests { serde_json::to_vec(&json!({ "steamid":ACCOUNT,"max_concurrent":2,"revision":1,"items":[ {"appid":1,"is_update":false,"state":"completed"}, - {"appid":2,"is_update":false,"state":"paused"}, - {"appid":3,"is_update":false,"state":"failed"}, - {"appid":4,"is_update":false,"state":"cancelled"} + {"appid":2,"is_update":false,"state":"completed"} ] })) .unwrap(), ) .unwrap(); let (executor, _) = mock(ACCOUNT, HashMap::new()); + let gate = Arc::new(Semaphore::new(0)); + *executor.cleanup_gate.lock().unwrap() = Some(gate.clone()); let (tx, mut rx) = mpsc::unbounded_channel(); - let queue = DownloadQueue::start(ACCOUNT.into(), path, executor.clone(), tx).unwrap(); - // A correlated snapshot is handled only after startup cleanup finishes. + let queue = + DownloadQueue::start(ACCOUNT.into(), path.clone(), executor.clone(), tx).unwrap(); send(&queue, "ready", "get_download_queue", 1); - tokio::time::timeout(Duration::from_secs(3), async { - while let Some(event) = rx.recv().await { - if event.id.as_deref() == Some("ready") { - break; - } + matching_event(&mut rx, |event| event.id.as_deref() == Some("ready")).await; + let mut shutdown = tokio::spawn(queue.shutdown()); + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut shutdown) + .await + .is_err() + ); + assert_eq!(*executor.cleanup_calls.lock().unwrap(), [1]); + gate.add_permits(1); + tokio::time::timeout(Duration::from_secs(2), async { + while executor.cleanup_calls.lock().unwrap().len() != 2 { + tokio::time::sleep(Duration::from_millis(5)).await; } }) .await .unwrap(); + assert!(!shutdown.is_finished()); + gate.add_permits(1); + tokio::time::timeout(Duration::from_secs(2), shutdown) + .await + .unwrap() + .unwrap(); + let saved: SavedQueue = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert!( + saved + .items + .iter() + .all(|item| item.state == DownloadState::Completed) + ); + } + + #[tokio::test] + async fn manual_cleanup_reports_correlated_failure_and_releases_reservation() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("queue.json"); + std::fs::write( + &path, + serde_json::to_vec(&json!({ + "steamid":ACCOUNT,"max_concurrent":2,"revision":1,"items":[ + {"appid":1,"is_update":false,"state":"completed"} + ] + })) + .unwrap(), + ) + .unwrap(); + let (executor, mut starts) = mock(ACCOUNT, HashMap::new()); + executor.cleanup_fails.store(true, Ordering::SeqCst); + let (tx, mut rx) = mpsc::unbounded_channel(); + let queue = DownloadQueue::start(ACCOUNT.into(), path, executor.clone(), tx).unwrap(); + matching_event(&mut rx, |event| { + event.event == "download_cache_cleanup_warning" + }) + .await; + send(&queue, "manual-cleanup", "cleanup_download_cache", 1); + let failed = matching_event(&mut rx, |event| { + event.id.as_deref() == Some("manual-cleanup") + }) + .await; + assert_eq!(failed.event, "error"); + assert_eq!(failed.payload["code"], "download_cache_cleanup_failed"); + executor.cleanup_fails.store(false, Ordering::SeqCst); + send(&queue, "manual-success", "cleanup_download_cache", 1); + let cleaned = matching_event(&mut rx, |event| { + event.id.as_deref() == Some("manual-success") + }) + .await; + assert_eq!(cleaned.event, "download_cache_cleaned"); + send(&queue, "retry-install", "update_game", 1); + assert_eq!(started(&mut starts).await, 1); + send(&queue, "cancel", "cancel_download", 1); + snapshot(&mut rx, |value| state(value, 1) == Some("cancelled")).await; + queue.shutdown().await; + assert_eq!(*executor.cleanup_calls.lock().unwrap(), [1, 1, 1]); + } + + #[tokio::test] + async fn startup_cleanup_targets_only_previously_completed_rows() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("queue.json"); + std::fs::write( + &path, + serde_json::to_vec(&json!({ + "steamid":ACCOUNT,"max_concurrent":2,"revision":1,"items":[ + {"appid":1,"is_update":false,"state":"completed"}, + {"appid":2,"is_update":false,"state":"paused"}, + {"appid":3,"is_update":false,"state":"failed"}, + {"appid":4,"is_update":false,"state":"cancelled"} + ] + })) + .unwrap(), + ) + .unwrap(); + let (executor, _) = mock(ACCOUNT, HashMap::new()); + let (tx, mut rx) = mpsc::unbounded_channel(); + let queue = DownloadQueue::start(ACCOUNT.into(), path, executor.clone(), tx).unwrap(); + matching_event(&mut rx, |event| event.event == "download_cache_cleaned").await; assert_eq!(*executor.cleanup_calls.lock().unwrap(), [1]); queue.shutdown().await; }