Added a lot of functionality. Bugfixes and changes

This commit is contained in:
2026-02-15 16:46:06 +01:00
parent 2434e554f8
commit 04c9b8da1e
31 changed files with 6653 additions and 239 deletions

View File

@@ -18,6 +18,7 @@ struct ConversationRecord: Codable, FetchableRecord, PersistableRecord, Sendable
var name: String
var createdAt: String
var updatedAt: String
var primaryModel: String?
}
struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
@@ -31,6 +32,7 @@ struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
var cost: Double?
var timestamp: String
var sortOrder: Int
var modelId: String?
}
struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
@@ -48,6 +50,23 @@ struct HistoryRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
var timestamp: String
}
struct EmailLogRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
static let databaseTableName = "email_logs"
var id: String
var timestamp: String
var sender: String
var subject: String
var emailContent: String
var aiResponse: String?
var status: String // "success" or "error"
var errorMessage: String?
var tokens: Int?
var cost: Double?
var responseTime: Double?
var modelId: String?
}
// MARK: - DatabaseService
final class DatabaseService: Sendable {
@@ -127,6 +146,42 @@ final class DatabaseService: Sendable {
)
}
migrator.registerMigration("v4") { db in
// Add modelId to messages table (nullable for existing messages)
try db.alter(table: "messages") { t in
t.add(column: "modelId", .text)
}
// Add primaryModel to conversations table (nullable)
try db.alter(table: "conversations") { t in
t.add(column: "primaryModel", .text)
}
}
migrator.registerMigration("v5") { db in
// Email handler logs
try db.create(table: "email_logs") { t in
t.primaryKey("id", .text)
t.column("timestamp", .text).notNull()
t.column("sender", .text).notNull()
t.column("subject", .text).notNull()
t.column("emailContent", .text).notNull()
t.column("aiResponse", .text)
t.column("status", .text).notNull() // "success" or "error"
t.column("errorMessage", .text)
t.column("tokens", .integer)
t.column("cost", .double)
t.column("responseTime", .double)
t.column("modelId", .text)
}
try db.create(
index: "email_logs_on_timestamp",
on: "email_logs",
columns: ["timestamp"]
)
}
return migrator
}
@@ -152,32 +207,68 @@ final class DatabaseService: Sendable {
}
}
// MARK: - Encrypted Settings Operations
/// Store an encrypted setting (for sensitive data like API keys)
nonisolated func setEncryptedSetting(key: String, value: String) throws {
let encryptedValue = try EncryptionService.shared.encrypt(value)
try dbQueue.write { db in
let record = SettingRecord(key: "encrypted_\(key)", value: encryptedValue)
try record.save(db)
}
}
/// Retrieve and decrypt an encrypted setting
nonisolated func getEncryptedSetting(key: String) throws -> String? {
let encryptedValue = try dbQueue.read { db in
try SettingRecord.fetchOne(db, key: "encrypted_\(key)")?.value
}
guard let encryptedValue = encryptedValue else {
return nil
}
return try EncryptionService.shared.decrypt(encryptedValue)
}
/// Delete an encrypted setting
nonisolated func deleteEncryptedSetting(key: String) {
try? dbQueue.write { db in
_ = try SettingRecord.deleteOne(db, key: "encrypted_\(key)")
}
}
// MARK: - Conversation Operations
nonisolated func saveConversation(name: String, messages: [Message]) throws -> Conversation {
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages")
let convId = UUID()
return try saveConversation(id: UUID(), name: name, messages: messages, primaryModel: nil)
}
nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws -> Conversation {
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))")
let now = Date()
let nowString = isoFormatter.string(from: now)
let convRecord = ConversationRecord(
id: convId.uuidString,
id: id.uuidString,
name: name,
createdAt: nowString,
updatedAt: nowString
updatedAt: nowString,
primaryModel: primaryModel
)
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
guard msg.role != .system else { return nil }
return MessageRecord(
id: UUID().uuidString,
conversationId: convId.uuidString,
conversationId: id.uuidString,
role: msg.role.rawValue,
content: msg.content,
tokens: msg.tokens,
cost: msg.cost,
timestamp: isoFormatter.string(from: msg.timestamp),
sortOrder: index
sortOrder: index,
modelId: msg.modelId
)
}
@@ -190,11 +281,12 @@ final class DatabaseService: Sendable {
let savedMessages = messages.filter { $0.role != .system }
return Conversation(
id: convId,
id: id,
name: name,
messages: savedMessages,
createdAt: now,
updatedAt: now
updatedAt: now,
primaryModel: primaryModel
)
}
@@ -221,7 +313,8 @@ final class DatabaseService: Sendable {
content: record.content,
tokens: record.tokens,
cost: record.cost,
timestamp: timestamp
timestamp: timestamp,
modelId: record.modelId
)
}
@@ -235,7 +328,8 @@ final class DatabaseService: Sendable {
name: convRecord.name,
messages: messages,
createdAt: createdAt,
updatedAt: updatedAt
updatedAt: updatedAt,
primaryModel: convRecord.primaryModel
)
return (conversation, messages)
@@ -404,4 +498,80 @@ final class DatabaseService: Sendable {
}
}
}
// MARK: - Email Log Operations
nonisolated func saveEmailLog(_ log: EmailLog) {
let record = EmailLogRecord(
id: log.id.uuidString,
timestamp: isoFormatter.string(from: log.timestamp),
sender: log.sender,
subject: log.subject,
emailContent: log.emailContent,
aiResponse: log.aiResponse,
status: log.status.rawValue,
errorMessage: log.errorMessage,
tokens: log.tokens,
cost: log.cost,
responseTime: log.responseTime,
modelId: log.modelId
)
try? dbQueue.write { db in
try record.insert(db)
}
}
nonisolated func loadEmailLogs(limit: Int = 100) throws -> [EmailLog] {
try dbQueue.read { db in
let records = try EmailLogRecord
.order(Column("timestamp").desc)
.limit(limit)
.fetchAll(db)
return records.compactMap { record in
guard let timestamp = isoFormatter.date(from: record.timestamp),
let status = EmailLogStatus(rawValue: record.status),
let id = UUID(uuidString: record.id) else {
return nil
}
return EmailLog(
id: id,
timestamp: timestamp,
sender: record.sender,
subject: record.subject,
emailContent: record.emailContent,
aiResponse: record.aiResponse,
status: status,
errorMessage: record.errorMessage,
tokens: record.tokens,
cost: record.cost,
responseTime: record.responseTime,
modelId: record.modelId
)
}
}
}
nonisolated func deleteEmailLog(id: UUID) {
try? dbQueue.write { db in
try db.execute(
sql: "DELETE FROM email_logs WHERE id = ?",
arguments: [id.uuidString]
)
}
}
nonisolated func clearEmailLogs() {
try? dbQueue.write { db in
try db.execute(sql: "DELETE FROM email_logs")
}
}
nonisolated func getEmailLogCount() throws -> Int {
try dbQueue.read { db in
try EmailLogRecord.fetchCount(db)
}
}
}

View File

