#if PHONE_LINK import AppKit import CloudKit // MARK: - Services up close, for the iPhone // // When a service's screen opens on the iPhone, it writes a `ServiceAction` // record of kind "refresh"; to act (redeploy, re-run, merge…) it writes one // with that action's kind and target. The CloudKit push wakes this Mac (with // a check every minute in case a push is missed); it takes each request once // (deleted on read, and only those it could delete), reads the service's API // with the key in its Keychain and writes a `ServiceDetail` record, // encrypted. An action runs only if it was offered on an item of the last // detail sent for that service, and was asked for in the last 5 minutes. // Nothing that moves money or sends an email is ever offered. @MainActor final class ServiceDetailRunner { static let shared = ServiceDetailRunner() private var database: CKDatabase { CKContainer(identifier: CloudProbe.containerID).privateCloudDatabase } private var pollTask: Task? private var changeToken: CKServerChangeToken? /// The last detail sent per service: actions are checked against it. private var lastDetails: [String: ServiceDetail] = [:] private let maxAge: TimeInterval = 5 * 60 private var checking = false private var again = false func start() { guard pollTask == nil else { return } pollTask = Task { [weak self] in while !Task.isCancelled { await self?.checkNow() // In case a push is missed. try? await Task.sleep(for: .seconds(60)) } } } /// Stops and deletes the details this Mac wrote to iCloud. func stop() { pollTask?.cancel() pollTask = nil lastDetails = [:] let ids = PillCatalog.phoneServices.map { CKRecord.ID(recordName: ServiceDetail.recordName(for: $0), zoneID: SessionSnapshot.zoneID) } Task { _ = try? await database.modifyRecords(saving: [], deleting: ids, savePolicy: .changedKeys, atomically: false) } } /// From the CloudKit push and the fallback check. One check at a time: two /// overlapping checks would share the change token and could run an action twice. func checkNow() async { guard pollTask != nil else { return } guard !checking else { again = true; return } checking = true repeat { again = false await check() } while again checking = false } // MARK: Requests private func check() async { var found: [CKRecord] = [] do { var more = true while more { let changes = try await database.recordZoneChanges(inZoneWith: SessionSnapshot.zoneID, since: changeToken, desiredKeys: ["pillId", "kind", "requestedAt", "target"]) for (_, result) in changes.modificationResultsByID { if case .success(let mod) = result, mod.record.recordType == ServiceDetail.requestType { found.append(mod.record) } } changeToken = changes.changeToken more = changes.moreComing } } catch let error as CKError where error.code == .changeTokenExpired { changeToken = nil return } catch { return } guard !found.isEmpty else { return } // Single use: act only on the requests this Mac actually removed from iCloud. guard let removed = try? await database.modifyRecords(saving: [], deleting: found.map(\.recordID), savePolicy: .changedKeys, atomically: false) else { log("couldn't take \(found.count) request(s) off iCloud; skipped") return } let taken = found.filter { guard case .success? = removed.deleteResults[$0.recordID] else { return false } return true } // Several refreshes for one service count once. var refreshed: Set = [] for record in taken.sorted(by: { ($0["requestedAt"] as? Date ?? .distantPast) < ($1["requestedAt"] as? Date ?? .distantPast) }) { let pillId = record["pillId"] as? String ?? "" let kind = record["kind"] as? String ?? "" let target = record.encryptedValues["target"] as? String ?? "" let requestedAt = record["requestedAt"] as? Date ?? .distantPast guard Date().timeIntervalSince(requestedAt) < maxAge, PillCatalog.phoneServices.contains(pillId) else { continue } if kind == ServiceDetail.refreshKind { guard refreshed.insert(pillId).inserted else { continue } await publish(pillId: pillId, lastAction: lastDetails[pillId]?.lastAction) } else { await run(kind: kind, target: target, pillId: pillId) } } } private func run(kind: String, target: String, pillId: String) async { guard let action = lastDetails[pillId]?.offered(kind: kind, target: target), ServiceAPI.allowedKinds.contains(kind), kind.hasPrefix(ServiceAPI.prefix(of: pillId)) else { log("ignored an action that wasn't offered: \(kind)") await publish(pillId: pillId, lastAction: ServiceActionResult( title: "Not done", ok: false, message: "This action isn't offered any more. The list was refreshed.", date: Date())) return } log("\(action.title) (\(pillId)) asked from the iPhone") let result: ServiceActionResult do { let message = try await ServiceAPI.perform(kind: kind, target: target) result = ServiceActionResult(title: action.title, ok: true, message: message, date: Date()) } catch { result = ServiceActionResult(title: action.title, ok: false, message: ServiceAPI.describe(error), date: Date()) } log("\(action.title): \(result.ok ? "done" : "failed, \(result.message)")") // Give the service a moment, then show where things are. try? await Task.sleep(for: .seconds(2)) await publish(pillId: pillId, lastAction: result) } private func publish(pillId: String, lastAction: ServiceActionResult?) async { var detail = await ServiceAPI.detail(for: pillId) // The iPhone sync was turned off while the API was read: write nothing. guard !Task.isCancelled, pollTask != nil else { return } detail.lastAction = lastAction lastDetails[pillId] = detail guard let json = try? JSONEncoder().encode(detail), let payload = String(data: json, encoding: .utf8) else { return } let record = CKRecord(recordType: ServiceDetail.recordType, recordID: CKRecord.ID(recordName: ServiceDetail.recordName(for: pillId), zoneID: SessionSnapshot.zoneID)) record["pillId"] = pillId record["fetchedAt"] = detail.fetchedAt record.encryptedValues["payload"] = payload do { _ = try await database.modifyRecords(saving: [record], deleting: [], savePolicy: .allKeys) } catch { log("detail for \(pillId) not saved: \(error.localizedDescription)") } } private func log(_ message: String) { CloudProbe.shared.log("[services] \(message)") } } // MARK: - The services' APIs enum ServiceAPI { struct Failure: Error { let status: Int let message: String } /// Every action the iPhone can ask for. Nothing moves money or sends an email. static let allowedKinds: Set = [ "vercel.redeploy", "vercel.promote", "vercel.cancel", "github.rerun", "github.approve", "github.merge", "n8n.activate", "n8n.deactivate", "n8n.retry", ] static func prefix(of pillId: String) -> String { pillId.replacingOccurrences(of: "integration_", with: "") + "." } static func describe(_ error: Error) -> String { if let failure = error as? Failure { switch failure.status { case 401: if failure.message.localizedCaseInsensitiveContains("restricted") { // Resend `restricted_api_key`: a Sending-access key can't read emails or domains. return "This key can only send emails. Use a Full access key in Coucou's Settings on your Mac to see them here." } return "The key was refused (401). Check it in Coucou's Settings on your Mac." case 403: return "Not allowed with this key (403). \(failure.message)" case 404: return "Not found (404)." default: return failure.message.isEmpty ? "Error \(failure.status)" : String(failure.message.prefix(160)) } } return error.localizedDescription } // MARK: HTTP private static func request(_ urlString: String, method: String = "GET", headers: [String: String], body: [String: Any]? = nil) async throws -> Any { guard let url = URL(string: urlString) else { throw Failure(status: 0, message: "Bad address") } var request = URLRequest(url: url, timeoutInterval: 15) request.httpMethod = method for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } if let body { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body) } let (data, response) = try await URLSession.shared.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 guard (200..<300).contains(status) else { let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] let message = (json?["message"] as? String) ?? ((json?["error"] as? [String: Any])?["message"] as? String) ?? (json?["error"] as? String) ?? String(decoding: data.prefix(200), as: UTF8.self) throw Failure(status: status, message: message) } if data.isEmpty { return [String: Any]() } return try JSONSerialization.jsonObject(with: data) } private static func date(_ value: Any?) -> Date? { if let ms = value as? Double { return Date(timeIntervalSince1970: ms > 1e11 ? ms / 1000 : ms) } if let ms = value as? Int { return Date(timeIntervalSince1970: ms > 100_000_000_000 ? Double(ms) / 1000 : Double(ms)) } guard let text = value as? String else { return nil } let fractional = ISO8601DateFormatter() fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = fractional.date(from: text) { return date } let plain = ISO8601DateFormatter() if let date = plain.date(from: text) { return date } // Resend: "2024-05-01 10:00:00.123456+00" let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") for format in ["yyyy-MM-dd HH:mm:ss.SSSSSSZ", "yyyy-MM-dd HH:mm:ss.SSSZ", "yyyy-MM-dd HH:mm:ssZ"] { formatter.dateFormat = format if let date = formatter.date(from: text.count > 3 && text.hasSuffix("+00") ? text + "00" : text) { return date } } return nil } private static func money(_ cents: Int, _ currency: String) -> String { let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.currencyCode = currency.uppercased() // Stripe zero-decimal currencies (docs.stripe.com/currencies#zero-decimal). UGX and ISK stay ×100 // for backward compatibility; HUF and TWD charges are two-decimal. let zeroDecimal: Set = ["bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf", "vnd", "vuv", "xaf", "xof", "xpf"] let amount = zeroDecimal.contains(currency.lowercased()) ? Double(cents) : Double(cents) / 100 return formatter.string(from: NSNumber(value: amount)) ?? "\(amount) \(currency.uppercased())" } // MARK: Reading static func detail(for pillId: String) async -> ServiceDetail { do { switch pillId { case "integration_vercel": return try await vercel() case "integration_github": return try await github() case "integration_stripe": return try await stripe() case "integration_resend": return try await resend() case "integration_calcom": return try await calcom() case "integration_n8n": return try await n8n() case "integration_notion": return try await notion() default: return ServiceDetail(pillId: pillId, fetchedAt: Date(), error: "Not available from the iPhone yet.") } } catch { return ServiceDetail(pillId: pillId, fetchedAt: Date(), error: describe(error)) } } private static func secret(_ name: String, _ service: String) throws -> String { guard let value = KeychainStore.shared.get(name), !value.isEmpty else { throw Failure(status: 0, message: "No \(service) key on your Mac. Add it in Coucou's Settings.") } return value } // Vercel: deployments (redeploy, promote to production, cancel) and projects. private static func vercel() async throws -> ServiceDetail { let token = try secret("vercel-token", "Vercel") let headers = ["Authorization": "Bearer \(token)", "Accept": "application/json"] let deploymentsJSON = try await request("https://api.vercel.com/v6/deployments?limit=20", headers: headers) let deployments = (deploymentsJSON as? [String: Any])?["deployments"] as? [[String: Any]] ?? [] let projectsJSON = try? await request("https://api.vercel.com/v9/projects?limit=20", headers: headers) let projects = (projectsJSON as? [String: Any])?["projects"] as? [[String: Any]] ?? [] var items: [DetailItem] = [] var ready = 0, failed = 0, building = 0 for d in deployments { guard let uid = d["uid"] as? String else { continue } let name = d["name"] as? String ?? "Deployment" let state = (d["state"] as? String ?? d["readyState"] as? String ?? "").uppercased() let target = d["target"] as? String let meta = d["meta"] as? [String: Any] ?? [:] let message = meta["githubCommitMessage"] as? String ?? "" let branch = meta["githubCommitRef"] as? String ?? "" let creator = (d["creator"] as? [String: Any])?["username"] as? String ?? "" var tone: ServiceTone = .idle var actions: [ServiceActionDef] = [] switch state { case "READY": ready += 1 tone = .ok actions.append(ServiceActionDef(kind: "vercel.redeploy", title: "Redeploy", symbol: "arrow.clockwise", target: "\(uid)|\(name)|\(target ?? "")")) if target != "production" { actions.append(ServiceActionDef(kind: "vercel.promote", title: "Promote to production", symbol: "arrow.up.circle", target: "\(uid)|\(name)", confirm: "A new production build starts from this deployment, using your production environment variables.")) } case "ERROR": failed += 1 tone = .error actions.append(ServiceActionDef(kind: "vercel.redeploy", title: "Redeploy", symbol: "arrow.clockwise", target: "\(uid)|\(name)|\(target ?? "")")) case "BUILDING", "QUEUED", "INITIALIZING": building += 1 tone = .warning actions.append(ServiceActionDef(kind: "vercel.cancel", title: "Cancel build", symbol: "xmark.circle", target: uid, destructive: true, confirm: "Stop this build?")) case "CANCELED": tone = .idle default: break } let subtitle = [branch, message.split(separator: "\n").first.map(String.init) ?? "", creator] .filter { !$0.isEmpty }.joined(separator: " · ") items.append(DetailItem(title: name, subtitle: subtitle.isEmpty ? state.capitalized : subtitle, tone: tone, date: date(d["created"] ?? d["createdAt"]), url: (d["url"] as? String).map { "https://\($0)" }, badge: target == "production" ? "Production" : (state == "READY" ? "Preview" : state.capitalized), actions: actions)) } let projectItems = projects.prefix(20).map { p in DetailItem(title: p["name"] as? String ?? "Project", subtitle: (p["framework"] as? String).map { $0.capitalized } ?? "", date: date(p["updatedAt"])) } var sections = [DetailSection(title: "Deployments", items: items)] if !projectItems.isEmpty { sections.append(DetailSection(title: "Projects · \(projectItems.count)", items: Array(projectItems))) } return ServiceDetail(pillId: "integration_vercel", stats: [DetailStat(label: "Ready", value: "\(ready)", tone: .ok), DetailStat(label: "Building", value: "\(building)", tone: building > 0 ? .warning : .idle), DetailStat(label: "Failed", value: "\(failed)", tone: failed > 0 ? .error : .idle), DetailStat(label: "Projects", value: "\(projects.count)")], sections: sections, fetchedAt: Date()) } // GitHub: your pull requests (merge), reviews asked of you (approve), CI runs (re-run failed jobs). private static func github() async throws -> ServiceDetail { let token = try secret("github-token", "GitHub") let headers = ["Authorization": "Bearer \(token)", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"] let user = try await request("https://api.github.com/user", headers: headers) as? [String: Any] ?? [:] let login = user["login"] as? String ?? "" func search(_ query: String) async -> [[String: Any]] { var components = URLComponents(string: "https://api.github.com/search/issues")! components.queryItems = [URLQueryItem(name: "q", value: query), URLQueryItem(name: "per_page", value: "10"), URLQueryItem(name: "sort", value: "updated")] let json = try? await request(components.url!.absoluteString, headers: headers) return (json as? [String: Any])?["items"] as? [[String: Any]] ?? [] } /// "owner/repo#12" from a search result. func pullRef(_ item: [String: Any]) -> String? { guard let repoURL = item["repository_url"] as? String, let number = item["number"] as? Int else { return nil } let parts = repoURL.split(separator: "/").suffix(2).joined(separator: "/") return "\(parts)#\(number)" } let mine = await search("is:pr is:open author:\(login) archived:false") let asked = await search("is:pr is:open review-requested:\(login) archived:false") let mineItems = mine.compactMap { item -> DetailItem? in guard let ref = pullRef(item) else { return nil } let draft = item["draft"] as? Bool ?? false return DetailItem(title: item["title"] as? String ?? "Pull request", subtitle: ref, tone: draft ? .idle : .info, date: date(item["updated_at"]), url: item["html_url"] as? String, badge: draft ? "Draft" : nil, actions: draft ? [] : [ServiceActionDef(kind: "github.merge", title: "Squash and merge", symbol: "arrow.triangle.merge", target: ref, confirm: "Merge \(ref) into its base branch?")]) } let askedItems = asked.compactMap { item -> DetailItem? in guard let ref = pullRef(item) else { return nil } let author = (item["user"] as? [String: Any])?["login"] as? String ?? "" return DetailItem(title: item["title"] as? String ?? "Pull request", subtitle: author.isEmpty ? ref : "\(ref) · \(author)", tone: .warning, date: date(item["updated_at"]), url: item["html_url"] as? String, actions: [ServiceActionDef(kind: "github.approve", title: "Approve", symbol: "checkmark.seal", target: ref)]) } // CI on the repos you pushed to last. let reposJSON = try? await request("https://api.github.com/user/repos?sort=pushed&per_page=5&affiliation=owner,collaborator", headers: headers) let repos = (reposJSON as? [[String: Any]] ?? []).compactMap { $0["full_name"] as? String } var runItems: [DetailItem] = [] var failing = 0 for repo in repos { let runsJSON = try? await request("https://api.github.com/repos/\(repo)/actions/runs?per_page=3", headers: headers) let runs = (runsJSON as? [String: Any])?["workflow_runs"] as? [[String: Any]] ?? [] for run in runs { guard let id = run["id"] as? Int else { continue } let status = run["status"] as? String ?? "" let conclusion = run["conclusion"] as? String ?? "" var tone: ServiceTone = .idle var actions: [ServiceActionDef] = [] if status != "completed" { tone = .warning } else if conclusion == "success" { tone = .ok } else if conclusion == "failure" || conclusion == "timed_out" { tone = .error failing += 1 actions.append(ServiceActionDef(kind: "github.rerun", title: "Re-run failed jobs", symbol: "arrow.clockwise", target: "\(repo)/\(id)")) } let label = status == "completed" ? conclusion.replacingOccurrences(of: "_", with: " ") : status.replacingOccurrences(of: "_", with: " ") runItems.append(DetailItem(title: run["name"] as? String ?? "Workflow", subtitle: "\(repo) · \(run["head_branch"] as? String ?? "")", tone: tone, date: date(run["created_at"]), url: run["html_url"] as? String, badge: label.capitalized, actions: actions)) } } var sections: [DetailSection] = [] if !askedItems.isEmpty { sections.append(DetailSection(title: "Reviews asked of you", items: askedItems)) } sections.append(DetailSection(title: "Your pull requests", items: mineItems, footer: mineItems.isEmpty ? "No open pull request." : nil)) if !runItems.isEmpty { sections.append(DetailSection(title: "CI on your latest repos", items: runItems)) } return ServiceDetail(pillId: "integration_github", stats: [DetailStat(label: "Your PRs", value: "\(mineItems.count)", tone: .info), DetailStat(label: "Reviews", value: "\(askedItems.count)", tone: askedItems.isEmpty ? .idle : .warning), DetailStat(label: "CI failing", value: "\(failing)", tone: failing > 0 ? .error : .ok)], sections: sections, fetchedAt: Date()) } // Stripe: balance, payments, payouts, subscriptions. Read only. private static func stripe() async throws -> ServiceDetail { let key = try secret("stripe-api-key", "Stripe") let headers = ["Authorization": "Bearer \(key)"] let balance = try await request("https://api.stripe.com/v1/balance", headers: headers) as? [String: Any] ?? [:] // One entry per currency in each list, in no set order: pair them by currency. let availableList = balance["available"] as? [[String: Any]] ?? [] let pendingList = balance["pending"] as? [[String: Any]] ?? [] let currency = ((availableList.first ?? pendingList.first)?["currency"] as? String ?? "eur").lowercased() func amount(_ list: [[String: Any]]) -> Int { list.first { ($0["currency"] as? String)?.lowercased() == currency }?["amount"] as? Int ?? 0 } let available = amount(availableList) let pending = amount(pendingList) /// A list, or why it couldn't be read (shown instead of a made-up zero). func list(_ path: String) async -> ([[String: Any]], [String: Any], String?) { do { let json = try await request("https://api.stripe.com/v1/\(path)", headers: headers) as? [String: Any] ?? [:] return (json["data"] as? [[String: Any]] ?? [], json, nil) } catch { return ([], [:], describe(error)) } } let (charges, _, chargesError) = await list("charges?limit=15") let chargeItems = charges.map { c -> DetailItem in let status = c["status"] as? String ?? "" let refunded = c["refunded"] as? Bool ?? false let partlyRefunded = !refunded && (c["amount_refunded"] as? Int ?? 0) > 0 let uncaptured = !refunded && status == "succeeded" && (c["captured"] as? Bool) == false let who = ((c["billing_details"] as? [String: Any])?["name"] as? String) ?? (c["receipt_email"] as? String) ?? (c["description"] as? String) ?? "" let tone: ServiceTone = refunded ? .idle : (uncaptured || partlyRefunded) ? .warning : status == "succeeded" ? .ok : status == "failed" ? .error : .warning let badge = refunded ? "Refunded" : uncaptured ? "Uncaptured" : partlyRefunded ? "Partly refunded" : status.capitalized return DetailItem(title: money(c["amount"] as? Int ?? 0, c["currency"] as? String ?? currency), subtitle: who, tone: tone, date: date(c["created"]), badge: badge) } let (payouts, _, payoutsError) = await list("payouts?limit=5") let payoutItems = payouts.map { p in DetailItem(title: money(p["amount"] as? Int ?? 0, p["currency"] as? String ?? currency), subtitle: "Arrives", tone: (p["status"] as? String) == "failed" ? .error : .info, date: date(p["arrival_date"]), badge: (p["status"] as? String)?.replacingOccurrences(of: "_", with: " ").capitalized) } // Active subscriptions, every page (up to 1 000). var subs: [[String: Any]] = [] var moreSubs = false var subsError: String? var cursor: String? for _ in 0..<10 { let (page, json, error) = await list("subscriptions?status=active&limit=100" + (cursor.map { "&starting_after=\($0)" } ?? "")) if let error { subsError = error; break } subs += page moreSubs = json["has_more"] as? Bool ?? false guard moreSubs, let last = subs.last?["id"] as? String else { break } cursor = last } // Estimated monthly revenue, one total per currency. Metered and tiered // prices have no fixed amount per period and discounts aren't applied. var monthlyByCurrency: [String: Double] = [:] for sub in subs { let subCurrency = (sub["currency"] as? String)?.lowercased() let items = (sub["items"] as? [String: Any])?["data"] as? [[String: Any]] ?? [] for item in items { let price = item["price"] as? [String: Any] ?? [:] let recurring = price["recurring"] as? [String: Any] ?? [:] if recurring["usage_type"] as? String == "metered" || price["billing_scheme"] as? String == "tiered" { continue } let perUnit = (price["unit_amount"] as? Int).map { Double($0) } ?? Double(price["unit_amount_decimal"] as? String ?? "") ?? 0 let unit = perUnit * Double(item["quantity"] as? Int ?? 1) let count = Double(recurring["interval_count"] as? Int ?? 1) let perMonth: Double switch recurring["interval"] as? String { case "year": perMonth = unit / (12 * count) case "week": perMonth = unit * 4.345 / count case "day": perMonth = unit * 30.4 / count default: perMonth = unit / count } let itemCurrency = subCurrency ?? (price["currency"] as? String)?.lowercased() ?? currency monthlyByCurrency[itemCurrency, default: 0] += perMonth } } let largest = monthlyByCurrency.max { $0.value < $1.value } let mrrCurrency = monthlyByCurrency[currency] != nil ? currency : (largest?.key ?? currency) let monthly = monthlyByCurrency[mrrCurrency] ?? 0 var sections = [DetailSection(title: "Payments", items: chargeItems, footer: chargesError)] if !payoutItems.isEmpty || payoutsError != nil { sections.append(DetailSection(title: "Payouts", items: payoutItems, footer: payoutsError)) } if let subsError { sections.append(DetailSection(title: "Subscriptions", items: [], footer: subsError)) } return ServiceDetail(pillId: "integration_stripe", stats: [DetailStat(label: "Available", value: money(available, currency), tone: .ok), DetailStat(label: "Pending", value: money(pending, currency)), DetailStat(label: "Subscriptions", value: subsError != nil ? "—" : (moreSubs ? "\(subs.count)+" : "\(subs.count)"), tone: .info), DetailStat(label: "Est. MRR", value: subsError != nil ? "—" : money(Int(monthly.rounded()), mrrCurrency), tone: .info)], sections: sections, fetchedAt: Date()) } // Resend: the latest emails and how they went, and the domains. Read only. private static func resend() async throws -> ServiceDetail { let key = try secret("resend-api-key", "Resend") let headers = ["Authorization": "Bearer \(key)"] let emailsJSON = try await request("https://api.resend.com/emails?limit=20", headers: headers) let emails = (emailsJSON as? [String: Any])?["data"] as? [[String: Any]] ?? [] var bounced = 0, delivered = 0 let emailItems = emails.map { e -> DetailItem in let event = e["last_event"] as? String ?? "sent" let tone: ServiceTone switch event { case "delivered", "opened", "clicked": tone = .ok; delivered += 1 case "bounced", "complained", "failed", "suppressed": tone = .error; bounced += 1 case "delivery_delayed": tone = .warning default: tone = .info } let to = (e["to"] as? [String])?.joined(separator: ", ") ?? "" return DetailItem(title: e["subject"] as? String ?? "(no subject)", subtitle: to, tone: tone, date: date(e["created_at"]), badge: event.replacingOccurrences(of: "_", with: " ").capitalized) } let domainsJSON = try? await request("https://api.resend.com/domains", headers: headers) let domains = (domainsJSON as? [String: Any])?["data"] as? [[String: Any]] ?? [] let domainItems = domains.map { d in let status = d["status"] as? String ?? "" return DetailItem(title: d["name"] as? String ?? "Domain", subtitle: d["region"] as? String ?? "", tone: status == "verified" ? .ok : (status == "failed" ? .error : .warning), badge: status.replacingOccurrences(of: "_", with: " ").capitalized) } var sections = [DetailSection(title: "Latest emails", items: emailItems)] if !domainItems.isEmpty { sections.append(DetailSection(title: "Domains", items: domainItems)) } return ServiceDetail(pillId: "integration_resend", stats: [DetailStat(label: "Delivered", value: "\(delivered)", tone: .ok), DetailStat(label: "Bounced", value: "\(bounced)", tone: bounced > 0 ? .error : .idle), DetailStat(label: "Domains", value: "\(domains.count)")], sections: sections, fetchedAt: Date()) } // Cal.com: upcoming bookings. Read only: cancelling emails the guest and can refund or charge them. private static func calcom() async throws -> ServiceDetail { let key = try secret("calcom-api-key", "Cal.com") let headers = ["Authorization": "Bearer \(key)", "cal-api-version": "2024-08-13"] let iso = ISO8601DateFormatter() let now = Date() var components = URLComponents(string: "https://api.cal.com/v2/bookings")! components.queryItems = [URLQueryItem(name: "status", value: "upcoming"), URLQueryItem(name: "afterStart", value: iso.string(from: now)), URLQueryItem(name: "take", value: "30")] let json = try await request(components.url!.absoluteString, headers: headers) let bookings = (json as? [String: Any])?["data"] as? [[String: Any]] ?? [] let calendar = Calendar.current var today = 0, week = 0 let items = bookings.compactMap { b -> DetailItem? in guard let start = date(b["start"] ?? b["startTime"]) else { return nil } if calendar.isDateInToday(start) { today += 1 } if start.timeIntervalSince(now) < 7 * 24 * 3600 { week += 1 } let attendee = (b["attendees"] as? [[String: Any]])?.first let who = attendee?["name"] as? String ?? attendee?["email"] as? String ?? "" let when = start.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) return DetailItem(title: b["title"] as? String ?? "Meeting", subtitle: who.isEmpty ? when : "\(when) · \(who)", tone: calendar.isDateInToday(start) ? .warning : .info, date: start, url: (b["meetingUrl"] as? String) ?? (b["location"] as? String).flatMap { $0.hasPrefix("https://") ? $0 : nil }, badge: (b["status"] as? String)?.capitalized) } return ServiceDetail(pillId: "integration_calcom", stats: [DetailStat(label: "Today", value: "\(today)", tone: today > 0 ? .warning : .idle), DetailStat(label: "Next 7 days", value: "\(week)", tone: .info), DetailStat(label: "Upcoming", value: "\(items.count)")], sections: [DetailSection(title: "Upcoming bookings", items: items, footer: items.isEmpty ? "Nothing booked." : nil)], fetchedAt: Date()) } // n8n: workflows (activate, deactivate) and executions (retry a failed one). private static func n8n() async throws -> ServiceDetail { let key = try secret("n8n-api-key", "n8n") let base = try secret("n8n-url", "n8n").trimmingCharacters(in: CharacterSet(charactersIn: "/")) let headers = ["X-N8N-API-KEY": key, "Accept": "application/json"] // The list comes in pages (at most 250, then `cursor` = the previous `nextCursor`): read // them all so the counts are right. var workflows: [[String: Any]] = [] var cursor: String? repeat { var url = "\(base)/api/v1/workflows?limit=250" if let cursor, let encoded = cursor.addingPercentEncoding(withAllowedCharacters: .alphanumerics) { url += "&cursor=\(encoded)" } let page = try await request(url, headers: headers) as? [String: Any] workflows += page?["data"] as? [[String: Any]] ?? [] cursor = page?["nextCursor"] as? String } while !(cursor ?? "").isEmpty && workflows.count < 5_000 var names: [String: String] = [:] var active = 0 let workflowItems = workflows.compactMap { w -> DetailItem? in guard let id = (w["id"] as? String) ?? (w["id"] as? Int).map(String.init) else { return nil } let name = w["name"] as? String ?? "Workflow" names[id] = name // Archived workflows are listed but can't be turned on (n8n answers 400). if w["isArchived"] as? Bool ?? false { return nil } let isActive = w["active"] as? Bool ?? false if isActive { active += 1 } return DetailItem(title: name, tone: isActive ? .ok : .idle, date: date(w["updatedAt"]), url: "\(base)/workflow/\(id)", badge: isActive ? "Active" : "Off", actions: [isActive ? ServiceActionDef(kind: "n8n.deactivate", title: "Turn off", symbol: "pause.circle", target: id, destructive: true, confirm: "Stop \(name) from running?") : ServiceActionDef(kind: "n8n.activate", title: "Turn on", symbol: "play.circle", target: id)]) } let executionsJSON = try? await request("\(base)/api/v1/executions?limit=15", headers: headers) let executions = (executionsJSON as? [String: Any])?["data"] as? [[String: Any]] ?? [] var failed = 0 let executionItems = executions.compactMap { e -> DetailItem? in guard let id = (e["id"] as? String) ?? (e["id"] as? Int).map(String.init) else { return nil } let workflowId = (e["workflowId"] as? String) ?? (e["workflowId"] as? Int).map(String.init) ?? "" let status = e["status"] as? String ?? ((e["finished"] as? Bool ?? false) ? "success" : "error") let isFailure = status == "error" || status == "crashed" || status == "failed" if isFailure { failed += 1 } return DetailItem(title: names[workflowId] ?? "Workflow \(workflowId)", subtitle: "Execution \(id)", tone: isFailure ? .error : (status == "success" ? .ok : .warning), date: date(e["startedAt"]), url: "\(base)/workflow/\(workflowId)/executions/\(id)", badge: status.capitalized, actions: isFailure ? [ServiceActionDef(kind: "n8n.retry", title: "Retry", symbol: "arrow.clockwise", target: id)] : []) } return ServiceDetail(pillId: "integration_n8n", stats: [DetailStat(label: "Workflows", value: "\(workflowItems.count)"), DetailStat(label: "Active", value: "\(active)", tone: .ok), DetailStat(label: "Failed runs", value: "\(failed)", tone: failed > 0 ? .error : .idle)], sections: [DetailSection(title: "Latest runs", items: executionItems), DetailSection(title: "Workflows", items: Array(workflowItems.sorted { ($0.date ?? .distantPast) > ($1.date ?? .distantPast) }.prefix(50)))], fetchedAt: Date()) } // Notion: the pages and databases edited last. Read only. private static func notion() async throws -> ServiceDetail { let key = try secret("notion-api-key", "Notion") let headers = ["Authorization": "Bearer \(key)", "Notion-Version": "2022-06-28"] let json = try await request("https://api.notion.com/v1/search", method: "POST", headers: headers, body: ["sort": ["direction": "descending", "timestamp": "last_edited_time"], "page_size": 25]) let results = (json as? [String: Any])?["results"] as? [[String: Any]] ?? [] var pages: [DetailItem] = [] var databases: [DetailItem] = [] for object in results { let kind = object["object"] as? String ?? "page" var title = "" if kind == "database" { title = ((object["title"] as? [[String: Any]])?.compactMap { $0["plain_text"] as? String }.joined()) ?? "" } else if let properties = object["properties"] as? [String: Any] { for case let property as [String: Any] in properties.values where property["type"] as? String == "title" { title = ((property["title"] as? [[String: Any]])?.compactMap { $0["plain_text"] as? String }.joined()) ?? "" } } var emoji = "" if let icon = object["icon"] as? [String: Any], icon["type"] as? String == "emoji" { emoji = (icon["emoji"] as? String ?? "") + " " } let item = DetailItem(title: emoji + (title.isEmpty ? "Untitled" : title), date: date(object["last_edited_time"]), url: object["url"] as? String, badge: (object["archived"] as? Bool ?? false) ? "Archived" : nil) if kind == "database" { databases.append(item) } else { pages.append(item) } } var sections = [DetailSection(title: "Edited lately", items: pages)] if !databases.isEmpty { sections.append(DetailSection(title: "Databases", items: databases)) } let editedToday = results.filter { date($0["last_edited_time"]).map(Calendar.current.isDateInToday) ?? false }.count return ServiceDetail(pillId: "integration_notion", stats: [DetailStat(label: "Edited today", value: "\(editedToday)", tone: editedToday > 0 ? .info : .idle), DetailStat(label: "Pages", value: "\(pages.count)"), DetailStat(label: "Databases", value: "\(databases.count)")], sections: sections, fetchedAt: Date()) } // MARK: Acting /// Runs one action. Returns a short line for the iPhone. static func perform(kind: String, target: String) async throws -> String { switch kind { case "vercel.redeploy": let parts = target.split(separator: "|", omittingEmptySubsequences: false).map(String.init) guard parts.count == 3 else { throw Failure(status: 0, message: "Unknown deployment") } var body: [String: Any] = ["name": parts[1], "deploymentId": parts[0]] if !parts[2].isEmpty { body["target"] = parts[2] } let json = try await request("https://api.vercel.com/v13/deployments", method: "POST", headers: try vercelHeaders(), body: body) let url = (json as? [String: Any])?["url"] as? String return url.map { "Building \($0)" } ?? "New deployment started" case "vercel.promote": // Vercel only promotes production builds in place: a preview is rebuilt for // production, as `vercel promote` does. let parts = target.split(separator: "|", omittingEmptySubsequences: false).map(String.init) guard parts.count == 2 else { throw Failure(status: 0, message: "Unknown deployment") } let json = try await request("https://api.vercel.com/v13/deployments", method: "POST", headers: try vercelHeaders(), body: ["deploymentId": parts[0], "name": parts[1], "target": "production", "meta": ["action": "promote"]]) let url = (json as? [String: Any])?["url"] as? String return url.map { "Building \($0) for production" } ?? "Production build started" case "vercel.cancel": _ = try await request("https://api.vercel.com/v12/deployments/\(target)/cancel", method: "PATCH", headers: try vercelHeaders()) return "Build canceled" case "github.rerun": // "owner/repo/123" let parts = target.split(separator: "/").map(String.init) guard parts.count == 3 else { throw Failure(status: 0, message: "Unknown run") } _ = try await request("https://api.github.com/repos/\(parts[0])/\(parts[1])/actions/runs/\(parts[2])/rerun-failed-jobs", method: "POST", headers: try githubHeaders()) return "Failed jobs started again" case "github.approve", "github.merge": // "owner/repo#12" let pieces = target.split(separator: "#").map(String.init) guard pieces.count == 2 else { throw Failure(status: 0, message: "Unknown pull request") } if kind == "github.approve" { _ = try await request("https://api.github.com/repos/\(pieces[0])/pulls/\(pieces[1])/reviews", method: "POST", headers: try githubHeaders(), body: ["event": "APPROVE"]) return "Approved" } _ = try await request("https://api.github.com/repos/\(pieces[0])/pulls/\(pieces[1])/merge", method: "PUT", headers: try githubHeaders(), body: ["merge_method": "squash"]) return "Merged" case "n8n.activate", "n8n.deactivate", "n8n.retry": let key = try secret("n8n-api-key", "n8n") let base = try secret("n8n-url", "n8n").trimmingCharacters(in: CharacterSet(charactersIn: "/")) let headers = ["X-N8N-API-KEY": key, "Accept": "application/json"] switch kind { case "n8n.activate": _ = try await request("\(base)/api/v1/workflows/\(target)/activate", method: "POST", headers: headers) return "Turned on" case "n8n.deactivate": _ = try await request("\(base)/api/v1/workflows/\(target)/deactivate", method: "POST", headers: headers) return "Turned off" default: do { _ = try await request("\(base)/api/v1/executions/\(target)/retry", method: "POST", headers: headers) } catch let failure as Failure where failure.status == 404 || failure.status == 405 { // POST /api/v1/executions/{id}/retry only exists from n8n 1.112.0. throw Failure(status: 0, message: "Couldn't retry this run. It may have been deleted, or your n8n is older than 1.112, which is needed to retry from the iPhone.") } return "Running again" } default: throw Failure(status: 0, message: "This action isn't available") } } private static func vercelHeaders() throws -> [String: String] { let token = try secret("vercel-token", "Vercel") return ["Authorization": "Bearer \(token)", "Accept": "application/json"] } private static func githubHeaders() throws -> [String: String] { let token = try secret("github-token", "GitHub") return ["Authorization": "Bearer \(token)", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"] } } #endif