69595f0fb27da1ae5eced5aeb5635d1849b5833e
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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+}