Overview
Create a helper utility to detect, install, and launch Ollama automatically. This enables the auto-escalation feature where users are prompted to install Ollama when Apple Intelligence produces low-confidence results.
Dependencies
None - This is a standalone utility that can be developed in parallel with other PRs.
Files to Create
| File |
Action |
Description |
Sources/SortAI/Core/LLM/OllamaInstaller.swift |
Create |
Installation helper |
Sources/SortAI/Core/LLM/OllamaError.swift |
Create |
Error types |
Implementation Details
1. OllamaInstaller Actor
import Foundation
import AppKit
actor OllamaInstaller {
enum InstallationStatus: Sendable {
case notInstalled
case installing
case installed
case failed(String)
}
private(set) var status: InstallationStatus = .notInstalled
/// Check if Ollama is installed on the system
func isInstalled() -> Bool {
let paths = [
"/usr/local/bin/ollama",
"/opt/homebrew/bin/ollama",
NSHomeDirectory() + "/.ollama/ollama",
"/Applications/Ollama.app"
]
return paths.contains { FileManager.default.fileExists(atPath: $0) }
}
/// Check if Ollama server is running
func isServerRunning() async -> Bool {
guard let url = URL(string: "http://127.0.0.1:11434/api/tags") else {
return false
}
do {
var request = URLRequest(url: url)
request.timeoutInterval = 5
let (_, response) = try await URLSession.shared.data(for: request)
return (response as? HTTPURLResponse)?.statusCode == 200
} catch {
return false
}
}
/// Install Ollama from official source
func install() async throws {
status = .installing
do {
// Download official macOS installer
let installerURL = URL(string: "https://ollama.ai/download/Ollama-darwin.zip")!
let (downloadURL, _) = try await URLSession.shared.download(from: installerURL)
// Unzip to Applications
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
process.arguments = ["-o", downloadURL.path, "-d", "/Applications"]
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw OllamaError.installationFailed
}
status = .installed
// Launch Ollama
try await launchOllama()
} catch {
status = .failed(error.localizedDescription)
throw error
}
}
/// Launch Ollama application
func launchOllama() async throws {
let ollamaAppPath = "/Applications/Ollama.app"
guard FileManager.default.fileExists(atPath: ollamaAppPath) else {
throw OllamaError.notInstalled
}
let config = NSWorkspace.OpenConfiguration()
config.activates = false // Launch in background
try await NSWorkspace.shared.openApplication(
at: URL(fileURLWithPath: ollamaAppPath),
configuration: config
)
// Wait for server to start (max 30 seconds)
for _ in 0..<30 {
if await isServerRunning() {
return
}
try await Task.sleep(nanoseconds: 1_000_000_000)
}
throw OllamaError.serverStartTimeout
}
/// Pull a model
func pullModel(_ modelName: String, progress: @escaping (Double) -> Void) async throws {
guard let url = URL(string: "http://127.0.0.1:11434/api/pull") else {
throw OllamaError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(["name": modelName, "stream": true])
let (bytes, response) = try await URLSession.shared.bytes(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw OllamaError.modelPullFailed(modelName)
}
// Parse streaming progress
for try await line in bytes.lines {
if let data = line.data(using: .utf8),
let json = try? JSONDecoder().decode(PullProgress.self, from: data) {
if let total = json.total, let completed = json.completed, total > 0 {
progress(Double(completed) / Double(total))
}
}
}
}
}
private struct PullProgress: Codable {
let status: String?
let total: Int64?
let completed: Int64?
}
2. Installation Prompt UI
extension OllamaInstaller {
@MainActor
func showInstallationPrompt() async -> Bool {
let alert = NSAlert()
alert.messageText = "Ollama Not Found"
alert.informativeText = """
Ollama provides more powerful AI models for complex file analysis.
Would you like to install Ollama now? This will:
1. Download Ollama (~500 MB)
2. Install it to Applications
3. Download the deepseek-r1:8b model (~5 GB)
You can continue using Apple Intelligence in the meantime.
"""
alert.alertStyle = .informational
alert.addButton(withTitle: "Install Ollama")
alert.addButton(withTitle: "Use Apple Intelligence Only")
alert.addButton(withTitle: "Later")
let response = alert.runModal()
return response == .alertFirstButtonReturn
}
@MainActor
func showInstallationProgress() -> NSWindow {
// Create progress window
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 150),
styleMask: [.titled],
backing: .buffered,
defer: false
)
window.title = "Installing Ollama"
window.center()
// Add progress indicator and status label
// ... SwiftUI or AppKit implementation
return window
}
}
3. Error Types
enum OllamaError: LocalizedError {
case notInstalled
case installationFailed
case serverStartTimeout
case invalidURL
case modelPullFailed(String)
case serverUnreachable
var errorDescription: String? {
switch self {
case .notInstalled:
return "Ollama is not installed"
case .installationFailed:
return "Failed to install Ollama"
case .serverStartTimeout:
return "Ollama server failed to start within 30 seconds"
case .invalidURL:
return "Invalid Ollama server URL"
case .modelPullFailed(let model):
return "Failed to pull model: \(model)"
case .serverUnreachable:
return "Cannot reach Ollama server at http://127.0.0.1:11434"
}
}
var recoverySuggestion: String? {
switch self {
case .notInstalled:
return "Install Ollama from https://ollama.ai"
case .serverStartTimeout:
return "Try launching Ollama manually from Applications"
case .modelPullFailed:
return "Check your internet connection and try again"
default:
return nil
}
}
}
Acceptance Criteria
Testing
func testOllamaInstallationCheck() {
let installer = OllamaInstaller()
// Just verify check doesnt crash
let installed = installer.isInstalled()
XCTAssertNotNil(installed)
}
func testOllamaServerCheck() async {
let installer = OllamaInstaller()
let running = await installer.isServerRunning()
// Result depends on system state
XCTAssertNotNil(running)
}
func testOllamaErrorMessages() {
let error = OllamaError.serverStartTimeout
XCTAssertEqual(error.errorDescription, "Ollama server failed to start within 30 seconds")
XCTAssertNotNil(error.recoverySuggestion)
}
Security Considerations
- Downloads only from official ollama.ai domain
- Uses system unzip utility (no custom decompression)
- Requires user confirmation before installation
- No elevated privileges required
Estimated Size
~200 lines of code
Risk Assessment
Medium - External process management. Mitigation: timeout handling, graceful failures, clear error messages.
Overview
Create a helper utility to detect, install, and launch Ollama automatically. This enables the auto-escalation feature where users are prompted to install Ollama when Apple Intelligence produces low-confidence results.
Dependencies
None - This is a standalone utility that can be developed in parallel with other PRs.
Files to Create
Sources/SortAI/Core/LLM/OllamaInstaller.swiftSources/SortAI/Core/LLM/OllamaError.swiftImplementation Details
1. OllamaInstaller Actor
2. Installation Prompt UI
3. Error Types
Acceptance Criteria
Testing
Security Considerations
Estimated Size
~200 lines of code
Risk Assessment
Medium - External process management. Mitigation: timeout handling, graceful failures, clear error messages.