diff --git a/Berthly/BerthlyApp.swift b/Berthly/BerthlyApp.swift index 7060062..94e6393 100644 --- a/Berthly/BerthlyApp.swift +++ b/Berthly/BerthlyApp.swift @@ -1,6 +1,7 @@ // Copyright 2026 Berthly Contributors // Licensed under the Apache License, Version 2.0 +import AppIntents import SwiftUI @main @@ -18,6 +19,17 @@ struct BerthlyApp: App { init() { StatusButtonImageDedupe.install() SheetTerminationPolicy.install() + // Intents run outside the view tree, so they reach the one service and bridge through + // the dependency manager. Both must be registered before any intent can run. + let service = Self.makeService() + let bridge = MenuBarBridge() + _service = State(initialValue: service) + _menuBarBridge = State(initialValue: bridge) + AppDependencyManager.shared.add(dependency: service) + AppDependencyManager.shared.add(dependency: bridge) + if SpotlightIndexer.shouldRun(environment: ProcessInfo.processInfo.environment) { + spotlightIndexer.start(service: service) + } if Self.disableAnimations { // SwiftUI transactions are handled per-scene below; these AppKit-level defaults // cover what transactions can't reach — window/sheet present-dismiss slides and @@ -27,8 +39,9 @@ struct BerthlyApp: App { } } - @State private var service: ContainerServiceBase = Self.makeService() - @State private var menuBarBridge = MenuBarBridge() + @State private var service: ContainerServiceBase + @State private var menuBarBridge: MenuBarBridge + private let spotlightIndexer = SpotlightIndexer() @State private var buildJobManager = BuildJobManager() /// Sparkle self-updater (PLAN/UPGRADE.md). Not started under tests, so nothing can hit the /// update feed or pop update UI mid-test. diff --git a/Berthly/Intents/ComputeEntities.swift b/Berthly/Intents/ComputeEntities.swift new file mode 100644 index 0000000..be6f79a --- /dev/null +++ b/Berthly/Intents/ComputeEntities.swift @@ -0,0 +1,134 @@ +// Copyright 2026 Berthly Contributors +// Licensed under the Apache License, Version 2.0 + +import AppIntents +import CoreSpotlight + +/// A container as Shortcuts, Siri and Spotlight see it. Carries display strings only; actions go +/// back through the service by `id`, so a stale entity can never act on stale state. +struct ContainerEntity: AppEntity, IndexedEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Container") + static let defaultQuery = ContainerEntityQuery() + + let id: String + let name: String + let image: String + let status: String + + init(_ container: Container) { + id = container.id + name = container.name + image = container.image + status = container.status.label + } + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(name)", subtitle: "\(image) · \(status)", + image: .init(systemName: "shippingbox")) + } + + var attributeSet: CSSearchableItemAttributeSet { + let attributes = defaultAttributeSet + attributes.displayName = name + attributes.contentDescription = "\(image) · \(status)" + attributes.keywords = ["container", image] + return attributes + } +} + +struct MachineEntity: AppEntity, IndexedEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Machine") + static let defaultQuery = MachineEntityQuery() + + let id: String + let name: String + let image: String + let status: String + + init(_ machine: Machine) { + id = machine.id + name = machine.name + image = machine.image + status = machine.status.label + } + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(name)", subtitle: "\(image) · \(status)", + image: .init(systemName: "desktopcomputer")) + } + + var attributeSet: CSSearchableItemAttributeSet { + let attributes = defaultAttributeSet + attributes.displayName = name + attributes.contentDescription = "\(image) · \(status)" + attributes.keywords = ["machine", "virtual machine", image] + return attributes + } +} + +/// Lookup rules shared by the queries and the tests. Utility machines (the internal default VM) +/// are excluded for the same reason the sidebar hides them: they are runtime plumbing, not +/// something a user starts or stops by name. +enum ComputeEntityCatalog { + @MainActor + static func containers(in service: ContainerServiceBase, ids: [String]? = nil, matching text: String? = nil) -> [ContainerEntity] { + service.containers + .filter { ids?.contains($0.id) ?? true } + .filter { matches(text, $0.name, $0.image) } + .map(ContainerEntity.init) + } + + @MainActor + static func machines(in service: ContainerServiceBase, ids: [String]? = nil, matching text: String? = nil) -> [MachineEntity] { + service.machines + .filter { !$0.isUtility } + .filter { ids?.contains($0.id) ?? true } + .filter { matches(text, $0.name, $0.image) } + .map(MachineEntity.init) + } + + private static func matches(_ text: String?, _ fields: String...) -> Bool { + guard let text, !text.isEmpty else { return true } + return fields.contains { $0.localizedCaseInsensitiveContains(text) } + } +} + +struct ContainerEntityQuery: EntityStringQuery { + @Dependency private var service: ContainerServiceBase + + @MainActor + func entities(for identifiers: [String]) async throws -> [ContainerEntity] { + ComputeEntityCatalog.containers(in: service, ids: identifiers) + } + + @MainActor + func entities(matching string: String) async throws -> [ContainerEntity] { + ComputeEntityCatalog.containers(in: service, matching: string) + } + + @MainActor + func suggestedEntities() async throws -> [ContainerEntity] { + ComputeEntityCatalog.containers(in: service) + } +} + +struct MachineEntityQuery: EntityStringQuery { + @Dependency private var service: ContainerServiceBase + + @MainActor + func entities(for identifiers: [String]) async throws -> [MachineEntity] { + ComputeEntityCatalog.machines(in: service, ids: identifiers) + } + + @MainActor + func entities(matching string: String) async throws -> [MachineEntity] { + ComputeEntityCatalog.machines(in: service, matching: string) + } + + @MainActor + func suggestedEntities() async throws -> [MachineEntity] { + ComputeEntityCatalog.machines(in: service) + } +} diff --git a/Berthly/Intents/ComputeIntents.swift b/Berthly/Intents/ComputeIntents.swift new file mode 100644 index 0000000..3a4f7a9 --- /dev/null +++ b/Berthly/Intents/ComputeIntents.swift @@ -0,0 +1,118 @@ +// Copyright 2026 Berthly Contributors +// Licensed under the Apache License, Version 2.0 + +import AppIntents + +/// What each intent does, apart from the App Intents plumbing. `@Parameter`/`@Dependency` assert +/// when touched outside the system's intent runtime, so tests cover the behavior here instead of +/// calling `perform()`. +enum ComputeIntentActions { + @MainActor + static func start(_ item: ComputeItem, in service: ContainerServiceBase) async throws { + switch item { + case .container(let id): try await service.startContainer(id) + case .machine(let id): try await service.startMachine(id) + } + } + + @MainActor + static func stop(_ item: ComputeItem, in service: ContainerServiceBase) async throws { + switch item { + case .container(let id): try await service.stopContainer(id) + case .machine(let id): try await service.stopMachine(id) + } + } + + @MainActor + static func open(_ item: ComputeItem, in bridge: MenuBarBridge) { + bridge.pendingIntent = .selectCompute(item) + } +} + +struct StartContainerIntent: AppIntent { + static let title: LocalizedStringResource = "Start Container" + static let description = IntentDescription("Starts a stopped container.") + static var parameterSummary: some ParameterSummary { Summary("Start \(\.$container)") } + + @Parameter(title: "Container") var container: ContainerEntity + @Dependency private var service: ContainerServiceBase + + @MainActor + func perform() async throws -> some IntentResult { + try await ComputeIntentActions.start(.container(container.id), in: service) + return .result() + } +} + +struct StopContainerIntent: AppIntent { + static let title: LocalizedStringResource = "Stop Container" + static let description = IntentDescription("Stops a running container.") + static var parameterSummary: some ParameterSummary { Summary("Stop \(\.$container)") } + + @Parameter(title: "Container") var container: ContainerEntity + @Dependency private var service: ContainerServiceBase + + @MainActor + func perform() async throws -> some IntentResult { + try await ComputeIntentActions.stop(.container(container.id), in: service) + return .result() + } +} + +struct OpenContainerIntent: OpenIntent { + static let title: LocalizedStringResource = "Open Container" + static let description = IntentDescription("Shows a container in Berthly.") + + @Parameter(title: "Container") var target: ContainerEntity + @Dependency private var bridge: MenuBarBridge + + @MainActor + func perform() async throws -> some IntentResult { + ComputeIntentActions.open(.container(target.id), in: bridge) + return .result() + } +} + +struct StartMachineIntent: AppIntent { + static let title: LocalizedStringResource = "Start Machine" + static let description = IntentDescription("Starts a stopped machine.") + static var parameterSummary: some ParameterSummary { Summary("Start \(\.$machine)") } + + @Parameter(title: "Machine") var machine: MachineEntity + @Dependency private var service: ContainerServiceBase + + @MainActor + func perform() async throws -> some IntentResult { + try await ComputeIntentActions.start(.machine(machine.id), in: service) + return .result() + } +} + +struct StopMachineIntent: AppIntent { + static let title: LocalizedStringResource = "Stop Machine" + static let description = IntentDescription("Stops a running machine.") + static var parameterSummary: some ParameterSummary { Summary("Stop \(\.$machine)") } + + @Parameter(title: "Machine") var machine: MachineEntity + @Dependency private var service: ContainerServiceBase + + @MainActor + func perform() async throws -> some IntentResult { + try await ComputeIntentActions.stop(.machine(machine.id), in: service) + return .result() + } +} + +struct OpenMachineIntent: OpenIntent { + static let title: LocalizedStringResource = "Open Machine" + static let description = IntentDescription("Shows a machine in Berthly.") + + @Parameter(title: "Machine") var target: MachineEntity + @Dependency private var bridge: MenuBarBridge + + @MainActor + func perform() async throws -> some IntentResult { + ComputeIntentActions.open(.machine(target.id), in: bridge) + return .result() + } +} diff --git a/Berthly/Intents/SpotlightIndexer.swift b/Berthly/Intents/SpotlightIndexer.swift new file mode 100644 index 0000000..f099762 --- /dev/null +++ b/Berthly/Intents/SpotlightIndexer.swift @@ -0,0 +1,106 @@ +// Copyright 2026 Berthly Contributors +// Licensed under the Apache License, Version 2.0 + +import AppIntents +import CoreSpotlight +import Observation + +/// What changed between two index snapshots, keyed by entity id. A snapshot maps id to a +/// fingerprint of everything the index shows, so an unchanged poll costs nothing. +nonisolated struct IndexDiff { + var upserts: [String] + var removals: [String] + + init(previous: [String: String], current: [String: String]) { + upserts = current.filter { previous[$0.key] != $0.value }.keys.sorted() + removals = previous.keys.filter { current[$0] == nil }.sorted() + } + + var isEmpty: Bool { upserts.isEmpty && removals.isEmpty } +} + +/// Keeps containers and machines in Spotlight's index in step with the service. Entities are +/// indexed through App Intents (`IndexedEntity`, macOS 15+), so the same code serves macOS 26 +/// and 27; the macOS 27 `IndexedEntityQuery` reindex callback is not adopted because this +/// indexer already re-sends everything on launch. +@MainActor +final class SpotlightIndexer { + private var containerSnapshot: [String: String] = [:] + private var machineSnapshot: [String: String] = [:] + private var hasSyncedSinceLaunch = false + private var task: Task? + + /// No indexing under tests: UI and unit test hosts must not write mock or real container + /// names into the developer's system-wide Spotlight index. + nonisolated static func shouldRun(environment: [String: String]) -> Bool { + UpdaterService.shouldStartUpdater(environment: environment) + } + + func start(service: ContainerServiceBase) { + guard task == nil else { return } + task = Task { [weak self] in + while !Task.isCancelled { + guard let self else { return } + await self.sync(service) + await Self.nextChange(of: service) + } + } + } + + /// `onChange` fires before the new value lands, but resuming only schedules this task's + /// continuation, so it runs after the mutating turn has finished and sees the new state. + private static func nextChange(of service: ContainerServiceBase) async { + await withCheckedContinuation { continuation in + withObservationTracking { + _ = service.containers + _ = service.machines + _ = service.isConnected + } onChange: { + continuation.resume() + } + } + } + + private func sync(_ service: ContainerServiceBase) async { + // Before the first poll lands the lists are empty, and diffing that against a previous + // launch's index would wipe it for nothing. + guard service.isConnected else { return } + let containers = ComputeEntityCatalog.containers(in: service) + let machines = ComputeEntityCatalog.machines(in: service) + let index = CSSearchableIndex.default() + + if !hasSyncedSinceLaunch { + // Drops entries for items deleted while Berthly was not running. + try? await index.deleteAppEntities(ofType: ContainerEntity.self) + try? await index.deleteAppEntities(ofType: MachineEntity.self) + containerSnapshot = [:] + machineSnapshot = [:] + hasSyncedSinceLaunch = true + } + + containerSnapshot = await Self.sync( + containers, previous: containerSnapshot, index: index, + fingerprint: { Self.fingerprint(name: $0.name, image: $0.image, status: $0.status) }) + machineSnapshot = await Self.sync( + machines, previous: machineSnapshot, index: index, + fingerprint: { Self.fingerprint(name: $0.name, image: $0.image, status: $0.status) }) + } + + /// Returns the snapshot to keep: the new one after sending a change, the old one when nothing + /// changed. Duplicate ids keep the first entry instead of trapping. + private static func sync( + _ entities: [Entity], previous: [String: String], index: CSSearchableIndex, + fingerprint: (Entity) -> String + ) async -> [String: String] where Entity.ID == String { + let current = Dictionary(entities.map { ($0.id, fingerprint($0)) }, uniquingKeysWith: { first, _ in first }) + let diff = IndexDiff(previous: previous, current: current) + guard !diff.isEmpty else { return previous } + try? await index.indexAppEntities(entities.filter { diff.upserts.contains($0.id) }) + try? await index.deleteAppEntities(identifiedBy: diff.removals, ofType: Entity.self) + return current + } + + nonisolated private static func fingerprint(name: String, image: String, status: String) -> String { + "\(name)|\(image)|\(status)" + } +} diff --git a/BerthlyTests/ComputeIntentsTests.swift b/BerthlyTests/ComputeIntentsTests.swift new file mode 100644 index 0000000..242226c --- /dev/null +++ b/BerthlyTests/ComputeIntentsTests.swift @@ -0,0 +1,104 @@ +// Copyright 2026 Berthly Contributors +// Licensed under the Apache License, Version 2.0 + +import Testing +@testable import Berthly + +@MainActor +struct ComputeEntityCatalogTests { + + @Test func containersMatchByNameOrImageIgnoringCase() { + let service = MockContainerService() + let first = service.containers[0] + + let byName = ComputeEntityCatalog.containers(in: service, matching: first.name.uppercased()) + #expect(byName.contains { $0.id == first.id }) + + let byImage = ComputeEntityCatalog.containers(in: service, matching: first.image) + #expect(byImage.contains { $0.id == first.id }) + + #expect(ComputeEntityCatalog.containers(in: service, matching: "no-such-thing-xyz").isEmpty) + } + + @Test func lookupByIdentifiersReturnsOnlyThoseContainers() { + let service = MockContainerService() + let wanted = service.containers[0].id + let result = ComputeEntityCatalog.containers(in: service, ids: [wanted, "missing"]) + #expect(result.map(\.id) == [wanted]) + } + + @Test func emptySearchTextReturnsEverything() { + let service = MockContainerService() + #expect(ComputeEntityCatalog.containers(in: service, matching: "").count == service.containers.count) + } + + @Test func utilityMachinesAreNeverOffered() { + let service = MockContainerService() + let offered = Set(ComputeEntityCatalog.machines(in: service).map(\.id)) + let utility = Set(service.machines.filter(\.isUtility).map(\.id)) + #expect(offered.isDisjoint(with: utility)) + #expect(offered.count == service.machines.filter { !$0.isUtility }.count) + } + + @Test func entityCarriesTheStatusLabelShownInTheApp() { + let service = MockContainerService() + let container = service.containers[0] + #expect(ContainerEntity(container).status == container.status.label) + } +} + +struct IndexDiffTests { + + @Test func newChangedAndRemovedIdsAreSeparated() { + let diff = IndexDiff( + previous: ["a": "1", "b": "1", "c": "1"], + current: ["b": "1", "c": "2", "d": "1"]) + #expect(diff.upserts == ["c", "d"]) + #expect(diff.removals == ["a"]) + } + + @Test func identicalSnapshotsAreEmpty() { + #expect(IndexDiff(previous: ["a": "1"], current: ["a": "1"]).isEmpty) + } + + @Test func indexingIsSkippedUnderTests() { + #expect(!SpotlightIndexer.shouldRun(environment: ["XCTestConfigurationFilePath": "x"])) + #expect(!SpotlightIndexer.shouldRun(environment: ["UITEST_USE_MOCK_SERVICE": "1"])) + #expect(SpotlightIndexer.shouldRun(environment: [:])) + } +} + +@MainActor +struct ComputeIntentActionsTests { + + @Test func startContainerStartsTheContainer() async throws { + let service = MockContainerService() + let stopped = try #require(service.containers.first { $0.status == .stopped }) + try await ComputeIntentActions.start(.container(stopped.id), in: service) + #expect(service.containers.first { $0.id == stopped.id }?.status == .running) + } + + @Test func stopContainerStopsTheContainer() async throws { + let service = MockContainerService() + let running = try #require(service.containers.first { $0.status == .running }) + try await ComputeIntentActions.stop(.container(running.id), in: service) + #expect(service.containers.first { $0.id == running.id }?.status == .stopped) + } + + @Test func stopThenStartMachineChangesTheMachine() async throws { + let service = MockContainerService() + let machine = try #require(service.machines.first { !$0.isUtility }) + try await ComputeIntentActions.stop(.machine(machine.id), in: service) + #expect(service.machines.first { $0.id == machine.id }?.status == .stopped) + try await ComputeIntentActions.start(.machine(machine.id), in: service) + #expect(service.machines.first { $0.id == machine.id }?.status == .running) + } + + @Test func openAsksTheMainWindowToSelectTheItem() { + let bridge = MenuBarBridge() + ComputeIntentActions.open(.container("c1"), in: bridge) + #expect(bridge.pendingIntent == .selectCompute(.container("c1"))) + ComputeIntentActions.open(.machine("m1"), in: bridge) + #expect(bridge.pendingIntent == .selectCompute(.machine("m1"))) + } +}