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
107 changes: 77 additions & 30 deletions Modules/Leaderboard/Sources/Device.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import Foundation
/// *DEVICE* (design §2a): one of a member's machines, which is one key. `/me`
/// lists them, removed ones included; a removed device's days stay and count.
public struct Device: Sendable, Equatable, Codable {
/// The device's public key, base64url, as `X-Key` names it.
public let publicKey: String
public let label: String
/// The key it signs with: the device, as the server knows it.
public let publicKey: DeviceKey
public let label: DeviceLabel
public let addedAt: Date
public let removedAt: Date?
/// The device that removed it, `nil` while it's in use.
public let removedBy: DeviceRef?

public init(publicKey: String, label: String, addedAt: Date, removedAt: Date? = nil, removedBy: DeviceRef? = nil) {
public init(publicKey: DeviceKey, label: DeviceLabel, addedAt: Date, removedAt: Date? = nil, removedBy: DeviceRef? = nil) {
self.publicKey = publicKey
self.label = label
self.addedAt = addedAt
Expand All @@ -21,18 +21,23 @@ public struct Device: Sendable, Equatable, Codable {

public var isRemoved: Bool { removedAt != nil }

/// This device once `by` removed it at `time`, as `/me` will list it.
func removed(by: DeviceRef, at time: Date) -> Device {
Device(publicKey: publicKey, label: label, addedAt: addedAt, removedAt: time, removedBy: by)
}

private enum CodingKeys: String, CodingKey { case publicKey, label, addedAt, removedAt, removedBy }

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
publicKey = try container.decode(String.self, forKey: .publicKey)
label = try container.decode(String.self, forKey: .label)
publicKey = try container.decode(DeviceKey.self, forKey: .publicKey)
label = try container.decode(DeviceLabel.self, forKey: .label)
addedAt = try ServerTime.decode(from: container, forKey: .addedAt)
removedAt = try ServerTime.decodeIfPresent(from: container, forKey: .removedAt)
removedBy = try container.decodeIfPresent(DeviceRef.self, forKey: .removedBy)
}

/// Written as `ServerTime` reads it, so an exported `/me` answer reads back
/// Written as the server writes it, so an exported `/me` answer reads back
/// as the same instants; `JSONEncoder`'s own form counts from 2001.
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
Expand All @@ -46,29 +51,71 @@ public struct Device: Sendable, Equatable, Codable {

/// A device named in passing: the one that removed another.
public struct DeviceRef: Sendable, Equatable, Codable {
public let publicKey: String
public let label: String
public let publicKey: DeviceKey
public let label: DeviceLabel

public init(publicKey: String, label: String) {
public init(publicKey: DeviceKey, label: DeviceLabel) {
self.publicKey = publicKey
self.label = label
}
}

/// A device, as the server knows it: the public half of the key it signs
/// with, base64url, as `X-Key` names it. Only the wire and the stored record
/// hold it as text.
public struct DeviceKey: Sendable, Hashable, Codable, CustomStringConvertible {
public let value: String

public init(_ value: String) {
self.value = value
}

public init(from decoder: Decoder) throws {
value = try decoder.singleValueContainer().decode(String.self)
}

public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(value)
}

public var description: String { value }
}

extension SigningKey {
/// The device this key signs as.
public var deviceKey: DeviceKey { DeviceKey(publicKey) }
}

/// What a device is called on the member's list: the label the person typed
/// when it joined or was added, filled in with the machine's model and never
/// its computer name, which is often a person's (design §6). The server takes
/// 1–40 characters without control characters.
public struct DeviceLabel: Sendable, Hashable, CustomStringConvertible {
/// 1–40 characters without control characters, counting them as JavaScript
/// does, in UTF-16 units: an emoji can count as several.
public struct DeviceLabel: Sendable, Hashable, Codable, CustomStringConvertible {
public let value: String

public init?(_ text: String) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard (1...40).contains(trimmed.count),
guard (1...40).contains(trimmed.utf16.count),
!trimmed.unicodeScalars.contains(where: { $0.properties.generalCategory == .control }) else { return nil }
value = trimmed
}

public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let text = try container.decode(String.self)
guard let label = DeviceLabel(text) else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Not a device's label: \(text)")
}
self = label
}

public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(value)
}

public var description: String { value }
}

Expand All @@ -93,10 +140,10 @@ public struct DeviceCode: Sendable, Hashable, CustomStringConvertible {
/// when, shown above *Approve* so a code read out to a stranger isn't
/// approved blind (RFC 8628 §5.4).
public struct PendingDevice: Sendable, Equatable, Decodable {
public let label: String
public let label: DeviceLabel
public let requestedAt: Date

public init(label: String, requestedAt: Date) {
public init(label: DeviceLabel, requestedAt: Date) {
self.label = label
self.requestedAt = requestedAt
}
Expand All @@ -105,7 +152,7 @@ public struct PendingDevice: Sendable, Equatable, Decodable {

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
label = try container.decode(String.self, forKey: .label)
label = try container.decode(DeviceLabel.self, forKey: .label)
requestedAt = try ServerTime.decode(from: container, forKey: .requestedAt)
}
}
Expand All @@ -132,16 +179,11 @@ public enum DeviceApproval: Sendable, Equatable {
case approved(MemberSummary)
}

/// A time as the server writes it. §5 doesn't fix the form, so the ones a
/// Worker on D1 is likely to send are read: ISO 8601 with or without
/// fractional seconds, SQLite's `YYYY-MM-DD HH:MM:SS` in UTC, and Unix time in
/// seconds or milliseconds. Anything else fails the answer rather than guess.
/// Written back as ISO 8601 in UTC with milliseconds.
/// A time as the server writes it: JavaScript's `toISOString()`, UTC with
/// milliseconds (§5). Read without the milliseconds too; anything else fails
/// the answer rather than guess.
enum ServerTime {
static func decode<Key: CodingKey>(from container: KeyedDecodingContainer<Key>, forKey key: Key) throws -> Date {
if let seconds = try? container.decode(Double.self, forKey: key) {
return Date(timeIntervalSince1970: seconds > 100_000_000_000 ? seconds / 1000 : seconds)
}
let text = try container.decode(String.self, forKey: key)
guard let date = date(text) else {
throw DecodingError.dataCorruptedError(forKey: key, in: container, debugDescription: "Not a time: \(text)")
Expand All @@ -162,13 +204,18 @@ enum ServerTime {

private static func date(_ text: String) -> Date? {
let iso = ISO8601DateFormatter()
if let date = iso.date(from: text) { return date }
iso.formatOptions.insert(.withFractionalSeconds)
if let date = iso.date(from: text) { return date }
let sqlite = DateFormatter()
sqlite.locale = Locale(identifier: "en_US_POSIX")
sqlite.timeZone = TimeZone(identifier: "UTC")
sqlite.dateFormat = "yyyy-MM-dd HH:mm:ss"
return sqlite.date(from: text)
iso.formatOptions.remove(.withFractionalSeconds)
return iso.date(from: text)
}
}

/// How a device is removed, as the dialog offers it (design §2a): its key is
/// revoked either way; its days stay, or are deleted once it is removed.
public enum Removal: Sendable, Equatable, CaseIterable {
/// *Remove*: its days stay and count until deleted.
case keepingItsDays
/// *Remove and delete its days*: offered only for another device in its first week.
case deletingItsDays
}
114 changes: 114 additions & 0 deletions Modules/Leaderboard/Sources/DeviceRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import Foundation
import Observation

/// *DEVICE REQUEST* (design §2a): this Mac asking to be added to a member,
/// with the code it shows until a Mac the member has approves it. A Mac is
/// either asking or a member's device, never both, so the request is its own
/// aggregate, as `device_requests` is on the server. Its key lives only here,
/// never saved, until the person agrees to the member it was added to and
/// `LeaderboardMembership.join(through:sharing:)` takes it.
@MainActor
@Observable
public final class DeviceRequest {
/// Where the request stands.
public enum Stage: Sendable, Equatable {
/// It shows `code` until a device of the member approves it.
case waiting
/// A device of `member` approved it; nothing is kept or sent until the person agrees.
case approved(member: Username)
/// The person agreed: this Mac is that member's device now.
case joined
/// The code expired before anyone approved it.
case expired
/// The person didn't know the member, and this Mac removed itself.
case declined
}

/// What an approved request hands to the membership it joins: the member,
/// the key it asked with, and the member's `/me` answer.
struct Approval {
let member: Username
let key: SigningKey
let summary: MemberSummary
}

/// `Stage`, with what an approved request holds: the member's `/me` answer.
private enum State {
case waiting
case approved(member: Username, summary: MemberSummary)
case joined, expired, declined
}

/// The code to type on a Mac the member has.
public let code: DeviceCode
/// The key this Mac asked with; saved only once it joins.
let key: SigningKey
private var state = State.waiting

@ObservationIgnored private let api: any LeaderboardAPI
@ObservationIgnored private let interval: TimeInterval
@ObservationIgnored private let sleep: @MainActor (TimeInterval) async throws -> Void

init(code: DeviceCode, key: SigningKey, interval: TimeInterval, api: any LeaderboardAPI,
sleep: @escaping @MainActor (TimeInterval) async throws -> Void = { try await Task.sleep(for: .seconds($0)) }) {
self.code = code
self.key = key
self.interval = interval
self.api = api
self.sleep = sleep
}

public var stage: Stage {
switch state {
case .waiting: .waiting
case .approved(let member, _): .approved(member: member)
case .joined: .joined
case .expired: .expired
case .declined: .declined
}
}

/// Asks, at the interval the server gave, until a device of the member
/// approves the code; answers the member it joined. Nothing is kept or
/// sent until the person agrees: a code that leaked could have been
/// approved by someone else.
public func waitForApproval() async throws -> Username {
guard case .waiting = state else { throw LeaderboardError.notJoined }
while true {
let approval: DeviceApproval
do {
approval = try await api.approval(of: key)
} catch LeaderboardError.codeExpired {
state = .expired
throw LeaderboardError.codeExpired
}
switch approval {
case .waiting:
try await sleep(interval)
case .approved(let summary):
guard let member = summary.username.flatMap(Username.init) else {
throw LeaderboardError.rejected("The leaderboard answered with something unreadable.")
}
state = .approved(member: member, summary: summary)
return member
}
}
}

/// The person doesn't know the member this Mac was added to: it removes
/// itself from that member, and keeps nothing.
public func decline() async throws {
guard case .approved(let member, _) = state else { throw LeaderboardError.notJoined }
try await api.removeDevice(key.publicKey, as: MemberCredentials(username: member, key: key))
state = .declined
}

/// The person agreed, and the membership joins through it: hands over
/// what joining takes, once, and this Mac is the member's device from
/// here. Refused until approved, and once handed over.
func handOver() throws -> Approval {
guard case .approved(let member, let summary) = state else { throw LeaderboardError.notJoined }
state = .joined
return Approval(member: member, key: key, summary: summary)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ struct LeaderboardHTTPClient: LeaderboardAPI {
private static func error(status: Int, data: Data) -> LeaderboardError {
let failure = try? JSONDecoder().decode(Failure.self, from: data)
switch (status, failure?.error) {
case (401, "unauthorized"), (401, nil): return failure?.removedBy.map { .removed(by: $0.label) } ?? .unauthorized
case (401, "unauthorized"), (401, nil): return failure?.removedBy.map { .removed(by: $0) } ?? .unauthorized
case (409, "usernameTaken"), (409, nil): return .usernameTaken
case (409, "deviceLimit"): return .deviceLimit
case (409, "lastDevice"): return .lastDevice
Expand Down
10 changes: 8 additions & 2 deletions Modules/Leaderboard/Sources/LeaderboardAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,14 @@ public enum LeaderboardError: Error, Sendable, Equatable, LocalizedError {
case notShareable(String)
case nothingShared
case notJoined
/// This Mac is already a member's device, so it doesn't ask to be added.
case alreadyJoined
/// A removal the dialog didn't offer for that device.
case notOffered
/// The server refused the signature: the key no longer matches the name.
case unauthorized
/// Another device of the member removed this one: its key is revoked.
case removed(by: String)
case removed(by: DeviceRef)
/// A new device's code expired before a device of the member approved it.
case codeExpired
/// No code like that is waiting: unknown, expired or used.
Expand All @@ -218,8 +222,10 @@ public enum LeaderboardError: Error, Sendable, Equatable, LocalizedError {
case .notShareable(let provider): "\(provider) has no token logs on this Mac, so it can't be shared."
case .nothingShared: "Pick at least one provider to share."
case .notJoined: "You haven't joined the leaderboard."
case .alreadyJoined: "This Mac is already on the leaderboard."
case .notOffered: "That isn't offered for this device."
case .unauthorized: "The leaderboard didn't accept this Mac's key for your username."
case .removed(let label): "\(label) removed this Mac from your leaderboard devices."
case .removed(let device): "\(device.label) removed this Mac from your leaderboard devices."
case .codeExpired: "The code expired before it was approved. Ask for a new one."
case .unknownCode: "No device is waiting with that code. Check it, or ask for a new one."
case .deviceLimit: "You already have five devices. Remove one to add another."
Expand Down
Loading