import Foundation // The Mac app's RAIL record: which spaces show, in what order, plus legacy // fields from the webview era (the full-frame webview and its join are gone — // identity is `SavedInstance` in the Keychain, arriving via the vault). // Retiring SavedChat entirely in favour of SavedInstance is deliberate future // work; today it only drives rail order and carries legacy tokens. // The persisted session for one chat. The key never leaves the device — it lives in // the webview's own storage, not here; this just carries what the webview needs. struct Session: Codable, Equatable { var baseURL: URL // e.g. https://chat.example.com — derived from the invite link var token: String // invite token (transport auth only; NOT a decryption key) var keyB64: String // base64url AES-GCM key from the link's #fragment (legacy) var name: String // screen name /// wss/ws URL for the live feed, matching baseURL's scheme. var socketURL: URL { var c = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! c.scheme = baseURL.scheme == "https" ? "wss" : "ws" c.path = "/ws" c.queryItems = [URLQueryItem(name: "token", value: token)] return c.url! } /// The Origin header value for the WebSocket upgrade (same-origin check). var origin: String { var s = baseURL.scheme.map { "\($0)://" } ?? "" s += baseURL.host ?? "" if let port = baseURL.port { s += ":\(port)" } return s } } // A saved chat: the invite URL (which carries host, token and key in its #fragment // for legacy links) plus the screen name. New-format (server-blind) links don't // carry the key — the web join flow inside the WKWebView fetches + unwraps it and // hands back a resolved session, persisted as claimedToken/claimedKey. struct SavedChat: Codable, Identifiable, Equatable { var url: String var name: String var lastSeenId: Int64 = 0 var claimedToken: String? var claimedKey: String? var id: String { url } /// The origin (scheme://host[:port]) of the stored link. var origin: URL? { guard let c = URLComponents(string: url), let scheme = c.scheme, let host = c.host else { return nil } var o = URLComponents() o.scheme = scheme o.host = host o.port = c.port return o.url } /// A usable Session, or nil if we don't have the key yet. Prefers a resolved /// (claimed) new-format session; otherwise parses a legacy `#k=` link. var session: Session? { if let token = claimedToken, let key = claimedKey, let base = origin { return Session(baseURL: base, token: token, keyB64: key, name: name) } guard let p = SavedChat.parseInvite(url) else { return nil } return Session(baseURL: p.baseURL, token: p.token, keyB64: p.keyB64, name: name) } /// The URL the WKWebView should load: the chat origin (`/`) once we have a /// session to seed, otherwise the raw `/join/…` link so the web claims it. var loadURL: URL? { session?.baseURL ?? URL(string: url) } /// De-dupe token (claimed device token, else the legacy invite token from the URL). var token: String? { claimedToken ?? SavedChat.parseInvite(url)?.token } struct ParsedInvite { let baseURL: URL; let token: String; let keyB64: String } /// Parse a legacy `https://host/join/#k=` link. Lives here because /// main's shared `LChatAPI` dropped this when iOS moved to native key management. static func parseInvite(_ raw: String) -> ParsedInvite? { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard var comps = URLComponents(string: trimmed), let scheme = comps.scheme, comps.host != nil, scheme == "http" || scheme == "https" else { return nil } let token = comps.path.split(separator: "/").last.map(String.init) // The key lives in the fragment as `k=…` (URLComponents leaves it raw). var keyB64: String? if let frag = comps.fragment { for pair in frag.split(separator: "&") { let kv = pair.split(separator: "=", maxSplits: 1) if kv.first == "k", kv.count == 2 { keyB64 = String(kv[1]) } } } guard let token, !token.isEmpty, let keyB64, !keyB64.isEmpty else { return nil } // Reduce to a clean origin URL (scheme://host[:port]). comps.path = "" comps.query = nil comps.fragment = nil guard let base = comps.url else { return nil } return ParsedInvite(baseURL: base, token: token, keyB64: keyB64) } }