@@ -0,0 +1,451 @@
//
// EmailHandlerService.swift
// oAI
//
// AI-powered email auto-responder service
//
import Foundation
import os
@Observable
final class EmailHandlerService {
static let shared = EmailHandlerService()
private let settings = SettingsService.shared
private let emailService = EmailService.shared
private let emailLog = EmailLogService.shared
private let mcp = MCPService.shared
private let log = Logger(subsystem: "com.oai.oAI", category: "email-handler")
// Rate limiting
private var emailsProcessedThisHour: Int = 0
private var hourResetTimer: Timer?
// Processing state
private var isProcessing = false
private init() {}
// MARK: - Lifecycle
/// Start email handler (call at app startup)
func start() {
guard settings.emailHandlerEnabled else {
log.info("Email handler disabled")
return
}
guard settings.emailHandlerConfigured else {
log.warning("Email handler not configured properly")
return
}
log.info("Starting email handler...")
// Set up email monitoring callback
emailService.onNewEmail = { [weak self] email in
Task {
await self?.handleIncomingEmail(email)
}
}
// Start IMAP IDLE monitoring
emailService.startMonitoring()
// Start rate limit timer
startRateLimitTimer()
log.info("Email handler started successfully")
}
/// Stop email handler
func stop() {
log.info("Stopping email handler...")
emailService.stopMonitoring()
hourResetTimer?.invalidate()
hourResetTimer = nil
log.info("Email handler stopped")
}
// MARK: - Email Processing
private func handleIncomingEmail(_ email: IncomingEmail) async {
log.info("New email received from \(email.from): \(email.subject)")
// Check rate limiting
if self.settings.emailRateLimitEnabled && self.settings.emailRateLimitPerHour < 100 {
if self.emailsProcessedThisHour >= self.settings.emailRateLimitPerHour {
log.warning("Rate limit exceeded (\(self.emailsProcessedThisHour)/\(self.settings.emailRateLimitPerHour)), skipping email")
emailLog.logError(
sender: email.from,
subject: email.subject,
emailContent: String(email.body.prefix(200)),
errorMessage: "Rate limit exceeded (\(self.settings.emailRateLimitPerHour) emails/hour)",
modelId: nil
)
return
}
}
// Prevent concurrent processing
guard !isProcessing else {
log.warning("Already processing an email, queueing not implemented")
return
}
isProcessing = true
defer { isProcessing = false }
// Process with retry
var attemptCount = 0
let maxAttempts = 2
while attemptCount < maxAttempts {
attemptCount += 1
do {
try await processEmailWithAI(email, attempt: attemptCount)
emailsProcessedThisHour += 1
// Delete email after successful processing
try? await deleteEmail(email.uid)
log.info("Successfully processed and deleted email (attempt \(attemptCount))")
return
} catch {
log.error("Failed to process email (attempt \(attemptCount)): \(error.localizedDescription)")
if attemptCount >= maxAttempts {
// Send error email to sender
try? await sendErrorEmail(to: email.from, subject: email.subject, error: error)
// Log error
emailLog.logError(
sender: email.from,
subject: email.subject,
emailContent: String(email.body.prefix(200)),
errorMessage: error.localizedDescription,
modelId: settings.emailHandlerModel
)
}
}
}
}
private func processEmailWithAI(_ email: IncomingEmail, attempt: Int) async throws {
let startTime = Date()
log.info("Processing email with AI (attempt \(attempt))...")
// Get AI provider for email handling
guard let provider = getEmailProvider() else {
throw EmailServiceError.notConfigured
}
// Build AI prompt
let prompt = buildEmailPrompt(from: email)
// Prepare tools (read-only MCP access)
var tools: [Tool]? = nil
if settings.mcpEnabled && !mcp.allowedFolders.isEmpty {
tools = mcp.getToolSchemas().filter { tool in
isReadOnlyTool(tool.function.name)
}
}
// Build messages
let userMessage = Message(
role: .user,
content: prompt
)
// Create chat request
// IMPORTANT: Email handler uses ONLY its own system prompt (custom or default)
// All other prompts (main chat system prompt, user custom prompts) are excluded
// This ensures complete isolation of email handling behavior
let request = ChatRequest(
messages: [userMessage],
model: settings.emailHandlerModel,
stream: false,
maxTokens: settings.emailMaxTokens,
temperature: 0.7,
systemPrompt: getEmailSystemPrompt(), // Email-specific prompt ONLY
tools: tools,
onlineMode: settings.emailOnlineMode, // User-configurable online mode for emails
imageGeneration: false // Disable image generation for emails
)
// Call AI (non-streaming)
let response = try await provider.chat(request: request)
let fullResponse = response.content
let totalTokens = response.usage.map { $0.promptTokens + $0.completionTokens }
let totalCost: Double? = nil // Calculate if provider supports it
let responseTime = Date().timeIntervalSince(startTime)
log.info("AI response generated in \(String(format: "%.2f", responseTime))s")
// Generate HTML email
let htmlBody = generateHTMLEmail(aiResponse: fullResponse, originalEmail: email)
// Send response email
let replySubject = email.subject.hasPrefix("Re:") ? email.subject : "Re: \(email.subject)"
let messageId = try await emailService.sendEmail(
to: email.from,
subject: replySubject,
body: fullResponse, // Plain text fallback
htmlBody: htmlBody,
inReplyTo: email.messageId
)
log.info("Response email sent: \(messageId)")
// Log success
emailLog.logSuccess(
sender: email.from,
subject: email.subject,
emailContent: String(email.body.prefix(500)),
aiResponse: String(fullResponse.prefix(500)),
tokens: totalTokens,
cost: totalCost,
responseTime: responseTime,
modelId: settings.emailHandlerModel
)
}
// MARK: - Prompt Building
private func buildEmailPrompt(from email: IncomingEmail) -> String {
// Remove subject identifier from subject
var cleanSubject = email.subject
if let range = cleanSubject.range(of: settings.emailSubjectIdentifier) {
cleanSubject.removeSubrange(range)
cleanSubject = cleanSubject.trimmingCharacters(in: .whitespacesAndNewlines)
}
let prompt = """
You have received an email that requires a response. Please provide a helpful, professional reply.
From: \(email.from)
Subject: \(cleanSubject)
Date: \(email.receivedDate.formatted())
Email Content:
\(email.body)
---
Please provide a complete, well-formatted response to this email. Your response will be sent as an HTML email.
"""
return prompt
}
/// Get the email handler system prompt
/// - Returns: Email-specific system prompt (custom if set, default otherwise)
/// - Note: This is completely isolated from the main chat system prompt.
/// No other prompts are merged or concatenated.
private func getEmailSystemPrompt() -> String {
// ISOLATION: Use custom email prompt if provided, otherwise use default email prompt
// This completely overrides and replaces any other system prompts
// Main chat prompts and user custom prompts are NOT used for email handling
if let customPrompt = settings.emailHandlerSystemPrompt, !customPrompt.isEmpty {
return customPrompt
}
// Default email system prompt (used only when no custom email prompt is set)
return """
You are an AI email assistant. You respond to emails on behalf of the user.
Guidelines:
- Be professional and courteous
- Keep responses concise and relevant
- Use proper email etiquette
- Format your response using Markdown (it will be converted to HTML)
- If you need information from files, you have read-only access via MCP tools
- Never claim to write, modify, or delete files (read-only access)
- Sign emails appropriately
Your response will be automatically formatted and sent via email.
"""
}
// MARK: - HTML Email Generation
private func generateHTMLEmail(aiResponse: String, originalEmail: IncomingEmail) -> String {
// Convert markdown to HTML (basic implementation)
let htmlContent = markdownToHTML(aiResponse)
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.content {
background: #ffffff;
padding: 20px;
border-radius: 8px;
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
font-size: 12px;
color: #666;
}
code {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
}
pre {
background: #f5f5f5;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
</style>
</head>
<body>
<div class="content">
\(htmlContent)
</div>
<div class="footer">
<p>🤖 This response was generated by AI using oAI Email Handler</p>
</div>
</body>
</html>
"""
}
private func markdownToHTML(_ markdown: String) -> String {
// Basic markdown to HTML conversion
// TODO: Use proper markdown parser for production
var html = markdown
// Paragraphs
html = html.replacingOccurrences(of: "\n\n", with: "</p><p>")
html = "<p>\(html)</p>"
// Bold
html = html.replacingOccurrences(of: #"\*\*(.+?)\*\*"#, with: "<strong>$1</strong>", options: .regularExpression)
// Italic
html = html.replacingOccurrences(of: #"\*(.+?)\*"#, with: "<em>$1</em>", options: .regularExpression)
// Inline code
html = html.replacingOccurrences(of: #"`(.+?)`"#, with: "<code>$1</code>", options: .regularExpression)
// Line breaks
html = html.replacingOccurrences(of: "\n", with: "<br>")
return html
}
// MARK: - Error Handling
private func sendErrorEmail(to: String, subject: String, error: Error) async throws {
let errorHTML = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.error { background: #fee; padding: 20px; border-left: 4px solid #f00; }
</style>
</head>
<body>
<div class="error">
<h2>⚠️ Email Processing Error</h2>
<p>We encountered an error while processing your email:</p>
<p><strong>\(error.localizedDescription)</strong></p>
<p>Please try again later or contact support if the problem persists.</p>
</div>
</body>
</html>
"""
_ = try await emailService.sendEmail(
to: to,
subject: "Re: \(subject) - Processing Error",
body: "An error occurred: \(error.localizedDescription)",
htmlBody: errorHTML
)
}
// MARK: - Helpers
private func deleteEmail(_ uid: UInt32) async throws {
guard let host = settings.emailImapHost,
let username = settings.emailUsername,
let password = settings.emailPassword else {
return
}
let client = IMAPClient(
host: host,
port: UInt16(settings.emailImapPort),
username: username,
password: password,
useTLS: true
)
try await client.connect()
try await client.login()
try await client.selectMailbox("INBOX")
try await client.deleteEmail(uid: uid)
try await client.expunge()
client.disconnect()
log.info("Deleted email UID \(uid)")
}
private func getEmailProvider() -> AIProvider? {
let providerNameString = settings.emailHandlerProvider
let registry = ProviderRegistry.shared
// Convert string to Settings.Provider enum
guard let providerType = Settings.Provider(rawValue: providerNameString) else {
log.error("Invalid provider name: '\(providerNameString)'")
return nil
}
// Check if provider is configured
let configuredProviders = registry.configuredProviders
guard configuredProviders.contains(providerType) else {
log.error("Email handler provider '\(providerNameString)' not configured (no API key)")
return nil
}
// Get provider instance
return registry.getProvider(for: providerType)
}
private func isReadOnlyTool(_ toolName: String) -> Bool {
// Only allow read-only MCP tools for email handling
let readOnlyTools = ["read_file", "list_directory", "search_files", "get_file_info"]
return readOnlyTools.contains(toolName)
}
private func startRateLimitTimer() {
// Reset counter every hour
hourResetTimer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { [weak self] _ in
self?.emailsProcessedThisHour = 0
self?.log.info("Rate limit counter reset")
}
}
}

View File

@@ -0,0 +1,154 @@
//
// EmailLogService.swift
// oAI
//
// Service for managing email handler activity logs
//
import Foundation
import os
@Observable
final class EmailLogService {
static let shared = EmailLogService()
private let db = DatabaseService.shared
private let log = Logger(subsystem: "com.oai.oAI", category: "email-log")
private init() {}
// MARK: - Log Operations
/// Save a successful email processing log
func logSuccess(
sender: String,
subject: String,
emailContent: String,
aiResponse: String,
tokens: Int?,
cost: Double?,
responseTime: TimeInterval?,
modelId: String?
) {
let entry = EmailLog(
sender: sender,
subject: subject,
emailContent: emailContent,
aiResponse: aiResponse,
status: .success,
tokens: tokens,
cost: cost,
responseTime: responseTime,
modelId: modelId
)
db.saveEmailLog(entry)
log.info("Email log saved: \(sender) - \(subject) [SUCCESS]")
}
/// Save a failed email processing log
func logError(
sender: String,
subject: String,
emailContent: String,
errorMessage: String,
modelId: String?
) {
let entry = EmailLog(
sender: sender,
subject: subject,
emailContent: emailContent,
aiResponse: nil,
status: .error,
errorMessage: errorMessage,
modelId: modelId
)
db.saveEmailLog(entry)
log.error("Email log saved: \(sender) - \(subject) [ERROR: \(errorMessage)]")
}
/// Load recent email logs (default: 100 most recent)
func loadLogs(limit: Int = 100) -> [EmailLog] {
do {
return try db.loadEmailLogs(limit: limit)
} catch {
log.error("Failed to load email logs: \(error.localizedDescription)")
return []
}
}
/// Delete a specific log entry
func deleteLog(id: UUID) {
db.deleteEmailLog(id: id)
log.info("Email log deleted: \(id.uuidString)")
}
/// Clear all email logs
func clearAllLogs() {
db.clearEmailLogs()
log.info("All email logs cleared")
}
/// Get total count of email logs
func getLogCount() -> Int {
do {
return try db.getEmailLogCount()
} catch {
log.error("Failed to get email log count: \(error.localizedDescription)")
return 0
}
}
// MARK: - Statistics
/// Get email processing statistics
func getStatistics() -> EmailStatistics {
let logs = loadLogs(limit: 1000) // Last 1000 for stats
let total = logs.count
let successful = logs.filter { $0.status == .success }.count
let errors = logs.filter { $0.status == .error }.count
let totalTokens = logs.compactMap { $0.tokens }.reduce(0, +)
let totalCost = logs.compactMap { $0.cost }.reduce(0.0, +)
let avgResponseTime: TimeInterval?
let responseTimes = logs.compactMap { $0.responseTime }
if !responseTimes.isEmpty {
avgResponseTime = responseTimes.reduce(0, +) / Double(responseTimes.count)
} else {
avgResponseTime = nil
}
return EmailStatistics(
total: total,
successful: successful,
errors: errors,
totalTokens: totalTokens,
totalCost: totalCost,
averageResponseTime: avgResponseTime
)
}
}
// MARK: - Statistics Model
struct EmailStatistics {
let total: Int
let successful: Int
let errors: Int
let totalTokens: Int
let totalCost: Double
let averageResponseTime: TimeInterval?
var successRate: Double {
guard total > 0 else { return 0.0 }
return Double(successful) / Double(total)
}
var errorRate: Double {
guard total > 0 else { return 0.0 }
return Double(errors) / Double(total)
}
}

View File

@@ -0,0 +1,313 @@
//
// EmailService.swift
// oAI
//
// IMAP IDLE email monitoring service for AI email handler
//
import Foundation
import os
// MARK: - Email Models
struct IncomingEmail: Identifiable {
let id: UUID
let uid: UInt32
let messageId: String
let from: String
let to: [String]
let subject: String
let body: String
let receivedDate: Date
let inReplyTo: String? // For threading
}
enum EmailServiceError: LocalizedError {
case notConfigured
case connectionFailed(String)
case authenticationFailed
case invalidCredentials
case idleNotSupported
case sendingFailed(String)
var errorDescription: String? {
switch self {
case .notConfigured:
return "Email not configured. Check Settings > Email."
case .connectionFailed(let msg):
return "Connection failed: \(msg)"
case .authenticationFailed:
return "Authentication failed. Check credentials."
case .invalidCredentials:
return "Invalid email credentials"
case .idleNotSupported:
return "IMAP IDLE not supported by server"
case .sendingFailed(let msg):
return "Failed to send email: \(msg)"
}
}
}
// MARK: - EmailService
@Observable
final class EmailService {
static let shared = EmailService()
private let settings = SettingsService.shared
private let log = Logger(subsystem: "com.oai.oAI", category: "email")
// IMAP IDLE state
private var isConnected = false
private var isIdling = false
private var monitoringTask: Task<Void, Never>?
// Callback for new emails
var onNewEmail: ((IncomingEmail) -> Void)?
private init() {}
// MARK: - Connection Management
/// Start IMAP IDLE monitoring (called at app startup)
func startMonitoring() {
guard settings.emailHandlerEnabled else {
log.info("Email handler disabled, skipping monitoring")
return
}
guard settings.emailHandlerConfigured else {
log.warning("Email handler not configured")
return
}
// Email credentials should be configured in Settings > Email tab
// (This check can be expanded when email settings are added)
log.info("Starting IMAP IDLE monitoring...")
monitoringTask = Task { [weak self] in
await self?.monitorInbox()
}
}
/// Stop IMAP IDLE monitoring
func stopMonitoring() {
log.info("Stopping IMAP IDLE monitoring...")
monitoringTask?.cancel()
monitoringTask = nil
isIdling = false
isConnected = false
}
// MARK: - IMAP Polling Implementation
private func monitorInbox() async {
guard let host = settings.emailImapHost,
let username = settings.emailUsername,
let password = settings.emailPassword else {
log.error("Email credentials not configured")
return
}
var checkedUIDs = Set<UInt32>()
var retryDelay: UInt64 = 30 // Start with 30 seconds
while !Task.isCancelled {
do {
let client = IMAPClient(
host: host,
port: UInt16(settings.emailImapPort),
username: username,
password: password,
useTLS: true
)
try await client.connect()
try await client.login()
try await client.selectMailbox("INBOX")
// Search for ALL unseen emails first
let allUnseenUIDs = try await client.searchUnseen()
// Check each email for the subject identifier
for uid in allUnseenUIDs {
if !checkedUIDs.contains(uid) {
checkedUIDs.insert(uid)
do {
let email = try await client.fetchEmail(uid: uid)
// Check if email has the correct subject identifier
if email.subject.contains(settings.emailSubjectIdentifier) {
// Valid email - process it
log.info("New email found: \(email.subject) from \(email.from)")
// Call callback on main thread
await MainActor.run {
onNewEmail?(email)
}
} else {
// Wrong subject - delete it
log.warning("Deleting email without subject identifier: \(email.subject) from \(email.from)")
try await client.deleteEmail(uid: uid)
}
} catch {
log.error("Failed to fetch/process email \(uid): \(error.localizedDescription)")
}
}
}
// Expunge deleted messages
try await client.expunge()
client.disconnect()
// Reset retry delay on success
retryDelay = 30
// Wait before next check
try await Task.sleep(for: .seconds(retryDelay))
} catch {
log.error("IMAP monitoring error: \(error.localizedDescription)")
// Exponential backoff (max 5 minutes)
retryDelay = min(retryDelay * 2, 300)
try? await Task.sleep(for: .seconds(retryDelay))
}
}
log.info("IMAP monitoring stopped")
}
/// Connect to IMAP and SMTP servers and verify credentials
func testConnection() async throws -> String {
guard let imapHost = settings.emailImapHost,
let smtpHost = settings.emailSmtpHost,
let username = settings.emailUsername,
let password = settings.emailPassword else {
throw EmailServiceError.notConfigured
}
// Test IMAP connection
let imapClient = IMAPClient(
host: imapHost,
port: UInt16(settings.emailImapPort),
username: username,
password: password,
useTLS: true
)
try await imapClient.connect()
try await imapClient.login()
try await imapClient.selectMailbox("INBOX")
imapClient.disconnect()
// Test SMTP connection
let smtpClient = SMTPClient(
host: smtpHost,
port: UInt16(settings.emailSmtpPort),
username: username,
password: password
)
try await smtpClient.connect()
smtpClient.disconnect()
return "Successfully connected to IMAP (\(imapHost)) and SMTP (\(smtpHost))"
}
// MARK: - Email Sending (SMTP)
/// Send email response via SMTP
func sendEmail(
to: String,
subject: String,
body: String,
htmlBody: String? = nil,
inReplyTo: String? = nil
) async throws -> String {
guard let host = settings.emailSmtpHost,
let username = settings.emailUsername,
let password = settings.emailPassword else {
throw EmailServiceError.notConfigured
}
let client = SMTPClient(
host: host,
port: UInt16(settings.emailSmtpPort),
username: username,
password: password
)
let messageId = try await client.sendEmail(
from: username,
to: [to],
subject: subject,
body: body,
htmlBody: htmlBody,
inReplyTo: inReplyTo
)
log.info("Email sent successfully: \(messageId)")
return messageId
}
// MARK: - Email Fetching (for manual retrieval)
/// Fetch recent emails from INBOX (for testing/debugging)
func fetchRecentEmails(limit: Int = 10) async throws -> [IncomingEmail] {
// TODO: Implement IMAP FETCH
// 1. Connect to IMAP server
// 2. SELECT INBOX
// 3. SEARCH or FETCH recent UIDs
// 4. FETCH headers and body for each UID
// 5. Parse into IncomingEmail structs
log.warning("IMAP FETCH implementation pending")
return []
}
// MARK: - Helper Methods
/// Check if email subject contains the identifier
private func matchesSubjectIdentifier(_ subject: String) -> Bool {
let identifier = settings.emailSubjectIdentifier
return subject.contains(identifier)
}
/// Extract plain text body from email
private func extractPlainText(from body: String) -> String {
// TODO: Handle multipart MIME, HTML stripping, etc.
return body
}
}
// MARK: - Implementation Notes
/*
IMAP IDLE Implementation Options:
1. MailCore2 (Recommended for production)
- Add via SPM: https://github.com/MailCore/mailcore2
- Create Objective-C bridging header
- Use MCOIMAPSession with MCOIMAPIdleOperation
- Pros: Battle-tested, full IMAP/SMTP support
- Cons: Objective-C bridging required
2. Swift NIO + Custom IMAP
- Implement IMAP protocol manually
- Pros: Pure Swift, no bridging
- Cons: Complex, error-prone, time-consuming
3. Third-party Swift Libraries
- Research Swift-native IMAP libraries on GitHub/SPM
- Pros: Easier than custom implementation
- Cons: May not be as mature as MailCore2
For now, this service provides the interface and structure.
The actual IMAP IDLE implementation should be added based on
chosen library/approach.
*/

View File

@@ -0,0 +1,114 @@
//
// EncryptionService.swift
// oAI
//
// Secure encryption for sensitive data (API keys)
// Uses CryptoKit with machine-specific key derivation
//
import Foundation
import CryptoKit
import IOKit
class EncryptionService {
static let shared = EncryptionService()
private let salt = "oAI-secure-storage-v1" // App-specific salt
private lazy var encryptionKey: SymmetricKey = {
deriveEncryptionKey()
}()
private init() {}
// MARK: - Public Interface
/// Encrypt a string value
func encrypt(_ value: String) throws -> String {
guard let data = value.data(using: .utf8) else {
throw EncryptionError.invalidInput
}
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
guard let combined = sealedBox.combined else {
throw EncryptionError.encryptionFailed
}
return combined.base64EncodedString()
}
/// Decrypt a string value
func decrypt(_ encryptedValue: String) throws -> String {
guard let data = Data(base64Encoded: encryptedValue) else {
throw EncryptionError.invalidInput
}
let sealedBox = try AES.GCM.SealedBox(combined: data)
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
guard let decryptedString = String(data: decryptedData, encoding: .utf8) else {
throw EncryptionError.decryptionFailed
}
return decryptedString
}
// MARK: - Key Derivation
/// Derive encryption key from machine-specific data
private func deriveEncryptionKey() -> SymmetricKey {
// Combine machine UUID + bundle ID + salt for key material
let machineUUID = getMachineUUID()
let bundleID = Bundle.main.bundleIdentifier ?? "com.oai.oAI"
let keyMaterial = "\(machineUUID)-\(bundleID)-\(salt)"
// Hash to create consistent 256-bit key
let hash = SHA256.hash(data: Data(keyMaterial.utf8))
return SymmetricKey(data: hash)
}
/// Get machine-specific UUID (IOPlatformUUID)
private func getMachineUUID() -> String {
// Get IOPlatformUUID from IOKit
let platformExpert = IOServiceGetMatchingService(
kIOMainPortDefault,
IOServiceMatching("IOPlatformExpertDevice")
)
guard platformExpert != 0 else {
// Fallback to a stable identifier if IOKit unavailable
return "oai-fallback-uuid"
}
defer { IOObjectRelease(platformExpert) }
guard let uuidData = IORegistryEntryCreateCFProperty(
platformExpert,
"IOPlatformUUID" as CFString,
kCFAllocatorDefault,
0
) else {
return "oai-fallback-uuid"
}
return (uuidData.takeRetainedValue() as? String) ?? "oai-fallback-uuid"
}
// MARK: - Errors
enum EncryptionError: LocalizedError {
case invalidInput
case encryptionFailed
case decryptionFailed
var errorDescription: String? {
switch self {
case .invalidInput:
return "Invalid input data for encryption/decryption"
case .encryptionFailed:
return "Failed to encrypt data"
case .decryptionFailed:
return "Failed to decrypt data"
}
}
}
}

View File

@@ -0,0 +1,657 @@
import Foundation
import os
@Observable
class GitSyncService {
static let shared = GitSyncService()
private let settings = SettingsService.shared
private let db = DatabaseService.shared
private let log = Logger(subsystem: "com.oai.oAI", category: "sync")
private(set) var syncStatus = SyncStatus()
private(set) var isSyncing = false
private(set) var lastSyncError: String?
// Debounce tracking
private var pendingSyncTask: Task<Void, Never>?
// MARK: - Repository Operations
/// Test connection to remote repository
func testConnection() async throws -> String {
let url = try buildAuthenticatedURL()
_ = try await runGit(["ls-remote", url])
return "Connected to \(extractProvider())"
}
/// Clone repository to local path
func cloneRepository() async throws {
guard settings.syncConfigured else {
throw SyncError.notConfigured
}
let url = try buildAuthenticatedURL()
let localPath = expandPath(settings.syncLocalPath)
// Check if already cloned
if FileManager.default.fileExists(atPath: localPath + "/.git") {
log.info("Repository already cloned at \(localPath)")
syncStatus.isCloned = true
return
}
log.info("Cloning repository from \(self.settings.syncRepoURL)")
_ = try await runGit(["clone", url, localPath])
syncStatus.isCloned = true
await updateStatus()
}
/// Pull latest changes from remote
func pull() async throws {
try ensureCloned()
let localPath = expandPath(settings.syncLocalPath)
log.info("Pulling changes from remote")
_ = try await runGit(["pull", "--ff-only"], cwd: localPath)
syncStatus.lastSyncTime = Date()
await updateStatus()
}
/// Push local changes to remote
func push(message: String = "Sync from oAI") async throws {
try ensureCloned()
let localPath = expandPath(settings.syncLocalPath)
// 1. Scan for secrets before committing
try scanForSecrets(in: localPath)
// 2. Add all changes
log.info("Adding changes to git")
_ = try await runGit(["add", "."], cwd: localPath)
// 3. Check if there are changes to commit
let status = try await runGit(["status", "--porcelain"], cwd: localPath)
guard !status.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
log.info("No changes to commit")
return
}
// 4. Commit
log.info("Committing changes")
_ = try await runGit(["commit", "-m", message], cwd: localPath)
// 5. Push
log.info("Pushing to remote")
// Check if upstream is set, if not set it (for first push to empty repo)
do {
_ = try await runGit(["push"], cwd: localPath)
} catch {
// First push might fail if no upstream, try with -u origin HEAD
log.info("First push - setting upstream")
_ = try await runGit(["push", "-u", "origin", "HEAD"], cwd: localPath)
}
syncStatus.lastSyncTime = Date()
await updateStatus()
}
// MARK: - Conversation Export/Import
/// Export all conversations to markdown files
func exportAllConversations() async throws {
try ensureCloned()
let conversations = try db.listConversations()
let localPath = expandPath(settings.syncLocalPath)
let conversationsDir = localPath + "/conversations"
// Create conversations directory
try FileManager.default.createDirectory(atPath: conversationsDir, withIntermediateDirectories: true)
// Create README if it doesn't exist
try createReadmeIfNeeded()
log.info("Exporting \(conversations.count) conversations")
for conversation in conversations {
// Load full conversation with messages
guard let (_, messages) = try db.loadConversation(id: conversation.id) else {
log.warning("Could not load conversation \(conversation.id.uuidString)")
continue
}
let export = ConversationExport(
id: conversation.id.uuidString,
name: conversation.name,
createdAt: conversation.createdAt,
updatedAt: conversation.updatedAt,
primaryModel: conversation.primaryModel,
messages: messages.map { msg in
ConversationExport.MessageExport(
role: msg.role.rawValue,
content: msg.content,
timestamp: msg.timestamp,
tokens: msg.tokens,
cost: msg.cost,
modelId: msg.modelId
)
}
)
let markdown = export.toMarkdown()
let filename = sanitizeFilename(conversation.name) + ".md"
let filepath = conversationsDir + "/" + filename
try markdown.write(toFile: filepath, atomically: true, encoding: String.Encoding.utf8)
log.debug("Exported: \(filename)")
}
await updateStatus()
}
/// Import conversations from markdown files
func importAllConversations() async throws -> (imported: Int, skipped: Int, errors: Int) {
try ensureCloned()
let localPath = expandPath(settings.syncLocalPath)
let conversationsDir = localPath + "/conversations"
guard FileManager.default.fileExists(atPath: conversationsDir) else {
log.warning("No conversations directory found")
return (0, 0, 0)
}
let files = try FileManager.default.contentsOfDirectory(atPath: conversationsDir)
let mdFiles = files.filter { $0.hasSuffix(".md") }
log.info("Importing \(mdFiles.count) conversation files")
var imported = 0
var skipped = 0
var errors = 0
for filename in mdFiles {
let filepath = conversationsDir + "/" + filename
do {
// Read markdown file
let markdown = try String(contentsOfFile: filepath, encoding: .utf8)
// Parse markdown to ConversationExport
let export = try ConversationExport.fromMarkdown(markdown)
// Check if conversation already exists (by ID)
if let existingId = UUID(uuidString: export.id) {
if let existing = try? db.loadConversation(id: existingId) {
// Already exists - skip
log.debug("Skipping existing conversation: \(export.name)")
skipped += 1
continue
}
}
// Convert MessageExport to Message
let messages = export.messages.map { msgExport -> Message in
let role: MessageRole
switch msgExport.role.lowercased() {
case "user": role = .user
case "assistant": role = .assistant
case "system": role = .system
default: role = .user
}
return Message(
role: role,
content: msgExport.content,
tokens: msgExport.tokens,
cost: msgExport.cost,
timestamp: msgExport.timestamp,
modelId: msgExport.modelId
)
}
// Import to database with primaryModel
let conversationId = UUID(uuidString: export.id) ?? UUID()
_ = try db.saveConversation(
id: conversationId,
name: export.name,
messages: messages,
primaryModel: export.primaryModel
)
log.info("Imported: \(export.name)")
imported += 1
} catch {
log.error("Failed to import \(filename): \(error.localizedDescription)")
errors += 1
}
}
log.info("Import complete: \(imported) imported, \(skipped) skipped, \(errors) errors")
return (imported, skipped, errors)
}
/// Create README.md in sync repository
private func createReadmeIfNeeded() throws {
let localPath = expandPath(settings.syncLocalPath)
let readmePath = localPath + "/README.md"
// Only create if doesn't exist
guard !FileManager.default.fileExists(atPath: readmePath) else {
return
}
let readme = """
# oAI Conversation Sync
This repository contains your oAI conversations in markdown format.
## ⚠️ WARNING - DO NOT MANUALLY EDIT
**This repository is automatically managed by oAI.**
- ❌ **DO NOT manually edit** these files
- ❌ **DO NOT add** files to this repository
- ❌ **DO NOT delete** files from this repository
- ❌ **DO NOT merge conflicts** manually (let oAI handle it)
**Why?** oAI rebuilds its internal database from these files. Manual edits will be:
- Overwritten on next sync
- May cause data corruption
- May prevent proper import/restore
## How It Works
### Export (Automatic)
- oAI saves conversations to its local database
- Auto-sync exports conversations to `conversations/*.md`
- Files are committed and pushed to this git repository
### Import (On New Machine)
- Clone this repository on a new machine
- oAI imports markdown files into its database
- Your conversation history is restored
### Sync Across Machines
- Machine A: Chat → Auto-save → Export → Push to git
- Machine B: Pull from git → Auto-import → Database updated
- Conversations stay in sync across all machines
## File Structure
```
/
├── README.md # This file
└── conversations/ # Your conversations
├── conversation-1.md
├── conversation-2.md
└── ...
```
## Conversation File Format
Each `.md` file contains:
- Conversation metadata (ID, name, dates)
- All messages (user and assistant)
- Token counts and costs
- Timestamps
Example:
```markdown
# Python async patterns guide
**ID**: `abc-123-def`
**Created**: 2026-02-14T10:30:00Z
**Updated**: 2026-02-14T11:45:00Z
---
## User
How do I use async/await in Python?
---
## Assistant
[Response here...]
```
## Security Notes
- This repository contains **plain text** conversations
- API keys and secrets are **automatically scanned and blocked**
- Keep this repository **private** if conversations contain sensitive info
- Use **.gitignore** if you want to exclude specific conversations
## Troubleshooting
**Problem:** Files not syncing?
- Check Settings → Sync in oAI
- Verify git credentials are correct
- Check network connection
**Problem:** Conflicts after editing?
- Restore from git: `git reset --hard origin/main`
- Re-export from oAI: Manual Sync → Export All → Push
**Problem:** Lost conversations?
- Conversations are in your local oAI database
- Export manually: Settings → Sync → Export All
- Check git history for deleted files
## Support
For help with oAI, see:
- Settings → Help in oAI app
- GitHub issues (if open source)
---
**Generated by oAI v1.0**
**Last updated:** \(ISO8601DateFormatter().string(from: Date()))
"""
try readme.write(toFile: readmePath, atomically: true, encoding: .utf8)
log.info("Created README.md in sync repository")
}
// MARK: - Auto-Sync
/// Perform auto-sync with debouncing (export + push)
/// Debounces multiple rapid sync requests to avoid spamming git
func autoSync() async {
// Cancel any pending sync
pendingSyncTask?.cancel()
// Schedule new sync with 5 second delay
pendingSyncTask = Task {
do {
// Wait for debounce period
try await Task.sleep(for: .seconds(5))
// Check if cancelled during sleep
guard !Task.isCancelled else { return }
// Set syncing state
await MainActor.run {
isSyncing = true
lastSyncError = nil
}
log.info("Auto-sync starting (export + push)...")
// Export conversations
try await exportAllConversations()
// Push to git
try await push(message: "Auto-sync from oAI")
// Success
await MainActor.run {
isSyncing = false
syncStatus.lastSyncTime = Date()
}
log.info("Auto-sync completed successfully")
} catch {
// Error
await MainActor.run {
isSyncing = false
lastSyncError = error.localizedDescription
}
log.error("Auto-sync failed: \(error.localizedDescription)")
}
}
// Wait for the task to complete
await pendingSyncTask?.value
}
// MARK: - Secret Scanning
/// Scan for API keys and secrets in conversations
func scanForSecrets(in directory: String) throws {
let conversationsDir = directory + "/conversations"
guard FileManager.default.fileExists(atPath: conversationsDir) else {
return
}
let files = try FileManager.default.contentsOfDirectory(atPath: conversationsDir)
let mdFiles = files.filter { $0.hasSuffix(".md") }
var detectedSecrets: [String] = []
for filename in mdFiles {
let filepath = conversationsDir + "/" + filename
let content = try String(contentsOfFile: filepath, encoding: .utf8)
let secrets = detectSecretsInText(content)
if !secrets.isEmpty {
detectedSecrets.append("\(filename): \(secrets.joined(separator: ", "))")
}
}
if !detectedSecrets.isEmpty {
log.error("Secrets detected in conversations!")
throw SyncError.secretsDetected(detectedSecrets)
}
}
private func detectSecretsInText(_ text: String) -> [String] {
let patterns: [(name: String, pattern: String)] = [
("OpenAI Key", "sk-[a-zA-Z0-9]{32,}"),
("Anthropic Key", "sk-ant-[a-zA-Z0-9_-]+"),
("Bearer Token", "Bearer [a-zA-Z0-9_-]{20,}"),
("API Key", "api[_-]?key[\"']?\\s*[:=]\\s*[\"']?[a-zA-Z0-9]{20,}"),
("Access Token", "ghp_[a-zA-Z0-9]{36}"), // GitHub personal access token
]
var found: [String] = []
for (name, pattern) in patterns {
if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
let range = NSRange(text.startIndex..., in: text)
let matches = regex.matches(in: text, range: range)
if !matches.isEmpty {
found.append(name)
}
}
}
return Array(Set(found)) // Remove duplicates
}
// MARK: - Status Management
func updateStatus() async {
let localPath = expandPath(settings.syncLocalPath)
// Check if cloned
syncStatus.isCloned = FileManager.default.fileExists(atPath: localPath + "/.git")
guard syncStatus.isCloned else { return }
do {
// Get current branch
let branch = try await runGit(["branch", "--show-current"], cwd: localPath)
syncStatus.currentBranch = branch.trimmingCharacters(in: .whitespacesAndNewlines)
// Get uncommitted changes count
let status = try await runGit(["status", "--porcelain"], cwd: localPath)
let lines = status.components(separatedBy: .newlines).filter { !$0.isEmpty }
syncStatus.uncommittedChanges = lines.count
// Get remote status
_ = try await runGit(["fetch"], cwd: localPath)
let remoteDiff = try await runGit(["rev-list", "--left-right", "--count", "HEAD...@{u}"], cwd: localPath)
let parts = remoteDiff.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\t")
if parts.count == 2 {
let ahead = Int(parts[0]) ?? 0
let behind = Int(parts[1]) ?? 0
if ahead == 0 && behind == 0 {
syncStatus.remoteStatus = "up-to-date"
} else if ahead > 0 && behind == 0 {
syncStatus.remoteStatus = "ahead \(ahead)"
} else if ahead == 0 && behind > 0 {
syncStatus.remoteStatus = "behind \(behind)"
} else {
syncStatus.remoteStatus = "diverged"
}
}
} catch {
log.error("Failed to update status: \(error.localizedDescription)")
}
}
// MARK: - Helper Methods
private func buildAuthenticatedURL() throws -> String {
guard settings.syncConfigured else {
throw SyncError.notConfigured
}
let baseURL = settings.syncRepoURL
switch settings.syncAuthMethod {
case "ssh":
// Convert HTTPS URL to SSH format if needed
return convertToSSH(baseURL)
case "password":
guard let username = settings.syncUsername,
let password = settings.syncPassword else {
throw SyncError.missingCredentials
}
return injectCredentials(baseURL, username: username, password: password)
case "token":
guard let token = settings.syncAccessToken else {
throw SyncError.missingCredentials
}
// Use oauth2 as username for tokens
return injectCredentials(baseURL, username: "oauth2", password: token)
default:
return baseURL
}
}
private func convertToSSH(_ url: String) -> String {
// If already SSH format, return as-is
if url.hasPrefix("git@") {
return url
}
// Convert HTTPS to SSH format
// https://gitlab.pm/rune/oAI-Sync.git -> git@gitlab.pm:rune/oAI-Sync.git
if url.hasPrefix("https://") {
let withoutScheme = url.replacingOccurrences(of: "https://", with: "")
// Replace first "/" with ":"
if let firstSlash = withoutScheme.firstIndex(of: "/") {
var sshURL = withoutScheme
sshURL.replaceSubrange(firstSlash...firstSlash, with: ":")
return "git@" + sshURL
}
}
// If http:// (rare but possible)
if url.hasPrefix("http://") {
let withoutScheme = url.replacingOccurrences(of: "http://", with: "")
if let firstSlash = withoutScheme.firstIndex(of: "/") {
var sshURL = withoutScheme
sshURL.replaceSubrange(firstSlash...firstSlash, with: ":")
return "git@" + sshURL
}
}
// Unknown format, return as-is
return url
}
private func injectCredentials(_ url: String, username: String, password: String) -> String {
// Convert https://github.com/user/repo.git
// To: https://username:password@github.com/user/repo.git
if url.hasPrefix("https://") {
let withoutScheme = url.replacingOccurrences(of: "https://", with: "")
return "https://\(username):\(password)@\(withoutScheme)"
}
return url // SSH or other protocol
}
private func runGit(_ args: [String], cwd: String? = nil) async throws -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
process.arguments = args
if let cwd = cwd {
process.currentDirectoryURL = URL(fileURLWithPath: expandPath(cwd))
}
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe
try process.run()
process.waitUntilExit()
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outputData, encoding: .utf8) ?? ""
let error = String(data: errorData, encoding: .utf8) ?? ""
guard process.terminationStatus == 0 else {
log.error("Git command failed: \(args.joined(separator: " "))")
log.error("Error: \(error)")
throw SyncError.gitFailed(error.isEmpty ? "Unknown error" : error)
}
return output
}
private func expandPath(_ path: String) -> String {
return NSString(string: path).expandingTildeInPath
}
private func ensureCloned() throws {
let localPath = expandPath(settings.syncLocalPath)
guard FileManager.default.fileExists(atPath: localPath + "/.git") else {
throw SyncError.repoNotCloned
}
}
private func sanitizeFilename(_ name: String) -> String {
// Remove invalid filename characters
let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|")
return name.components(separatedBy: invalid).joined(separator: "-")
}
private func extractProvider() -> String {
let url = settings.syncRepoURL
if url.contains("github.com") {
return "GitHub"
} else if url.contains("gitlab.com") {
return "GitLab"
} else if url.contains("gitea") {
return "Gitea"
} else {
return "Git repository"
}
}
}

