53b4f60da1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
109 lines
4.6 KiB
Swift
109 lines
4.6 KiB
Swift
import Foundation
|
|
|
|
// MARK: - VercelPoller
|
|
// Polls Vercel API for latest deployments every 30s.
|
|
// On new terminal deployment: updates integration_vercel task state + AppState.vercelDeployments.
|
|
|
|
final class VercelPoller: @unchecked Sendable {
|
|
static let shared = VercelPoller()
|
|
private var timer: DispatchSourceTimer?
|
|
private var lastDeploymentId: String = ""
|
|
|
|
private init() {}
|
|
|
|
func start() {
|
|
guard timer == nil else { return }
|
|
let t = DispatchSource.makeTimerSource(queue: .global(qos: .background))
|
|
t.schedule(deadline: .now() + 5, repeating: 30)
|
|
t.setEventHandler { [weak self] in self?.poll() }
|
|
t.resume()
|
|
timer = t
|
|
}
|
|
|
|
// MARK: - Poll
|
|
|
|
private func poll() {
|
|
guard let token = KeychainStore.shared.get("vercel-token") else { return }
|
|
|
|
// Fetch last 5 terminal deployments
|
|
guard let url = URL(string: "https://api.vercel.com/v6/deployments?limit=5") else { return }
|
|
var req = URLRequest(url: url, timeoutInterval: 10)
|
|
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
URLSession.shared.dataTask(with: req) { [weak self] data, response, error in
|
|
guard let self else { return }
|
|
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
guard let data, code == 200 else { return }
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let rawList = json["deployments"] as? [[String: Any]] else { return }
|
|
|
|
// Only terminal deployments (READY, ERROR, CANCELED)
|
|
let terminal = ["READY", "ERROR", "CANCELED"]
|
|
let parsed = rawList
|
|
.compactMap { self.parseDeployment($0) }
|
|
.filter { terminal.contains($0.state) }
|
|
guard !parsed.isEmpty else { return }
|
|
|
|
DispatchQueue.main.async { self.handleDeployments(parsed) }
|
|
}.resume()
|
|
}
|
|
|
|
private func parseDeployment(_ d: [String: Any]) -> VercelDeployment? {
|
|
guard let uid = d["uid"] as? String,
|
|
let name = d["name"] as? String,
|
|
let state = d["state"] as? String else { return nil }
|
|
|
|
let url = (d["url"] as? String) ?? ""
|
|
let createdAtMs = (d["createdAt"] as? Double) ?? 0
|
|
let createdAt = Date(timeIntervalSince1970: createdAtMs / 1000)
|
|
|
|
let meta = d["meta"] as? [String: Any]
|
|
let commitMessage = meta?["githubCommitMessage"] as? String
|
|
?? meta?["gitlabCommitMessage"] as? String
|
|
?? meta?["bitbucketCommitMessage"] as? String
|
|
let branch = meta?["githubCommitRef"] as? String
|
|
?? meta?["gitlabCommitRef"] as? String
|
|
?? meta?["bitbucketBranch"] as? String
|
|
|
|
return VercelDeployment(id: uid, projectName: name, url: url, state: state,
|
|
createdAt: createdAt, commitMessage: commitMessage, branch: branch)
|
|
}
|
|
|
|
@MainActor
|
|
private func handleDeployments(_ deployments: [VercelDeployment]) {
|
|
let appState = AppState.shared
|
|
appState.vercelDeployments = deployments
|
|
|
|
// Apply project filter (empty = all projects)
|
|
let filter = appState.vercelProjectFilter
|
|
let filtered = filter.isEmpty ? deployments : deployments.filter { filter.contains($0.projectName) }
|
|
guard let latest = filtered.first else { return }
|
|
guard latest.id != lastDeploymentId else { return }
|
|
lastDeploymentId = latest.id
|
|
|
|
guard let idx = appState.tasks.firstIndex(where: { $0.id == "integration_vercel" }) else { return }
|
|
let focused = appState.focusId == "integration_vercel"
|
|
|
|
appState.tasks[idx].state = latest.isSuccess ? .finished : .error
|
|
appState.tasks[idx].steps = [latest.projectName]
|
|
|
|
if !focused {
|
|
appState.tasks[idx].pillBadge = latest.isSuccess ? .finished : .error
|
|
}
|
|
SoundEngine.shared.play(latest.isSuccess ? "finish" : "error")
|
|
|
|
// Reveal compact island so user sees the badge
|
|
NotificationCenter.default.post(name: .hookReveal, object: nil)
|
|
|
|
// Auto-clear task state after 60s (deployments list stays)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 60) {
|
|
guard let i = appState.tasks.firstIndex(where: { $0.id == "integration_vercel" }) else { return }
|
|
guard appState.tasks[i].state == .finished || appState.tasks[i].state == .error else { return }
|
|
appState.tasks[i].state = .idle
|
|
appState.tasks[i].steps = []
|
|
appState.tasks[i].pillBadge = nil
|
|
}
|
|
}
|
|
}
|