Преглед на файлове

Harden OpenAI networking and embed an obfuscated API key in code.

Add retry-capable network client and shared OpenAI service to fix first-request connection failures, while storing the API key XOR-encoded for builds without Secrets.plist.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 преди 1 месец
родител
ревизия
6c10edda9f

+ 3 - 0
gramora/App/GramoraApp.swift

@@ -12,6 +12,9 @@ struct GramoraApp: App {
                 .initialWindowSize(width: AppTheme.windowWidth, height: AppTheme.windowHeight)
                 .configureWindowChrome()
                 .preferredColorScheme(.light)
+                .task {
+                    await NetworkClient.prepare()
+                }
         }
         .windowStyle(.hiddenTitleBar)
     }

+ 1 - 1
gramora/Models/DictionaryModels.swift

@@ -32,7 +32,7 @@ enum DictionaryServiceError: LocalizedError {
     var errorDescription: String? {
         switch self {
         case .missingAPIKey:
-            "OpenAI API key is missing. Add it to Secrets.plist."
+            "OpenAI API key is missing."
         case .emptyInput:
             "Enter a word to look up."
         case .wordNotFound:

+ 1 - 1
gramora/Models/GrammarCheckModels.swift

@@ -21,7 +21,7 @@ enum GrammarCheckServiceError: LocalizedError {
     var errorDescription: String? {
         switch self {
         case .missingAPIKey:
-            "OpenAI API key is missing. Add it to Secrets.plist."
+            "OpenAI API key is missing."
         case .emptyInput:
             "Enter some text to check."
         case .invalidResponse:

+ 4 - 2
gramora/Services/DictionaryService.swift

@@ -7,7 +7,7 @@ protocol DictionaryServicing {
 struct DictionaryService: DictionaryServicing {
     private let openAI: OpenAIChatServicing
 
-    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+    init(openAI: OpenAIChatServicing = OpenAIChatService.shared) {
         self.openAI = openAI
     }
 
@@ -67,6 +67,8 @@ struct DictionaryService: DictionaryServicing {
             throw DictionaryServiceError.fromOpenAI(error)
         } catch let error as DictionaryServiceError {
             throw error
+        } catch let error as URLError {
+            throw DictionaryServiceError.networkError(error.localizedDescription)
         } catch {
             throw DictionaryServiceError.invalidResponse
         }
@@ -98,7 +100,7 @@ private extension DictionaryServiceError {
             .missingAPIKey
         case .invalidResponse:
             .invalidResponse
-        case .apiError(let message):
+        case .networkError(let message), .apiError(let message):
             .networkError(message)
         }
     }

+ 1 - 1
gramora/Services/EmailWriterService.swift

@@ -12,7 +12,7 @@ protocol EmailWriterServicing {
 struct EmailWriterService: EmailWriterServicing {
     private let openAI: OpenAIChatServicing
 
-    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+    init(openAI: OpenAIChatServicing = OpenAIChatService.shared) {
         self.openAI = openAI
     }
 

+ 2 - 2
gramora/Services/GrammarCheckService.swift

@@ -7,7 +7,7 @@ protocol GrammarCheckServicing {
 struct GrammarCheckService: GrammarCheckServicing {
     private let openAI: OpenAIChatServicing
 
-    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+    init(openAI: OpenAIChatServicing = OpenAIChatService.shared) {
         self.openAI = openAI
     }
 
@@ -68,7 +68,7 @@ private extension GrammarCheckServiceError {
             .missingAPIKey
         case .invalidResponse:
             .invalidResponse
-        case .apiError(let message):
+        case .networkError(let message), .apiError(let message):
             .apiError(message)
         }
     }

+ 2 - 2
gramora/Services/JournalFinderService.swift

@@ -7,7 +7,7 @@ protocol JournalFinderServicing {
 struct JournalFinderService: JournalFinderServicing {
     private let openAI: OpenAIChatServicing
 
-    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+    init(openAI: OpenAIChatServicing = OpenAIChatService.shared) {
         self.openAI = openAI
     }
 
@@ -130,7 +130,7 @@ private extension JournalFinderServiceError {
             .missingAPIKey
         case .invalidResponse:
             .invalidResponse
-        case .apiError(let message):
+        case .networkError(let message), .apiError(let message):
             .apiError(message)
         }
     }

+ 35 - 5
gramora/Services/OpenAIService.swift

@@ -3,14 +3,17 @@ import Foundation
 enum OpenAIServiceError: LocalizedError {
     case missingAPIKey
     case invalidResponse
+    case networkError(String)
     case apiError(String)
 
     var errorDescription: String? {
         switch self {
         case .missingAPIKey:
-            "OpenAI API key is missing. Add it to Secrets.plist."
+            "OpenAI API key is missing."
         case .invalidResponse:
             "Could not read the response from OpenAI."
+        case .networkError(let message):
+            message
         case .apiError(let message):
             message
         }
@@ -27,16 +30,15 @@ protocol OpenAIChatServicing {
 }
 
 struct OpenAIChatService: OpenAIChatServicing {
-    private let session: URLSession
+    static let shared = OpenAIChatService()
+
     private let apiKeyProvider: () -> String?
     private let model: String
 
     init(
-        session: URLSession = .shared,
         apiKeyProvider: @escaping () -> String? = { APIConfiguration.openAIAPIKey },
         model: String = "gpt-4o-mini"
     ) {
-        self.session = session
         self.apiKeyProvider = apiKeyProvider
         self.model = model
     }
@@ -68,6 +70,7 @@ struct OpenAIChatService: OpenAIChatServicing {
 
         var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
         request.httpMethod = "POST"
+        request.timeoutInterval = 60
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
 
@@ -82,7 +85,14 @@ struct OpenAIChatService: OpenAIChatServicing {
 
         request.httpBody = try JSONEncoder().encode(body)
 
-        let (data, response) = try await session.data(for: request)
+        let data: Data
+        let response: URLResponse
+
+        do {
+            (data, response) = try await NetworkClient.data(for: request)
+        } catch let error as URLError {
+            throw OpenAIServiceError.networkError(Self.userFacingMessage(for: error))
+        }
 
         if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
             let message = Self.errorMessage(from: data) ?? "Request failed with status \(httpResponse.statusCode)."
@@ -97,6 +107,19 @@ struct OpenAIChatService: OpenAIChatServicing {
         return content
     }
 
+    private static func userFacingMessage(for error: URLError) -> String {
+        switch error.code {
+        case .notConnectedToInternet:
+            "You appear to be offline. Check your internet connection and try again."
+        case .timedOut:
+            "The request timed out. Please try again."
+        case .networkConnectionLost:
+            "The connection was interrupted. Please try again."
+        default:
+            "A network error occurred. Please try again."
+        }
+    }
+
     private static func errorMessage(from data: Data) -> String? {
         struct APIErrorResponse: Decodable {
             struct APIError: Decodable {
@@ -129,6 +152,13 @@ private struct ChatCompletionRequest: Encodable {
         case messages
         case responseFormat = "response_format"
     }
+
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+        try container.encode(model, forKey: .model)
+        try container.encode(messages, forKey: .messages)
+        try container.encodeIfPresent(responseFormat, forKey: .responseFormat)
+    }
 }
 
 private struct ChatCompletionResponse: Decodable {

+ 1 - 1
gramora/Services/ParaphrasingService.swift

@@ -7,7 +7,7 @@ protocol ParaphrasingServicing {
 struct ParaphrasingService: ParaphrasingServicing {
     private let openAI: OpenAIChatServicing
 
-    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+    init(openAI: OpenAIChatServicing = OpenAIChatService.shared) {
         self.openAI = openAI
     }
 

+ 1 - 1
gramora/Services/PunctuationCheckService.swift

@@ -7,7 +7,7 @@ protocol PunctuationCheckServicing {
 struct PunctuationCheckService: PunctuationCheckServicing {
     private let openAI: OpenAIChatServicing
 
-    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+    init(openAI: OpenAIChatServicing = OpenAIChatService.shared) {
         self.openAI = openAI
     }
 

+ 1 - 1
gramora/Services/SpellCheckService.swift

@@ -12,7 +12,7 @@ protocol SpellCheckServicing {
 struct SpellCheckService: SpellCheckServicing {
     private let openAI: OpenAIChatServicing
 
-    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+    init(openAI: OpenAIChatServicing = OpenAIChatService.shared) {
         self.openAI = openAI
     }
 

+ 1 - 1
gramora/Services/SummarizerService.swift

@@ -7,7 +7,7 @@ protocol SummarizerServicing {
 struct SummarizerService: SummarizerServicing {
     private let openAI: OpenAIChatServicing
 
-    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+    init(openAI: OpenAIChatServicing = OpenAIChatService.shared) {
         self.openAI = openAI
     }
 

+ 1 - 1
gramora/Services/TranslationService.swift

@@ -11,7 +11,7 @@ protocol TranslationServicing {
 struct TranslationService: TranslationServicing {
     private let openAI: OpenAIChatServicing
 
-    init(openAI: OpenAIChatServicing = OpenAIChatService()) {
+    init(openAI: OpenAIChatServicing = OpenAIChatService.shared) {
         self.openAI = openAI
     }
 

+ 15 - 3
gramora/Utilities/APIConfiguration.swift

@@ -2,15 +2,27 @@ import Foundation
 
 enum APIConfiguration {
     static var openAIAPIKey: String? {
+        if let plistKey = secretsPlistKey {
+            return plistKey
+        }
+
+        return APIKeyObfuscator.openAIAPIKey()
+    }
+
+    private static var secretsPlistKey: String? {
         guard
             let path = Bundle.main.path(forResource: "Secrets", ofType: "plist"),
             let dictionary = NSDictionary(contentsOfFile: path),
-            let key = dictionary["OPENAI_API_KEY"] as? String,
-            !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+            let key = dictionary["OPENAI_API_KEY"] as? String
         else {
             return nil
         }
 
-        return key
+        let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty, trimmed != "your-openai-api-key-here" else {
+            return nil
+        }
+
+        return trimmed
     }
 }

+ 32 - 0
gramora/Utilities/APIKeyObfuscator.swift

@@ -0,0 +1,32 @@
+import Foundation
+
+enum APIKeyObfuscator {
+    private static let xorSeed: [UInt8] = Array("GramoraOpenAIKey.v1".utf8)
+
+    private static let obfuscatedOpenAIKey: [UInt8] = [
+        0x34, 0x19, 0x4C, 0x1E, 0x19, 0x11, 0x00, 0x2C, 0x13, 0x11, 0x43, 0x14, 0x7F, 0x0F, 0x10, 0x54,
+        0x67, 0x4E, 0x40, 0x7E, 0x24, 0x1B, 0x1F, 0x28, 0x4B, 0x03, 0x26, 0x28, 0x36, 0x03, 0x07, 0x04,
+        0x33, 0x32, 0x3C, 0x78, 0x38, 0x69, 0x3E, 0x2A, 0x08, 0x03, 0x20, 0x01, 0x2B, 0x17, 0x5D, 0x17,
+        0x5A, 0x0E, 0x19, 0x26, 0x3A, 0x4F, 0x58, 0x1F, 0x00, 0x29, 0x20, 0x00, 0x25, 0x42, 0x28, 0x04,
+        0x25, 0x03, 0x0B, 0x0C, 0x20, 0x0F, 0x20, 0x2E, 0x12, 0x7F, 0x26, 0x7D, 0x2B, 0x3A, 0x38, 0x0A,
+        0x02, 0x45, 0x30, 0x16, 0x3A, 0x31, 0x5D, 0x03, 0x25, 0x29, 0x0E, 0x3F, 0x64, 0x41, 0x79, 0x23,
+        0x07, 0x11, 0x21, 0x2E, 0x47, 0x59, 0x76, 0x1A, 0x2C, 0x56, 0x05, 0x0B, 0x3E, 0x04, 0x23, 0x54,
+        0x18, 0x01, 0x00, 0x1E, 0x3E, 0x08, 0x2C, 0x1A, 0x19, 0x05, 0x22, 0x01, 0x2A, 0x70, 0x7A, 0x31,
+        0x48, 0x41, 0x4C, 0x19, 0x65, 0x12, 0x34, 0x17, 0x5A, 0x2A, 0x2B, 0x02, 0x2A, 0x25, 0x0E, 0x5F,
+        0x1E, 0x7C, 0x2E, 0x3A, 0x13, 0x78, 0x17, 0x5A, 0x6A, 0x3C, 0x23, 0x5D, 0x3C, 0x34, 0x18, 0x22,
+        0x38, 0x55, 0x20, 0x10, 0x01, 0x7B, 0x24
+    ]
+
+    static func openAIAPIKey() -> String? {
+        guard !xorSeed.isEmpty else { return nil }
+
+        let decoded = obfuscatedOpenAIKey.enumerated().map { index, byte in
+            byte ^ xorSeed[index % xorSeed.count]
+        }
+
+        guard let key = String(bytes: decoded, encoding: .utf8) else { return nil }
+
+        let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
+        return trimmed.isEmpty ? nil : trimmed
+    }
+}

+ 65 - 0
gramora/Utilities/NetworkClient.swift

@@ -0,0 +1,65 @@
+import Foundation
+
+enum NetworkClient {
+    static let session: URLSession = {
+        let configuration = URLSessionConfiguration.default
+        configuration.waitsForConnectivity = true
+        configuration.timeoutIntervalForRequest = 60
+        configuration.timeoutIntervalForResource = 120
+        configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
+        return URLSession(configuration: configuration)
+    }()
+
+    static func data(for request: URLRequest, retries: Int = 2) async throws -> (Data, URLResponse) {
+        var lastError: Error?
+
+        for attempt in 0...retries {
+            if attempt > 0 {
+                try await Task.sleep(nanoseconds: UInt64(attempt) * 400_000_000)
+            }
+
+            do {
+                return try await session.data(for: request)
+            } catch let error as URLError where error.isTransientNetworkFailure {
+                lastError = error
+            } catch {
+                throw error
+            }
+        }
+
+        throw lastError ?? URLError(.unknown)
+    }
+
+    static func data(from url: URL, retries: Int = 2) async throws -> (Data, URLResponse) {
+        var request = URLRequest(url: url)
+        request.httpMethod = "GET"
+        return try await data(for: request, retries: retries)
+    }
+
+    static func prepare() async {
+        guard let url = URL(string: "https://api.openai.com") else { return }
+
+        var request = URLRequest(url: url)
+        request.httpMethod = "HEAD"
+        request.timeoutInterval = 15
+        _ = try? await session.data(for: request)
+    }
+}
+
+private extension URLError {
+    var isTransientNetworkFailure: Bool {
+        switch code {
+        case .networkConnectionLost,
+             .notConnectedToInternet,
+             .timedOut,
+             .cannotConnectToHost,
+             .dnsLookupFailed,
+             .cannotFindHost,
+             .internationalRoamingOff,
+             .callIsActive:
+            return true
+        default:
+            return false
+        }
+    }
+}

+ 1 - 1
gramora/ViewModels/DictionaryViewModel.swift

@@ -119,7 +119,7 @@ final class DictionaryViewModel: ObservableObject {
 
     private func playRemoteAudio(from url: URL) async {
         do {
-            let (data, _) = try await URLSession.shared.data(from: url)
+            let (data, _) = try await NetworkClient.data(from: url)
             audioPlayer = try AVAudioPlayer(data: data)
             audioPlayer?.prepareToPlay()
             audioPlayer?.play()