View File

@@ -0,0 +1,294 @@
//
// IMAPClient.swift
// oAI
//
// Swift-native IMAP client for email monitoring
//
import Foundation
import Network
import os
class IMAPClient {
private let log = Logger(subsystem: "com.oai.oAI", category: "imap")
private var connection: NWConnection?
private var host: String
private var port: UInt16
private var username: String
private var password: String
private var useTLS: Bool
private var commandTag = 1
private var receiveBuffer = Data()
init(host: String, port: UInt16 = 993, username: String, password: String, useTLS: Bool = true) {
self.host = host
self.port = port
self.username = username
self.password = password
self.useTLS = useTLS
}
// MARK: - Connection
func connect() async throws {
let tlsOptions = useTLS ? NWProtocolTLS.Options() : nil
let tcpOptions = NWProtocolTCP.Options()
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port), using: params)
let hostName = self.host
return try await withCheckedThrowingContinuation { continuation in
final class ResumeOnce {
var resumed = false
let lock = NSLock()
}
let resumeOnce = ResumeOnce()
connection?.stateUpdateHandler = { [weak self] state in
resumeOnce.lock.lock()
defer { resumeOnce.lock.unlock() }
guard !resumeOnce.resumed else { return }
switch state {
case .ready:
resumeOnce.resumed = true
self?.log.info("IMAP connected to \(hostName)")
continuation.resume()
case .failed(let error):
resumeOnce.resumed = true
self?.log.error("IMAP connection failed: \(error.localizedDescription)")
continuation.resume(throwing: EmailServiceError.connectionFailed(error.localizedDescription))
default:
break
}
}
connection?.start(queue: .global())
}
}
func disconnect() {
connection?.cancel()
connection = nil
log.info("IMAP disconnected")
}
// MARK: - Authentication
func login() async throws {
// Wait for server greeting
_ = try await readResponse()
// Send LOGIN command
let loginCmd = "LOGIN \"\(username)\" \"\(password)\""
let response = try await sendCommand(loginCmd)
if !response.contains("OK") {
throw EmailServiceError.authenticationFailed
}
log.info("IMAP login successful")
}
// MARK: - Mailbox Operations
func selectMailbox(_ mailbox: String = "INBOX") async throws {
let response = try await sendCommand("SELECT \(mailbox)")
if !response.contains("OK") {
throw EmailServiceError.connectionFailed("Failed to select mailbox")
}
}
func searchUnseenWithSubject(_ subject: String) async throws -> [UInt32] {
let response = try await sendCommand("SEARCH UNSEEN SUBJECT \"\(subject)\"")
// Parse UIDs from response like "* SEARCH 123 124 125"
var uids: [UInt32] = []
for line in response.split(separator: "\r\n") {
if line.hasPrefix("* SEARCH") {
let parts = line.split(separator: " ")
for part in parts.dropFirst(2) {
if let uid = UInt32(part) {
uids.append(uid)
}
}
}
}
return uids
}
func searchUnseen() async throws -> [UInt32] {
let response = try await sendCommand("SEARCH UNSEEN")
// Parse UIDs from response like "* SEARCH 123 124 125"
var uids: [UInt32] = []
for line in response.split(separator: "\r\n") {
if line.hasPrefix("* SEARCH") {
let parts = line.split(separator: " ")
for part in parts.dropFirst(2) {
if let uid = UInt32(part) {
uids.append(uid)
}
}
}
}
return uids
}
func fetchEmail(uid: UInt32) async throws -> IncomingEmail {
let response = try await sendCommand("FETCH \(uid) (BODY.PEEK[] FLAGS)")
// Parse email from response
return try parseEmailResponse(response, uid: uid)
}
func markAsRead(uid: UInt32) async throws {
_ = try await sendCommand("STORE \(uid) +FLAGS (\\Seen)")
}
func deleteEmail(uid: UInt32) async throws {
_ = try await sendCommand("STORE \(uid) +FLAGS (\\Deleted)")
}
func expunge() async throws {
_ = try await sendCommand("EXPUNGE")
}
// MARK: - Low-level Protocol
private func sendCommand(_ command: String) async throws -> String {
guard let connection = connection else {
throw EmailServiceError.connectionFailed("Not connected")
}
let tag = "A\(commandTag)"
commandTag += 1
let fullCommand = "\(tag) \(command)\r\n"
let data = fullCommand.data(using: .utf8)!
// Send command
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
connection.send(content: data, completion: .contentProcessed { error in
if let error = error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
})
}
// Read response
return try await readResponse(until: tag)
}
private func readResponse(until tag: String? = nil) async throws -> String {
guard let connection = connection else {
throw EmailServiceError.connectionFailed("Not connected")
}
var fullResponse = ""
while true {
let data: Data = try await withCheckedThrowingContinuation { continuation in
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in
if let error = error {
continuation.resume(throwing: error)
} else if let data = data {
continuation.resume(returning: data)
} else {
continuation.resume(throwing: EmailServiceError.connectionFailed("No data received"))
}
}
}
receiveBuffer.append(data)
if let response = String(data: receiveBuffer, encoding: .utf8) {
fullResponse = response
// Check if we have a complete response
if let tag = tag {
if response.contains("\(tag) OK") || response.contains("\(tag) NO") || response.contains("\(tag) BAD") {
receiveBuffer.removeAll()
break
}
} else {
// Just return what we got
receiveBuffer.removeAll()
break
}
}
}
return fullResponse
}
// MARK: - Email Parsing
private func parseEmailResponse(_ response: String, uid: UInt32) throws -> IncomingEmail {
// Basic email parsing - extract headers and body
let lines = response.split(separator: "\r\n", omittingEmptySubsequences: false)
var from = ""
var subject = ""
var messageId = ""
var inReplyTo: String?
var body = ""
var inBody = false
var receivedDate = Date()
for line in lines {
let lineStr = String(line)
if lineStr.hasPrefix("From: ") {
from = String(lineStr.dropFirst(6))
// Extract email from "Name <email@domain.com>"
if let start = from.firstIndex(of: "<"), let end = from.firstIndex(of: ">") {
from = String(from[start...end].dropFirst().dropLast())
}
} else if lineStr.hasPrefix("Subject: ") {
subject = String(lineStr.dropFirst(9))
} else if lineStr.hasPrefix("Message-ID: ") || lineStr.hasPrefix("Message-Id: ") {
messageId = String(lineStr.split(separator: ": ", maxSplits: 1).last ?? "")
} else if lineStr.hasPrefix("In-Reply-To: ") {
inReplyTo = String(lineStr.split(separator: ": ", maxSplits: 1).last ?? "")
} else if lineStr.hasPrefix("Date: ") {
let dateStr = String(lineStr.dropFirst(6))
// Parse RFC 2822 date format
let formatter = DateFormatter()
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
formatter.locale = Locale(identifier: "en_US_POSIX")
if let date = formatter.date(from: dateStr) {
receivedDate = date
}
} else if lineStr.isEmpty && !inBody {
// Empty line marks end of headers
inBody = true
} else if inBody {
body += lineStr + "\n"
}
}
return IncomingEmail(
id: UUID(),
uid: uid,
messageId: messageId.isEmpty ? "msg-\(uid)" : messageId,
from: from,
to: [username],
subject: subject,
body: body.trimmingCharacters(in: .whitespacesAndNewlines),
receivedDate: receivedDate,
inReplyTo: inReplyTo
)
}
}

