From a864dcf6ee155c6dc9cfdc4c6da8eba787056bf5 Mon Sep 17 00:00:00 2001 From: Steve Korshakov Date: Sun, 8 Nov 2026 14:19:18 -0800 Subject: [PATCH 1/2] feat(ios): add read-only Cloud compute monitoring --- .../AgentConnection/SettingsLinks.swift | 3 + .../HappyCore/Agents/CloudCompute.swift | 104 ++++++++++ .../HappyCore/Agents/ComputeMonitor.swift | 113 +++++++++++ .../HappyCore/Agents/Permissions.swift | 4 +- .../Agents/ComputeViewController.swift | 179 +++++++++++++++++ .../HappyUI/Agents/HomeViewController.swift | 5 +- .../Sources/HappyUI/Agents/Session.swift | 7 +- .../HappyUI/Agents/SettingsIndex.swift | 1 + .../HappyUI/Agents/SettingsLinks.swift | 3 + .../Agents/SettingsViewController.swift | 13 +- .../Sources/HappyUI/Strings/Compute.swift | 35 ++++ .../HappyCoreTests/CloudComputeTests.swift | 183 ++++++++++++++++++ .../HostedComputeMonitoringTests.swift | 179 +++++++++++++++++ 13 files changed, 825 insertions(+), 4 deletions(-) create mode 100644 packages/happy-ios/Sources/HappyCore/Agents/CloudCompute.swift create mode 100644 packages/happy-ios/Sources/HappyCore/Agents/ComputeMonitor.swift create mode 100644 packages/happy-ios/Sources/HappyUI/Agents/ComputeViewController.swift create mode 100644 packages/happy-ios/Sources/HappyUI/Strings/Compute.swift create mode 100644 packages/happy-ios/Tests/HappyCoreTests/CloudComputeTests.swift create mode 100644 packages/happy-ios/Tests/HappyUIKitHostedTests/HostedComputeMonitoringTests.swift diff --git a/packages/happy-ios/Sources/HappyCore/AgentConnection/SettingsLinks.swift b/packages/happy-ios/Sources/HappyCore/AgentConnection/SettingsLinks.swift index e17a5cd2fc..55702c80ef 100644 --- a/packages/happy-ios/Sources/HappyCore/AgentConnection/SettingsLinks.swift +++ b/packages/happy-ios/Sources/HappyCore/AgentConnection/SettingsLinks.swift @@ -15,6 +15,8 @@ public enum SettingsLinkPlace: Equatable, Sendable { case voice /// Happy Cloud's servers. case servers + /// Read-only Happy Cloud compute pools and machines. + case computes /// The page in the web app, in the system browser. case web(URL) /// A Happy Cloud server's AI providers: the list, the chooser or one @@ -34,6 +36,7 @@ public enum SettingsLinkPlace: Equatable, Sendable { // Reading this phone's files is the desktop's: here, the chooser. case .providersImport: return cloud ? .providers(.providersAdd) : HappyCloud.webSettings(server: server, page: page).map(SettingsLinkPlace.web) + case .computes: return cloud ? .computes : nil default: return HappyCloud.webSettings(server: server, page: page).map(SettingsLinkPlace.web) } } diff --git a/packages/happy-ios/Sources/HappyCore/Agents/CloudCompute.swift b/packages/happy-ios/Sources/HappyCore/Agents/CloudCompute.swift new file mode 100644 index 0000000000..25173d95ee --- /dev/null +++ b/packages/happy-ios/Sources/HappyCore/Agents/CloudCompute.swift @@ -0,0 +1,104 @@ +import Foundation + +/// Monitoring snapshots from `/v0/compute`. These types expose no node +/// management operations. Slots are configured capacity, not free resources. +public struct ComputeCapacity: Decodable, Equatable, Sendable { + public let used: Int + public let total: Int? + + enum CodingKeys: String, CodingKey { case used, total } + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + used = try c.decode(Int.self, forKey: .used) + total = try c.decode(Int?.self, forKey: .total) + guard used >= 0, total.map({ $0 >= 0 }) ?? true else { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Negative sandbox capacity")) + } + } +} + +public struct ComputePool: Decodable, Equatable, Sendable { + public struct Counts: Decodable, Equatable, Sendable { + public let total: Int + public let online: Int + public let offline: Int + public let draining: Int + public let enrolling: Int + public let revoked: Int + } + + public let id: String + public let name: String + public let kind: String + public let isDefault: Bool + public let nodeCounts: Counts + public let sandboxCapacity: ComputeCapacity + + enum CodingKeys: String, CodingKey { + case id, name, kind, nodeCounts, sandboxCapacity + case isDefault = "default" + } +} + +public struct ComputeNode: Decodable, Equatable, Sendable { + /// Startup host totals. Missing facts remain unknown, including on B + /// servers, which omit `host` altogether. + public struct Host: Decodable, Equatable, Sendable { + public let cpuCores: Double? + public let totalMemoryBytes: Double? + public let totalDiskBytes: Double? + } + + /// Durable placements, including idle/reserved sandboxes; not processes. + public struct Placement: Decodable, Equatable, Sendable { + public let sandboxId: String + public let agentId: String + public let agentTitle: String? + } + + public let id: String + public let pool: String + public let name: String? + public let hostname: String? + public let os: String? + public let arch: String? + public let version: String? + public let osVersion: String? + public let shell: String? + public let sandboxError: String? + public let status: String + public let since: Double? + public let enrolledAt: Double? + public let lastSeenAt: Double? + public let backend: String? + public let capabilities: [String: String]? + public let features: [String: Bool]? + public let sandboxCapacity: ComputeCapacity + public let host: Host? + public let activeSandboxes: [Placement] + + public var displayName: String { + [name, hostname].compactMap { $0 }.first { !$0.isEmpty } ?? id + } +} + +public struct ComputePoolDetail: Decodable, Equatable, Sendable { + public let pool: ComputePool + public let nodes: [ComputeNode] +} + +extension AgentClient { + public func computePools() async throws -> [ComputePool] { + struct Response: Decodable { let pools: [ComputePool] } + return try await decode(Response.self, "GET", "/v0/compute/pools").pools + } + + public func computePool(_ id: String) async throws -> ComputePoolDetail { + try await decode(ComputePoolDetail.self, "GET", "/v0/compute/pools/\(Self.escape(id))") + } + + public func computeNode(_ id: String) async throws -> ComputeNode { + struct Response: Decodable { let node: ComputeNode } + return try await decode(Response.self, "GET", "/v0/compute/nodes/\(Self.escape(id))").node + } +} \ No newline at end of file diff --git a/packages/happy-ios/Sources/HappyCore/Agents/ComputeMonitor.swift b/packages/happy-ios/Sources/HappyCore/Agents/ComputeMonitor.swift new file mode 100644 index 0000000000..dd52952387 --- /dev/null +++ b/packages/happy-ios/Sources/HappyCore/Agents/ComputeMonitor.swift @@ -0,0 +1,113 @@ +import Foundation + +/// One visible read-only monitoring page. Cancellation and authorization +/// are checked again after every response, even if a transport ignores cancellation. +@MainActor +public final class ComputeMonitor { + public enum Page: Equatable, Sendable { + case pools + case pool(String) + case node(String) + } + public enum Snapshot: Equatable, Sendable { + case pools([ComputePool]) + case pool(ComputePoolDetail) + case node(ComputeNode) + } + public enum Failure: Equatable, Sendable { case unavailable, forbidden, missing, signedOut } + + public let page: Page + public private(set) var snapshot: Snapshot? + public private(set) var failure: Failure? + public private(set) var reading: Task? + public var changed: (() -> Void)? + private let client: AgentClient + private var visible = false + private var allowed = false + private var again = false + private var poll: Task? + + public static let refreshEvents = ["compute.nodes.updated", "compute.pools.updated"] + + public init(client: AgentClient, page: Page) { + self.client = client + self.page = page + } + + deinit { reading?.cancel(); poll?.cancel() } + + public func appear(allowed: Bool, interval: Duration = .seconds(30)) { + visible = true + authorize(allowed) + guard allowed, failure != .forbidden, failure != .signedOut, poll == nil else { return } + poll = Task { [weak self] in + while !Task.isCancelled { + do { try await Task.sleep(for: interval) } catch { return } + self?.refresh() + } + } + } + + public func disappear(clear: Bool = false) { + visible = false + cancel() + if clear { snapshot = nil; changed?() } + } + + public func authorize(_ allowed: Bool) { + let wasAllowed = self.allowed + self.allowed = allowed + guard allowed else { + cancel() + snapshot = nil + failure = .forbidden + changed?() + return + } + if !wasAllowed { failure = nil } + if visible { refresh() } + } + + private func cancel() { + reading?.cancel(); reading = nil + poll?.cancel(); poll = nil + again = false + } + + /// Overlapping polling/event reads become at most one follow-up read. + public func refresh() { + guard visible, allowed, failure != .forbidden, failure != .signedOut else { return } + guard reading == nil else { again = true; return } + let client = client, page = page + reading = Task { [weak self] in + do { + let next: Snapshot + switch page { + case .pools: next = .pools(try await client.computePools()) + case .pool(let id): next = .pool(try await client.computePool(id)) + case .node(let id): next = .node(try await client.computeNode(id)) + } + guard !Task.isCancelled, let self, visible, allowed else { return } + snapshot = next + failure = nil + } catch { + guard !Task.isCancelled, let self, visible, allowed else { return } + switch error { + case AgentClientError.revoked: + snapshot = nil; failure = .signedOut; poll?.cancel(); poll = nil + case AgentClientError.status(403, _, _): + snapshot = nil; failure = .forbidden; poll?.cancel(); poll = nil + case AgentClientError.status(404, _, _): + snapshot = nil; failure = .missing + default: + // A transient failure retains the last reading, marked stale. + failure = .unavailable + } + } + guard !Task.isCancelled, let self, visible, allowed else { return } + reading = nil + changed?() + if again { again = false; refresh() } + } + } +} \ No newline at end of file diff --git a/packages/happy-ios/Sources/HappyCore/Agents/Permissions.swift b/packages/happy-ios/Sources/HappyCore/Agents/Permissions.swift index 7db77ca83c..25b2d923c0 100644 --- a/packages/happy-ios/Sources/HappyCore/Agents/Permissions.swift +++ b/packages/happy-ios/Sources/HappyCore/Agents/Permissions.swift @@ -36,9 +36,11 @@ public struct Permissions: Decodable, Equatable, Sendable { /// The team's inference providers; the person's own take `providers.own`. case providersManage = "providers.manage" case providersOwn = "providers.own" + /// Read-only Compute pools and node monitoring on this phone. + case poolsManage = "pools.manage" } - public static let refreshEvents = ["role."] + public static let refreshEvents = ["role.", "member.updated", "member.removed"] } /// What a connection lets the person do. A computer, or a server that is diff --git a/packages/happy-ios/Sources/HappyUI/Agents/ComputeViewController.swift b/packages/happy-ios/Sources/HappyUI/Agents/ComputeViewController.swift new file mode 100644 index 0000000000..29b6240788 --- /dev/null +++ b/packages/happy-ios/Sources/HappyUI/Agents/ComputeViewController.swift @@ -0,0 +1,179 @@ +#if canImport(UIKit) +import HappyCore +import UIKit + +/// Read-only pools → machines → host facts and sandbox placements. +/// Each page owns only its visible query, not an account-wide polling loop. +final class ComputeViewController: SettingsPageViewController { + private let session: Session + let monitor: ComputeMonitor + private var visible = false + private var invalidated = false + private var expandedRevoked = false + private var accessWatch: Watch? + private var accountWatch: AccountsObservation? + private var events: Task? + + init(session: Session, page: ComputeMonitor.Page = .pools) { + self.session = session + monitor = ComputeMonitor(client: session.client, page: page) + super.init(title: Strings.Compute.title) + monitor.changed = { [weak self] in self?.reload() } + } + + deinit { events?.cancel() } + + static func offered(_ session: Session) -> Bool { + guard let context = session.context else { return false } + return session.isCloud && HappyCloud.isOffered(in: context.variant) && session.gate.allows(.poolsManage) + } + + override func viewDidLoad() { + super.viewDidLoad() + view.accessibilityIdentifier = "compute-monitoring" + accessWatch = session.watchAccess { [weak self] in + guard let self, !invalidated else { return } + if visible { monitor.appear(allowed: Self.offered(session)) } + else { monitor.authorize(Self.offered(session)) } + } + if let account = session.context?.account { + let activeID = account.accounts.first(where: \.isActive)?.id + accountWatch = account.observeAccounts { [weak self] accounts in + guard let self else { return } + guard accounts.contains(where: { $0.id == session.accountID }), accounts.first(where: \.isActive)?.id == activeID else { + invalidated = true + stop(clear: true) + return + } + } + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + guard !invalidated else { return } + visible = true + session.refreshPermissions() + monitor.appear(allowed: Self.offered(session)) + let store = session.store + events = Task { [weak self] in + for await _ in await store.events(matching: ComputeMonitor.refreshEvents) { + guard !Task.isCancelled, let self, visible, !invalidated else { return } + monitor.refresh() + } + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + stop() + } + + private func stop(clear: Bool = false) { + visible = false + events?.cancel(); events = nil + monitor.disappear(clear: clear) + } + + override func makeRows() -> [[SettingsRow]] { + typealias Text = Strings.Compute + guard !invalidated else { return [] } + var cards: [[SettingsRow]] = [] + if let failure = monitor.failure { + let message: String + switch failure { + case .forbidden: message = Text.denied + case .missing: message = Text.missing + case .signedOut: message = Strings.ServerSignIn.needsSignIn + case .unavailable: message = Text.failed + } + var row = SettingsRow(id: "compute-status", title: message, accessory: .none) + if failure == .unavailable { + row.action = { [weak self] in self?.monitor.refresh() } + if monitor.snapshot != nil { row.subtitle = Text.stale } + } + cards.append([row]) + } + guard let snapshot = monitor.snapshot else { + if cards.isEmpty { cards.append([fact("compute-loading", Text.loading)]) } + return cards + } + switch snapshot { + case .pools(let pools): + if pools.isEmpty { cards.append([fact("compute-empty", Text.emptyPools)]) } + for pool in pools { + var row = SettingsRow(id: "compute-pool-\(pool.id)", title: pool.name, value: Text.capacity(pool.sandboxCapacity), action: { [weak self] in self?.open(.pool(pool.id)) }) + row.subtitle = Text.counts(pool.nodeCounts) + row.footer = Text.capacityNote + cards.append([row]) + } + case .pool(let detail): + title = detail.pool.name + var capacity = fact("compute-capacity", Text.capacity, Text.capacity(detail.pool.sandboxCapacity)) + capacity.footer = Text.capacityNote + cards.append([capacity]) + let current = detail.nodes.filter { $0.status != "revoked" } + let revoked = detail.nodes.filter { $0.status == "revoked" } + cards += current.map { [nodeRow($0)] } + if current.isEmpty { cards.append([fact("compute-nodes-empty", Text.emptyNodes)]) } + if !revoked.isEmpty { + cards.append([SettingsRow(id: "compute-revoked-toggle", title: Text.revoked(revoked.count), value: expandedRevoked ? "−" : "+", accessory: .none, action: { [weak self] in + self?.expandedRevoked.toggle() + })]) + if expandedRevoked { cards += revoked.map { [nodeRow($0)] } } + } + case .node(let node): + title = node.displayName + cards += nodeFacts(node) + } + return cards + } + + private func open(_ page: ComputeMonitor.Page) { + guard Self.offered(session), !invalidated, monitor.failure != .forbidden else { return } + navigationController?.pushViewController(ComputeViewController(session: session, page: page), animated: true) + } + + private func fact(_ id: String, _ title: String, _ value: String? = nil) -> SettingsRow { + SettingsRow(id: id, title: title, value: value, accessory: .none) + } + + private func nodeRow(_ node: ComputeNode) -> SettingsRow { + var row = SettingsRow(id: "compute-node-\(node.id)", title: node.displayName, value: node.status, action: { [weak self] in self?.open(.node(node.id)) }) + row.subtitle = Strings.Compute.capacity(node.sandboxCapacity) + return row + } + + private func nodeFacts(_ node: ComputeNode) -> [[SettingsRow]] { + typealias Text = Strings.Compute + let lastSeen = node.lastSeenAt.map { Date(timeIntervalSince1970: $0 / 1000).formatted(date: .abbreviated, time: .shortened) } ?? Text.unknown + var capacity = fact("compute-capacity", Text.capacity, Text.capacity(node.sandboxCapacity)) + capacity.footer = Text.placementNote + var disk = fact("compute-host-disk", Text.disk, Self.bytes(node.host?.totalDiskBytes)) + disk.footer = Text.hostNote + var cards = [[ + fact("compute-node-status", node.displayName, node.status), + fact("compute-last-seen", Text.lastSeen, lastSeen), + fact("compute-version", Text.version, node.version ?? Text.unknown), + fact("compute-os", Text.operatingSystem, [node.os, node.osVersion, node.arch].compactMap { $0 }.joined(separator: " · ")), + fact("compute-backend", Text.backend, node.backend ?? Text.unknown), + ], [capacity], [ + fact("compute-host-cpu", Text.cpu, node.host?.cpuCores.map { $0.formatted() } ?? Text.unknown), + fact("compute-host-memory", Text.memory, Self.bytes(node.host?.totalMemoryBytes)), disk, + ]] + if let error = node.sandboxError, !error.isEmpty { cards.append([fact("compute-sandbox-error", error)]) } + let placements = node.activeSandboxes.map { + var row = fact("compute-placement-\($0.sandboxId)", $0.agentTitle.flatMap { $0.isEmpty ? nil : $0 } ?? $0.agentId) + row.subtitle = $0.sandboxId + return row + } + cards.append(placements.isEmpty ? [fact("compute-placements-empty", Text.emptyPlacements)] : placements) + return cards + } + + static func bytes(_ value: Double?) -> String { + guard let value, value.isFinite, value >= 0, value < Double(Int64.max) else { return Strings.Compute.unknown } + return ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .binary) + } +} +#endif \ No newline at end of file diff --git a/packages/happy-ios/Sources/HappyUI/Agents/HomeViewController.swift b/packages/happy-ios/Sources/HappyUI/Agents/HomeViewController.swift index 26da10e99f..12341dba25 100644 --- a/packages/happy-ios/Sources/HappyUI/Agents/HomeViewController.swift +++ b/packages/happy-ios/Sources/HappyUI/Agents/HomeViewController.swift @@ -27,6 +27,7 @@ final class HomeViewController: UIViewController { private var agentHome = HomeSnapshot() private var connectionWatch: Task? private var headerWatch: Watch? + private var accessWatch: Watch? /// No network at all: the header says so, so the list doesn't too. private var waitingForNetwork = false /// A new task on its way to the screen: a second tap meanwhile opens nothing more. @@ -189,6 +190,7 @@ final class HomeViewController: UIViewController { servesSearch = nil connectionWatch?.cancel(); connectionWatch = nil headerWatch = nil; waitingForNetwork = false + accessWatch?.invalidate(); accessWatch = nil session = id.flatMap { Session.session(for: $0, context: context) } sidebar.images = session?.images resultsView.list.images = session?.images @@ -196,6 +198,7 @@ final class HomeViewController: UIViewController { snapshot = Snapshot() agentHome = HomeSnapshot() if let session { + accessWatch = session.watchAccess { [weak self] in self?.showResults() } connectionWatch = Task { [weak self, engine = session.engine] in for await snapshot in await engine.snapshots() { guard !Task.isCancelled, let self else { return } @@ -552,7 +555,7 @@ final class HomeViewController: UIViewController { } let actions = HappyCore.Search.ranked(quickActions().filter { !$0.id.hasPrefix("new-work") || session?.gate.allows(.contentWrite) ?? true }, query: query) { $0.title } let computer = session?.accountID - let entries = SettingsIndex.computer + let entries = SettingsIndex.computer + (session.map(ComputeViewController.offered) == true ? [SettingsIndex.computes] : []) let settings = HappyCore.Search.ranked(entries, query: query) { $0.title } return [ section("actions", Strings.quickActions, actions.map { ($0.id, $0.title, $0.glyph, $0.run) }), diff --git a/packages/happy-ios/Sources/HappyUI/Agents/Session.swift b/packages/happy-ios/Sources/HappyUI/Agents/Session.swift index b4cb4be1fc..0335087383 100644 --- a/packages/happy-ios/Sources/HappyUI/Agents/Session.swift +++ b/packages/happy-ios/Sources/HappyUI/Agents/Session.swift @@ -246,7 +246,7 @@ final class Session { } var parts: Set = [] if Onboarding.refreshEvents.contains(where: { type == $0 || ($0.hasSuffix(".") && type.hasPrefix($0)) }) { parts.insert(.onboarding) } - if type.hasPrefix("role.") { parts.insert(.permissions) } + if Permissions.refreshEvents.contains(where: { type == $0 || ($0.hasSuffix(".") && type.hasPrefix($0)) }) { parts.insert(.permissions) } if type.hasPrefix("surface.") || type == "vault.settings.updated" { parts.insert(.vaults) } read(parts) } @@ -254,6 +254,11 @@ final class Session { } private enum CloudPart { case onboarding, permissions, vaults } + /// A protected settings page gaining focus checks current grants. + func refreshPermissions() { + guard isCloud else { return } + read([.permissions]) + } /// One read of each part at a time; asked again meanwhile, it reads /// once more when that lands. private var reading: Set = [] diff --git a/packages/happy-ios/Sources/HappyUI/Agents/SettingsIndex.swift b/packages/happy-ios/Sources/HappyUI/Agents/SettingsIndex.swift index 98e87ac907..843b0b82d6 100644 --- a/packages/happy-ios/Sources/HappyUI/Agents/SettingsIndex.swift +++ b/packages/happy-ios/Sources/HappyUI/Agents/SettingsIndex.swift @@ -21,6 +21,7 @@ enum SettingsIndex { /// On Appearance, this phone's own: whether a shimmer still runs in Low Power Mode. static var shimmerInLowPower: Entry { Entry(id: "shimmer-in-low-power", title: Strings.LowPowerShimmer.title, glyph: "flash-outline", tile: .orange) } static var connection: Entry { Entry(id: "connection", title: Strings.Connection.title, glyph: "pulse-outline", tile: .teal) } + static var computes: Entry { Entry(id: "computes", title: Strings.Compute.title, glyph: "server-outline", tile: .teal) } static var account: Entry { Entry(id: "account", title: Strings.Settings.account, glyph: "person-circle-outline", tile: .blue) } static var backupRestore: Entry { Entry(id: "backup-restore", title: Strings.BackupRestore.title, glyph: "key-outline", tile: .green) } static var language: Entry { Entry(id: "language", title: Strings.SettingsLanguage.title, glyph: "language-outline", tile: .green) } diff --git a/packages/happy-ios/Sources/HappyUI/Agents/SettingsLinks.swift b/packages/happy-ios/Sources/HappyUI/Agents/SettingsLinks.swift index fa4be9022f..4871c8411a 100644 --- a/packages/happy-ios/Sources/HappyUI/Agents/SettingsLinks.swift +++ b/packages/happy-ios/Sources/HappyUI/Agents/SettingsLinks.swift @@ -20,6 +20,9 @@ enum SettingsLinks { SettingsViewController.open(context: context, computer: session.accountID, reveal: SettingsIndex.agentVoice.id, from: controller) case .servers: AccountAdding.connectToCloud(context: context, from: controller) + case .computes: + guard ComputeViewController.offered(session) else { return } + controller.navigationController?.pushViewController(ComputeViewController(session: session), animated: true) case .web(let url): UIApplication.shared.open(url) case .providers(let page): diff --git a/packages/happy-ios/Sources/HappyUI/Agents/SettingsViewController.swift b/packages/happy-ios/Sources/HappyUI/Agents/SettingsViewController.swift index 96bf9b7abb..397988e276 100644 --- a/packages/happy-ios/Sources/HappyUI/Agents/SettingsViewController.swift +++ b/packages/happy-ios/Sources/HappyUI/Agents/SettingsViewController.swift @@ -105,6 +105,7 @@ final class SettingsViewController: UIViewController, UIScrollViewDelegate, UISe /// followed while its page shows, for New Sessions. private var modelPreferences = ModelPreferences() private var modelsWatch: Watch? + private var accessWatch: Watch? /// The person's own profile on the computer shown, for its row. private var profileWatch: (id: String, watch: Watch)? private var profileName = "" @@ -375,6 +376,8 @@ final class SettingsViewController: UIViewController, UIScrollViewDelegate, UISe modelPreferences = ModelPreferences() modelsWatch?.invalidate() modelsWatch = nil + accessWatch?.invalidate() + accessWatch = nil prioritizingWatch?.invalidate() prioritizingWatch = nil prioritizing = nil @@ -392,6 +395,7 @@ final class SettingsViewController: UIViewController, UIScrollViewDelegate, UISe return } guard let session = Session.session(for: profile.id, context: context) else { return } + accessWatch = session.watchAccess { [weak self] in self?.rebuild() } connectionSettingsWatch = Task { [weak self, engine = session.engine] in for await snapshot in await engine.snapshots() { guard !Task.isCancelled, let self else { return } @@ -490,9 +494,16 @@ final class SettingsViewController: UIViewController, UIScrollViewDelegate, UISe let removal: [[SettingsRow]] = [[ Row(id: "disconnect", title: server == nil ? Strings.disconnect : Strings.ServerSignIn.signOut, accessory: .none, destructive: true, action: { [weak self] in self?.remove(id, direct: true) }), ]] + let computeRows: [[SettingsRow]] + if let session = Session.session(for: id, context: context), ComputeViewController.offered(session) { + computeRows = [[Row(Index.computes, action: { [weak self] in + guard let self, ComputeViewController.offered(session) else { return } + navigationController?.pushViewController(ComputeViewController(session: session), animated: true) + })]] + } else { computeRows = [] } return [ accountRows(), - ] + serverRows + [ + ] + serverRows + computeRows + [ [Row(Index.profile, image: profilePhoto, value: profileName.isEmpty ? nil : profileName, action: { [weak self] in self?.openProfile() })], [supportRow()], [ diff --git a/packages/happy-ios/Sources/HappyUI/Strings/Compute.swift b/packages/happy-ios/Sources/HappyUI/Strings/Compute.swift new file mode 100644 index 0000000000..4a82af6a8a --- /dev/null +++ b/packages/happy-ios/Sources/HappyUI/Strings/Compute.swift @@ -0,0 +1,35 @@ +import HappyCore + +extension Strings { + /// Native read-only Cloud monitoring; no shared mobile catalog yet. + enum Compute { + static let title = "Compute pools" + static let loading = "Loading…" + static let unknown = "Unknown" + static let emptyPools = "No compute pools" + static let emptyNodes = "No machines" + static let emptyPlacements = "No active sandboxes" + static let denied = "You no longer have permission to view compute pools." + static let missing = "This pool or machine is no longer available." + static let failed = "Couldn’t refresh. Tap to retry." + static let stale = "Showing the last reading." + static let capacityNote = "Configured slots across non-revoked machines, including offline and draining machines. Not available capacity." + static let hostNote = "Startup host totals, not current usage or sandbox limits. Disk is the total workspace volume size." + static let placementNote = "Durable sandbox placements, including idle and reserved sandboxes." + static let cpu = "Logical CPU cores" + static let memory = "Physical RAM" + static let disk = "Workspace volume" + static let lastSeen = "Last seen" + static let version = "Supervisor version" + static let operatingSystem = "Operating system" + static let backend = "Backend" + static let capacity = "Sandbox capacity" + static func capacity(_ value: ComputeCapacity) -> String { + value.total.map { "\(value.used) / \($0) sandboxes" } ?? "\(value.used) sandboxes" + } + static func revoked(_ count: Int) -> String { "\(count) revoked" } + static func counts(_ value: ComputePool.Counts) -> String { + "\(value.online) online · \(value.offline) offline · \(value.draining) draining · \(value.enrolling) enrolling" + } + } +} \ No newline at end of file diff --git a/packages/happy-ios/Tests/HappyCoreTests/CloudComputeTests.swift b/packages/happy-ios/Tests/HappyCoreTests/CloudComputeTests.swift new file mode 100644 index 0000000000..e51c98d20b --- /dev/null +++ b/packages/happy-ios/Tests/HappyCoreTests/CloudComputeTests.swift @@ -0,0 +1,183 @@ +import Foundation +import XCTest +@testable import HappyCore + +private actor ComputeTransport: AgentTransport { + var responses: [HTTP1Response] + var requests: [(String, String)] = [] + var held: CheckedContinuation? + var hold = false + + init(_ responses: [HTTP1Response] = []) { self.responses = responses } + func suspendNext() { hold = true } + func release(_ response: HTTP1Response) { held?.resume(returning: response); held = nil } + func request(method: String, path: String, credential: String, body: Data?, headers: [String: String]) async throws -> HTTP1Response { + requests.append((method, path)) + if hold { + hold = false + return await withCheckedContinuation { held = $0 } + } + return responses.isEmpty ? HTTP1Response(status: 500) : responses.removeFirst() + } + func stream(path: String, credential: String, headers: [String: String]) async throws -> HTTP1Stream { throw URLError(.notConnectedToInternet) } + func reset() async {} + func close() async {} +} + +@MainActor +final class CloudComputeTests: XCTestCase { + private static let pool = #"{"id":"default","name":"Default","kind":"shared","default":true,"nodeCounts":{"total":2,"online":1,"offline":0,"draining":0,"enrolling":0,"revoked":1},"sandboxCapacity":{"used":3,"total":null}}"# + private static let node = #"{"id":"node-1","pool":"default","name":null,"hostname":"worker","status":"online","version":"linux.9","lastSeenAt":1730000000000,"sandboxCapacity":{"used":3,"total":null},"activeSandboxes":[{"sandboxId":"s1","agentId":"a1","agentTitle":null}]}"# + private var pools: HTTP1Response { response("{\"pools\":[\(Self.pool)]}") } + private func response(_ json: String) -> HTTP1Response { HTTP1Response(status: 200, body: Data(json.utf8)) } + + func testUnknownCapacityAndHostStayUnknownAcrossBAndC() throws { + let b = try JSONDecoder().decode(ComputeNode.self, from: Data(Self.node.utf8)) + XCTAssertNil(b.host) + XCTAssertNil(b.sandboxCapacity.total) + XCTAssertEqual(b.sandboxCapacity.used, 3) + XCTAssertEqual(b.displayName, "worker") + XCTAssertNil(b.activeSandboxes[0].agentTitle) + let c = Self.node.dropLast() + #", "host":{"cpuCores":4,"totalMemoryBytes":8326934528,"totalDiskBytes":null}}"# + let reported = try JSONDecoder().decode(ComputeNode.self, from: Data(c.utf8)) + XCTAssertEqual(reported.host?.cpuCores, 4) + XCTAssertEqual(reported.host?.totalMemoryBytes, 8326934528) + XCTAssertNil(reported.host?.totalDiskBytes) + let old = Self.node.dropLast() + #", "host":null}"# + XCTAssertNil(try JSONDecoder().decode(ComputeNode.self, from: Data(old.utf8)).host) + } + + func testCapacityRejectsMalformedFactsInsteadOfInventingZero() throws { + for json in [#"{"used":-1,"total":null}"#, #"{"used":1,"total":-1}"#, #"{"used":1}"#] { + XCTAssertThrowsError(try JSONDecoder().decode(ComputeCapacity.self, from: Data(json.utf8))) + } + let empty = try JSONDecoder().decode(ComputeCapacity.self, from: Data(#"{"used":0,"total":0}"#.utf8)) + XCTAssertEqual(empty.total, 0) + } + + func testReadOnlyEndpointsAndEscapedIdentifiers() async throws { + let transport = ComputeTransport([pools, response("{\"pool\":\(Self.pool),\"nodes\":[\(Self.node)]}"), response("{\"node\":\(Self.node)}")]) + let client = AgentClient(transport: transport, token: "test") + let listed = try await client.computePools() + let detail = try await client.computePool("a/b") + let node = try await client.computeNode("n/?") + XCTAssertEqual(listed.count, 1) + XCTAssertEqual(detail.nodes.count, 1) + XCTAssertEqual(node.id, "node-1") + let requests = await transport.requests + XCTAssertEqual(requests.map(\.0), ["GET", "GET", "GET"]) + XCTAssertEqual(requests.map(\.1), ["/v0/compute/pools", "/v0/compute/pools/a%2Fb", "/v0/compute/nodes/n%2F%3F"]) + } + + func testMonitoringRequiresPoolsManageNotComputeUse() { + XCTAssertFalse(RoleGate.cloud(Permissions(granted: ["compute.use"])).allows(.poolsManage)) + XCTAssertTrue(RoleGate.cloud(Permissions(granted: ["pools.manage"])).allows(.poolsManage)) + XCTAssertFalse(RoleGate.cloud(nil).allows(.poolsManage)) + XCTAssertEqual(SettingsLinkPlace.place(.computes, server: nil), nil) + XCTAssertEqual(Permissions.refreshEvents, ["role.", "member.updated", "member.removed"]) + } + + func testPermissionLossClearsSnapshotAndStopsRequests() async { + let transport = ComputeTransport([pools]) + let monitor = ComputeMonitor(client: AgentClient(transport: transport, token: "test"), page: .pools) + monitor.appear(allowed: true) + await monitor.reading?.value + XCTAssertNotNil(monitor.snapshot) + monitor.authorize(false) + XCTAssertNil(monitor.snapshot) + XCTAssertEqual(monitor.failure, .forbidden) + monitor.refresh() + let count = await transport.requests.count + XCTAssertEqual(count, 1) + monitor.disappear() + } + + func testTransientFailureRetainsStaleFactsBut403ClearsAndLatches() async { + let transport = ComputeTransport([pools, HTTP1Response(status: 500), HTTP1Response(status: 403)]) + let monitor = ComputeMonitor(client: AgentClient(transport: transport, token: "test"), page: .pools) + monitor.appear(allowed: true) + await monitor.reading?.value + monitor.refresh() + await monitor.reading?.value + XCTAssertNotNil(monitor.snapshot) + XCTAssertEqual(monitor.failure, .unavailable) + monitor.refresh() + await monitor.reading?.value + XCTAssertNil(monitor.snapshot) + XCTAssertEqual(monitor.failure, .forbidden) + monitor.refresh() + let count = await transport.requests.count + XCTAssertEqual(count, 3) + monitor.disappear() + } + + func testLateResponseCannotRepopulateHiddenPage() async throws { + let transport = ComputeTransport() + await transport.suspendNext() + let monitor = ComputeMonitor(client: AgentClient(transport: transport, token: "test"), page: .pools) + monitor.appear(allowed: true) + let reading = try XCTUnwrap(monitor.reading) + let deadline = ContinuousClock.now + .seconds(3) + while await transport.held == nil, ContinuousClock.now < deadline { await Task.yield() } + monitor.disappear(clear: true) + XCTAssertTrue(reading.isCancelled) + await transport.release(pools) + await reading.value + XCTAssertNil(monitor.snapshot) + XCTAssertNil(monitor.reading) + } + + func testCancelledAppearanceCannotClearTheNewRead() async throws { + let transport = ComputeTransport([pools]) + await transport.suspendNext() + let monitor = ComputeMonitor(client: AgentClient(transport: transport, token: "test"), page: .pools) + monitor.appear(allowed: true) + let old = try XCTUnwrap(monitor.reading) + let deadline = ContinuousClock.now + .seconds(3) + while await transport.held == nil, ContinuousClock.now < deadline { await Task.yield() } + monitor.disappear() + monitor.appear(allowed: true) + let current = try XCTUnwrap(monitor.reading) + await current.value + let snapshot = monitor.snapshot + await transport.release(HTTP1Response(status: 403)) + await old.value + XCTAssertEqual(monitor.snapshot, snapshot) + XCTAssertNil(monitor.failure, "the old appearance's refusal cannot replace new facts") + monitor.disappear() + } + + func testRepeatedEventsCoalesceAndHiddenRefreshDoesNothing() async throws { + let transport = ComputeTransport([pools]) + await transport.suspendNext() + let monitor = ComputeMonitor(client: AgentClient(transport: transport, token: "test"), page: .pools) + monitor.appear(allowed: true) + let first = try XCTUnwrap(monitor.reading) + let deadline = ContinuousClock.now + .seconds(3) + while await transport.held == nil, ContinuousClock.now < deadline { await Task.yield() } + for _ in 0..<20 { monitor.refresh() } + await transport.release(pools) + await first.value + await monitor.reading?.value + let count = await transport.requests.count + XCTAssertEqual(count, 2) + monitor.disappear() + monitor.refresh() + XCTAssertNil(monitor.reading) + XCTAssertEqual(ComputeMonitor.refreshEvents, ["compute.nodes.updated", "compute.pools.updated"]) + } + + func testMissingAndSignedOutResponsesNeverKeepProtectedFacts() async { + for (status, failure) in [(404, ComputeMonitor.Failure.missing), (401, .signedOut)] { + let transport = ComputeTransport([pools, HTTP1Response(status: status)]) + let monitor = ComputeMonitor(client: AgentClient(transport: transport, token: "test"), page: .pools) + monitor.appear(allowed: true) + await monitor.reading?.value + monitor.refresh() + await monitor.reading?.value + XCTAssertNil(monitor.snapshot) + XCTAssertEqual(monitor.failure, failure) + monitor.disappear() + } + } +} \ No newline at end of file diff --git a/packages/happy-ios/Tests/HappyUIKitHostedTests/HostedComputeMonitoringTests.swift b/packages/happy-ios/Tests/HappyUIKitHostedTests/HostedComputeMonitoringTests.swift new file mode 100644 index 0000000000..17aa104579 --- /dev/null +++ b/packages/happy-ios/Tests/HappyUIKitHostedTests/HostedComputeMonitoringTests.swift @@ -0,0 +1,179 @@ +#if canImport(UIKit) +import Foundation +@testable import HappyCore +import HappyWire +@testable import HappyUI +import UIKit +import XCTest + +private actor MonitoringTransport: AgentTransport { + private var granted: [String] + private var forbidden = false + init(granted: [String]) { self.granted = granted } + func deny() { forbidden = true } + func setPermissions(_ granted: [String]) { self.granted = granted } + private let pool = #"{"id":"default","name":"Default","kind":"shared","default":true,"nodeCounts":{"total":2,"online":1,"offline":0,"draining":0,"enrolling":0,"revoked":1},"sandboxCapacity":{"used":3,"total":null}}"# + private func node(_ id: String, status: String) -> String { + #"{"id":"\#(id)","pool":"default","name":null,"hostname":"\#(id)","status":"\#(status)","version":"linux.9","lastSeenAt":null,"sandboxCapacity":{"used":3,"total":null},"host":null,"activeSandboxes":[{"sandboxId":"sandbox-1","agentId":"agent-1","agentTitle":null}]}"# + } + func request(method: String, path: String, credential: String, body: Data?, headers: [String: String]) async throws -> HTTP1Response { + if path == "/v0/permissions" { + return HTTP1Response(status: 200, body: try JSONEncoder().encode(["granted": granted])) + } + guard path.hasPrefix("/v0/compute/") else { return HTTP1Response(status: 404) } + guard method == "GET" else { return HTTP1Response(status: 405) } + if forbidden { + return HTTP1Response(status: 403, headers: ["content-type": "application/json"], + body: Data(#"{"code":"forbidden","error":"This needs the pools.manage permission."}"#.utf8)) + } + let json: String + switch path { + case "/v0/compute/pools": json = "{\"pools\":[\(pool)]}" + case "/v0/compute/pools/default": json = "{\"pool\":\(pool),\"nodes\":[\(node("worker", status: "online")),\(node("old-worker", status: "revoked"))]}" + case "/v0/compute/nodes/worker": json = "{\"node\":\(node("worker", status: "online"))}" + default: return HTTP1Response(status: 404) + } + return HTTP1Response(status: 200, body: Data(json.utf8)) + } + func stream(path: String, credential: String, headers: [String: String]) async throws -> HTTP1Stream { throw URLError(.notConnectedToInternet) } + func reset() async {} + func close() async {} +} + +@MainActor +final class HostedComputeMonitoringTests: XCTestCase { + private func rig(granted: [String] = ["pools.manage"], variant: AppVariant = .preview) async throws -> (AppContext, Session, MonitoringTransport) { + let transport = MonitoringTransport(granted: granted) + let account = AccountController(serverConfig: ServerConfig(storage: InMemoryKeyValueStore()), clientID: "compute-test", + credentialStore: InMemoryCredentialStore(), storeDirectory: nil, + agentConnectionCredentialStore: InMemoryAgentConnectionCredentialStore(), agentTransport: { _ in transport }) + await account.agentConnections.setForeground(false) + let server = HappyCloud.server(named: "compute-test") + let connection = try await account.agentConnections.addServer(AgentOrigin(server), + signIn: ServerSignInRecord(method: HappyCloud.method(server: server), grant: OAuthGrant(clientId: "test"), userId: "me"), + access: OAuthAccess(token: "test", expiresAt: nil), name: "Compute test") + let context = AppContext(account: account, variant: variant, version: "test") + let session = try XCTUnwrap(Session.session(for: connection.id, context: context)) + addTeardownBlock { @MainActor [context] in + _ = context // Session intentionally holds its app context weakly. + Session.forget(connection.id) + try? await account.agentConnections.remove(connection.id) + } + try await until { session.permissions != nil } + return (context, session, transport) + } + + private func host(_ controller: UIViewController) async throws -> UINavigationController { + let navigation = UINavigationController(rootViewController: controller) + let scene = try await hostedActiveScene() + let previous = scene.keyWindow + let window = hostedKeyWindow(in: scene, root: navigation) + addTeardownBlock { @MainActor in window.isHidden = true; previous?.makeKey() } + window.layoutIfNeeded() + return navigation + } + + private func until(_ condition: () -> Bool) async throws { + let deadline = ContinuousClock.now + .seconds(5) + while !condition(), ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(20)) } + XCTAssertTrue(condition()) + } + + private func screenshot(_ controller: UIViewController, name: String) throws { + let window = try XCTUnwrap(controller.view.window) + window.layoutIfNeeded() + let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + let attachment = XCTAttachment(image: image) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + func testEntryRequiresMonitoringPermissionAndDeepLinkStaysNative() async throws { + let (context, session, _) = try await rig() + let settings = SettingsViewController(context: context, computer: session.accountID) + let navigation = try await host(settings) + XCTAssertNotNil(settings.shownRow("computes")) + SettingsLinks.open(.computes, session: session, from: settings) + try await until { navigation.topViewController is ComputeViewController && navigation.transitionCoordinator == nil } + let page = try XCTUnwrap(navigation.topViewController as? ComputeViewController) + try await until { page.shownRow("compute-pool-default") != nil } + } + + func testComputeUseDoesNotShowMonitoring() async throws { + let (context, session, _) = try await rig(granted: ["compute.use"]) + let settings = SettingsViewController(context: context, computer: session.accountID) + let navigation = try await host(settings) + XCTAssertNil(settings.shownRow("computes")) + SettingsLinks.open(.computes, session: session, from: settings) + XCTAssertTrue(navigation.topViewController === settings) + } + + func testProductionKeepsCloudMonitoringOff() async throws { + let (context, session, _) = try await rig(variant: .production) + let settings = SettingsViewController(context: context, computer: session.accountID) + let navigation = try await host(settings) + XCTAssertNil(settings.shownRow("computes")) + SettingsLinks.open(.computes, session: session, from: settings) + XCTAssertTrue(navigation.topViewController === settings) + } + + func testRevokedNodesStartCollapsedAndUnknownFactsStayUnknown() async throws { + let (_, session, _) = try await rig() + let page = ComputeViewController(session: session, page: .pool("default")) + let navigation = try await host(page) + try await until { page.shownRow("compute-node-worker") != nil } + XCTAssertEqual(page.shownRow("compute-capacity")?.value, "3 sandboxes") + XCTAssertNil(page.shownRow("compute-node-old-worker")) + XCTAssertEqual(page.shownRow("compute-revoked-toggle")?.title, "1 revoked") + try screenshot(page, name: "Compute pool — revoked collapsed") + page.tapRow("compute-revoked-toggle") + XCTAssertNotNil(page.shownRow("compute-node-old-worker")) + page.tapRow("compute-revoked-toggle") + XCTAssertNil(page.shownRow("compute-node-old-worker")) + page.tapRow("compute-node-worker") + try await until { navigation.topViewController !== page && navigation.transitionCoordinator == nil } + let node = try XCTUnwrap(navigation.topViewController as? ComputeViewController) + try await until { node.shownRow("compute-host-cpu") != nil } + XCTAssertEqual(node.shownRow("compute-host-cpu")?.value, "Unknown") + XCTAssertEqual(node.shownRow("compute-host-memory")?.value, "Unknown") + XCTAssertEqual(node.shownRow("compute-host-disk")?.value, "Unknown") + XCTAssertEqual(node.shownRow("compute-placement-sandbox-1")?.title, "agent-1") + XCTAssertFalse(node.shownRowIDs.joined().contains { $0.contains("drain") || $0.contains("attach") || $0.contains("join") }) + try screenshot(node, name: "Compute machine — unknown host facts") + } + + func testForbiddenResponseRemovesProtectedRows() async throws { + let (_, session, transport) = try await rig() + let page = ComputeViewController(session: session) + _ = try await host(page) + try await until { page.shownRow("compute-pool-default") != nil } + await transport.deny() + page.monitor.refresh() + await page.monitor.reading?.value + XCTAssertNil(page.shownRow("compute-pool-default")) + XCTAssertEqual(page.monitor.failure, .forbidden) + } + + func testPermissionRefreshRemovesTheSettingsAndSearchEntries() async throws { + let (context, session, transport) = try await rig() + let settings = SettingsViewController(context: context, computer: session.accountID) + let navigation = try await host(settings) + XCTAssertNotNil(settings.shownRow("computes")) + let home = HomeViewController(context: context, computer: session.accountID) + navigation.setViewControllers([home], animated: false) + home.loadViewIfNeeded() + home.beginSearch() + home.search("Compute pools") + let results = try XCTUnwrap(home.view.subviews.compactMap { $0 as? HomeSearchResultsView }.first) + XCTAssertTrue(results.list.shownRows.contains { $0.id == "search:settings:computes" }) + await transport.setPermissions(["compute.use"]) + session.refreshPermissions() + try await until { !session.gate.allows(.poolsManage) } + XCTAssertFalse(results.list.shownRows.contains { $0.id == "search:settings:computes" }) + XCTAssertNil(settings.shownRow("computes")) + } +} +#endif \ No newline at end of file From 003e9d0e06aa1ed290adfd233d8dcde968b1e38d Mon Sep 17 00:00:00 2001 From: Steve Korshakov Date: Sun, 8 Nov 2026 14:27:21 -0800 Subject: [PATCH 2/2] fix(ios): keep one Compute event subscription across appearances --- .../Sources/HappyCore/Agents/Store.swift | 5 +++ .../Agents/ComputeViewController.swift | 9 +++-- .../HostedComputeMonitoringTests.swift | 35 +++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/happy-ios/Sources/HappyCore/Agents/Store.swift b/packages/happy-ios/Sources/HappyCore/Agents/Store.swift index 11641d1f0b..e8518e87ff 100644 --- a/packages/happy-ios/Sources/HappyCore/Agents/Store.swift +++ b/packages/happy-ios/Sources/HappyCore/Agents/Store.swift @@ -337,6 +337,11 @@ public actor Store { return stream } + /// Subscription ownership diagnostics: one listener may name several prefixes. + func eventListenerCount(matching prefixes: [String]) -> Int { + Set(prefixes.flatMap { prefixed[$0] ?? [] }).count + } + // MARK: Reads /// Everything the sidebar is drawn from, read whole. Events that arrive diff --git a/packages/happy-ios/Sources/HappyUI/Agents/ComputeViewController.swift b/packages/happy-ios/Sources/HappyUI/Agents/ComputeViewController.swift index 29b6240788..93a66e0452 100644 --- a/packages/happy-ios/Sources/HappyUI/Agents/ComputeViewController.swift +++ b/packages/happy-ios/Sources/HappyUI/Agents/ComputeViewController.swift @@ -12,7 +12,8 @@ final class ComputeViewController: SettingsPageViewController { private var expandedRevoked = false private var accessWatch: Watch? private var accountWatch: AccountsObservation? - private var events: Task? + /// Owned event subscription, exposed internally for lifecycle tests. + private(set) var events: Task? init(session: Session, page: ComputeMonitor.Page = .pools) { self.session = session @@ -56,6 +57,9 @@ final class ComputeViewController: SettingsPageViewController { session.refreshPermissions() monitor.appear(allowed: Self.offered(session)) let store = session.store + // A cancelled interactive pop can announce another appearance + // without disappearing first. Keep exactly one owned subscription. + events?.cancel() events = Task { [weak self] in for await _ in await store.events(matching: ComputeMonitor.refreshEvents) { guard !Task.isCancelled, let self, visible, !invalidated else { return } @@ -147,6 +151,7 @@ final class ComputeViewController: SettingsPageViewController { private func nodeFacts(_ node: ComputeNode) -> [[SettingsRow]] { typealias Text = Strings.Compute let lastSeen = node.lastSeenAt.map { Date(timeIntervalSince1970: $0 / 1000).formatted(date: .abbreviated, time: .shortened) } ?? Text.unknown + let system = [node.os, node.osVersion, node.arch].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ") var capacity = fact("compute-capacity", Text.capacity, Text.capacity(node.sandboxCapacity)) capacity.footer = Text.placementNote var disk = fact("compute-host-disk", Text.disk, Self.bytes(node.host?.totalDiskBytes)) @@ -155,7 +160,7 @@ final class ComputeViewController: SettingsPageViewController { fact("compute-node-status", node.displayName, node.status), fact("compute-last-seen", Text.lastSeen, lastSeen), fact("compute-version", Text.version, node.version ?? Text.unknown), - fact("compute-os", Text.operatingSystem, [node.os, node.osVersion, node.arch].compactMap { $0 }.joined(separator: " · ")), + fact("compute-os", Text.operatingSystem, system.isEmpty ? Text.unknown : system), fact("compute-backend", Text.backend, node.backend ?? Text.unknown), ], [capacity], [ fact("compute-host-cpu", Text.cpu, node.host?.cpuCores.map { $0.formatted() } ?? Text.unknown), diff --git a/packages/happy-ios/Tests/HappyUIKitHostedTests/HostedComputeMonitoringTests.swift b/packages/happy-ios/Tests/HappyUIKitHostedTests/HostedComputeMonitoringTests.swift index 17aa104579..23b70d266a 100644 --- a/packages/happy-ios/Tests/HappyUIKitHostedTests/HostedComputeMonitoringTests.swift +++ b/packages/happy-ios/Tests/HappyUIKitHostedTests/HostedComputeMonitoringTests.swift @@ -140,6 +140,7 @@ final class HostedComputeMonitoringTests: XCTestCase { XCTAssertEqual(node.shownRow("compute-host-cpu")?.value, "Unknown") XCTAssertEqual(node.shownRow("compute-host-memory")?.value, "Unknown") XCTAssertEqual(node.shownRow("compute-host-disk")?.value, "Unknown") + XCTAssertEqual(node.shownRow("compute-os")?.value, "Unknown") XCTAssertEqual(node.shownRow("compute-placement-sandbox-1")?.title, "agent-1") XCTAssertFalse(node.shownRowIDs.joined().contains { $0.contains("drain") || $0.contains("attach") || $0.contains("join") }) try screenshot(node, name: "Compute machine — unknown host facts") @@ -175,5 +176,39 @@ final class HostedComputeMonitoringTests: XCTestCase { XCTAssertFalse(results.list.shownRows.contains { $0.id == "search:settings:computes" }) XCTAssertNil(settings.shownRow("computes")) } + + func testRepeatedAppearanceOwnsExactlyOneEventSubscription() async throws { + let (_, session, _) = try await rig() + let page = ComputeViewController(session: session) + _ = try await host(page) + try await until { page.shownRow("compute-pool-default") != nil } + var tasks: [Task] = [] + defer { tasks.forEach { $0.cancel() } } + for _ in 0..<3 { + let previous = try XCTUnwrap(page.events) + tasks.append(previous) + // A cancelled interactive back swipe can announce another + // appearance without a preceding viewDidDisappear. + page.viewWillAppear(false) + XCTAssertTrue(previous.isCancelled) + if previous.isCancelled { await previous.value } + let deadline = ContinuousClock.now + .seconds(3) + while await session.store.eventListenerCount(matching: ComputeMonitor.refreshEvents) != 1, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + let active = await session.store.eventListenerCount(matching: ComputeMonitor.refreshEvents) + XCTAssertEqual(active, 1) + } + let last = try XCTUnwrap(page.events) + tasks.append(last) + page.viewDidDisappear(false) + await last.value + let deadline = ContinuousClock.now + .seconds(3) + while await session.store.eventListenerCount(matching: ComputeMonitor.refreshEvents) != 0, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + let hidden = await session.store.eventListenerCount(matching: ComputeMonitor.refreshEvents) + XCTAssertEqual(hidden, 0) + } } #endif \ No newline at end of file