Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 13 additions & 73 deletions apps/apple/CascadeShared/App/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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 }
Expand All @@ -151,75 +150,16 @@ 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
/// 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) }
/// 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
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
Expand All @@ -240,7 +180,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))
}
}
Expand Down
134 changes: 59 additions & 75 deletions apps/apple/CascadeShared/App/Dto.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)")
}
Expand All @@ -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 {
Expand All @@ -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:
Expand All @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions apps/apple/CascadeShared/Sync/AccountControlsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions apps/apple/CascadeShared/Sync/ServerCarrier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation

/// 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
}
}
}
Loading
Loading