69595f0fb27da1ae5eced5aeb5635d1849b5833e

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Add iOS client scaffolding: shared server config, keychain, settings store, API client

Diff

This diff is truncated to protect this page.

  1diff --git a/Package.swift b/Package.swift
  2index 69d43f1e9bb7a13107b8d22e13420783f018b488..1facf09daa3b7e5d20f1163c0954356d710a4d27 100644
  3--- a/Package.swift
  4+++ b/Package.swift
  5@@ -18,7 +18,11 @@ let package = Package(
  6     ],
  7     targets: [
  8         .target(
  9-            name: "japanese"
 10+            name: "japaneseShared"
 11+        ),
 12+        .target(
 13+            name: "japanese",
 14+            dependencies: ["japaneseShared"]
 15         ),
 16     ]
 17 )
 18diff --git a/Sources/japanese/APIClient.swift b/Sources/japanese/APIClient.swift
 19new file mode 100644
 20index 0000000000000000000000000000000000000000..c4b81f80adf6ba734a7ca917517d170dbf3d3968
 21--- /dev/null
 22+++ b/Sources/japanese/APIClient.swift
 23@@ -0,0 +1,163 @@
 24+import Foundation
 25+import japaneseShared
 26+
 27+struct LogEntry: Decodable, Sendable {
 28+    let action: String?
 29+    let desc: String?
 30+    let romaji: String?
 31+    let english: String?
 32+    let hasSpeech: Bool
 33+    let judge: Judge?
 34+    let ask: Ask?
 35+}
 36+
 37+struct Judge: Decodable, Sendable {
 38+    let score: Int
 39+    let romaji: String?
 40+    let feedback: String?
 41+}
 42+
 43+struct Ask: Decodable, Sendable {
 44+    let romaji: String?
 45+    let translation: String?
 46+    let breakdown: String?
 47+}
 48+
 49+struct TurnResponse: Decodable, Sendable {
 50+    let action: String?
 51+    let desc: String?
 52+    let romaji: String?
 53+    let english: String?
 54+    let hasSpeech: Bool
 55+    let judge: Judge?
 56+    let ask: Ask?
 57+    let location: String?
 58+    let talk: String?
 59+    let audio: String?
 60+    let speakError: String?
 61+    let judgeError: String?
 62+}
 63+
 64+private struct CreateSessionResponse: Decodable, Sendable {
 65+    let id: String
 66+}
 67+
 68+private struct FlashcardResponse: Decodable, Sendable {
 69+    let added: Int
 70+}
 71+
 72+private struct ActionRequest: Encodable {
 73+    let action: String
 74+}
 75+
 76+private struct ErrorResponse: Decodable, Sendable {
 77+    let error: String
 78+}
 79+
 80+// The server caps a single turn at three minutes; leave headroom under it.
 81+private let turnTimeout: TimeInterval = 200
 82+
 83+struct APIClient: Sendable {
 84+    func createSession(using configuration: ServerConfiguration) async throws -> String {
 85+        let response: CreateSessionResponse = try await request(
 86+            endpoint: configuration.endpoint(path: "v1/sessions"),
 87+            method: "POST",
 88+            configuration: configuration
 89+        )
 90+        return response.id
 91+    }
 92+
 93+    func fetchLog(sessionID: String, using configuration: ServerConfiguration) async throws -> [LogEntry] {
 94+        try await request(
 95+            endpoint: configuration.endpoint(path: "v1/sessions/\(sessionID)/log"),
 96+            method: "GET",
 97+            configuration: configuration
 98+        )
 99+    }
100+
101+    func performAction(sessionID: String, action: String, using configuration: ServerConfiguration) async throws -> TurnResponse {
102+        try await request(
103+            endpoint: configuration.endpoint(path: "v1/sessions/\(sessionID)/actions"),
104+            method: "POST",
105+            body: try JSONEncoder().encode(ActionRequest(action: action)),
106+            contentType: "application/json",
107+            timeout: turnTimeout,
108+            configuration: configuration
109+        )
110+    }
111+
112+    func addFlashcards(sessionID: String, instructions: String, using configuration: ServerConfiguration) async throws -> Int {
113+        let response: FlashcardResponse = try await request(
114+            endpoint: configuration.endpoint(path: "v1/sessions/\(sessionID)/actions"),
115+            method: "POST",
116+            body: try JSONEncoder().encode(ActionRequest(action: "!\(instructions)")),
117+            contentType: "application/json",
118+            timeout: turnTimeout,
119+            configuration: configuration
120+        )
121+        return response.added
122+    }
123diff --git a/Sources/japanese/KeychainStore.swift b/Sources/japanese/KeychainStore.swift
124new file mode 100644
125index 0000000000000000000000000000000000000000..c7f381caedff38abdbb1acad17e0089bd628352f
126--- /dev/null
127+++ b/Sources/japanese/KeychainStore.swift
128@@ -0,0 +1,60 @@
129+import Foundation
130+import Security
131+
132+enum KeychainStore {
133+    static func load(account: String) throws -> String? {
134+        let query = baseQuery(account: account).merging([
135+            kSecReturnData as String: true,
136+            kSecMatchLimit as String: kSecMatchLimitOne,
137+        ]) { _, new in new }
138+
139+        var result: CFTypeRef?
140+        let status = SecItemCopyMatching(query as CFDictionary, &result)
141+
142+        if status == errSecItemNotFound {
143+            return nil
144+        }
145+
146+        guard status == errSecSuccess,
147+              let data = result as? Data,
148+              let value = String(data: data, encoding: .utf8) else {
149+            throw KeychainError.unavailable(status)
150+        }
151+
152+        return value
153+    }
154+
155+    static func save(_ value: String, account: String) throws {
156+        let query = baseQuery(account: account)
157+        let attributes = [kSecValueData as String: Data(value.utf8)]
158+        let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
159+
160+        if updateStatus == errSecItemNotFound {
161+            let addStatus = SecItemAdd(query.merging(attributes) { _, new in new } as CFDictionary, nil)
162+            guard addStatus == errSecSuccess else {
163+                throw KeychainError.unavailable(addStatus)
164+            }
165+            return
166+        }
167+
168+        guard updateStatus == errSecSuccess else {
169+            throw KeychainError.unavailable(updateStatus)
170+        }
171+    }
172+
173+    private static func baseQuery(account: String) -> [String: Any] {
174+        [
175+            kSecClass as String: kSecClassGenericPassword,
176+            kSecAttrService as String: "com.example.japanese",
177+            kSecAttrAccount as String: account,
178+        ]
179+    }
180+}
181+
182+enum KeychainError: LocalizedError {
183+    case unavailable(OSStatus)
184+
185+    var errorDescription: String? {
186+        "The API token could not be stored securely."
187+    }
188+}
189diff --git a/Sources/japanese/SettingsStore.swift b/Sources/japanese/SettingsStore.swift
190new file mode 100644
191index 0000000000000000000000000000000000000000..ca4a47680ca838ddf644a2f0e9a23245b5679a82
192--- /dev/null
193+++ b/Sources/japanese/SettingsStore.swift
194@@ -0,0 +1,34 @@
195+import Foundation
196+import Observation
197+import japaneseShared
198+
199+@MainActor
200+@Observable
201+final class SettingsStore {
202+    private static let serverURLKey = "serverURL"
203+    private static let tokenAccount = "apiToken"
204+
205+    private let defaults: UserDefaults
206+    private(set) var serverURL: String
207+    private(set) var token: String
208+
209+    init(defaults: UserDefaults = .standard) {
210+        self.defaults = defaults
211+        serverURL = defaults.string(forKey: Self.serverURLKey) ?? ""
212+        token = (try? KeychainStore.load(account: Self.tokenAccount)) ?? ""
213+    }
214+
215+    var configuration: ServerConfiguration? {
216+        try? ServerConfiguration(serverURL: serverURL, token: token)
217+    }
218+
219+    func save(serverURL: String, token: String) throws {
220+        let configuration = try ServerConfiguration(serverURL: serverURL, token: token)
221+
222+        try KeychainStore.save(configuration.token, account: Self.tokenAccount)
223+        defaults.set(configuration.baseURL.absoluteString, forKey: Self.serverURLKey)
224+
225+        self.serverURL = configuration.baseURL.absoluteString
226+        self.token = configuration.token
227+    }
228+}
229diff --git a/Sources/japaneseShared/ServerConfiguration.swift b/Sources/japaneseShared/ServerConfiguration.swift
230new file mode 100644
231index 0000000000000000000000000000000000000000..f6a003fd4ba07123a951bf88f064766b36fee44a
232--- /dev/null
233+++ b/Sources/japaneseShared/ServerConfiguration.swift
234@@ -0,0 +1,46 @@
235+import Foundation
236+
237+public struct ServerConfiguration: Sendable {
238+    public let baseURL: URL
239+    public let token: String
240+
241+    public init(serverURL: String, token: String) throws {
242+        let trimmedServerURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
243+
244+        guard !trimmedServerURL.isEmpty,
245+              let url = URL(string: trimmedServerURL),
246+              url.scheme?.lowercased() == "https",
247+              url.host != nil,
248+              url.user == nil,
249+              url.password == nil,
250+              url.query == nil,
251+              url.fragment == nil else {
252+            throw ConfigurationError.invalidServerURL
253+        }
254+
255+        guard !token.isEmpty else {
256+            throw ConfigurationError.missingToken
257+        }
258+
259+        baseURL = url
260+        self.token = token
261+    }
262+
263+    public func endpoint(path: String) -> URL {
264+        baseURL.appendingPathComponent(path)
265+    }
266+}
267+
268+public enum ConfigurationError: LocalizedError {
269+    case invalidServerURL
270+    case missingToken
271+
272+    public var errorDescription: String? {
273+        switch self {
274+        case .invalidServerURL:
275+            "Enter a valid HTTPS server URL."
276+        case .missingToken:
277+            "Enter the API token."
278+        }
279+    }
280+}