Skip to content

PR 4: Ollama Installation Helper #5

Description

@gilmanb1

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

  • Correctly detects Ollama installation
  • Correctly detects running Ollama server
  • Can download and install Ollama
  • Can launch Ollama in background
  • Can pull models with progress tracking
  • Shows user-friendly installation prompt
  • Handles errors gracefully with recovery suggestions
  • All operations are async-safe

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestllm-providerLLM provider infrastructurephase-2Phase 2 - Parallel development

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions