diff --git a/packages/happy-ios/Sources/HappyCore/AgentConnection/SidebarRows.swift b/packages/happy-ios/Sources/HappyCore/AgentConnection/SidebarRows.swift index 8f73414470..074ec24ff2 100644 --- a/packages/happy-ios/Sources/HappyCore/AgentConnection/SidebarRows.swift +++ b/packages/happy-ios/Sources/HappyCore/AgentConnection/SidebarRows.swift @@ -68,7 +68,7 @@ public enum SidebarRows { }.sorted { ($0.1, $0.0.id) < ($1.1, $1.0.id) } for (surface, _) in joined { var row = Row(id: "surface:\(surface.id)", nodeId: "surface:\(surface.id)", kind: .space, - label: surface.name, mark: .glyph(Rows.surfaceGlyph(surface.type, access: surface.access)), agentId: surface.chatAgentId, targetKind: "surface") + label: surface.name, mark: .glyph(Rows.surfaceGlyph(surface.type, access: surface.access, policyVault: surface.policyVault)), agentId: surface.chatAgentId, targetKind: "surface") row.targetId = surface.id row.surfaceType = surface.type rows.append(row) diff --git a/packages/happy-ios/Sources/HappyCore/Agents/Rows.swift b/packages/happy-ios/Sources/HappyCore/Agents/Rows.swift index 8f9d532aca..55ddc8bb31 100644 --- a/packages/happy-ios/Sources/HappyCore/Agents/Rows.swift +++ b/packages/happy-ios/Sources/HappyCore/Agents/Rows.swift @@ -351,16 +351,19 @@ public enum Rows { } if space.title == nil { return .seedling(seed: space.id) } switch space.target.kind { - case "surface": return .glyph(surfaceGlyph(places.surface(space.target.id)?.type, access: places.surface(space.target.id)?.access)) + case "surface": + let surface = places.surface(space.target.id) + return .glyph(surfaceGlyph(surface?.type, access: surface?.access, policyVault: surface?.policyVault)) case "task", "bot", "agent": return .placeholder(seed: space.target.id) default: return .glyph("agents") } } /// The glyph desktop's Create menu gives each kind of Surface; a vault - /// the person's role may not open (`access: "none"`) wears a lock. - public static func surfaceGlyph(_ type: String?, access: String? = nil) -> String { - if type == "vault", access == VaultAccess.Level.none.rawValue { return lockGlyph } + /// the person's role may not open, or the server's Policy vault, wears + /// a lock. The mark says nothing about whether its documents can be edited. + public static func surfaceGlyph(_ type: String?, access: String? = nil, policyVault: Bool? = nil) -> String { + if type == "vault", access == VaultAccess.Level.none.rawValue || policyVault == true { return lockGlyph } return switch type { case "todo": "tasks" case "calendar": "calendar" diff --git a/packages/happy-ios/Sources/HappyCore/Agents/SearchShown.swift b/packages/happy-ios/Sources/HappyCore/Agents/SearchShown.swift index 34ce637343..b67b1b6584 100644 --- a/packages/happy-ios/Sources/HappyCore/Agents/SearchShown.swift +++ b/packages/happy-ios/Sources/HappyCore/Agents/SearchShown.swift @@ -168,8 +168,9 @@ extension Search { case "surface": guard let surfaceId = result.surfaceId else { return nil } let name = result.name ?? "" - let type = names.surfaces[surfaceId]?.type ?? result.surfaceType - shown = Shown(title: name, detail: surfaceTypeLabel(type), glyph: Rows.surfaceGlyph(type), + let surface = names.surfaces[surfaceId] + let type = surface?.type ?? result.surfaceType + shown = Shown(title: name, detail: surfaceTypeLabel(type), glyph: Rows.surfaceGlyph(type, access: surface?.access, policyVault: surface?.policyVault), opens: .surface(id: surfaceId, type: type ?? "", name: name)) case "agent": guard let agentId = result.agentId, result.archived != true, result.workspaceId != nil else { return nil } diff --git a/packages/happy-ios/Sources/HappyUI/Agents/HomeViewController.swift b/packages/happy-ios/Sources/HappyUI/Agents/HomeViewController.swift index 9dcb0bffa8..717ee47c1a 100644 --- a/packages/happy-ios/Sources/HappyUI/Agents/HomeViewController.swift +++ b/packages/happy-ios/Sources/HappyUI/Agents/HomeViewController.swift @@ -437,8 +437,8 @@ final class HomeViewController: UIViewController { row = Row(id: id, nodeId: id, kind: .result, label: held.label, mark: .glyph(Self.glyph(held))) runs[id] = { [weak self] in self?.open(held) } case .surface(let surface): - row = Row(id: id, nodeId: id, kind: .result, label: surface.name, mark: .glyph(Rows.surfaceGlyph(surface.type))) - runs[id] = { [weak self] in self?.openSurface(AgentWire.Surface(id: surface.id, type: surface.type, name: surface.name), row: id) } + row = Row(id: id, nodeId: id, kind: .result, label: surface.name, mark: .glyph(Rows.surfaceGlyph(surface.type, access: surface.access, policyVault: surface.policyVault))) + runs[id] = { [weak self] in self?.openSurface(surface, row: id) } case .artifact(let artifact): row = Row(id: id, nodeId: id, kind: .result, label: artifact.title, mark: .glyph(artifact.live ? "globe-outline" : HappyCore.Search.artifactGlyph(artifact.type))) runs[id] = { [weak self] in self?.openArtifact(id: artifact.id, type: artifact.type, title: artifact.title, row: id) } @@ -477,10 +477,12 @@ final class HomeViewController: UIViewController { /// A sidebar row's glyph among the results, as the computer's result /// for it would have. - private static func glyph(_ row: Row) -> String { + static func glyph(_ row: Row) -> String { if row.kind == .session { return "chatbubbles-outline" } switch row.targetKind { - case "surface": return Rows.surfaceGlyph(row.surfaceType) + case "surface": + if case .glyph(let glyph) = row.mark { return glyph } + return Rows.surfaceGlyph(row.surfaceType) case "task", "bot", "agent": return "agents" default: if case .glyph(let glyph) = row.mark { return glyph } @@ -556,7 +558,8 @@ 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 + (session.map(ComputeViewController.offered) == true ? [SettingsIndex.computes] : []) + let entries = SettingsIndex.computer(isCloud: session?.isCloud == true) + + (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/SettingsIndex.swift b/packages/happy-ios/Sources/HappyUI/Agents/SettingsIndex.swift index 843b0b82d6..6ad20ec52e 100644 --- a/packages/happy-ios/Sources/HappyUI/Agents/SettingsIndex.swift +++ b/packages/happy-ios/Sources/HappyUI/Agents/SettingsIndex.swift @@ -61,6 +61,11 @@ enum SettingsIndex { [profile, changeAgentName, changeAgentAvatar, supportBoba, appearance, hideProjects, shimmerInLowPower, agentVoice, agents, dictationOpensTask, multilingualDictation, dataStorage, connection, analytics, connectAgent, addAccount] + aboutRows } + /// Cloud has no project settings; paired and self-hosted agents keep them. + static func computer(isCloud: Bool) -> [Entry] { + computer.filter { !isCloud || $0.id != hideProjects.id } + } + /// What a Happy account's settings show under its accounts card, in /// their order: the same places as a computer's, with the account's own /// pages beside them. Sign out is left out, as Disconnect is. diff --git a/packages/happy-ios/Sources/HappyUI/Agents/SettingsViewController.swift b/packages/happy-ios/Sources/HappyUI/Agents/SettingsViewController.swift index 397988e276..6a75345c14 100644 --- a/packages/happy-ios/Sources/HappyUI/Agents/SettingsViewController.swift +++ b/packages/happy-ios/Sources/HappyUI/Agents/SettingsViewController.swift @@ -644,8 +644,9 @@ final class SettingsViewController: UIViewController, UIScrollViewDelegate, UISe themes.map { theme in Row(id: "theme-\(theme)", title: Self.themeName(theme), value: theme == current ? "✓" : nil, accessory: .none, action: { [weak self] in self?.setTheme(theme) }) }, - [Row(SettingsIndex.hideProjects, accessory: .toggle, isOn: projectsHidden, toggled: { [weak self] on in - guard let self, let engine = context.account.agentConnections.syncEngine(for: id) else { return } + HappyCloud.isCloud(context.account.agentConnections.server(for: id)) ? [] : [Row(SettingsIndex.hideProjects, accessory: .toggle, isOn: projectsHidden, toggled: { [weak self] on in + guard let self, shown?.id == id, !HappyCloud.isCloud(context.account.agentConnections.server(for: id)), + let engine = context.account.agentConnections.syncEngine(for: id) else { return } projectsHidden = on Task { try? await engine.setProjectsHidden(on) } })], diff --git a/packages/happy-ios/Tests/HappyCoreTests/PolicyVaultPresentationTests.swift b/packages/happy-ios/Tests/HappyCoreTests/PolicyVaultPresentationTests.swift new file mode 100644 index 0000000000..363d27fd83 --- /dev/null +++ b/packages/happy-ios/Tests/HappyCoreTests/PolicyVaultPresentationTests.swift @@ -0,0 +1,33 @@ +import Foundation +import XCTest +@testable import HappyCore + +final class PolicyVaultPresentationTests: XCTestCase { + func testJoinedPolicyVaultUsesItsFlagRatherThanItsName() throws { + let data = Data(#""" + {"cursor":"c","profile":{"userId":"me"},"userSettings":[],"projects":[],"workspaces":[], + "surfaces":[ + {"id":"policy","name":"Instructions","type":"vault","members":[{"userId":"me","orderKey":"a"}],"access":"write","policyVault":true}, + {"id":"ordinary","name":"Policy","type":"vault","members":[{"userId":"me","orderKey":"b"}],"access":"write"}]} + """#.utf8) + let rows = SidebarRows.make(try JSONDecoder().decode(DesktopSnapshot.self, from: data), settings: [:]) + XCTAssertEqual(rows.first { $0.targetId == "policy" }?.mark, .glyph(Rows.lockGlyph)) + XCTAssertEqual(rows.first { $0.targetId == "ordinary" }?.mark, .glyph("vault")) + } + + func testPolicyVaultSpaceKeepsItsLock() { + let surface = AgentWire.Surface(id: "policy", type: "vault", name: "Instructions", access: "write", policyVault: true) + let space = AgentWire.Space(id: "s", title: surface.name, icon: nil, target: .init(kind: "surface", id: surface.id, agentId: nil, workspaceId: nil), version: 1, createdAt: 0, updatedAt: 0) + XCTAssertEqual(Rows.mark(space, places: Places(surfaces: [surface])), .glyph(Rows.lockGlyph)) + XCTAssertTrue(surface.vaultAccess.canWrite(.open), "the icon does not make policy documents read-only") + } + + func testRemoteSurfaceResultsPreservePolicyAndAccessLocks() throws { + let result = try JSONDecoder().decode(Search.Result.self, from: Data(#"{"type":"surface","key":"surface:v","rank":1,"surfaceId":"v","surfaceType":"vault","name":"Policy"}"#.utf8)) + for (access, policy, glyph) in [("write", true, Rows.lockGlyph), ("none", false, Rows.lockGlyph), ("read", false, "vault")] { + let surface = AgentWire.Surface(id: "v", type: "vault", name: "Policy", access: access, policyVault: policy) + XCTAssertEqual(Search.shown(result, names: .init(surfaces: ["v": surface]))?.glyph, glyph) + } + XCTAssertEqual(Search.shown(result, names: .init())?.glyph, "vault", "a name is not policy metadata") + } +} \ No newline at end of file diff --git a/packages/happy-ios/Tests/HappyUIKitHostedTests/HostedCloudSettingsCleanupTests.swift b/packages/happy-ios/Tests/HappyUIKitHostedTests/HostedCloudSettingsCleanupTests.swift new file mode 100644 index 0000000000..9f02842ba5 --- /dev/null +++ b/packages/happy-ios/Tests/HappyUIKitHostedTests/HostedCloudSettingsCleanupTests.swift @@ -0,0 +1,109 @@ +#if canImport(UIKit) +import Foundation +@testable import HappyCore +import HappyWire +@testable import HappyUI +import UIKit +import XCTest + +private struct CleanupTransport: AgentTransport { + func request(method: String, path: String, credential: String, body: Data?, headers: [String: String]) async throws -> HTTP1Response { + if path == "/v0/surfaces" { + return HTTP1Response(status: 200, body: Data(#"{"surfaces":[{"id":"policy","type":"vault","name":"Instructions","access":"write","policyVault":true}]}"#.utf8)) + } + return HTTP1Response(status: 404) + } + func stream(path: String, credential: String, headers: [String: String]) async throws -> HTTP1Stream { throw URLError(.notConnectedToInternet) } + func reset() async {} + func close() async {} +} + +@MainActor +final class HostedCloudSettingsCleanupTests: XCTestCase { + private func rig(server: String?) async throws -> (AppContext, String) { + let account = AccountController(serverConfig: ServerConfig(storage: InMemoryKeyValueStore()), clientID: "cleanup-test", + credentialStore: InMemoryCredentialStore(), storeDirectory: nil, + agentConnectionCredentialStore: InMemoryAgentConnectionCredentialStore(), agentTransport: { _ in CleanupTransport() }) + await account.agentConnections.setForeground(false) + let connection: AgentConnection + if let server { + 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: "Settings test") + } else { + let credentials = try JSONDecoder().decode(AgentConnectionCredentials.self, from: Data(#"{"address":"cleanup-test","port":1,"deviceID":"\#(UUID().uuidString)","deviceToken":"test"}"#.utf8)) + connection = try await account.agentConnections.add(credentials, name: "Paired test") + } + addTeardownBlock { @MainActor in + Session.forget(connection.id) + try? await account.agentConnections.remove(connection.id) + } + return (AppContext(account: account, variant: .preview, version: "test"), connection.id) + } + + 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 checkProjects(server: String?, offered: Bool) async throws { + let (context, id) = try await rig(server: server) + let settings = SettingsViewController(context: context, computer: id) + let navigation = try await host(settings) + settings.tapRow("appearance") + try await until { navigation.topViewController is SettingsPageViewController && navigation.transitionCoordinator == nil } + let appearance = try XCTUnwrap(navigation.topViewController as? SettingsPageViewController) + XCTAssertEqual(appearance.shownRowIDs.joined().contains("hide-projects"), offered) + + navigation.popToRootViewController(animated: false) + let search = try XCTUnwrap(settings.navigationItem.searchController) + settings.willPresentSearchController(search) + search.searchBar.text = "Hide projects" + settings.updateSearchResults(for: search) + XCTAssertEqual(settings.shownRowIDs.joined().contains("hide-projects"), offered) + settings.willDismissSearchController(search) + + let home = HomeViewController(context: context, computer: id) + navigation.setViewControllers([home], animated: false) + home.loadViewIfNeeded() + home.beginSearch() + home.search("Hide projects") + let results = try XCTUnwrap(home.view.subviews.compactMap { $0 as? HomeSearchResultsView }.first) + XCTAssertEqual(results.list.shownRows.contains { $0.id == "search:settings:hide-projects" }, offered) + } + + func testCloudHasNoProjectVisibilitySettingOrSearchResult() async throws { + try await checkProjects(server: HappyCloud.server(named: "settings-test"), offered: false) + } + + func testSelfHostedKeepsProjectVisibility() async throws { + try await checkProjects(server: "https://self-hosted.example", offered: true) + } + + func testPairedAgentKeepsProjectVisibility() async throws { + try await checkProjects(server: nil, offered: true) + } + + func testLocalSearchKeepsThePolicyVaultLock() async throws { + let (context, id) = try await rig(server: HappyCloud.server(named: "settings-test")) + let home = HomeViewController(context: context, computer: id) + _ = try await host(home) + home.beginSearch() + home.search("Instructions") + let results = try XCTUnwrap(home.view.subviews.compactMap { $0 as? HomeSearchResultsView }.first) + try await until { results.list.shownRows.contains { $0.label == "Instructions" } } + XCTAssertEqual(results.list.shownRows.first { $0.label == "Instructions" }?.mark, .glyph(Rows.lockGlyph)) + } +} +#endif \ No newline at end of file diff --git a/packages/happy-ios/Tests/HappyUIKitTests/PolicyVaultSearchTests.swift b/packages/happy-ios/Tests/HappyUIKitTests/PolicyVaultSearchTests.swift new file mode 100644 index 0000000000..1e2fbab0e4 --- /dev/null +++ b/packages/happy-ios/Tests/HappyUIKitTests/PolicyVaultSearchTests.swift @@ -0,0 +1,22 @@ +#if canImport(UIKit) +import HappyCore +@testable import HappyUI +import XCTest + +@MainActor +final class PolicyVaultSearchTests: XCTestCase { + func testHomeSearchKeepsAnExistingSurfaceLock() { + var row = Row(id: "surface:v", nodeId: "surface:v", kind: .space, label: "Instructions", mark: .glyph(Rows.lockGlyph), targetKind: "surface") + row.surfaceType = "vault" + XCTAssertEqual(HomeViewController.glyph(row), Rows.lockGlyph) + row.mark = .none + XCTAssertEqual(HomeViewController.glyph(row), "vault", "unknown metadata keeps the ordinary fallback") + } + + func testProjectVisibilitySearchIsCloudSpecific() { + XCTAssertFalse(SettingsIndex.computer(isCloud: true).contains { $0.id == "hide-projects" }) + XCTAssertTrue(SettingsIndex.computer(isCloud: false).contains { $0.id == "hide-projects" }) + XCTAssertEqual(SettingsIndex.computer(isCloud: true).map(\.id), SettingsIndex.computer.filter { $0.id != "hide-projects" }.map(\.id)) + } +} +#endif \ No newline at end of file