diff --git a/apps/macos/Sources/SkinOS/AppModel.swift b/apps/macos/Sources/SkinOS/AppModel.swift index ad4b8e6..3919c15 100644 --- a/apps/macos/Sources/SkinOS/AppModel.swift +++ b/apps/macos/Sources/SkinOS/AppModel.swift @@ -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 = [] var locallyReadNoticeIDs: Set = [] @@ -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 diff --git a/apps/macos/Sources/SkinOS/AppUpdates.swift b/apps/macos/Sources/SkinOS/AppUpdates.swift index 7cfd320..e694364 100644 --- a/apps/macos/Sources/SkinOS/AppUpdates.swift +++ b/apps/macos/Sources/SkinOS/AppUpdates.swift @@ -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() } @@ -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))") diff --git a/apps/macos/Sources/SkinOS/CommunityModels.swift b/apps/macos/Sources/SkinOS/CommunityModels.swift index b98c919..84c122e 100644 --- a/apps/macos/Sources/SkinOS/CommunityModels.swift +++ b/apps/macos/Sources/SkinOS/CommunityModels.swift @@ -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 = [:] diff --git a/apps/macos/Sources/SkinOS/DownloadFailurePresentation.swift b/apps/macos/Sources/SkinOS/DownloadFailurePresentation.swift new file mode 100644 index 0000000..728224b --- /dev/null +++ b/apps/macos/Sources/SkinOS/DownloadFailurePresentation.swift @@ -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 + ? "Загрузчик не передал подробностей. Обновите очередь и проверьте состояние загрузки." + : "Причина не распознана. Откройте технические подробности; их можно скопировать для сообщения об ошибке." + } + } +} diff --git a/apps/macos/Sources/SkinOS/DownloadQueue.swift b/apps/macos/Sources/SkinOS/DownloadQueue.swift index ed42828..3daaf18 100644 --- a/apps/macos/Sources/SkinOS/DownloadQueue.swift +++ b/apps/macos/Sources/SkinOS/DownloadQueue.swift @@ -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, @@ -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) @@ -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) } @@ -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) } @@ -82,7 +100,6 @@ 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 } @@ -90,22 +107,29 @@ extension AppModel { 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" { diff --git a/apps/macos/Sources/SkinOS/DownloadsView.swift b/apps/macos/Sources/SkinOS/DownloadsView.swift index eff32eb..91b11cc 100644 --- a/apps/macos/Sources/SkinOS/DownloadsView.swift +++ b/apps/macos/Sources/SkinOS/DownloadsView.swift @@ -6,7 +6,7 @@ struct DownloadsView: View { VStack(alignment: .leading, spacing: 23) { HStack(alignment: .top, spacing: 25) { VStack(alignment: .leading, spacing: 9) { - Text("Загрузки под контролем.").font(.system(size: 27, weight: .bold, design: .rounded)).tracking(-0.8) + Text("Загрузки").font(.system(size: 27, weight: .bold, design: .rounded)).tracking(-0.8) Text(model.activeDownloads == 0 ? "Устанавливайте и обновляйте игры из вашей библиотеки." : "\(model.runningDownloadCount) в работе · \(model.queuedDownloads.count) в очереди · \(model.downloadSpeed)") .font(.system(size: 12)).foregroundStyle(Theme.muted).monospacedDigit() } @@ -30,18 +30,29 @@ struct DownloadsView: View { if let notice = model.updatesNotice { Text(notice).font(.system(size: 11)).foregroundStyle(Theme.muted).lineLimit(2) } } if let error = model.downloadQueueError { - HStack { - Text(error).font(.system(size: 12)).foregroundStyle(.orange).textSelection(.enabled) - Spacer() - Button { model.downloadQueueError = nil } label: { Image(systemName: "xmark") }.buttonStyle(.plain) + VStack(alignment: .leading, spacing: 10) { + Label("Не удалось получить или изменить очередь", systemImage: "exclamationmark.triangle") + .font(.system(size: 12, weight: .medium)).foregroundStyle(.orange) + if !model.downloads.isEmpty { + Text("Показана последняя полученная очередь. Она может быть устаревшей.") + .font(.system(size: 11)).foregroundStyle(Theme.muted) + } + DownloadFailureDetails(failure: DownloadFailurePresentation(error)) + Button("Повторить запрос очереди") { model.refreshDownloadQueue() } + .buttonStyle(.bordered).disabled(model.account == nil || model.downloadQueueLoading || !model.coreReady) }.padding(13).background(Color.orange.opacity(0.07), in: RoundedRectangle(cornerRadius: 9)) } if model.account == nil { WelcomeView(model: model) } - else if model.downloadQueueLoading && model.downloads.isEmpty { + else if model.downloadFeedState == .loading { ProgressView("Загружаем очередь…").frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { LazyVStack(spacing: 13) { + if model.downloadFeedState == .refreshing { + ProgressView("Обновляем очередь. Показаны последние полученные данные.") + .controlSize(.small).font(.system(size: 11)) + .frame(maxWidth: .infinity, alignment: .leading) + } ForEach(model.games.filter { $0.updateAvailable && model.downloads[$0.id]?.isActive != true }) { game in HStack { Label(game.name, systemImage: "arrow.down.circle").font(.system(size: 12, weight: .medium)) @@ -52,8 +63,18 @@ struct DownloadsView: View { ForEach(model.orderedDownloads) { download in DownloadRow(model: model, download: download) } - if model.downloads.isEmpty { - EmptyState(symbol: "arrow.down.to.line", title: "Всё скачано", subtitle: "Выберите игру в библиотеке и нажмите «Установить».").frame(height: 300) + if model.downloadFeedState == .empty { + EmptyState(symbol: "arrow.down.to.line", title: "Очередь пуста", subtitle: "Выберите игру в библиотеке и нажмите «Установить».").frame(height: 300) + } else if model.downloadFeedState == .failed { + EmptyState(symbol: "exclamationmark.circle", + title: "Очередь недоступна", subtitle: "Не удалось проверить текущие загрузки. Повторите запрос очереди.").frame(height: 220) + } else if model.downloadFeedState == .initial { + VStack(spacing: 12) { + EmptyState(symbol: "arrow.down.circle", title: "Очередь ещё не загружена", + subtitle: "Обновите очередь, чтобы увидеть текущие загрузки.").frame(height: 200) + Button("Загрузить очередь") { model.refreshDownloadQueue() } + .buttonStyle(.bordered).disabled(!model.coreReady) + } } }.padding(.bottom, 10) }.scrollIndicators(.hidden) @@ -132,6 +153,9 @@ struct DownloadControls: View { .help("Отменить загрузку").accessibilityLabel("Отменить загрузку") } if download.canRetry { + if download.failed, DownloadFailurePresentation(download.message).action == .inspectAccount { + Button("Проверить аккаунт") { model.section = .account } + } Button { model.controlDownload("resume_download", appID: download.id) } label: { Label("Повторить", systemImage: "arrow.clockwise") } } Menu { @@ -186,8 +210,10 @@ struct DownloadMeter: View { } else if download.totalBytes > 0 || download.installTotalBytes > 0 { Text(download.byteDescription).font(.system(size: 10)).foregroundStyle(Theme.muted) } - if let message = download.message, !message.isEmpty { - Text(message).font(.system(size: 10)).foregroundStyle(download.failed ? .orange : Theme.muted) + if download.failed { + DownloadFailureDetails(failure: DownloadFailurePresentation(download.message)) + } else if let message = download.message, !message.isEmpty { + Text(message).font(.system(size: 10)).foregroundStyle(Theme.muted) .textSelection(.enabled).fixedSize(horizontal: false, vertical: true) } if download.reusedBytes > 0, !download.failed { @@ -197,3 +223,21 @@ struct DownloadMeter: View { } } } + +private struct DownloadFailureDetails: View { + let failure: DownloadFailurePresentation + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Text(failure.title).font(.system(size: 11, weight: .medium)).foregroundStyle(.orange) + Text(failure.guidance).font(.system(size: 11)).foregroundStyle(Theme.muted) + .fixedSize(horizontal: false, vertical: true) + if let diagnostic = failure.diagnostic { + DisclosureGroup("Технические подробности") { + Text(diagnostic).font(.system(size: 10, design: .monospaced)).foregroundStyle(Theme.muted) + .textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading).padding(.top, 5) + }.font(.system(size: 11)) + } + } + } +} diff --git a/apps/macos/Sources/SkinOS/FriendsView.swift b/apps/macos/Sources/SkinOS/FriendsView.swift index c73711e..65d15f3 100644 --- a/apps/macos/Sources/SkinOS/FriendsView.swift +++ b/apps/macos/Sources/SkinOS/FriendsView.swift @@ -73,7 +73,7 @@ struct FriendsView: View { if let friend = model.selectedFriend { ChatView(model: model, friend: friend).id(friend.id) } else { - EmptyState(symbol: "bubble.left.and.bubble.right", title: "Всегда на связи", subtitle: "Выберите друга, чтобы открыть переписку.\nПри входе ваш статус — «Невидимка».") + EmptyState(symbol: "bubble.left.and.bubble.right", title: "Выберите чат", subtitle: "Выберите друга, чтобы открыть переписку.\nПри входе установлен статус «Невидимка».") } } .onAppear { @@ -161,7 +161,7 @@ struct ChatView: View { }.buttonStyle(AccentButtonStyle()).disabled(!canSend).help("Отправить сообщение") } if draft.utf8.count > 6_000 { Text("Сообщение слишком длинное").font(.system(size: 10)).foregroundStyle(.orange) } - else { Text("Enter — отправить · Shift+Enter — новая строка").font(.system(size: 9)).foregroundStyle(Theme.muted) } + else { Text("Enter: отправить · Shift+Enter: новая строка").font(.system(size: 9)).foregroundStyle(Theme.muted) } }.padding(22) } } diff --git a/apps/macos/Sources/SkinOS/LaunchLogView.swift b/apps/macos/Sources/SkinOS/LaunchLogView.swift index 2172b48..8bda7d4 100644 --- a/apps/macos/Sources/SkinOS/LaunchLogView.swift +++ b/apps/macos/Sources/SkinOS/LaunchLogView.swift @@ -104,7 +104,7 @@ struct LaunchLogView: View { }.background(Theme.sidebar) } else { EmptyState(symbol: "doc.text.magnifyingglass", title: "Журнал пока пуст", - subtitle: "Запустите игру — здесь появятся этапы запуска и подробности ошибок.") + subtitle: "После запуска здесь появятся его этапы и подробности ошибок.") } Rectangle().fill(Theme.divider).frame(height: 1) HStack(spacing: 13) { diff --git a/apps/macos/Sources/SkinOS/LibraryFilters.swift b/apps/macos/Sources/SkinOS/LibraryFilters.swift index 3a9d79b..517b10e 100644 --- a/apps/macos/Sources/SkinOS/LibraryFilters.swift +++ b/apps/macos/Sources/SkinOS/LibraryFilters.swift @@ -8,11 +8,17 @@ struct LibraryFilters: View { HStack(spacing: 6) { ForEach(GameFilter.allCases) { filter in Button { model.filter = filter } label: { - Text(filter.rawValue).font(.system(size: 11, weight: .medium)) - .padding(.horizontal, 12).padding(.vertical, 8) + HStack(spacing: 5) { + Image(systemName: "checkmark").font(.system(size: 9, weight: .bold)) + .opacity(model.filter == filter ? 1 : 0) + .accessibilityHidden(true) + Text(filter.rawValue).font(.system(size: 11, weight: .medium)) + }.padding(.horizontal, 12).padding(.vertical, 8) .foregroundStyle(model.filter == filter ? .white : Theme.muted) .background(model.filter == filter ? Theme.elevated : .clear, in: Capsule()) }.buttonStyle(.plain) + .accessibilityLabel(filter.rawValue) + .accessibilityAddTraits(model.filter == filter ? .isSelected : []) } Spacer(minLength: 0) Text("\(model.filteredGames.count)").font(.system(size: 10, design: .monospaced)).foregroundStyle(Theme.muted) diff --git a/apps/macos/Sources/SkinOS/Models.swift b/apps/macos/Sources/SkinOS/Models.swift index dc00032..d1e397d 100644 --- a/apps/macos/Sources/SkinOS/Models.swift +++ b/apps/macos/Sources/SkinOS/Models.swift @@ -109,7 +109,7 @@ struct GameDownload: Identifiable { return phase == "queued" ? "Размер будет определён при подготовке" : "Определяем размер загрузки…" } var speedDescription: String { - guard bytesPerSecond > 0, bytesPerSecond.isFinite else { return "— МБ/с" } + guard bytesPerSecond > 0, bytesPerSecond.isFinite else { return "Скорость пока неизвестна" } return "\(Self.bytes(Int64(min(bytesPerSecond, Double(Int64.max - 1024)))))/с" } var etaDescription: String { diff --git a/apps/macos/Sources/SkinOS/NotificationIntegration.swift b/apps/macos/Sources/SkinOS/NotificationIntegration.swift index a8cbd87..66fa9ba 100644 --- a/apps/macos/Sources/SkinOS/NotificationIntegration.swift +++ b/apps/macos/Sources/SkinOS/NotificationIntegration.swift @@ -4,6 +4,10 @@ import Foundation @MainActor extension AppModel { var unreadNoticeCount: Int { noticeItems.filter { !$0.read }.count } + var noticesFeedState: FeedLoadState { + FeedLoadState(hasItems: !noticeItems.isEmpty, loaded: noticesLoaded, + loading: noticesLoading, hasError: noticesError != nil) + } var mainContentVisible: Bool { applicationIsActive && applicationWindowVisible && !isShowingLogin && gameForSettings == nil && gameForLog == nil @@ -85,16 +89,19 @@ extension AppModel { func refreshNotices() { guard account != nil, !noticesLoading else { return } - noticesLoading = true; noticesError = nil + noticesLoading = true if let id = send("get_notifications") { notificationRequests.insert(id) } - else { noticesLoading = false } + else { + noticesLoading = false + noticesError = "Нет соединения с SkinOS Core. Перезапустите приложение." + } } func receiveNotificationEvent(_ event: String, payload: [String: Any], requestID: String?) -> Bool { if event == "steam_notifications_status" { guard payload["steamid"] as? String == account?.id, account != nil else { return true } switch payload["state"] as? String { - case "ready": noticesError = nil + case "ready": break // Service availability does not refresh the displayed snapshot. case "unavailable", "degraded": noticesError = payload["message"] as? String ?? payload["error"] as? String ?? "Уведомления Steam временно недоступны." default: break @@ -104,7 +111,9 @@ extension AppModel { if event == "error" { let notificationError = ["notifications_failed", "notifications_storage_failed", "notifications_busy"] .contains(payload["code"] as? String ?? "") + if let owner = payload["steamid"] as? String, owner != account?.id { return notificationError } let matchingRequest = requestID.map { notificationRequests.remove($0) != nil } ?? false + if notificationError, requestID != nil, !matchingRequest { return true } if notificationError || matchingRequest { noticesLoading = false noticesError = payload["message"] as? String ?? "Не удалось загрузить уведомления Steam." @@ -114,16 +123,22 @@ extension AppModel { guard event == "steam_notifications" || event == "steam_notification" else { return false } guard let owner = payload["steamid"] as? String, owner == account?.id else { return true } if event == "steam_notifications" { + if let requestID { notificationRequests.remove(requestID) } + noticesLoading = false + guard let rows = payload["notifications"] as? [[String: Any]] else { + noticesError = "Получен некорректный список уведомлений. Обновите его." + return true + } + let notices = rows.compactMap { makeSteamNotice($0, owner: owner) } + guard notices.count == rows.count, Set(notices.map(\.id)).count == notices.count else { + noticesError = "Получен некорректный список уведомлений. Обновите его." + return true + } // The backend snapshot is authoritative, including withdrawn requests. // Local game/download events have a separate lifetime in this feed. noticeItems.removeAll { $0.kind == .message || $0.kind == .steam } - for row in payload["notifications"] as? [[String: Any]] ?? [] { - if let notice = makeSteamNotice(row, owner: owner) { - mergeNotice(notice) - } - } - noticesLoading = false; noticesError = nil - if let requestID { notificationRequests.remove(requestID) } + for notice in notices { mergeNotice(notice) } + noticesLoaded = true; noticesError = nil } else if let row = payload["notification"] as? [String: Any], let notice = makeSteamNotice(row, owner: owner) { mergeNotice(notice) let fresh = notifications.record(notice) @@ -165,7 +180,7 @@ extension AppModel { guard let account, let attempt = launchNotificationIDs[appID] else { return } let name = games.first { $0.id == appID }?.name ?? "Игра \(appID)" let notice = ClientNotice(id: "launch-\(attempt)-failed", owner: account.id, kind: .game, - title: "\(name) — не удалось запустить", body: "Откройте журнал запуска в карточке игры, чтобы посмотреть подробности.", + title: "Не удалось запустить: \(name)", body: "Откройте журнал запуска в карточке игры, чтобы посмотреть подробности.", target: .game(appID), timestamp: Date()) mergeNotice(notice); notifications.post(notice) } @@ -176,7 +191,7 @@ extension AppModel { guard let old = previous[item.id], old.isActive, old.phase != item.phase else { continue } let name = games.first { $0.id == item.id }?.name ?? "Игра \(item.id)" let notice = ClientNotice(id: "download-\(item.id)-\(revision)", owner: account.id, kind: .download, - title: item.complete ? "\(name) — \(item.isUpdate ? "обновление готово" : "установка завершена")" : "\(name) — загрузка прервана", + title: item.complete ? "\(item.isUpdate ? "Обновление готово" : "Установка завершена"): \(name)" : "Загрузка прервана: \(name)", body: item.complete ? "Можно запускать игру из библиотеки SkinOS." : "Откройте загрузки, чтобы посмотреть причину и повторить попытку.", target: .download(item.id), timestamp: Date()) mergeNotice(notice); notifications.post(notice) diff --git a/apps/macos/Sources/SkinOS/NotificationsView.swift b/apps/macos/Sources/SkinOS/NotificationsView.swift index 31c7938..23e0554 100644 --- a/apps/macos/Sources/SkinOS/NotificationsView.swift +++ b/apps/macos/Sources/SkinOS/NotificationsView.swift @@ -68,14 +68,42 @@ struct NotificationsView: View { Button { model.refreshNotices() } label: { Image(systemName: "arrow.clockwise") } .disabled(model.account == nil || model.noticesLoading || !model.coreReady).help("Обновить уведомления Steam") }.buttonStyle(.bordered) - if let error = model.noticesError { Text(error).font(.system(size: 12)).foregroundStyle(.orange) } + if let error = model.noticesError { + VStack(alignment: .leading, spacing: 9) { + Label("Не удалось обновить уведомления Steam", systemImage: "exclamationmark.triangle") + .font(.system(size: 12, weight: .medium)).foregroundStyle(.orange) + if !model.noticeItems.isEmpty { + Text("Показаны сохранённые уведомления и локальные события. Новые уведомления Steam могут отсутствовать.") + .font(.system(size: 11)).foregroundStyle(Theme.muted) + } + DisclosureGroup("Технические подробности") { + Text(error).font(.system(size: 11)).foregroundStyle(Theme.muted) + .textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading).padding(.top, 5) + }.font(.system(size: 11)) + Button("Повторить") { model.refreshNotices() } + .buttonStyle(.bordered).disabled(model.account == nil || model.noticesLoading || !model.coreReady) + }.padding(13).background(Color.orange.opacity(0.07), in: RoundedRectangle(cornerRadius: 9)) + } if model.account == nil { WelcomeView(model: model) } - else if model.noticesLoading && model.noticeItems.isEmpty { ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) } - else if model.noticeItems.isEmpty { + else if model.noticesFeedState == .loading { + ProgressView("Загружаем уведомления…").frame(maxWidth: .infinity, maxHeight: .infinity) + } else if model.noticesFeedState == .failed { + EmptyState(symbol: "bell.slash", title: "Уведомления недоступны", + subtitle: "Не удалось проверить новые события Steam. Повторите запрос.") + } else if model.noticesFeedState == .initial { + EmptyState(symbol: "bell", title: "Уведомления ещё не загружены", + subtitle: "Нажмите кнопку обновления, чтобы получить события Steam.") + } else if model.noticesFeedState == .empty { EmptyState(symbol: "bell", title: "Пока ничего нового", subtitle: "Здесь появятся уведомления Steam и сообщения о завершении загрузок.") } else { ScrollView { LazyVStack(spacing: 10) { + if model.noticesFeedState == .refreshing { + ProgressView("Обновляем уведомления. Показаны последние полученные данные.") + .controlSize(.small).font(.system(size: 11)) + .frame(maxWidth: .infinity, alignment: .leading) + } ForEach(model.noticeItems) { notice in Button { model.openNotice(notice) } label: { HStack(alignment: .top, spacing: 15) { diff --git a/apps/macos/Sources/SkinOS/SettingsView.swift b/apps/macos/Sources/SkinOS/SettingsView.swift index 3ca3d70..3453e54 100644 --- a/apps/macos/Sources/SkinOS/SettingsView.swift +++ b/apps/macos/Sources/SkinOS/SettingsView.swift @@ -10,7 +10,7 @@ struct SettingsView: View { ScrollView { VStack(alignment: .leading, spacing: 25) { VStack(alignment: .leading, spacing: 9) { - Text("Настроено под вас.") + Text("Настройки") .font(.system(size: 27, weight: .bold, design: .rounded)).tracking(-0.8) Text("Библиотека, загрузки и запуск Windows-игр на Mac.") .font(.system(size: 12)).foregroundStyle(Theme.muted) diff --git a/apps/macos/Sources/SkinOS/SkinOSApp.swift b/apps/macos/Sources/SkinOS/SkinOSApp.swift index 2c1778a..cc1fd2d 100644 --- a/apps/macos/Sources/SkinOS/SkinOSApp.swift +++ b/apps/macos/Sources/SkinOS/SkinOSApp.swift @@ -166,8 +166,10 @@ struct SidebarView: View { ForEach(AppSection.allCases) { section in Button { model.section = section } label: { HStack(spacing: 12) { - Image(systemName: section.symbol).font(.system(size: 15, weight: .medium)).frame(width: 20) - Text(section.rawValue).font(.system(size: 13, weight: .medium)) + Image(systemName: model.section == section ? "checkmark" : section.symbol) + .font(.system(size: 15, weight: .medium)).frame(width: 20) + .accessibilityHidden(true) + Text(section.rawValue).font(.system(size: 13, weight: .medium)).lineLimit(1) Spacer() if section == .library, !model.games.isEmpty { Text("\(model.games.count)").font(.system(size: 10, weight: .medium, design: .monospaced)) @@ -183,7 +185,9 @@ struct SidebarView: View { .padding(.horizontal, 13).padding(.vertical, 13) .background(model.section == section ? Theme.accent.opacity(0.085) : .clear, in: RoundedRectangle(cornerRadius: 9)) .contentShape(Rectangle()) - }.buttonStyle(.plain).padding(.horizontal, 12).padding(.bottom, 4) + }.buttonStyle(.plain) + .accessibilityAddTraits(model.section == section ? .isSelected : []) + .padding(.horizontal, 12).padding(.bottom, 4) } Spacer(minLength: 30) @@ -194,7 +198,7 @@ struct SidebarView: View { .font(.system(size: 8, weight: .semibold, design: .monospaced)).tracking(1.1) }.foregroundStyle(Theme.muted) Text("Нативный клиент для Mac") - .font(.system(size: 10)).foregroundStyle(Theme.muted.opacity(0.65)) + .font(.system(size: 10)).foregroundStyle(Theme.muted) }.padding(25) Rectangle().fill(Theme.divider).frame(height: 1) Button { model.account == nil ? (model.isShowingLogin = true) : (model.section = .account) } label: { @@ -248,7 +252,7 @@ struct LibraryView: View { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 9) { - Text("Всё, во что хочется играть.") + Text("Библиотека") .font(.system(size: 27, weight: .bold, design: .rounded)).tracking(-0.8) Text(subtitle).font(.system(size: 12)).foregroundStyle(Theme.muted) } @@ -288,8 +292,8 @@ struct LibraryView: View { } var subtitle: String { - guard model.account != nil else { return "Ваша библиотека Steam в собственном пространстве." } - return model.libraryIsCached ? "Сохранённая библиотека · обновите, чтобы получить последние данные." : "Ваша коллекция. Без лишнего." + guard model.account != nil else { return "Подключите аккаунт Steam, чтобы загрузить список игр." } + return model.libraryIsCached ? "Сохранённая библиотека · обновите, чтобы получить последние данные." : "Игры текущего аккаунта Steam." } } @@ -304,7 +308,7 @@ struct WelcomeView: View { RoundedRectangle(cornerRadius: 22).fill(Theme.surface).frame(width: 90, height: 90).rotationEffect(.degrees(-8)) Image(systemName: "gamecontroller.fill").font(.system(size: 35)).foregroundStyle(Theme.accent) }.padding(.bottom, 14) - Text("Добро пожаловать домой.").font(.system(size: 24, weight: .semibold, design: .rounded)).tracking(-0.5) + Text("Подключение Steam").font(.system(size: 24, weight: .semibold, design: .rounded)).tracking(-0.5) Text("Подключите Steam, чтобы увидеть свои игры,\nустанавливать их и запускать прямо отсюда.") .font(.system(size: 13)).foregroundStyle(Theme.muted).multilineTextAlignment(.center).lineSpacing(5).padding(.top, 13) if model.isAuthenticating || model.isReconnecting { @@ -324,7 +328,7 @@ struct WelcomeView: View { }.buttonStyle(AccentButtonStyle()).disabled(!model.coreReady).padding(.top, 28) } Text(model.isAuthenticating ? model.authMessage : "Вход через Steam Guard · пароль не нужен") - .font(.system(size: 10)).foregroundStyle(Theme.muted.opacity(0.7)).padding(.top, 16) + .font(.system(size: 10)).foregroundStyle(Theme.muted).padding(.top, 16) Spacer() HStack(spacing: 28) { Label("Нативный интерфейс", systemImage: "apple.logo") @@ -466,7 +470,7 @@ struct GameDetailView: View { } Rectangle().fill(Theme.divider).frame(height: 1) VStack(alignment: .leading, spacing: 18) { - DetailValue(title: "АККАУНТ ДЛЯ ИГРЫ", value: model.account?.displayName ?? "—", symbol: "person.crop.circle") + DetailValue(title: "АККАУНТ ДЛЯ ИГРЫ", value: model.account?.displayName ?? "не выбран", symbol: "person.crop.circle") DetailValue(title: "ВРЕМЯ В ИГРЕ", value: game.playtime, symbol: "clock") DetailValue(title: "СОСТОЯНИЕ", value: game.isInstalled ? "Установлена" : "В вашей библиотеке", symbol: game.isInstalled ? "internaldrive" : "checkmark.seal") } @@ -501,7 +505,7 @@ struct AccountView: View { @ObservedObject var model: AppModel var body: some View { VStack(alignment: .leading, spacing: 28) { - Text("Ваш Steam. Ваше пространство.").font(.system(size: 27, weight: .bold, design: .rounded)).tracking(-0.8) + Text("Аккаунты Steam").font(.system(size: 27, weight: .bold, design: .rounded)).tracking(-0.8) if let account = model.account { HStack(spacing: 20) { AvatarView(account: account, size: 70) @@ -543,7 +547,7 @@ struct LoginView: View { Button { password = ""; guardCode = ""; model.cancelAuth() } label: { Image(systemName: "xmark").font(.system(size: 12)) }.buttonStyle(.plain) } VStack(spacing: 10) { - Text("Все ваши игры — здесь.").font(.system(size: 27, weight: .bold, design: .rounded)).tracking(-0.7) + Text("Вход в Steam").font(.system(size: 27, weight: .bold, design: .rounded)).tracking(-0.7) Text("Войдите в аккаунт, которому принадлежат игры.").font(.system(size: 12)).foregroundStyle(Theme.muted) } Picker("Способ входа", selection: $mode) { diff --git a/apps/macos/Sources/SkinOS/SteamInputDiagnostics.swift b/apps/macos/Sources/SkinOS/SteamInputDiagnostics.swift index 7deb6b9..a619c0a 100644 --- a/apps/macos/Sources/SkinOS/SteamInputDiagnostics.swift +++ b/apps/macos/Sources/SkinOS/SteamInputDiagnostics.swift @@ -197,7 +197,7 @@ extension AppModel { let owner = account?.id, payload["steamid"] as? String == owner else { return true } switch payload["state"] as? String { case "failed": - steamInputFeedbackErrors[appID] = "Вибрация недоступна. Подробности — в журнале запуска." + steamInputFeedbackErrors[appID] = "Вибрация недоступна. Подробности смотрите в журнале запуска." case "ready", "reconnected": steamInputFeedbackErrors[appID] = nil default: break @@ -262,7 +262,7 @@ extension AppModel { if state == "failed" { steamInputErrors[appID] = payload["message"] as? String ?? "Проверка Steam Input завершилась с ошибкой." } else { - let labels = ["authenticating": "Подключаем аккаунт…", "authenticated": "Аккаунт подключён", "starting": "Сканируем контроллеры — около 7 секунд…", "exited": "Завершаем проверку…"] + let labels = ["authenticating": "Подключаем аккаунт…", "authenticated": "Аккаунт подключён", "starting": "Сканируем контроллеры, около 7 секунд…", "exited": "Завершаем проверку…"] steamInputMessages[appID] = labels[state] ?? payload["message"] as? String ?? "Проверяем Steam Input…" } } @@ -320,7 +320,7 @@ struct SteamInputDiagnosticCard: View { if let report { Divider().overlay(Theme.divider) if report.fixture { - Label("Тестовый отчёт — физическое устройство не проверялось", systemImage: "info.circle") + Label("Тестовый отчёт: физическое устройство не проверялось", systemImage: "info.circle") .font(.system(size: 11)).foregroundStyle(.orange) } diagnosticRow("Steam Input API", result: report.apiInitialized ? "Инициализирован" : "Не инициализирован") diff --git a/apps/macos/Tests/SkinOSTests/DownloadFailurePresentationTests.swift b/apps/macos/Tests/SkinOSTests/DownloadFailurePresentationTests.swift new file mode 100644 index 0000000..636586e --- /dev/null +++ b/apps/macos/Tests/SkinOSTests/DownloadFailurePresentationTests.swift @@ -0,0 +1,61 @@ +import Testing +@testable import SkinOS + +struct DownloadFailurePresentationTests { + @Test func accessDeniedSuggestsInspectingAccountWithoutInventingOwnership() { + let raw = "Account cannot download depot 373307 (Steam result 15)" + let failure = DownloadFailurePresentation(raw) + #expect(failure.cause == .accessDenied) + #expect(failure.action == .inspectAccount) + #expect(failure.diagnostic == raw) + for unrecognized in [ + "Account cannot download depot 373307 (Steam result 2)", + "Account cannot download depot 373307 (Steam result 150)", + "Other error: \(raw)", + "\(raw)\nThe cause is different" + ] { + let fallback = DownloadFailurePresentation(unrecognized) + #expect(fallback.cause == .unknown) + #expect(fallback.action == .inspectDetails) + #expect(fallback.diagnostic == unrecognized) + } + } + + @Test func knownTransportFailuresOfferRetryAndPreserveOriginalDiagnostic() { + for raw in ["Steam request timed out", "Не удалось получить файлы игры: request timed out"] { + let failure = DownloadFailurePresentation(raw) + #expect(failure.cause == .timeout) + #expect(failure.action == .retry) + #expect(failure.diagnostic == raw) + } + for raw in ["Steam returned no usable HTTPS content servers", + "Не удалось получить файлы игры: connection failed", + "Не удалось получить файлы игры: content transfer interrupted"] { + let failure = DownloadFailurePresentation(raw) + #expect(failure.cause == .connection) + #expect(failure.action == .retry) + #expect(failure.diagnostic == raw) + } + } + + @Test func diskSpaceAndCoreFailureHaveDifferentRecoveryActions() { + let space = DownloadFailurePresentation("Недостаточно места: нужно дополнительно 512 МБ, доступно 12 МБ") + #expect(space.cause == .diskSpace) + #expect(space.action == .freeSpace) + let disconnected = DownloadFailurePresentation("Нет соединения с SkinOS Core. Перезапустите приложение.") + #expect(disconnected.cause == .coreUnavailable) + #expect(disconnected.action == .restartClient) + } + + @Test func unknownOrMissingReasonsDoNotInventDiagnosis() { + for raw in ["Steam result 15", "Лицензия не проверялась", "HTTP 403", "Unexpected private fixture error"] { + let failure = DownloadFailurePresentation(raw) + #expect(failure.cause == .unknown) + #expect(failure.action == .inspectDetails) + #expect(failure.diagnostic == raw) + } + #expect(DownloadFailurePresentation(nil).diagnostic == nil) + #expect(DownloadFailurePresentation("").diagnostic == nil) + #expect(DownloadFailurePresentation(nil).cause == .unknown) + } +} diff --git a/apps/macos/Tests/SkinOSTests/DownloadQueueTests.swift b/apps/macos/Tests/SkinOSTests/DownloadQueueTests.swift index a1fe1e5..0e6bb05 100644 --- a/apps/macos/Tests/SkinOSTests/DownloadQueueTests.swift +++ b/apps/macos/Tests/SkinOSTests/DownloadQueueTests.swift @@ -144,4 +144,90 @@ struct DownloadQueueTests { #expect(!cancelling.canCancel) #expect(!cancelling.canPause) } + + @Test func initialFailureAndSuccessfulEmptyQueueAreDifferentStates() { + let model = model() + model.commandSink = { _, _ in "refresh" } + #expect(model.downloadFeedState == .initial) + model.refreshDownloadQueue() + #expect(model.downloadFeedState == .loading) + model.receive(["event": "error", "id": "refresh", + "payload": ["code": "download_queue_failed", "message": "Fixture request failure"]]) + #expect(model.downloadFeedState == .failed) + #expect(!model.downloadQueueReady) + #expect(model.downloadQueueRequests.isEmpty) + model.refreshDownloadQueue() + #expect(model.downloadFeedState == .loading) + #expect(model.downloadQueueError != nil) + model.receive(queue(1, [], requestID: "refresh")) + #expect(model.downloadFeedState == .empty) + #expect(model.downloadQueueError == nil) + #expect(model.downloadQueueReady) + } + + @Test func failedRefreshKeepsRowsAndEqualRevisionSuccessClearsStaleness() { + let model = model() + model.commandSink = { _, _ in "refresh" } + model.receive(queue(10, [item(42, "paused")])) + model.refreshDownloadQueue() + #expect(model.downloadFeedState == .refreshing) + model.receive(["event": "error", "id": "refresh", "payload": ["message": "Fixture timeout"]]) + #expect(model.downloadFeedState == .stale) + #expect(model.downloads[42]?.phase == "paused") + model.refreshDownloadQueue() + #expect(model.downloadFeedState == .refreshing) + #expect(model.downloadQueueError != nil) + model.receive(queue(10, [item(42, "paused")], requestID: "refresh")) + #expect(model.downloadFeedState == .content) + #expect(model.downloadQueueError == nil) + #expect(model.downloadQueueRevision == 10) + #expect(model.downloads[42]?.phase == "paused") + } + + @Test func malformedQueueCannotEraseCachedRowsOrCompleteFirstLoad() { + let invalidPayloads: [[String: Any]] = [ + ["steamid": "100", "revision": 2], + ["steamid": "100", "items": []], + ["steamid": "100", "revision": 2, "items": [["appid": 42, "state": "unknown"]]], + ["steamid": "100", "revision": 2, "items": [item(42, "paused"), item(42, "queued")]] + ] + for populated in [false, true] { + for payload in invalidPayloads { + let model = model() + if populated { model.receive(queue(1, [item(42, "paused")])) } + model.commandSink = { _, _ in "refresh" } + model.refreshDownloadQueue() + model.receive(["event": "download_queue", "id": "refresh", "payload": payload]) + #expect(model.downloadFeedState == (populated ? .stale : .failed)) + #expect(model.downloadQueueRequests.isEmpty) + #expect(model.downloads[42]?.phase == (populated ? "paused" : nil)) + #expect(model.downloadQueueReady == populated) + } + } + } + + @Test func staleSnapshotAndStartingControlDoNotHideRefreshFailure() { + let model = model() + model.coreReady = true + model.receive(queue(10, [item(42, "paused")])) + model.downloadQueueError = "Fixture refresh failure" + model.commandSink = { command, _ in command } + model.controlDownload("resume_download", appID: 42) + #expect(model.downloadFeedState == .stale) + model.refreshDownloadQueue() + model.receive(queue(9, [item(42, "downloading")], requestID: "get_download_queue")) + #expect(model.downloadFeedState == .stale) + #expect(model.downloads[42]?.phase == "paused") + #expect(model.downloadQueueError != nil) + } + + @Test func failedQueueSendDoesNotLookLikeSuccessfulEmptyLoad() { + let model = model() + model.commandSink = { _, _ in nil } + model.refreshDownloadQueue() + #expect(model.downloadFeedState == .failed) + #expect(model.downloadQueueRequests.isEmpty) + #expect(!model.downloadQueueLoading) + #expect(!model.downloadQueueReady) + } } diff --git a/apps/macos/Tests/SkinOSTests/NotificationTests.swift b/apps/macos/Tests/SkinOSTests/NotificationTests.swift index 555d37f..49a16d5 100644 --- a/apps/macos/Tests/SkinOSTests/NotificationTests.swift +++ b/apps/macos/Tests/SkinOSTests/NotificationTests.swift @@ -83,7 +83,8 @@ struct NotificationTests { #expect(model.noticesError == "Service unavailable") #expect(model.noticeItems.map(\.id) == ["existing"]) model.receive(["event": "steam_notifications_status", "payload": ["steamid": "100", "state": "ready"]]) - #expect(model.noticesError == nil) + #expect(model.noticesError == "Service unavailable") + #expect(model.noticesFeedState == .stale) model.notificationRequests.insert("manual-refresh") model.noticesLoading = true model.receive(["id": "manual-refresh", "event": "error", "payload": ["code": "notifications_failed", "message": "Manual request timed out"]]) @@ -223,4 +224,79 @@ struct NotificationTests { #expect(deliveries.notices.count == 1) #expect(deliveries.notices.first?.kind == .download) } + + @Test func initialNotificationFailureRequiresSuccessfulSnapshotBeforeEmptyState() { + let (model, _) = fixture() + model.commandSink = { _, _ in "refresh" } + #expect(model.noticesFeedState == .initial) + model.refreshNotices() + #expect(model.noticesFeedState == .loading) + model.receive(["event": "error", "id": "refresh", + "payload": ["code": "notifications_failed", "message": "Fixture failure"]]) + #expect(model.noticesFeedState == .failed) + #expect(!model.noticesLoaded) + model.receive(["event": "steam_notifications_status", "payload": ["steamid": "100", "state": "ready"]]) + #expect(model.noticesFeedState == .failed) + model.refreshNotices() + #expect(model.noticesFeedState == .loading) + #expect(model.noticesError != nil) + model.receive(["event": "steam_notifications", "id": "refresh", + "payload": ["steamid": "100", "notifications": []]]) + #expect(model.noticesFeedState == .empty) + #expect(model.noticesLoaded) + #expect(model.noticesError == nil) + #expect(model.notificationRequests.isEmpty) + } + + @Test func notificationRefreshFailurePreservesDataUntilSuccessfulReplacement() { + let (model, _) = fixture() + model.receive(["event": "steam_notifications", "payload": ["steamid": "100", "notifications": [row("old")]]]) + model.commandSink = { _, _ in "refresh" } + model.refreshNotices() + #expect(model.noticesFeedState == .refreshing) + model.receive(["event": "error", "id": "refresh", + "payload": ["code": "notifications_failed", "message": "Fixture timeout"]]) + #expect(model.noticesFeedState == .stale) + #expect(model.noticeItems.map(\.id) == ["old"]) + model.refreshNotices() + #expect(model.noticesFeedState == .refreshing) + #expect(model.noticesError != nil) + model.receive(["event": "steam_notifications", "id": "refresh", + "payload": ["steamid": "100", "notifications": [row("new")]]]) + #expect(model.noticesFeedState == .content) + #expect(model.noticeItems.map(\.id) == ["new"]) + #expect(model.noticesError == nil) + } + + @Test func malformedNotificationSnapshotPreservesExistingEvents() { + let invalidSnapshots: [Any] = ["invalid", [row("duplicate"), row("duplicate")], [["id": "missing-title"]]] + for notifications in invalidSnapshots { + let (model, _) = fixture() + model.receive(["event": "steam_notifications", "payload": ["steamid": "100", "notifications": [row("old")]]]) + model.commandSink = { _, _ in "refresh" } + model.refreshNotices() + model.receive(["event": "steam_notifications", "id": "refresh", + "payload": ["steamid": "100", "notifications": notifications]]) + #expect(model.noticesFeedState == .stale) + #expect(model.noticeItems.map(\.id) == ["old"]) + #expect(!model.noticesLoading) + #expect(model.notificationRequests.isEmpty) + } + } + + @Test func failedNotificationSendAndAccountResetNeverProduceSuccessfulEmptyState() { + let (model, _) = fixture() + model.commandSink = { _, _ in nil } + model.refreshNotices() + #expect(model.noticesFeedState == .failed) + #expect(!model.noticesLoaded) + model.receive(["event": "steam_notifications", "payload": ["steamid": "100", "notifications": []]]) + #expect(model.noticesFeedState == .empty) + model.resetCommunity() + #expect(!model.noticesLoaded) + #expect(model.noticesFeedState == .initial) + model.receive(["event": "error", "id": "old-request", + "payload": ["code": "notifications_failed", "message": "Old account request"]]) + #expect(model.noticesError == nil) + } } diff --git a/release-notes/0.1.6.md b/release-notes/0.1.6.md new file mode 100644 index 0000000..2ceab9a --- /dev/null +++ b/release-notes/0.1.6.md @@ -0,0 +1,13 @@ +# SkinOS 0.1.6 + +Обновление интерфейса стабильной версии 0.1.5. + +- Ошибка получения очереди больше не показывает сообщение «Всё скачано». Уведомления также различают ошибку запроса и успешный пустой ответ. +- При сбое обновления списки сохраняются с предупреждением, что данные могут быть устаревшими. Доступен повтор запроса. +- Известные ошибки загрузки объясняются по-русски. Исходная диагностика доступна в раскрываемых подробностях; при отказе Steam в доступе можно перейти к проверке аккаунта. +- Повышен контраст мелких подписей. Активные разделы и фильтры отмечены галочкой и передают выбранное состояние средствам доступности macOS. +- Рабочие экраны получили прямые названия, уточнены подсказки и сообщения об отсутствующих данных. + +Требования: Apple Silicon, macOS 15 или новее. Для Windows-игр отдельно требуется CrossOver или CrossOver Preview. + +Этот выпуск сохраняет ядро и механизм запуска игр версии 0.1.5. Незавершённые изменения Steam Cloud, коллекций и навигации контроллером из ветки разработки в него не включены. diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 58cef4b..02728e1 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.5 - CFBundleVersion6 + CFBundleShortVersionString0.1.6 + CFBundleVersion7 LSMinimumSystemVersion15.0 NSHighResolutionCapable NSPrincipalClassNSApplication