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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ 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.
- 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,
Expand Down
20 changes: 2 additions & 18 deletions apps/macos/Sources/SkinOS/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ final class AppModel: ObservableObject {
@Published var downloadQueueError: String?
@Published var pendingDownloadControls: Set<Int> = []
@Published var pendingEnqueueGames: Set<Int> = []
@Published var installationRequest: GameInstallationRequest?
@Published var downloadLimitPending = false
var downloadQueueRevision: Int64 = -1
var downloadControlRequests: [String: Int] = [:]
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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":
Expand Down
2 changes: 2 additions & 0 deletions apps/macos/Sources/SkinOS/DownloadQueue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -42,6 +43,7 @@ extension AppModel {
downloadQueueRevision = -1; downloadQueueError = nil; pendingDownloadControls = []; pendingEnqueueGames = []
downloadControlRequests = [:]; downloadQueueRequests = []; downloadLimitRequests = []; downloadLimitPending = false
maxConcurrentDownloads = 2
installationRequest = nil
}

func refreshDownloadQueue() {
Expand Down
4 changes: 4 additions & 0 deletions apps/macos/Sources/SkinOS/DownloadsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
174 changes: 174 additions & 0 deletions apps/macos/Sources/SkinOS/InstallPlatform.swift
Original file line number Diff line number Diff line change
@@ -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 : [])
}
}
1 change: 1 addition & 0 deletions apps/macos/Sources/SkinOS/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
14 changes: 7 additions & 7 deletions apps/macos/Sources/SkinOS/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
Loading
Loading