View File

@@ -0,0 +1,368 @@
//
// SMTPClient.swift
// oAI
//
// Swift-native SMTP client for sending emails
//
import Foundation
import Network
import os
class SMTPClient {
private let log = Logger(subsystem: "com.oai.oAI", category: "smtp")
private var connection: NWConnection?
private var host: String
private var port: UInt16
private var username: String
private var password: String
init(host: String, port: UInt16 = 587, username: String, password: String) {
self.host = host
self.port = port
self.username = username
self.password = password
}
// MARK: - Connection
func connectWithTLS() async throws {
let tlsOptions = NWProtocolTLS.Options()
let tcpOptions = NWProtocolTCP.Options()
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port), using: params)
let hostName = self.host
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
final class ResumeOnce {
var resumed = false
let lock = NSLock()
}
let resumeOnce = ResumeOnce()
connection?.stateUpdateHandler = { [weak self] state in
resumeOnce.lock.lock()
defer { resumeOnce.lock.unlock() }
guard !resumeOnce.resumed else { return }
switch state {
case .ready:
resumeOnce.resumed = true
self?.log.info("SMTP connected to \(hostName) with TLS")
continuation.resume()
case .failed(let error):
resumeOnce.resumed = true
self?.log.error("SMTP TLS connection failed: \(error.localizedDescription)")
continuation.resume(throwing: EmailServiceError.connectionFailed(error.localizedDescription))
default:
break
}
}
connection?.start(queue: .global())
}
}
func connect() async throws {
let tcpOptions = NWProtocolTCP.Options()
let params = NWParameters(tls: nil, tcp: tcpOptions)
connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port), using: params)
let hostName = self.host
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
final class ResumeOnce {
var resumed = false
let lock = NSLock()
}
let resumeOnce = ResumeOnce()
connection?.stateUpdateHandler = { [weak self] state in
resumeOnce.lock.lock()
defer { resumeOnce.lock.unlock() }
guard !resumeOnce.resumed else { return }
switch state {
case .ready:
resumeOnce.resumed = true
self?.log.info("SMTP connected to \(hostName)")
continuation.resume()
case .failed(let error):
resumeOnce.resumed = true
self?.log.error("SMTP connection failed: \(error.localizedDescription)")
continuation.resume(throwing: EmailServiceError.connectionFailed(error.localizedDescription))
default:
break
}
}
connection?.start(queue: .global())
}
}
func disconnect() {
_ = try? sendCommandSync("QUIT")
connection?.cancel()
connection = nil
log.info("SMTP disconnected")
}
// MARK: - Send Email
func sendEmail(from: String, to: [String], subject: String, body: String, htmlBody: String? = nil, inReplyTo: String? = nil) async throws -> String {
// Port 465 uses direct TLS (implicit), port 587 uses STARTTLS (explicit)
let usesDirectTLS = port == 465
if usesDirectTLS {
// Direct TLS connection (port 465)
try await connectWithTLS()
} else {
// Plain connection first (port 587)
try await connect()
}
defer { disconnect() }
// Read server greeting (220)
_ = try await readResponse()
// EHLO
_ = try await sendCommand("EHLO \(host)")
// STARTTLS only for port 587
if !usesDirectTLS {
// Note: Network framework doesn't support mid-connection TLS upgrade
// For STARTTLS, we skip the upgrade and continue with plain connection
// This is insecure - recommend using port 465 instead
log.warning("Port 587 with STARTTLS not fully supported, consider using port 465 (direct TLS)")
}
// AUTH LOGIN
_ = try await sendCommand("AUTH LOGIN", expectCode: "334")
// Send base64 encoded username
let usernameB64 = Data(username.utf8).base64EncodedString()
_ = try await sendCommand(usernameB64, expectCode: "334")
// Send base64 encoded password
let passwordB64 = Data(password.utf8).base64EncodedString()
_ = try await sendCommand(passwordB64, expectCode: "235")
// MAIL FROM
_ = try await sendCommand("MAIL FROM:<\(from)>")
// RCPT TO
for recipient in to {
_ = try await sendCommand("RCPT TO:<\(recipient)>")
}
// DATA
_ = try await sendCommand("DATA", expectCode: "354")
// Build email
let messageId = "<\(UUID().uuidString)@\(host)>"
let date = formatEmailDate(Date())
var email = ""
email += "From: \(from)\r\n"
email += "To: \(to.joined(separator: ", "))\r\n"
email += "Subject: \(subject)\r\n"
email += "Date: \(date)\r\n"
email += "Message-ID: \(messageId)\r\n"
if let inReplyTo = inReplyTo {
email += "In-Reply-To: \(inReplyTo)\r\n"
email += "References: \(inReplyTo)\r\n"
}
if let htmlBody = htmlBody {
// Multipart alternative
let boundary = "----=_Part_\(UUID().uuidString.prefix(16))"
email += "MIME-Version: 1.0\r\n"
email += "Content-Type: multipart/alternative; boundary=\"\(boundary)\"\r\n"
email += "\r\n"
email += "--\(boundary)\r\n"
email += "Content-Type: text/plain; charset=utf-8\r\n"
email += "\r\n"
email += body
email += "\r\n\r\n"
email += "--\(boundary)\r\n"
email += "Content-Type: text/html; charset=utf-8\r\n"
email += "\r\n"
email += htmlBody
email += "\r\n\r\n"
email += "--\(boundary)--\r\n"
} else {
// Plain text
email += "Content-Type: text/plain; charset=utf-8\r\n"
email += "\r\n"
email += body
email += "\r\n"
}
// End with CRLF.CRLF
email += ".\r\n"
// Send email data
_ = try await sendCommand(email, expectCode: "250", raw: true)
log.info("Email sent successfully: \(messageId)")
return messageId
}
// MARK: - Low-level Protocol
private func upgradToTLS() async throws {
guard let connection = connection else {
throw EmailServiceError.connectionFailed("Not connected")
}
// Create TLS options
let tlsOptions = NWProtocolTLS.Options()
// Create new parameters with TLS
let params = NWParameters(tls: tlsOptions)
// Cancel old connection
connection.cancel()
// Create new connection with TLS
self.connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port), using: params)
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
final class ResumeOnce {
var resumed = false
let lock = NSLock()
}
let resumeOnce = ResumeOnce()
self.connection?.stateUpdateHandler = { [weak self] state in
resumeOnce.lock.lock()
defer { resumeOnce.lock.unlock() }
guard !resumeOnce.resumed else { return }
switch state {
case .ready:
resumeOnce.resumed = true
self?.log.info("SMTP upgraded to TLS")
continuation.resume()
case .failed(let error):
resumeOnce.resumed = true
continuation.resume(throwing: error)
default:
break
}
}
self.connection?.start(queue: .global())
}
}
private func sendCommand(_ command: String, expectCode: String = "250", raw: Bool = false) async throws -> String {
guard let connection = connection else {
throw EmailServiceError.connectionFailed("Not connected")
}
let fullCommand = raw ? command : "\(command)\r\n"
let data = fullCommand.data(using: .utf8)!
// Send command
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
connection.send(content: data, completion: .contentProcessed { error in
if let error = error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
})
}
// Read response
let response = try await readResponse()
if !response.hasPrefix(expectCode) {
throw EmailServiceError.sendingFailed("Expected \(expectCode), got: \(response)")
}
return response
}
private func sendCommandSync(_ command: String) throws {
guard let connection = connection else { return }
let fullCommand = "\(command)\r\n"
let data = fullCommand.data(using: .utf8)!
connection.send(content: data, completion: .idempotent)
}
private func readResponse() async throws -> String {
guard let connection = connection else {
throw EmailServiceError.connectionFailed("Not connected")
}
var fullResponse = ""
var isComplete = false
// Keep reading until we get a complete SMTP response
// Multi-line responses end with "XXX " (space), interim lines have "XXX-" (dash)
while !isComplete {
let data: Data = try await withCheckedThrowingContinuation { continuation in
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in
if let error = error {
continuation.resume(throwing: error)
} else if let data = data {
continuation.resume(returning: data)
} else {
continuation.resume(throwing: EmailServiceError.connectionFailed("No data received"))
}
}
}
guard let chunk = String(data: data, encoding: .utf8) else {
throw EmailServiceError.connectionFailed("Invalid response encoding")
}
fullResponse += chunk
// Check if we have a complete response
// SMTP responses end with a line like "250 OK\r\n" (code + space + message)
let lines = fullResponse.split(separator: "\r\n", omittingEmptySubsequences: false)
for line in lines {
if line.count >= 4 {
let prefix = String(line.prefix(4))
// Check if it's a final line (code + space, not code + dash)
// Format: "XXX " where X is a digit
if prefix.count == 4,
prefix[prefix.index(prefix.startIndex, offsetBy: 0)].isNumber,
prefix[prefix.index(prefix.startIndex, offsetBy: 1)].isNumber,
prefix[prefix.index(prefix.startIndex, offsetBy: 2)].isNumber,
prefix[prefix.index(prefix.startIndex, offsetBy: 3)] == " " {
isComplete = true
break
}
}
}
// Safety: don't loop forever
if fullResponse.count > 100000 {
throw EmailServiceError.connectionFailed("Response too large")
}
}
return fullResponse
}
// MARK: - Helpers
private func formatEmailDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.string(from: date)
}
}

