| 1 | import Foundation |
| 2 | |
| 3 | // The Mac app's RAIL record: which spaces show, in what order, plus legacy |
| 4 | // fields from the webview era (the full-frame webview and its join are gone — |
| 5 | // identity is `SavedInstance` in the Keychain, arriving via the vault). |
| 6 | // Retiring SavedChat entirely in favour of SavedInstance is deliberate future |
| 7 | // work; today it only drives rail order and carries legacy tokens. |
| 8 | |
| 9 | // The persisted session for one chat. The key never leaves the device — it lives in |
| 10 | // the webview's own storage, not here; this just carries what the webview needs. |
| 11 | struct Session: Codable, Equatable { |
| 12 | var baseURL: URL // e.g. https://chat.example.com — derived from the invite link |
| 13 | var token: String // invite token (transport auth only; NOT a decryption key) |
| 14 | var keyB64: String // base64url AES-GCM key from the link's #fragment (legacy) |
| 15 | var name: String // screen name |
| 16 | |
| 17 | /// wss/ws URL for the live feed, matching baseURL's scheme. |
| 18 | var socketURL: URL { |
| 19 | var c = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! |
| 20 | c.scheme = baseURL.scheme == "https" ? "wss" : "ws" |
| 21 | c.path = "/ws" |
| 22 | c.queryItems = [URLQueryItem(name: "token", value: token)] |
| 23 | return c.url! |
| 24 | } |
| 25 | |
| 26 | /// The Origin header value for the WebSocket upgrade (same-origin check). |
| 27 | var origin: String { |
| 28 | var s = baseURL.scheme.map { "\($0)://" } ?? "" |
| 29 | s += baseURL.host ?? "" |
| 30 | if let port = baseURL.port { s += ":\(port)" } |
| 31 | return s |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | // A saved chat: the invite URL (which carries host, token and key in its #fragment |
| 36 | // for legacy links) plus the screen name. New-format (server-blind) links don't |
| 37 | // carry the key — the web join flow inside the WKWebView fetches + unwraps it and |
| 38 | // hands back a resolved session, persisted as claimedToken/claimedKey. |
| 39 | struct SavedChat: Codable, Identifiable, Equatable { |
| 40 | var url: String |
| 41 | var name: String |
| 42 | var lastSeenId: Int64 = 0 |
| 43 | var claimedToken: String? |
| 44 | var claimedKey: String? |
| 45 | |
| 46 | var id: String { url } |
| 47 | |
| 48 | /// The origin (scheme://host[:port]) of the stored link. |
| 49 | var origin: URL? { |
| 50 | guard let c = URLComponents(string: url), let scheme = c.scheme, let host = c.host else { return nil } |
| 51 | var o = URLComponents() |
| 52 | o.scheme = scheme |
| 53 | o.host = host |
| 54 | o.port = c.port |
| 55 | return o.url |
| 56 | } |
| 57 | |
| 58 | /// A usable Session, or nil if we don't have the key yet. Prefers a resolved |
| 59 | /// (claimed) new-format session; otherwise parses a legacy `#k=` link. |
| 60 | var session: Session? { |
| 61 | if let token = claimedToken, let key = claimedKey, let base = origin { |
| 62 | return Session(baseURL: base, token: token, keyB64: key, name: name) |
| 63 | } |
| 64 | guard let p = SavedChat.parseInvite(url) else { return nil } |
| 65 | return Session(baseURL: p.baseURL, token: p.token, keyB64: p.keyB64, name: name) |
| 66 | } |
| 67 | |
| 68 | /// The URL the WKWebView should load: the chat origin (`/`) once we have a |
| 69 | /// session to seed, otherwise the raw `/join/…` link so the web claims it. |
| 70 | var loadURL: URL? { session?.baseURL ?? URL(string: url) } |
| 71 | |
| 72 | /// De-dupe token (claimed device token, else the legacy invite token from the URL). |
| 73 | var token: String? { claimedToken ?? SavedChat.parseInvite(url)?.token } |
| 74 | |
| 75 | struct ParsedInvite { let baseURL: URL; let token: String; let keyB64: String } |
| 76 | |
| 77 | /// Parse a legacy `https://host/join/<token>#k=<key>` link. Lives here because |
| 78 | /// main's shared `LChatAPI` dropped this when iOS moved to native key management. |
| 79 | static func parseInvite(_ raw: String) -> ParsedInvite? { |
| 80 | let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) |
| 81 | guard var comps = URLComponents(string: trimmed), |
| 82 | let scheme = comps.scheme, comps.host != nil, |
| 83 | scheme == "http" || scheme == "https" |
| 84 | else { return nil } |
| 85 | |
| 86 | let token = comps.path.split(separator: "/").last.map(String.init) |
| 87 | // The key lives in the fragment as `k=…` (URLComponents leaves it raw). |
| 88 | var keyB64: String? |
| 89 | if let frag = comps.fragment { |
| 90 | for pair in frag.split(separator: "&") { |
| 91 | let kv = pair.split(separator: "=", maxSplits: 1) |
| 92 | if kv.first == "k", kv.count == 2 { keyB64 = String(kv[1]) } |
| 93 | } |
| 94 | } |
| 95 | guard let token, !token.isEmpty, let keyB64, !keyB64.isEmpty else { return nil } |
| 96 | |
| 97 | // Reduce to a clean origin URL (scheme://host[:port]). |
| 98 | comps.path = "" |
| 99 | comps.query = nil |
| 100 | comps.fragment = nil |
| 101 | guard let base = comps.url else { return nil } |
| 102 | return ParsedInvite(baseURL: base, token: token, keyB64: keyB64) |
| 103 | } |
| 104 | } |
| 105 | |