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.

Activity

  1. added
    enhancementNew feature or request
    llm-providerLLM provider infrastructure
    phase-2Phase 2 - Parallel development
    on Jan 13, 2026
  2. gilmanb1 commented on Jan 13, 2026

    @gilmanb1
    OwnerAuthor

    ✅ Implementation Complete

    This feature was already implemented as part of the LLM_Research.md implementation. The code exists in the codebase and all 233 tests pass.

    A minor build fix was applied in commit 4341283 to fix the Apple Intelligence availability check API.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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