From 10acf7568d2fea63ab617d51263acd8f3aef59c4 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 3 Oct 2026 19:15:54 -0400 Subject: [PATCH 1/2] Apple shell: carry every server request through one HTTP adapter The DTOs mirror the core's new wire: one serverRequest effect in place of the six request effects, one serverResponse command in place of the seven settles, newDeviceId on deleteListeningData / deleteAccount (a fresh UUID made when the user asks) and the optional platform on requestSignInLink. ServerCarrier replaces the typed SyncApi client and its response types: it sends any request to the sync-server base URL as described and returns the serverResponse that settles it, status 0 with no sync server or on a network error, timeout or cancellation. AppStore carries every request, the sign-out revoke included, and dispatches the settle on the main actor. Co-Authored-By: Claude Opus 5.5 --- apps/apple/CascadeShared/App/AppStore.swift | 84 ++--------- apps/apple/CascadeShared/App/Dto.swift | 134 ++++++++---------- .../Sync/AccountControlsView.swift | 4 +- .../CascadeShared/Sync/ServerCarrier.swift | 46 ++++++ apps/apple/CascadeShared/Sync/SyncApi.swift | 75 ---------- 5 files changed, 120 insertions(+), 223 deletions(-) create mode 100644 apps/apple/CascadeShared/Sync/ServerCarrier.swift delete mode 100644 apps/apple/CascadeShared/Sync/SyncApi.swift diff --git a/apps/apple/CascadeShared/App/AppStore.swift b/apps/apple/CascadeShared/App/AppStore.swift index f94941d..3e02706 100644 --- a/apps/apple/CascadeShared/App/AppStore.swift +++ b/apps/apple/CascadeShared/App/AppStore.swift @@ -22,7 +22,7 @@ final class AppStore { var syncAvailable: Bool { SyncConfig.available } private let accountStore = AccountStore() - private let syncApi = SyncApi() + private let serverCarrier = ServerCarrier() /// Side-channel observer for non-SwiftUI consumers (the iPhone's /// `PhoneConnectivityService` uses this to push every snapshot down to @@ -119,9 +119,8 @@ final class AppStore { settings.writeListeningSafely(json) case .persistAccount(let json): accountStore.writeAccountJson(json) - case .pushListening, .sendSignInLink, .verifySignInToken, .revokeSession, - .deleteServerListening, .deleteServerAccount: - carry(effect) + case .serverRequest(let request): + carry(request) } } @@ -138,7 +137,7 @@ final class AppStore { // MARK: - Sync - /// Offer the core a listening sync. It answers with a `pushListening` only + /// Offer the core a listening sync. It answers with a listening push only /// if there is something to send and it holds a session; `apply` carries it. func sync(reason: SyncReason) { guard syncAvailable else { return } @@ -151,75 +150,18 @@ final class AppStore { dispatch(.submitSignInLink(input: url.absoluteString)) } - /// Carry one request effect over HTTP and settle it with the core. Every - /// request but `revokeSession` must be settled, or the core won't start - /// another; the settle's own effects come back through `apply`. A request + /// Carry one request over HTTP and settle it with the core. Every request + /// is settled, the sign-out revoke included, or the core won't start + /// another; with no sync server, or on a network error, timeout or + /// cancellation, the settle carries status 0. The settle is dispatched on + /// the main actor, and its own effects come back through `apply`. A request /// can come from any dispatch, including a tick, so carry everything /// `apply` hands over. - private func carry(_ effect: Effect) { - let api = syncApi - let accountFailed = { (unauthorized: Bool) in Command.accountRequestFailed(unauthorized: unauthorized) } + private func carry(_ request: ServerRequest) { + let carrier = serverCarrier Task { @MainActor in - switch effect { - case .pushListening(let deviceId, let deviceTotalMs, let sessionToken): - await settle( - { - let res = try await api.putListening( - token: sessionToken, - deviceId: deviceId, - deviceTotalMs: Int64(clamping: deviceTotalMs)) - return .listeningSyncSucceeded(serverTotalMs: UInt64(max(0, res.serverTotalMs))) - }, - failed: { .listeningSyncFailed(unauthorized: $0) }) - case .sendSignInLink(let email): - await settle( - { - try await api.requestLink(email: email) - return .signInLinkSent - }, - failed: accountFailed) - case .verifySignInToken(let token): - await settle( - { - let res = try await api.verify(token: token) - return .signInVerified(sessionToken: res.sessionToken, email: res.email) - }, - failed: accountFailed) - case .revokeSession(let sessionToken): - // Already gone server-side or offline — local sign-out stands. - try? await api.logout(token: sessionToken) - case .deleteServerListening(let sessionToken): - await settle( - { - try await api.deleteListening(token: sessionToken) - return .listeningDataDeleted(newDeviceId: UUID().uuidString) - }, - failed: accountFailed) - case .deleteServerAccount(let sessionToken): - await settle( - { - try await api.deleteAccount(token: sessionToken) - return .accountDeleted(newDeviceId: UUID().uuidString) - }, - failed: accountFailed) - case .startPlayback, .pausePlayback, .setPlatformVolume, .persistSettings, - .persistListening, .persistAccount: - // Not requests: `apply` handles these itself. - break - } - } - } - - /// Run one request and dispatch the command that settles it; `failed` is - /// told whether the failure was an HTTP 401. - private func settle(_ request: () async throws -> Command, failed: (Bool) -> Command) async { - let outcome: Command - do { - outcome = try await request() - } catch { - outcome = failed((error as? SyncError)?.status == 401) + dispatch(await carrier.send(request)) } - dispatch(outcome) } // MARK: - Tick loop @@ -240,7 +182,7 @@ final class AppStore { let elapsedMs = UInt64(now.timeIntervalSince(last) * 1000) last = now // A tick that brings unsynced listening to the threshold - // comes back with a `pushListening`; `apply` carries it. + // comes back with a listening push; `apply` carries it. self.dispatch(.tick(elapsedMs: elapsedMs)) } } diff --git a/apps/apple/CascadeShared/App/Dto.swift b/apps/apple/CascadeShared/App/Dto.swift index e30a255..967fb08 100644 --- a/apps/apple/CascadeShared/App/Dto.swift +++ b/apps/apple/CascadeShared/App/Dto.swift @@ -23,26 +23,25 @@ enum Command: Codable { /// Always sent, so the core holds the account. case restore(settingsJson: String, listeningJson: String, fallbackDeviceId: String, accountJson: String) case beginListeningSync(reason: SyncReason) - case listeningSyncSucceeded(serverTotalMs: UInt64) - case listeningSyncFailed(unauthorized: Bool) case resetListeningData(newDeviceId: String) - case requestSignInLink(email: String) + /// `platform` names the platform whose app the emailed link hands off to; + /// nil leaves it out of the request. + case requestSignInLink(email: String, platform: String? = nil) /// The whole pasted or opened sign-in link, or the bare token. case submitSignInLink(input: String) case signOut - case deleteListeningData - case deleteAccount - case signInLinkSent - case signInVerified(sessionToken: String, email: String) - case listeningDataDeleted(newDeviceId: String) - case accountDeleted(newDeviceId: String) - /// `unauthorized` is true for an HTTP 401. - case accountRequestFailed(unauthorized: Bool) + /// `newDeviceId` is a fresh UUID made when the user asks; the core adopts + /// it only once the server confirms the delete. + case deleteListeningData(newDeviceId: String) + case deleteAccount(newDeviceId: String) + /// Settles the `serverRequest` with the same `id`: the HTTP status (0 when + /// it was not sent or got no response) and the response body verbatim. + case serverResponse(id: UInt64, status: UInt16, body: String) private enum CodingKeys: String, CodingKey { case type, percent, minutes, elapsedMs, message, enabled, settingsJson, listeningJson, fallbackDeviceId, accountJson, reason, - serverTotalMs, unauthorized, newDeviceId, email, input, sessionToken + newDeviceId, email, platform, input, id, status, body } func encode(to encoder: Encoder) throws { @@ -83,38 +82,28 @@ enum Command: Codable { case .beginListeningSync(let reason): try c.encode("beginListeningSync", forKey: .type) try c.encode(reason, forKey: .reason) - case .listeningSyncSucceeded(let serverTotalMs): - try c.encode("listeningSyncSucceeded", forKey: .type) - try c.encode(serverTotalMs, forKey: .serverTotalMs) - case .listeningSyncFailed(let unauthorized): - try c.encode("listeningSyncFailed", forKey: .type) - try c.encode(unauthorized, forKey: .unauthorized) case .resetListeningData(let newDeviceId): try c.encode("resetListeningData", forKey: .type) try c.encode(newDeviceId, forKey: .newDeviceId) - case .requestSignInLink(let email): + case .requestSignInLink(let email, let platform): try c.encode("requestSignInLink", forKey: .type) try c.encode(email, forKey: .email) + try c.encodeIfPresent(platform, forKey: .platform) case .submitSignInLink(let input): try c.encode("submitSignInLink", forKey: .type) try c.encode(input, forKey: .input) case .signOut: try c.encode("signOut", forKey: .type) - case .deleteListeningData: try c.encode("deleteListeningData", forKey: .type) - case .deleteAccount: try c.encode("deleteAccount", forKey: .type) - case .signInLinkSent: try c.encode("signInLinkSent", forKey: .type) - case .signInVerified(let sessionToken, let email): - try c.encode("signInVerified", forKey: .type) - try c.encode(sessionToken, forKey: .sessionToken) - try c.encode(email, forKey: .email) - case .listeningDataDeleted(let newDeviceId): - try c.encode("listeningDataDeleted", forKey: .type) + case .deleteListeningData(let newDeviceId): + try c.encode("deleteListeningData", forKey: .type) try c.encode(newDeviceId, forKey: .newDeviceId) - case .accountDeleted(let newDeviceId): - try c.encode("accountDeleted", forKey: .type) + case .deleteAccount(let newDeviceId): + try c.encode("deleteAccount", forKey: .type) try c.encode(newDeviceId, forKey: .newDeviceId) - case .accountRequestFailed(let unauthorized): - try c.encode("accountRequestFailed", forKey: .type) - try c.encode(unauthorized, forKey: .unauthorized) + case .serverResponse(let id, let status, let body): + try c.encode("serverResponse", forKey: .type) + try c.encode(id, forKey: .id) + try c.encode(status, forKey: .status) + try c.encode(body, forKey: .body) } } @@ -145,30 +134,24 @@ enum Command: Codable { accountJson: try c.decodeIfPresent(String.self, forKey: .accountJson) ?? "") case "beginListeningSync": self = .beginListeningSync(reason: try c.decode(SyncReason.self, forKey: .reason)) - case "listeningSyncSucceeded": - self = .listeningSyncSucceeded(serverTotalMs: try c.decode(UInt64.self, forKey: .serverTotalMs)) - case "listeningSyncFailed": - self = .listeningSyncFailed(unauthorized: try c.decode(Bool.self, forKey: .unauthorized)) case "resetListeningData": self = .resetListeningData(newDeviceId: try c.decode(String.self, forKey: .newDeviceId)) case "requestSignInLink": - self = .requestSignInLink(email: try c.decode(String.self, forKey: .email)) + self = .requestSignInLink( + email: try c.decode(String.self, forKey: .email), + platform: try c.decodeIfPresent(String.self, forKey: .platform)) case "submitSignInLink": self = .submitSignInLink(input: try c.decode(String.self, forKey: .input)) case "signOut": self = .signOut - case "deleteListeningData": self = .deleteListeningData - case "deleteAccount": self = .deleteAccount - case "signInLinkSent": self = .signInLinkSent - case "signInVerified": - self = .signInVerified( - sessionToken: try c.decode(String.self, forKey: .sessionToken), - email: try c.decode(String.self, forKey: .email)) - case "listeningDataDeleted": - self = .listeningDataDeleted(newDeviceId: try c.decode(String.self, forKey: .newDeviceId)) - case "accountDeleted": - self = .accountDeleted(newDeviceId: try c.decode(String.self, forKey: .newDeviceId)) - case "accountRequestFailed": - self = .accountRequestFailed(unauthorized: try c.decode(Bool.self, forKey: .unauthorized)) + case "deleteListeningData": + self = .deleteListeningData(newDeviceId: try c.decode(String.self, forKey: .newDeviceId)) + case "deleteAccount": + self = .deleteAccount(newDeviceId: try c.decode(String.self, forKey: .newDeviceId)) + case "serverResponse": + self = .serverResponse( + id: try c.decode(UInt64.self, forKey: .id), + status: try c.decode(UInt16.self, forKey: .status), + body: try c.decodeIfPresent(String.self, forKey: .body) ?? "") default: throw DecodingError.dataCorruptedError(forKey: .type, in: c, debugDescription: "unknown command type: \(type)") } @@ -194,19 +177,14 @@ enum Effect: Decodable { case setPlatformVolume(gain: Float) case persistSettings(json: String) case persistListening(json: String) - /// `sessionToken` is the bearer token to send. - case pushListening(deviceId: String, deviceTotalMs: UInt64, sessionToken: String) - case sendSignInLink(email: String) - case verifySignInToken(token: String) - /// Fire-and-forget: no settle command. - case revokeSession(sessionToken: String) - case deleteServerListening(sessionToken: String) - case deleteServerAccount(sessionToken: String) + /// One request to carry to the sync server and settle with + /// `.serverResponse`, whichever dispatch produced it. + case serverRequest(ServerRequest) /// Store verbatim; `""` means delete the stored account. case persistAccount(json: String) private enum CodingKeys: String, CodingKey { - case type, gain, json, deviceId, deviceTotalMs, sessionToken, email, token + case type, gain, json } init(from decoder: Decoder) throws { @@ -223,21 +201,8 @@ enum Effect: Decodable { self = .persistSettings(json: try c.decode(String.self, forKey: .json)) case "persistListening": self = .persistListening(json: try c.decode(String.self, forKey: .json)) - case "pushListening": - self = .pushListening( - deviceId: try c.decode(String.self, forKey: .deviceId), - deviceTotalMs: try c.decode(UInt64.self, forKey: .deviceTotalMs), - sessionToken: try c.decode(String.self, forKey: .sessionToken)) - case "sendSignInLink": - self = .sendSignInLink(email: try c.decode(String.self, forKey: .email)) - case "verifySignInToken": - self = .verifySignInToken(token: try c.decode(String.self, forKey: .token)) - case "revokeSession": - self = .revokeSession(sessionToken: try c.decode(String.self, forKey: .sessionToken)) - case "deleteServerListening": - self = .deleteServerListening(sessionToken: try c.decode(String.self, forKey: .sessionToken)) - case "deleteServerAccount": - self = .deleteServerAccount(sessionToken: try c.decode(String.self, forKey: .sessionToken)) + case "serverRequest": + self = .serverRequest(try ServerRequest(from: decoder)) case "persistAccount": self = .persistAccount(json: try c.decode(String.self, forKey: .json)) default: @@ -246,6 +211,25 @@ enum Effect: Decodable { } } +/// The HTTP verbs the core's request table uses, sent uppercase. +enum HttpMethod: String, Decodable { + case post = "POST" + case put = "PUT" + case delete = "DELETE" +} + +/// One request to the sync server, exactly as the core describes it: `method` +/// to `path` (relative to the sync-server base URL), with a bearer token and +/// a JSON `body` when there are any. The core owns what each one means. +struct ServerRequest: Decodable { + let id: UInt64 + let method: HttpMethod + let path: String + let bearerToken: String? + /// Sent verbatim as the JSON request body. + let body: String? +} + enum TimerKind: String, Codable { case off, sleep, pomodoro, stopwatch, justCompleted } diff --git a/apps/apple/CascadeShared/Sync/AccountControlsView.swift b/apps/apple/CascadeShared/Sync/AccountControlsView.swift index 1ed3fd0..82ca1d0 100644 --- a/apps/apple/CascadeShared/Sync/AccountControlsView.swift +++ b/apps/apple/CascadeShared/Sync/AccountControlsView.swift @@ -21,9 +21,9 @@ struct AccountControlsView: View { Text(signedInLabel).font(.callout) HStack(spacing: 12) { Button("Sign out") { store.dispatch(.signOut) } - Button("Delete data") { store.dispatch(.deleteListeningData) } + Button("Delete data") { store.dispatch(.deleteListeningData(newDeviceId: UUID().uuidString)) } .disabled(account.busy) - Button("Delete account", role: .destructive) { store.dispatch(.deleteAccount) } + Button("Delete account", role: .destructive) { store.dispatch(.deleteAccount(newDeviceId: UUID().uuidString)) } .disabled(account.busy) } .buttonStyle(.borderless) diff --git a/apps/apple/CascadeShared/Sync/ServerCarrier.swift b/apps/apple/CascadeShared/Sync/ServerCarrier.swift new file mode 100644 index 0000000..4095739 --- /dev/null +++ b/apps/apple/CascadeShared/Sync/ServerCarrier.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Base URL of cascade-sync-server. Empty disables the sync feature. +enum SyncConfig { + static let apiBase = "https://sync.cascade.stephens.page" + static var available: Bool { !apiBase.isEmpty } +} + +/// Carries the core's `serverRequest` effects to the sync server (URLSession). +/// It decides nothing per request: it sends what it is given and answers with +/// the `serverResponse` that settles it, holding the status and body verbatim. +/// The core reads them. +struct ServerCarrier { + private let session = URLSession.shared + + /// Send one request and return its settle. Status 0 when it was not sent + /// or got no response: no sync server configured, a network error, a + /// timeout or a cancelled task. + func send(_ request: ServerRequest) async -> Command { + let unsent = Command.serverResponse(id: request.id, status: 0, body: "") + guard SyncConfig.available, let url = URL(string: SyncConfig.apiBase + request.path) else { + return unsent + } + var req = URLRequest(url: url) + req.httpMethod = request.method.rawValue + req.timeoutInterval = 15 + if let token = request.bearerToken { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + if let body = request.body { + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = Data(body.utf8) + } + + do { + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { return unsent } + return .serverResponse( + id: request.id, + status: UInt16(clamping: http.statusCode), + body: String(decoding: data, as: UTF8.self)) + } catch { + return unsent + } + } +} diff --git a/apps/apple/CascadeShared/Sync/SyncApi.swift b/apps/apple/CascadeShared/Sync/SyncApi.swift deleted file mode 100644 index d118796..0000000 --- a/apps/apple/CascadeShared/Sync/SyncApi.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation - -/// Base URL of cascade-sync-server. Empty disables the sync feature. -enum SyncConfig { - static let apiBase = "https://sync.cascade.stephens.page" - static var available: Bool { !apiBase.isEmpty } -} - -struct SyncError: Error { - let status: Int - let message: String -} - -struct VerifyResponse: Decodable { - let sessionToken: String - let email: String -} - -struct ListeningResponse: Decodable { - let serverTotalMs: Int64 -} - -/// Typed async client for the sync endpoints (URLSession). -struct SyncApi { - private let base = SyncConfig.apiBase - private let session = URLSession.shared - - func requestLink(email: String) async throws { - _ = try await send("POST", "/auth/request", body: ["email": email], token: nil) - } - - func verify(token: String) async throws -> VerifyResponse { - let data = try await send("POST", "/auth/verify", body: ["token": token], token: nil) - return try JSONDecoder().decode(VerifyResponse.self, from: data) - } - - func logout(token: String) async throws { - _ = try await send("POST", "/auth/logout", body: nil, token: token) - } - - func putListening(token: String, deviceId: String, deviceTotalMs: Int64) async throws -> ListeningResponse { - let data = try await send( - "PUT", "/listening", - body: ["deviceId": deviceId, "deviceTotalMs": deviceTotalMs], - token: token) - return try JSONDecoder().decode(ListeningResponse.self, from: data) - } - - func deleteListening(token: String) async throws { - _ = try await send("DELETE", "/listening", body: nil, token: token) - } - - func deleteAccount(token: String) async throws { - _ = try await send("DELETE", "/account", body: nil, token: token) - } - - private func send(_ method: String, _ path: String, body: [String: Any]?, token: String?) async throws -> Data { - guard let url = URL(string: base + path) else { - throw SyncError(status: 0, message: "bad url") - } - var req = URLRequest(url: url) - req.httpMethod = method - req.timeoutInterval = 15 - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - if let token { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - if let body { req.httpBody = try JSONSerialization.data(withJSONObject: body) } - - let (data, response) = try await session.data(for: req) - let code = (response as? HTTPURLResponse)?.statusCode ?? 0 - guard (200...299).contains(code) else { - throw SyncError(status: code, message: String(data: data, encoding: .utf8) ?? "request failed (\(code))") - } - return data - } -} From cd8be8cad38284339ae3de6ca374aa3f974e0f45 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 3 Oct 2026 19:23:50 -0400 Subject: [PATCH 2/2] Address review: SyncConfig in its own file, status-0 cases said once Co-Authored-By: Claude Opus 5.5 --- apps/apple/CascadeShared/App/AppStore.swift | 12 +++++------- apps/apple/CascadeShared/Sync/ServerCarrier.swift | 6 ------ apps/apple/CascadeShared/Sync/SyncConfig.swift | 7 +++++++ 3 files changed, 12 insertions(+), 13 deletions(-) create mode 100644 apps/apple/CascadeShared/Sync/SyncConfig.swift diff --git a/apps/apple/CascadeShared/App/AppStore.swift b/apps/apple/CascadeShared/App/AppStore.swift index 3e02706..fc24c86 100644 --- a/apps/apple/CascadeShared/App/AppStore.swift +++ b/apps/apple/CascadeShared/App/AppStore.swift @@ -150,13 +150,11 @@ final class AppStore { dispatch(.submitSignInLink(input: url.absoluteString)) } - /// Carry one request over HTTP and settle it with the core. Every request - /// is settled, the sign-out revoke included, or the core won't start - /// another; with no sync server, or on a network error, timeout or - /// cancellation, the settle carries status 0. The settle is dispatched on - /// the main actor, and its own effects come back through `apply`. A request - /// can come from any dispatch, including a tick, so carry everything - /// `apply` hands over. + /// Carry one request over HTTP and settle it with the core, on the main + /// actor. Every request is settled, the sign-out revoke included, or the + /// core won't start another; the settle's own effects come back through + /// `apply`. A request can come from any dispatch, including a tick, so + /// carry everything `apply` hands over. private func carry(_ request: ServerRequest) { let carrier = serverCarrier Task { @MainActor in diff --git a/apps/apple/CascadeShared/Sync/ServerCarrier.swift b/apps/apple/CascadeShared/Sync/ServerCarrier.swift index 4095739..3307797 100644 --- a/apps/apple/CascadeShared/Sync/ServerCarrier.swift +++ b/apps/apple/CascadeShared/Sync/ServerCarrier.swift @@ -1,11 +1,5 @@ import Foundation -/// Base URL of cascade-sync-server. Empty disables the sync feature. -enum SyncConfig { - static let apiBase = "https://sync.cascade.stephens.page" - static var available: Bool { !apiBase.isEmpty } -} - /// Carries the core's `serverRequest` effects to the sync server (URLSession). /// It decides nothing per request: it sends what it is given and answers with /// the `serverResponse` that settles it, holding the status and body verbatim. diff --git a/apps/apple/CascadeShared/Sync/SyncConfig.swift b/apps/apple/CascadeShared/Sync/SyncConfig.swift new file mode 100644 index 0000000..b6625f2 --- /dev/null +++ b/apps/apple/CascadeShared/Sync/SyncConfig.swift @@ -0,0 +1,7 @@ +import Foundation + +/// Base URL of cascade-sync-server. Empty disables the sync feature. +enum SyncConfig { + static let apiBase = "https://sync.cascade.stephens.page" + static var available: Bool { !apiBase.isEmpty } +}