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
9 changes: 9 additions & 0 deletions apps/macos/Sources/SkinOS/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ final class AppModel: ObservableObject {
var launchNotificationIDs: [Int: String] = [:]
@Published var noticeItems: [ClientNotice] = []
@Published var noticesLoading = false
@Published var noticesLoaded = false
@Published var noticesError: String?
var notificationRequests: Set<String> = []
var locallyReadNoticeIDs: Set<String> = []
Expand Down Expand Up @@ -191,6 +192,14 @@ final class AppModel: ObservableObject {
self.loadingUninstallPlans = []
self.uninstallPlanRequests = [:]
self.downloadQueueLoading = false
self.noticesLoading = false
self.notificationRequests = []
self.downloadQueueRequests = []
self.downloadLimitRequests = []
if self.account != nil {
self.noticesError = "Соединение с SkinOS Core прервано. Перезапустите приложение."
self.downloadQueueError = "Нет соединения с SkinOS Core. Перезапустите приложение."
}
self.pendingDownloadControls = []
self.pendingEnqueueGames = []
self.downloadLimitPending = false
Expand Down
4 changes: 2 additions & 2 deletions apps/macos/Sources/SkinOS/AppUpdates.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ struct AppUpdateSettingsView: View {
SettingsCard(title: "Обновления SkinOS", symbol: "arrow.triangle.2.circlepath") {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Версия \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—")")
Text("Версия \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "не определена")")
.font(.system(size: 12, weight: .medium))
Spacer()
Button("Проверить обновления…") { updates.checkForUpdates() }
Expand All @@ -164,7 +164,7 @@ struct AppUpdateSettingsView: View {
get: { updates.automaticallyChecksForUpdates }, set: updates.setAutomaticChecks))
.toggleStyle(.switch).controlSize(.small).font(.system(size: 12))
.disabled(!updates.isConfigured)
Text("Обновления загружаются с GitHub и проверяются по цифровой подписи. Установка — с вашим подтверждением, когда игры и загрузки не выполняются.")
Text("Обновления загружаются с GitHub и проверяются по цифровой подписи. Установка требует вашего подтверждения и доступна, когда игры и загрузки не выполняются.")
.font(.system(size: 11)).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
if let date = updates.lastUpdateCheckDate {
Text("Последняя проверка: \(date.formatted(date: .abbreviated, time: .shortened))")
Expand Down
2 changes: 1 addition & 1 deletion apps/macos/Sources/SkinOS/CommunityModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ extension AppModel {
if !preserveNotificationRoute { notificationRouteWaiting = nil }
notifications.deactivate()
launchNotificationIDs = [:]
noticeItems = []; noticesLoading = false; noticesError = nil
noticeItems = []; noticesLoading = false; noticesLoaded = false; noticesError = nil
notificationRequests = []; locallyReadNoticeIDs = []
resetDownloadQueue()
uninstallingGames = []; uninstallRequests = [:]; uninstallNotices = [:]
Expand Down
57 changes: 57 additions & 0 deletions apps/macos/Sources/SkinOS/DownloadFailurePresentation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Foundation

struct DownloadFailurePresentation: Equatable {
enum Cause: Equatable { case accessDenied, timeout, connection, diskSpace, coreUnavailable, unknown }
enum Action: Equatable { case inspectAccount, retry, freeSpace, restartClient, inspectDetails }

let cause: Cause
let action: Action
let title: String
let guidance: String
let diagnostic: String?

init(_ message: String?) {
diagnostic = message.flatMap { $0.isEmpty ? nil : $0 }
let raw = message?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
// Match the downloader's complete messages, not arbitrary substrings in an error chain.
if raw.range(of: #"^Account cannot download depot [0-9]+ \(Steam result 15\)$"#,
options: .regularExpression) != nil {
cause = .accessDenied
action = .inspectAccount
title = "Steam отказал в доступе к файлам игры."
guidance = "Проверьте, какой аккаунт выбран в SkinOS, и доступен ли ему выбранный контент. По этому ответу нельзя определить точную причину отказа."
} else if ["Steam request timed out", "Не удалось получить файлы игры: request timed out"].contains(raw) {
cause = .timeout
action = .retry
title = "Сервер Steam не ответил вовремя."
guidance = "Проверьте подключение к интернету и повторите загрузку."
} else if ["Не удалось получить файлы игры: connection failed",
"Не удалось получить файлы игры: content transfer interrupted",
"Не удалось получить файлы игры: content server unavailable",
"Steam returned no usable HTTPS content servers"].contains(raw) {
cause = .connection
action = .retry
title = "Не удалось получить файлы с сервера загрузки."
guidance = "Проверьте подключение к интернету и повторите загрузку. Регион загрузки можно изменить в настройках."
} else if raw.range(of: #"^Недостаточно места: нужно дополнительно [0-9]+ МБ, доступно [0-9]+ МБ$"#,
options: .regularExpression) != nil {
cause = .diskSpace
action = .freeSpace
title = "Для установки не хватает места на диске."
guidance = "Освободите место на диске выбранной библиотеки и повторите загрузку. Требуемый объём указан в подробностях."
} else if raw == "Нет соединения с SkinOS Core. Перезапустите приложение."
|| raw == "Соединение прервано. Повторите загрузку после перезапуска." {
cause = .coreUnavailable
action = .restartClient
title = "Соединение с загрузчиком прервано."
guidance = "Перезапустите SkinOS, затем обновите очередь, чтобы проверить состояние загрузок."
} else {
cause = .unknown
action = .inspectDetails
title = "Не удалось выполнить операцию загрузки."
guidance = diagnostic == nil
? "Загрузчик не передал подробностей. Обновите очередь и проверьте состояние загрузки."
: "Причина не распознана. Откройте технические подробности; их можно скопировать для сообщения об ошибке."
}
}
}
42 changes: 33 additions & 9 deletions apps/macos/Sources/SkinOS/DownloadQueue.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import Foundation

enum FeedLoadState: Equatable {
case initial, loading, failed, empty, content, refreshing, stale

init(hasItems: Bool, loaded: Bool, loading: Bool, hasError: Bool) {
if hasItems {
self = loading ? .refreshing : hasError ? .stale : .content
} else {
self = loading ? .loading : hasError ? .failed : loaded ? .empty : .initial
}
}
}

extension GameDownload {
init?(queueJSON json: [String: Any]) {
guard let appID = json["appid"] as? Int, let state = json["state"] as? String,
Expand Down Expand Up @@ -29,6 +41,11 @@ extension GameDownload {

@MainActor
extension AppModel {
var downloadFeedState: FeedLoadState {
FeedLoadState(hasItems: !downloads.isEmpty, loaded: downloadQueueReady,
loading: downloadQueueLoading, hasError: downloadQueueError != nil)
}

var orderedDownloads: [GameDownload] {
let known = downloadOrder.compactMap { downloads[$0] }
let knownIDs = Set(downloadOrder)
Expand All @@ -48,14 +65,16 @@ extension AppModel {

func refreshDownloadQueue() {
guard account != nil, !downloadQueueLoading else { return }
downloadQueueLoading = true; downloadQueueError = nil
downloadQueueLoading = true
if let id = send("get_download_queue") { downloadQueueRequests.insert(id) }
else { downloadQueueLoading = false }
else {
downloadQueueLoading = false
downloadQueueError = "Нет соединения с SkinOS Core. Перезапустите приложение."
}
}

func controlDownload(_ command: String, appID: Int) {
guard account != nil, coreReady, !pendingDownloadControls.contains(appID) else { return }
downloadQueueError = nil
if let id = send(command, payload: ["appid": appID]) {
downloadControlRequests[id] = appID; pendingDownloadControls.insert(appID)
}
Expand All @@ -68,7 +87,6 @@ extension AppModel {

func moveDownload(_ appID: Int, to position: Int) {
guard canReorderDownload(appID), downloadOrder.indices.contains(position) else { return }
downloadQueueError = nil
if let id = send("move_download", payload: ["appid": appID, "position": position]) {
downloadControlRequests[id] = appID; pendingDownloadControls.insert(appID)
}
Expand All @@ -82,30 +100,36 @@ extension AppModel {

func setDownloadLimit(_ limit: Int) {
guard account != nil, coreReady, (1...4).contains(limit), !downloadLimitPending, limit != maxConcurrentDownloads else { return }
downloadQueueError = nil
if let id = send("set_download_limit", payload: ["limit": limit]) {
downloadLimitRequests.insert(id); downloadLimitPending = true
}
}

func receiveDownloadQueue(_ event: String, payload: [String: Any], requestID: String?) -> Bool {
if event == "download_queue" {
guard let account, payload["steamid"] as? String == account.id,
let revision = (payload["revision"] as? NSNumber)?.int64Value else { return true }
guard let account, payload["steamid"] as? String == account.id else { return true }
let refreshing = requestID.map { downloadQueueRequests.contains($0) } ?? false
acknowledgeDownloadRequest(requestID)
guard revision > downloadQueueRevision else { return true }
guard let rows = payload["items"] as? [[String: Any]] else { return true }
guard let revision = (payload["revision"] as? NSNumber)?.int64Value,
revision >= 0, let rows = payload["items"] as? [[String: Any]] else {
downloadQueueError = "Получена некорректная очередь загрузок. Обновите её."
return true
}
guard revision >= downloadQueueRevision else { return true }
let items = rows.compactMap(GameDownload.init(queueJSON:))
guard items.count == rows.count, Set(items.map(\.id)).count == items.count else {
downloadQueueError = "Получена некорректная очередь загрузок. Обновите её."
return true
}
if refreshing { downloadQueueError = nil }
guard revision > downloadQueueRevision else { return true }
notifyDownloadTransitions(previous: downloads, items: items, revision: revision)
downloads = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) })
downloadOrder = items.map(\.id)
maxConcurrentDownloads = min(4, max(1, payload["max_concurrent"] as? Int ?? 2))
downloadQueueRevision = revision
downloadQueueReady = true; downloadQueueLoading = false
downloadQueueError = nil
return true
}
if event == "download_enqueued" {
Expand Down
Loading
Loading