diff --git a/.gitignore b/.gitignore index 2585a80..d823643 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,12 @@ xcuserdata/ # Swift Package Manager .build/ +# `Packages/` is the historical SwiftPM checkout dir for downloaded deps +# (pre-Xcode-14). We keep it ignored — but NOT our local-package checkout +# at scarf/Packages/, which is part of the source tree (ScarfCore, etc.) +# and must ship in the repo. Packages/ +!scarf/Packages/ Package.pins Package.resolved *.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/ diff --git a/scarf/Packages/ScarfCore/Package.swift b/scarf/Packages/ScarfCore/Package.swift new file mode 100644 index 0000000..3026820 --- /dev/null +++ b/scarf/Packages/ScarfCore/Package.swift @@ -0,0 +1,51 @@ +// swift-tools-version: 6.0 +// Platform-neutral core for the Scarf app family (macOS and iOS). +// +// `ScarfCore` holds types that do not depend on AppKit, UIKit, or any +// platform-specific system service. The macOS and iOS app targets each link +// this package and provide their own platform shells (Sparkle + SwiftTerm on +// macOS; Citadel-based SSH transport on iOS). +// +// Minimums are chosen to match the Mac app (macOS 14.6) and the locked +// v1 iOS decision (iOS 18). Raising iOS later is free; lowering is not — +// the ViewModels on `@Observable` / `NavigationStack` are iOS 17+ features +// and we standardize on iOS 18 for feature parity with the Mac codebase. + +import PackageDescription + +let package = Package( + name: "ScarfCore", + defaultLocalization: "en", + platforms: [ + .macOS(.v14), + .iOS(.v18), + ], + products: [ + .library( + name: "ScarfCore", + targets: ["ScarfCore"] + ), + ], + targets: [ + .target( + name: "ScarfCore", + path: "Sources/ScarfCore", + swiftSettings: [ + // Swift 5 language mode mirrors the Mac app target's + // `SWIFT_VERSION = 5.0` build setting. Moving to strict + // Swift 6 concurrency is a real refactor — several types + // (`ACPEvent.availableCommands` carrying `[[String: Any]]`, + // `ACPToolCallEvent.rawInput: [String: Any]?`) claim + // `Sendable` without being strictly-Sendable. A follow-up + // phase will replace those with typed payloads, then this + // setting can bump to `.v6`. + .swiftLanguageMode(.v5), + ] + ), + .testTarget( + name: "ScarfCoreTests", + dependencies: ["ScarfCore"], + path: "Tests/ScarfCoreTests" + ), + ] +) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConstants.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConstants.swift new file mode 100644 index 0000000..14d080a --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConstants.swift @@ -0,0 +1,35 @@ +import Foundation +#if canImport(SQLite3) +import SQLite3 +#endif + +// MARK: - SQLite Constants + +#if canImport(SQLite3) +/// SQLITE_TRANSIENT tells SQLite to make its own copy of bound string data. +/// The C macro is defined as ((sqlite3_destructor_type)-1) which can't be imported directly into Swift. +/// +/// Gated behind `canImport(SQLite3)` so this file compiles on Linux (where +/// SPM has no built-in `SQLite3` system module). Apple platforms — the only +/// runtime targets that actually execute this code — compile it unchanged. +public nonisolated let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) +#endif + +// MARK: - Query Defaults + +public enum QueryDefaults: Sendable { + public nonisolated static let sessionLimit = 100 + public nonisolated static let messageSearchLimit = 50 + public nonisolated static let toolCallLimit = 50 + public nonisolated static let sessionPreviewLimit = 10 + public nonisolated static let previewContentLength = 100 + public nonisolated static let logLineLimit = 200 + public nonisolated static let defaultSilenceThreshold = 200 +} + +// MARK: - File Size Formatting + +public enum FileSizeUnit: Sendable { + public nonisolated static let kilobyte = 1_024.0 + public nonisolated static let megabyte = 1_048_576.0 +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMCPServer.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMCPServer.swift index c62cbc5..2578a6e 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMCPServer.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMCPServer.swift @@ -6,12 +6,14 @@ public enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiabl public var id: String { rawValue } + #if canImport(Darwin) public var displayName: LocalizedStringResource { switch self { case .stdio: return "Local (stdio)" case .http: return "Remote (HTTP)" } } + #endif } public struct HermesMCPServer: Identifiable, Sendable, Equatable { diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift index 357d731..ae1145a 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift @@ -125,6 +125,7 @@ public enum ToolKind: String, Sendable, CaseIterable { case browser case other + #if canImport(Darwin) public var displayName: LocalizedStringResource { switch self { case .read: return "Read" @@ -135,6 +136,7 @@ public enum ToolKind: String, Sendable, CaseIterable { case .other: return "Other" } } + #endif public var icon: String { switch self { diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfCoreSmokeTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfCoreSmokeTests.swift new file mode 100644 index 0000000..d943eba --- /dev/null +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfCoreSmokeTests.swift @@ -0,0 +1,295 @@ +import Testing +import Foundation +@testable import ScarfCore + +/// Smoke test — catches "does the package link?"-class regressions. +@Suite struct ScarfCoreSmokeTests { + @Test func packageLinks() { + // If this compiles and runs, ScarfCore loaded. + } +} + +/// Exercises every `public init` generated in M0a. If any memberwise init's +/// parameter list drifted away from the stored properties (wrong order, wrong +/// type, missing field), the compiler fails here — the whole point of these +/// tests is to give the Linux CI something to catch that before a reviewer +/// has to build on a Mac. +@Suite struct M0aPublicInitTests { + @Test func hermesSessionInitAndDerivations() { + let s = HermesSession( + id: "s1", + source: "cli", + userId: "u", + model: "gpt-4", + title: "Hello", + parentSessionId: nil, + startedAt: Date(timeIntervalSince1970: 0), + endedAt: Date(timeIntervalSince1970: 60), + endReason: nil, + messageCount: 3, + toolCallCount: 1, + inputTokens: 100, + outputTokens: 200, + cacheReadTokens: 0, + cacheWriteTokens: 0, + estimatedCostUSD: 0.01, + reasoningTokens: 50, + actualCostUSD: nil, + costStatus: nil, + billingProvider: nil + ) + #expect(s.displayTitle == "Hello") + #expect(s.totalTokens == 350) + #expect(s.duration == 60) + #expect(s.isSubagent == false) + #expect(s.costIsActual == false) + #expect(s.displayCostUSD == 0.01) + // Subagent variant + let child = s.withTitle("Child") + #expect(child.displayTitle == "Child") + } + + @Test func hermesMessageInitAndRolePredicates() { + let user = HermesMessage( + id: 1, sessionId: "s", role: "user", content: "hi", + toolCallId: nil, toolCalls: [], toolName: nil, + timestamp: nil, tokenCount: nil, finishReason: nil, reasoning: nil + ) + #expect(user.isUser && !user.isAssistant && !user.isToolResult) + + let asst = HermesMessage( + id: 2, sessionId: "s", role: "assistant", content: "hello", + toolCallId: nil, toolCalls: [], toolName: nil, + timestamp: nil, tokenCount: nil, finishReason: nil, + reasoning: "thinking..." + ) + #expect(asst.isAssistant && asst.hasReasoning) + } + + @Test func hermesToolCallExplicitInit() { + let call = HermesToolCall(callId: "c1", functionName: "read_file", arguments: "{\"path\":\"/tmp\"}") + #expect(call.id == "c1") + #expect(call.toolKind == .read) + #expect(call.argumentsSummary == "/tmp") + } + + @Test func hermesConfigEmptyAndMemberwise() { + // `.empty` exercises every nested init internally — if any nested + // settings struct's init drifted, HermesConfig.empty would fail to + // compile. Importing and touching .empty proves the chain works. + let c = HermesConfig.empty + #expect(c.model == "unknown") + #expect(c.display.skin == "default") + #expect(c.terminal.cwd == ".") + #expect(c.browser.inactivityTimeout == 120) + #expect(c.security.redactSecrets == true) + #expect(c.humanDelay.mode == "off") + #expect(c.compression.enabled == true) + #expect(c.checkpoints.enabled == true) + #expect(c.logging.level == "INFO") + #expect(c.discord.requireMention == true) + #expect(c.telegram.reactions == false) + #expect(c.slack.replyToMode == "first") + #expect(c.matrix.autoThread == true) + #expect(c.mattermost.replyMode == "off") + #expect(c.whatsapp.unauthorizedDMBehavior == "pair") + #expect(c.homeAssistant.cooldownSeconds == 30) + #expect(c.auxiliary.vision.provider == "auto") + } + + @Test func hermesCronJobCodableRoundTrip() throws { + let json = """ + { + "id": "job1", + "name": "Daily Summary", + "prompt": "summarize yesterday", + "skills": ["email"], + "model": null, + "schedule": { "kind": "daily", "run_at": "09:00", "display": "Every day 9am", "expression": null }, + "enabled": true, + "state": "scheduled", + "deliver": "discord:general:chat-chan", + "next_run_at": "2026-04-23T09:00:00Z", + "last_run_at": null, + "last_error": null, + "pre_run_script": null, + "delivery_failures": 0, + "last_delivery_error": null, + "timeout_type": "soft", + "timeout_seconds": 300, + "silent": false + } + """ + let job = try JSONDecoder().decode(HermesCronJob.self, from: Data(json.utf8)) + #expect(job.id == "job1") + #expect(job.stateIcon == "clock") + #expect(job.deliveryDisplay == "Discord thread chat-chan in general") + #expect(job.schedule.kind == "daily") + #expect(job.silent == false) + + // Re-encode and decode again to confirm encoder output is valid. + let encoded = try JSONEncoder().encode(job) + let roundTripped = try JSONDecoder().decode(HermesCronJob.self, from: encoded) + #expect(roundTripped.id == job.id) + } + + @Test func hermesMCPServerInit() { + let server = HermesMCPServer( + name: "gh", transport: .stdio, command: "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + url: nil, auth: nil, + env: ["GITHUB_TOKEN": "x"], headers: [:], + timeout: 30, connectTimeout: 5, enabled: true, + toolsInclude: [], toolsExclude: [], + resourcesEnabled: true, promptsEnabled: true, hasOAuthToken: false + ) + #expect(server.id == "gh") + #expect(server.summary == "npx -y @modelcontextprotocol/server-github") + + let http = HermesMCPServer( + name: "linear", transport: .http, command: nil, args: [], + url: "https://mcp.linear.app/sse", auth: "oauth", + env: [:], headers: [:], timeout: nil, connectTimeout: nil, + enabled: true, toolsInclude: [], toolsExclude: [], + resourcesEnabled: true, promptsEnabled: true, hasOAuthToken: true + ) + #expect(http.summary == "https://mcp.linear.app/sse") + } + + @Test func mcpServerPresetGalleryReadable() { + #expect(!MCPServerPreset.gallery.isEmpty) + #expect(MCPServerPreset.gallery.contains { $0.id == "filesystem" }) + // Every preset in the gallery should have a docsURL. + for p in MCPServerPreset.gallery { + #expect(!p.docsURL.isEmpty) + } + } + + @Test func hermesPathSetDerivations() { + let local = HermesPathSet(home: "/Users/alan/.hermes", isRemote: false, binaryHint: nil) + #expect(local.stateDB == "/Users/alan/.hermes/state.db") + #expect(local.memoryMD == "/Users/alan/.hermes/memories/MEMORY.md") + #expect(local.userMD == "/Users/alan/.hermes/memories/USER.md") + #expect(local.projectsRegistry == "/Users/alan/.hermes/scarf/projects.json") + // hermesBinary on local looks up real fs — we can only guarantee it + // returns one of the candidates (or the fallback). + #expect(HermesPathSet.hermesBinaryCandidates.contains(local.hermesBinary) + || local.hermesBinary == HermesPathSet.hermesBinaryCandidates[0]) + + let remote = HermesPathSet(home: "~/.hermes", isRemote: true, binaryHint: "/usr/local/bin/hermes") + #expect(remote.hermesBinary == "/usr/local/bin/hermes") + let remoteNoHint = HermesPathSet(home: "~/.hermes", isRemote: true, binaryHint: nil) + #expect(remoteNoHint.hermesBinary == "hermes") + } + + @Test func hermesSkillInit() { + let skill = HermesSkill( + id: "email.send", name: "send email", category: "Email", + path: "/a/b", files: ["send.py"], requiredConfig: ["SMTP_HOST"] + ) + let cat = HermesSkillCategory(id: "email", name: "Email", skills: [skill]) + #expect(cat.skills.first?.id == "email.send") + } + + @Test func hermesSlashCommandInit() { + let acp = HermesSlashCommand(name: "/clear", description: "Clear context", argumentHint: nil, source: .acp) + let quick = HermesSlashCommand(name: "/brief", description: "Summary", argumentHint: "topic", source: .quickCommand) + #expect(acp.source == .acp) + #expect(quick.source == .quickCommand) + #expect(acp.id == "/clear") + } + + @Test func hermesToolInitAndKnownPlatformIcon() { + let ts = HermesToolset(name: "browser", description: "Web", icon: "safari", enabled: true) + #expect(ts.id == "browser") + let plat = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal") + #expect(plat.id == "cli") + + // KnownPlatforms lookup — guards that the icon-map path didn't break. + #expect(KnownPlatforms.icon(for: "discord") == "bubble.left.and.bubble.right") + #expect(KnownPlatforms.icon(for: "unknown") == "bubble.left") + #expect(KnownPlatforms.all.count >= 13) + } + + @Test func acpRequestAndEvents() throws { + let req = ACPRequest(id: 1, method: "session/new", params: ["foo": AnyCodable("bar")]) + let data = try JSONEncoder().encode(req) + let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] + #expect(decoded?["method"] as? String == "session/new") + #expect(decoded?["jsonrpc"] as? String == "2.0") + + let evt = ACPToolCallEvent( + toolCallId: "t1", title: "read_file: /tmp", kind: "read", + status: "pending", content: "", rawInput: ["path": "/tmp"] + ) + #expect(evt.functionName == "read_file") + #expect(evt.argumentsSummary == "/tmp") + + let upd = ACPToolCallUpdateEvent( + toolCallId: "t1", kind: "read", status: "completed", + content: "hello", rawOutput: nil + ) + #expect(upd.status == "completed") + + let perm = ACPPermissionRequestEvent( + toolCallTitle: "write_file: /etc/passwd", toolCallKind: "edit", + options: [(optionId: "allow_once", name: "Allow once")] + ) + #expect(perm.options.first?.optionId == "allow_once") + + let prompt = ACPPromptResult( + stopReason: "end_turn", inputTokens: 100, outputTokens: 50, + thoughtTokens: 20, cachedReadTokens: 10 + ) + #expect(prompt.stopReason == "end_turn") + } + + @Test func projectDashboardInitChain() { + let point = ChartDataPoint(x: "Mon", y: 3) + let series = ChartSeries(name: "Calls", color: "blue", data: [point]) + let item = ListItem(text: "task 1", status: "done") + let widget = DashboardWidget( + type: "chart", title: "Calls per day", + value: .number(12), icon: nil, color: nil, subtitle: nil, + label: nil, content: nil, format: nil, + columns: nil, rows: nil, + chartType: "line", xLabel: "day", yLabel: "count", + series: [series], items: [item], + url: nil, height: nil + ) + #expect(widget.id == "chart:Calls per day") + #expect(widget.value?.displayString == "12") + + let theme = DashboardTheme(accent: "blue") + let section = DashboardSection(title: "Main", columns: 2, widgets: [widget]) + let dash = ProjectDashboard( + version: 1, title: "Demo", description: nil, + updatedAt: "2026-01-01", theme: theme, sections: [section] + ) + #expect(dash.sections.first?.columnCount == 2) + + let entry = ProjectEntry(name: "demo", path: "/a/b/demo") + #expect(entry.dashboardPath == "/a/b/demo/.scarf/dashboard.json") + + let reg = ProjectRegistry(projects: [entry]) + #expect(reg.projects.first?.id == "demo") + } + + @Test func widgetValueCodable() throws { + let a = try JSONDecoder().decode(WidgetValue.self, from: Data("42".utf8)) + #expect(a == .number(42)) + #expect(a.displayString == "42") + + let b = try JSONDecoder().decode(WidgetValue.self, from: Data("\"hi\"".utf8)) + #expect(b == .string("hi")) + + // Fraction formatting path + let c = WidgetValue.number(1.5) + #expect(c.displayString.contains("1.5") || c.displayString.contains("1,5")) + } + + @Test func queryDefaultsAndFileSizeUnit() { + #expect(QueryDefaults.sessionLimit == 100) + #expect(FileSizeUnit.kilobyte == 1_024.0) + } +} diff --git a/scarf/docs/IOS_PORT_PLAN.md b/scarf/docs/IOS_PORT_PLAN.md index 479e127..680767e 100644 --- a/scarf/docs/IOS_PORT_PLAN.md +++ b/scarf/docs/IOS_PORT_PLAN.md @@ -160,25 +160,69 @@ the Mac app in a working state: ## Progress Log -### M0a — in progress -**Goal:** Create `Packages/ScarfCore` scaffolding and migrate the 13 leaf -Model files to it. `ServerContext.swift` stays in the Mac target (it -depends on Transport + HermesFileService and is not a leaf). +### M0a — shipped in PR #31 -Expected artifacts when done: -- `Packages/ScarfCore/Package.swift` exists. -- 13 model files moved under `Sources/ScarfCore/Models/`. -- `HermesConstants.swift` split: portable parts (`sqliteTransient`, - `QueryDefaults`, `FileSizeUnit`) move to ScarfCore; the deprecated - `HermesPaths` enum stays behind (it references `ServerContext.local`). -- Every moved type annotated `public` with explicit `public init`. -- `scarf.xcodeproj/project.pbxproj` gains one `XCLocalSwiftPackageReference` - for `Packages/ScarfCore` and one `XCSwiftPackageProductDependency` on the - `scarf` target (and `scarfTests` + `scarfUITests` inherit via the target). -- 35 main-target files get `import ScarfCore` added. -- Mac app builds and runs identically to before. +**Shipped:** -(This section will be finalized when M0a is merged.) +- `Packages/ScarfCore/Package.swift` (Swift tools 6.0, targets macOS 14 + + iOS 18). **Language mode pinned at `.v5`** to match the Mac app's + `SWIFT_VERSION = 5.0`. Two types (`ACPEvent.availableCommands` and + `ACPToolCallEvent.rawInput`) claim `Sendable` while carrying + `[String: Any]` payloads — strict Swift 6 rejects that. A future + cleanup phase should replace those with typed payloads and bump to + `.v6`. +- 13 leaf model files moved under `Sources/ScarfCore/Models/`. +- `HermesConstants.swift` split: `sqliteTransient` + `QueryDefaults` + + `FileSizeUnit` are in ScarfCore; the deprecated `HermesPaths` enum is + parked in the Mac target at `HermesPaths+Deprecated.swift`. Zero + callers in-tree — it can be deleted in M0b alongside `ServerContext`. +- Every moved type, member, and (where needed) nested `CodingKeys` is + `public`. Every struct got an explicit `public init(...)` — Swift's + synthesized memberwise init is `internal` and would have broken + cross-module construction. A throwaway Python generator did the + mechanical work; tests in `ScarfCoreTests` exercise every generated + init so parameter drift would fail CI, not a reviewer. +- `scarf.xcodeproj/project.pbxproj` gains one + `XCLocalSwiftPackageReference` for `Packages/ScarfCore` and links the + product into the `scarf` target. +- 49 main-target files (not 35 as originally estimated — many `View` + files only `import SwiftUI` without `Foundation`) got + `import ScarfCore`. + +**Linux-CI compatibility additions (for `swift test` in containers):** + +- `SQLite3` system module exists on macOS/iOS but not on Linux + swift-corelibs. `sqliteTransient` in `HermesConstants.swift` is + wrapped in `#if canImport(SQLite3)`. Apple platforms compile it + unchanged; Linux just doesn't see it (no one on Linux will execute + Hermes DB code anyway). +- `LocalizedStringResource` is an Apple-only Foundation type. + `ToolKind.displayName` (in `HermesMessage.swift`) and + `MCPTransport.displayName` (in `HermesMCPServer.swift`) are wrapped + in `#if canImport(Darwin)`. Apple platforms compile them unchanged; + Linux builds skip them. + +**Test coverage (`ScarfCoreTests`):** 16 tests that construct every +moved type via its `public init`, verify computed properties, round-trip +Codable (`HermesCronJob`, `WidgetValue`), exercise nested config +`.empty` chains, and assert `KnownPlatforms` / `MCPServerPreset.gallery` +statics are readable. Run via `docker run --rm -v +$PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test`. + +**Rules next phases can rely on:** + +- The `public init` pattern is now established for ScarfCore structs. + M0b+ should add explicit `public init(...)` to every new struct moved + into the package. +- `#if canImport(Darwin)` is the package's "Apple-only API" guard. + Prefer this over `os(iOS) || os(macOS) || ...` — it's shorter and + catches the same platforms. +- `#if canImport(SQLite3)` is the pattern for anything that needs + Apple's built-in SQLite. When HermesDataService moves in M0c, use + this same guard for the actual Swift-SQLite bindings. +- The Mac app still uses Swift 5 language mode. Do **not** add + `nonisolated` to new ScarfCore APIs pre-emptively; match the + surrounding conventions. ### M0b — pending ### M0c — pending