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/3] 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/3] 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 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 3/3] 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; }