Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions Berthly/BerthlyApp.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright 2026 Berthly Contributors
// Licensed under the Apache License, Version 2.0

import AppIntents
import SwiftUI

@main
Expand All @@ -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
Expand All @@ -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.
Expand Down
134 changes: 134 additions & 0 deletions Berthly/Intents/ComputeEntities.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
118 changes: 118 additions & 0 deletions Berthly/Intents/ComputeIntents.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
106 changes: 106 additions & 0 deletions Berthly/Intents/SpotlightIndexer.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

/// 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<Entity: IndexedEntity>(
_ 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)"
}
}
Loading
Loading