View File

@@ -16,7 +16,16 @@ class SettingsService {
// In-memory cache of DB settings for fast reads
private var cache: [String: String] = [:]
// Keychain keys (secrets only)
// Encrypted database keys (for API keys)
private enum EncryptedKeys {
static let openrouterAPIKey = "openrouterAPIKey"
static let anthropicAPIKey = "anthropicAPIKey"
static let openaiAPIKey = "openaiAPIKey"
static let googleAPIKey = "googleAPIKey"
static let googleSearchEngineID = "googleSearchEngineID"
}
// Old keychain keys (for migration only)
private enum KeychainKeys {
static let openrouterAPIKey = "com.oai.apikey.openrouter"
static let anthropicAPIKey = "com.oai.apikey.anthropic"
@@ -32,6 +41,9 @@ class SettingsService {
// Migrate from UserDefaults on first launch
migrateFromUserDefaultsIfNeeded()
// Migrate API keys from Keychain to encrypted database
migrateFromKeychainIfNeeded()
}
// MARK: - Provider Settings
@@ -102,6 +114,20 @@ class SettingsService {
}
}
var customPromptMode: Settings.CustomPromptMode {
get {
if let rawValue = cache["customPromptMode"],
let mode = Settings.CustomPromptMode(rawValue: rawValue) {
return mode
}
return .append // Default to append mode
}
set {
cache["customPromptMode"] = newValue.rawValue
DatabaseService.shared.setSetting(key: "customPromptMode", value: newValue.rawValue)
}
}
// MARK: - Feature Settings
var onlineMode: Bool {
@@ -157,6 +183,26 @@ class SettingsService {
}
}
// MARK: - Toolbar Settings
/// Toolbar icon size default 16 (minimum)
var toolbarIconSize: Double {
get { cache["toolbarIconSize"].flatMap(Double.init) ?? 16.0 }
set {
cache["toolbarIconSize"] = String(newValue)
DatabaseService.shared.setSetting(key: "toolbarIconSize", value: String(newValue))
}
}
/// Show labels on toolbar icons default false
var showToolbarLabels: Bool {
get { cache["showToolbarLabels"] == "true" }
set {
cache["showToolbarLabels"] = String(newValue)
DatabaseService.shared.setSetting(key: "showToolbarLabels", value: String(newValue))
}
}
// MARK: - MCP Permissions
var mcpCanWriteFiles: Bool {
@@ -262,63 +308,392 @@ class SettingsService {
cache["ollamaBaseURL"] != nil && !(cache["ollamaBaseURL"]!.isEmpty)
}
// MARK: - API Keys (Keychain)
// MARK: - API Keys (Encrypted Database)
var openrouterAPIKey: String? {
get { getKeychainValue(for: KeychainKeys.openrouterAPIKey) }
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.openrouterAPIKey) }
set {
if let value = newValue {
setKeychainValue(value, for: KeychainKeys.openrouterAPIKey)
if let value = newValue, !value.isEmpty {
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.openrouterAPIKey, value: value)
} else {
deleteKeychainValue(for: KeychainKeys.openrouterAPIKey)
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.openrouterAPIKey)
}
}
}
var anthropicAPIKey: String? {
get { getKeychainValue(for: KeychainKeys.anthropicAPIKey) }
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.anthropicAPIKey) }
set {
if let value = newValue {
setKeychainValue(value, for: KeychainKeys.anthropicAPIKey)
if let value = newValue, !value.isEmpty {
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.anthropicAPIKey, value: value)
} else {
deleteKeychainValue(for: KeychainKeys.anthropicAPIKey)
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.anthropicAPIKey)
}
}
}
var openaiAPIKey: String? {
get { getKeychainValue(for: KeychainKeys.openaiAPIKey) }
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.openaiAPIKey) }
set {
if let value = newValue {
setKeychainValue(value, for: KeychainKeys.openaiAPIKey)
if let value = newValue, !value.isEmpty {
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.openaiAPIKey, value: value)
} else {
deleteKeychainValue(for: KeychainKeys.openaiAPIKey)
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.openaiAPIKey)
}
}
}
var googleAPIKey: String? {
get { getKeychainValue(for: KeychainKeys.googleAPIKey) }
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.googleAPIKey) }
set {
if let value = newValue {
setKeychainValue(value, for: KeychainKeys.googleAPIKey)
if let value = newValue, !value.isEmpty {
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.googleAPIKey, value: value)
} else {
deleteKeychainValue(for: KeychainKeys.googleAPIKey)
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.googleAPIKey)
}
}
}
var googleSearchEngineID: String? {
get { getKeychainValue(for: KeychainKeys.googleSearchEngineID) }
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.googleSearchEngineID) }
set {
if let value = newValue {
setKeychainValue(value, for: KeychainKeys.googleSearchEngineID)
if let value = newValue, !value.isEmpty {
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.googleSearchEngineID, value: value)
} else {
deleteKeychainValue(for: KeychainKeys.googleSearchEngineID)
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.googleSearchEngineID)
}
}
}
// MARK: - Git Sync Settings
var syncEnabled: Bool {
get { cache["syncEnabled"] == "true" }
set {
cache["syncEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "syncEnabled", value: String(newValue))
}
}
var syncRepoURL: String {
get { cache["syncRepoURL"] ?? "" }
set {
let trimmed = newValue.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
cache.removeValue(forKey: "syncRepoURL")
DatabaseService.shared.deleteSetting(key: "syncRepoURL")
} else {
cache["syncRepoURL"] = trimmed
DatabaseService.shared.setSetting(key: "syncRepoURL", value: trimmed)
}
}
}
var syncLocalPath: String {
get { cache["syncLocalPath"] ?? "~/Library/Application Support/oAI/sync" }
set {
cache["syncLocalPath"] = newValue
DatabaseService.shared.setSetting(key: "syncLocalPath", value: newValue)
}
}
var syncAuthMethod: String {
get { cache["syncAuthMethod"] ?? "token" } // Default to access token
set {
cache["syncAuthMethod"] = newValue
DatabaseService.shared.setSetting(key: "syncAuthMethod", value: newValue)
}
}
// Encrypted sync credentials
var syncUsername: String? {
get { try? DatabaseService.shared.getEncryptedSetting(key: "syncUsername") }
set {
if let value = newValue, !value.isEmpty {
try? DatabaseService.shared.setEncryptedSetting(key: "syncUsername", value: value)
} else {
DatabaseService.shared.deleteEncryptedSetting(key: "syncUsername")
}
}
}
var syncPassword: String? {
get { try? DatabaseService.shared.getEncryptedSetting(key: "syncPassword") }
set {
if let value = newValue, !value.isEmpty {
try? DatabaseService.shared.setEncryptedSetting(key: "syncPassword", value: value)
} else {
DatabaseService.shared.deleteEncryptedSetting(key: "syncPassword")
}
}
}
var syncAccessToken: String? {
get { try? DatabaseService.shared.getEncryptedSetting(key: "syncAccessToken") }
set {
if let value = newValue, !value.isEmpty {
try? DatabaseService.shared.setEncryptedSetting(key: "syncAccessToken", value: value)
} else {
DatabaseService.shared.deleteEncryptedSetting(key: "syncAccessToken")
}
}
}
var syncAutoExport: Bool {
get { cache["syncAutoExport"] == "true" }
set {
cache["syncAutoExport"] = String(newValue)
DatabaseService.shared.setSetting(key: "syncAutoExport", value: String(newValue))
}
}
var syncAutoPull: Bool {
get { cache["syncAutoPull"] == "true" }
set {
cache["syncAutoPull"] = String(newValue)
DatabaseService.shared.setSetting(key: "syncAutoPull", value: String(newValue))
}
}
var syncConfigured: Bool {
guard !syncRepoURL.isEmpty else { return false }
switch syncAuthMethod {
case "ssh":
return true // SSH uses system keys
case "password":
return syncUsername != nil && syncPassword != nil
case "token":
return syncAccessToken != nil
default:
return false
}
}
// MARK: - Auto-Sync Settings
var syncAutoSave: Bool {
get { cache["syncAutoSave"] == "true" }
set {
cache["syncAutoSave"] = String(newValue)
DatabaseService.shared.setSetting(key: "syncAutoSave", value: String(newValue))
}
}
var syncAutoSaveMinMessages: Int {
get { cache["syncAutoSaveMinMessages"].flatMap(Int.init) ?? 5 }
set {
cache["syncAutoSaveMinMessages"] = String(newValue)
DatabaseService.shared.setSetting(key: "syncAutoSaveMinMessages", value: String(newValue))
}
}
var syncAutoSaveOnModelSwitch: Bool {
get { cache["syncAutoSaveOnModelSwitch"] == "true" }
set {
cache["syncAutoSaveOnModelSwitch"] = String(newValue)
DatabaseService.shared.setSetting(key: "syncAutoSaveOnModelSwitch", value: String(newValue))
}
}
var syncAutoSaveOnAppQuit: Bool {
get { cache["syncAutoSaveOnAppQuit"] == "true" }
set {
cache["syncAutoSaveOnAppQuit"] = String(newValue)
DatabaseService.shared.setSetting(key: "syncAutoSaveOnAppQuit", value: String(newValue))
}
}
var syncAutoSaveOnIdle: Bool {
get { cache["syncAutoSaveOnIdle"] == "true" }
set {
cache["syncAutoSaveOnIdle"] = String(newValue)
DatabaseService.shared.setSetting(key: "syncAutoSaveOnIdle", value: String(newValue))
}
}
var syncAutoSaveIdleMinutes: Int {
get { cache["syncAutoSaveIdleMinutes"].flatMap(Int.init) ?? 5 }
set {
cache["syncAutoSaveIdleMinutes"] = String(newValue)
DatabaseService.shared.setSetting(key: "syncAutoSaveIdleMinutes", value: String(newValue))
}
}
var syncLastAutoSaveConversationId: String? {
get { cache["syncLastAutoSaveConversationId"] }
set {
if let value = newValue {
cache["syncLastAutoSaveConversationId"] = value
DatabaseService.shared.setSetting(key: "syncLastAutoSaveConversationId", value: value)
} else {
cache.removeValue(forKey: "syncLastAutoSaveConversationId")
DatabaseService.shared.deleteSetting(key: "syncLastAutoSaveConversationId")
}
}
}
// MARK: - Email Handler Settings
var emailHandlerEnabled: Bool {
get { cache["emailHandlerEnabled"] == "true" }
set {
cache["emailHandlerEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "emailHandlerEnabled", value: String(newValue))
}
}
var emailHandlerProvider: String {
get { cache["emailHandlerProvider"] ?? "openrouter" }
set {
cache["emailHandlerProvider"] = newValue
DatabaseService.shared.setSetting(key: "emailHandlerProvider", value: newValue)
}
}
var emailHandlerModel: String {
get { cache["emailHandlerModel"] ?? "" }
set {
cache["emailHandlerModel"] = newValue
DatabaseService.shared.setSetting(key: "emailHandlerModel", value: newValue)
}
}
var emailSubjectIdentifier: String {
get { cache["emailSubjectIdentifier"] ?? "[OAIBOT]" }
set {
cache["emailSubjectIdentifier"] = newValue
DatabaseService.shared.setSetting(key: "emailSubjectIdentifier", value: newValue)
}
}
var emailRateLimitEnabled: Bool {
get { cache["emailRateLimitEnabled"] == "true" }
set {
cache["emailRateLimitEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "emailRateLimitEnabled", value: String(newValue))
}
}
var emailRateLimitPerHour: Int {
get { cache["emailRateLimitPerHour"].flatMap(Int.init) ?? 10 }
set {
cache["emailRateLimitPerHour"] = String(newValue)
DatabaseService.shared.setSetting(key: "emailRateLimitPerHour", value: String(newValue))
}
}
var emailMaxTokens: Int {
get { cache["emailMaxTokens"].flatMap(Int.init) ?? 2000 }
set {
cache["emailMaxTokens"] = String(newValue)
DatabaseService.shared.setSetting(key: "emailMaxTokens", value: String(newValue))
}
}
var emailHandlerSystemPrompt: String? {
get { cache["emailHandlerSystemPrompt"] }
set {
if let value = newValue, !value.isEmpty {
cache["emailHandlerSystemPrompt"] = value
DatabaseService.shared.setSetting(key: "emailHandlerSystemPrompt", value: value)
} else {
cache.removeValue(forKey: "emailHandlerSystemPrompt")
DatabaseService.shared.deleteSetting(key: "emailHandlerSystemPrompt")
}
}
}
var emailOnlineMode: Bool {
get { cache["emailOnlineMode"] == "true" }
set {
cache["emailOnlineMode"] = String(newValue)
DatabaseService.shared.setSetting(key: "emailOnlineMode", value: String(newValue))
}
}
var emailHandlerConfigured: Bool {
guard emailHandlerEnabled else { return false }
guard !emailHandlerModel.isEmpty else { return false }
// Check if email server is configured
guard emailServerConfigured else { return false }
return true
}
// MARK: - Email Server Settings
var emailImapHost: String? {
get { cache["emailImapHost"] }
set {
if let value = newValue, !value.isEmpty {
cache["emailImapHost"] = value
DatabaseService.shared.setSetting(key: "emailImapHost", value: value)
} else {
cache.removeValue(forKey: "emailImapHost")
DatabaseService.shared.deleteSetting(key: "emailImapHost")
}
}
}
var emailSmtpHost: String? {
get { cache["emailSmtpHost"] }
set {
if let value = newValue, !value.isEmpty {
cache["emailSmtpHost"] = value
DatabaseService.shared.setSetting(key: "emailSmtpHost", value: value)
} else {
cache.removeValue(forKey: "emailSmtpHost")
DatabaseService.shared.deleteSetting(key: "emailSmtpHost")
}
}
}
var emailUsername: String? {
get { try? DatabaseService.shared.getEncryptedSetting(key: "emailUsername") }
set {
if let value = newValue, !value.isEmpty {
try? DatabaseService.shared.setEncryptedSetting(key: "emailUsername", value: value)
} else {
DatabaseService.shared.deleteEncryptedSetting(key: "emailUsername")
}
}
}
var emailPassword: String? {
get { try? DatabaseService.shared.getEncryptedSetting(key: "emailPassword") }
set {
if let value = newValue, !value.isEmpty {
try? DatabaseService.shared.setEncryptedSetting(key: "emailPassword", value: value)
} else {
DatabaseService.shared.deleteEncryptedSetting(key: "emailPassword")
}
}
}
var emailImapPort: Int {
get { cache["emailImapPort"].flatMap(Int.init) ?? 993 }
set {
cache["emailImapPort"] = String(newValue)
DatabaseService.shared.setSetting(key: "emailImapPort", value: String(newValue))
}
}
var emailSmtpPort: Int {
get { cache["emailSmtpPort"].flatMap(Int.init) ?? 587 }
set {
cache["emailSmtpPort"] = String(newValue)
DatabaseService.shared.setSetting(key: "emailSmtpPort", value: String(newValue))
}
}
var emailServerConfigured: Bool {
guard let imapHost = emailImapHost, !imapHost.isEmpty else { return false }
guard let smtpHost = emailSmtpHost, !smtpHost.isEmpty else { return false }
guard let username = emailUsername, !username.isEmpty else { return false }
guard let password = emailPassword, !password.isEmpty else { return false }
return true
}
// MARK: - UserDefaults Migration
private func migrateFromUserDefaultsIfNeeded() {
@@ -367,7 +742,47 @@ class SettingsService {
DatabaseService.shared.setSetting(key: "_migrated", value: "true")
}
// MARK: - Keychain Helpers
// MARK: - Keychain Migration
private func migrateFromKeychainIfNeeded() {
// Skip if already migrated
guard cache["_keychain_migrated"] == nil else { return }
Log.settings.info("Migrating API keys from Keychain to encrypted database...")
let keysToMigrate: [(keychainKey: String, encryptedKey: String)] = [
(KeychainKeys.openrouterAPIKey, EncryptedKeys.openrouterAPIKey),
(KeychainKeys.anthropicAPIKey, EncryptedKeys.anthropicAPIKey),
(KeychainKeys.openaiAPIKey, EncryptedKeys.openaiAPIKey),
(KeychainKeys.googleAPIKey, EncryptedKeys.googleAPIKey),
(KeychainKeys.googleSearchEngineID, EncryptedKeys.googleSearchEngineID),
]
var migratedCount = 0
for (keychainKey, encryptedKey) in keysToMigrate {
// Read from keychain
if let value = getKeychainValue(for: keychainKey), !value.isEmpty {
// Write to encrypted database
do {
try DatabaseService.shared.setEncryptedSetting(key: encryptedKey, value: value)
// Delete from keychain after successful migration
deleteKeychainValue(for: keychainKey)
migratedCount += 1
Log.settings.info("Migrated \(encryptedKey) from Keychain to encrypted database")
} catch {
Log.settings.error("Failed to migrate \(encryptedKey): \(error.localizedDescription)")
}
}
}
Log.settings.info("Keychain migration complete: \(migratedCount) keys migrated")
// Mark migration complete
cache["_keychain_migrated"] = "true"
DatabaseService.shared.setSetting(key: "_keychain_migrated", value: "true")
}
// MARK: - Keychain Helpers (for migration only)
private func getKeychainValue(for key: String) -> String? {
let query: [String: Any] = [

View File

@@ -0,0 +1,131 @@
//
// ThinkingVerbs.swift
// oAI
//
// Fun random verbs for AI thinking/processing states
//
import Foundation
struct ThinkingVerbs {
/// Get a random thinking verb with ellipsis
static func random() -> String {
verbs.randomElement()! + "..."
}
/// Collection of fun thinking verbs and phrases
private static let verbs = [
// Classic thinking
"Thinking",
"Pondering",
"Contemplating",
"Deliberating",
"Musing",
"Reflecting",
"Meditating",
// Fancy/sophisticated
"Cogitating",
"Ruminating",
"Cerebrating",
"Ratiocinating",
"Percolating",
// Technical/AI themed
"Computing",
"Processing",
"Analyzing",
"Synthesizing",
"Calculating",
"Inferring",
"Deducing",
"Compiling thoughts",
"Running algorithms",
"Crunching data",
"Parsing neurons",
// Creative/playful
"Daydreaming",
"Brainstorming",
"Mind-melding",
"Noodling",
"Brewing ideas",
"Cooking up thoughts",
"Marinating",
"Percolating wisdom",
"Spinning neurons",
"Warming up the ol' noggin",
// Mystical/fun
"Channeling wisdom",
"Consulting the oracle",
"Reading the tea leaves",
"Summoning knowledge",
"Conjuring responses",
"Casting neural nets",
"Divining answers",
"Communing with silicon",
// Quirky/silly
"Doing the thing",
"Making magic",
"Activating brain cells",
"Flexing neurons",
"Warming up transistors",
"Revving up synapses",
"Tickling the cortex",
"Waking up the hamsters",
"Consulting the void",
"Asking the magic 8-ball",
// Self-aware/meta
"Pretending to think",
"Looking busy",
"Stalling for time",
"Counting sheep",
"Twiddling thumbs",
"Organizing thoughts",
"Finding the right words",
// Speed variations
"Thinking really hard",
"Quick-thinking",
"Deep thinking",
"Speed-thinking",
"Hyper-thinking",
// Action-oriented
"Crafting",
"Weaving words",
"Assembling thoughts",
"Constructing responses",
"Formulating ideas",
"Orchestrating neurons",
"Choreographing bits",
// Whimsical
"Having an epiphany",
"Connecting the dots",
"Following the thread",
"Chasing thoughts",
"Herding ideas",
"Untangling neurons",
// Time-based
"Taking a moment",
"Pausing thoughtfully",
"Taking a beat",
"Gathering thoughts",
"Catching my breath",
"Taking five",
// Just plain weird
"Beep boop computing",
"Engaging brain mode",
"Activating smartness",
"Downloading thoughts",
"Buffering intelligence",
"Loading brilliance",
"Unfurling wisdom"
]
}