Skip to content

Adding a Service

Alan Wizemann edited this page Apr 20, 2026 · 4 revisions

Adding a Service

Services live under scarf/scarf/scarf/Core/Services/. They mediate between Hermes (filesystem, SQLite, subprocess) and feature ViewModels. The recipe is short.

Pick the isolation

Pattern When Examples in repo
actor The service owns mutable state across calls (a subprocess, file handles, an open SQLite connection, a snapshot dedup table). ACPClient, HermesDataService, HermesLogService.
Sendable struct The service is stateless — every call re-reads through the transport. HermesFileService, HermesEnvService, ModelCatalogService.
@MainActor @Observable Rare — only when the service IS UI-observable state (Sparkle wrapper). UpdaterService.

If you're not sure, start with Sendable struct. Promote to actor only when you find yourself wanting to cache mutable state across calls.

Skeleton: stateless struct

import Foundation

struct MyHermesService: Sendable {
    let context: ServerContext
    private var transport: any ServerTransport { context.transport }

    func loadSomething() async throws -> SomethingType {
        let data = try await transport.readFile(context.paths.somethingFile)
        return try JSONDecoder().decode(SomethingType.self, from: data)
    }

    func saveSomething(_ value: SomethingType) async throws {
        let data = try JSONEncoder().encode(value)
        try await transport.writeFile(context.paths.somethingFile, data: data)
    }
}

Skeleton: actor for stateful work

actor MyStatefulService {
    let context: ServerContext
    private var cache: [String: Value] = [:]

    init(context: ServerContext) {
        self.context = context
    }

    func get(_ key: String) async throws -> Value {
        if let cached = cache[key] { return cached }
        let value = try await fetchFromHermes(key: key)
        cache[key] = value
        return value
    }

    func invalidate() {
        cache.removeAll()
    }

    private func fetchFromHermes(key: String) async throws -> Value {
        // Use context.transport for I/O.
    }
}

Conventions

  • Take ServerContext in init. Never hardcode ServerContext.local — services must work against any window's bound server.
  • Route I/O through context.transport or the context.read*/write*/runHermes helpers. Never use FileManager, Process, or NSWorkspace.open directly for Hermes paths — those break on remote (and break the rule from the project's feedback memory).
  • Surface errors as throws or Result. Don't swallow them; the UI knows what to do with them.
  • Don't log to print — use os.Logger (logger.error() for unexpected, logger.warning() for expected).
  • Don't do synchronous file I/O on @MainActor. Either dispatch via Task.detached { }.value, or expose async methods.

Wiring the service into a feature

Two patterns:

Per-ViewModel (most common for stateless services):

@Observable
final class MyFeatureViewModel {
    private let service: MyHermesService
    init(context: ServerContext) {
        self.service = MyHermesService(context: context)
    }
}

Shared via Environment (for stateful services that multiple features want to share):

In scarfApp.swift's ContextBoundRoot:

@State private var fileWatcher: HermesFileWatcher

ContentView()
    .environment(fileWatcher)

Then in any view:

@Environment(HermesFileWatcher.self) private var fileWatcher

Use Environment for things every window has exactly one of — file watcher, server registry, updater service. Use per-ViewModel construction for everything else.

Tests

Service code that uses transport is testable with a mock transport. See Testing — what exists today is minimal, but the ServerTransport protocol is the obvious extension point for fakes.


Last updated: 2026-04-20 — Scarf v2.0.1

Clone this wiki locally