include argus workflow
This commit is contained in:
@@ -24,16 +24,34 @@ enum NetworkError: Error, LocalizedError {
|
||||
|
||||
final class TokenStore {
|
||||
static let shared = TokenStore()
|
||||
private let key = "lockInBro.jwt"
|
||||
private let accessKey = "lockInBro.jwt"
|
||||
private let refreshKey = "lockInBro.refreshToken"
|
||||
private init() {}
|
||||
|
||||
var token: String? {
|
||||
get { UserDefaults.standard.string(forKey: key) }
|
||||
get { UserDefaults.standard.string(forKey: accessKey) }
|
||||
set {
|
||||
if let v = newValue { UserDefaults.standard.set(v, forKey: key) }
|
||||
else { UserDefaults.standard.removeObject(forKey: key) }
|
||||
if let v = newValue { UserDefaults.standard.set(v, forKey: accessKey) }
|
||||
else { UserDefaults.standard.removeObject(forKey: accessKey) }
|
||||
}
|
||||
}
|
||||
|
||||
var refreshToken: String? {
|
||||
get { UserDefaults.standard.string(forKey: refreshKey) }
|
||||
set {
|
||||
if let v = newValue { UserDefaults.standard.set(v, forKey: refreshKey) }
|
||||
else { UserDefaults.standard.removeObject(forKey: refreshKey) }
|
||||
}
|
||||
}
|
||||
|
||||
func clear() {
|
||||
token = nil
|
||||
refreshToken = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let lockInBroAuthExpired = Notification.Name("lockInBroAuthExpired")
|
||||
}
|
||||
|
||||
// MARK: - APIClient
|
||||
@@ -46,13 +64,17 @@ final class APIClient {
|
||||
|
||||
// MARK: Core Request
|
||||
|
||||
// Coalesces concurrent 401-triggered refreshes into one request
|
||||
private var activeRefreshTask: Task<Bool, Never>?
|
||||
|
||||
private func req(
|
||||
_ path: String,
|
||||
method: String = "GET",
|
||||
body: Data? = nil,
|
||||
contentType: String = "application/json",
|
||||
auth: Bool = true,
|
||||
timeout: TimeInterval = 30
|
||||
timeout: TimeInterval = 30,
|
||||
isRetry: Bool = false
|
||||
) async throws -> Data {
|
||||
guard let url = URL(string: base + path) else {
|
||||
throw NetworkError.unknown(URLError(.badURL))
|
||||
@@ -75,6 +97,17 @@ final class APIClient {
|
||||
throw NetworkError.unknown(URLError(.badServerResponse))
|
||||
}
|
||||
guard http.statusCode < 400 else {
|
||||
if http.statusCode == 401 && auth && !isRetry {
|
||||
// Try to silently refresh the access token, then retry once
|
||||
let refreshed = await refreshAccessToken()
|
||||
if refreshed {
|
||||
return try await req(path, method: method, body: body,
|
||||
contentType: contentType, auth: auth,
|
||||
timeout: timeout, isRetry: true)
|
||||
}
|
||||
// Refresh also failed — force logout
|
||||
await MainActor.run { AuthManager.shared.handleSessionExpired() }
|
||||
}
|
||||
let msg = (try? JSONDecoder().decode(APIErrorResponse.self, from: data))?.detail
|
||||
?? String(data: data, encoding: .utf8)
|
||||
?? "Unknown error"
|
||||
@@ -83,6 +116,32 @@ final class APIClient {
|
||||
return data
|
||||
}
|
||||
|
||||
/// Refreshes the access token. Concurrent callers share one in-flight request.
|
||||
private func refreshAccessToken() async -> Bool {
|
||||
if let existing = activeRefreshTask { return await existing.value }
|
||||
let task = Task<Bool, Never> {
|
||||
defer { self.activeRefreshTask = nil }
|
||||
guard let refresh = TokenStore.shared.refreshToken else { return false }
|
||||
do {
|
||||
let body = try JSONSerialization.data(withJSONObject: ["refresh_token": refresh])
|
||||
guard let url = URL(string: base + "/auth/refresh") else { return false }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = body
|
||||
req.timeoutInterval = 30
|
||||
let (data, res) = try await urlSession.data(for: req)
|
||||
guard let http = res as? HTTPURLResponse, http.statusCode == 200 else { return false }
|
||||
let auth = try self.decode(AuthResponse.self, from: data)
|
||||
TokenStore.shared.token = auth.accessToken
|
||||
TokenStore.shared.refreshToken = auth.refreshToken
|
||||
return true
|
||||
} catch { return false }
|
||||
}
|
||||
activeRefreshTask = task
|
||||
return await task.value
|
||||
}
|
||||
|
||||
private func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
@@ -206,6 +265,16 @@ final class APIClient {
|
||||
|
||||
// MARK: - Sessions
|
||||
|
||||
/// Returns the currently active session, or nil if none (404).
|
||||
func getActiveSession() async throws -> FocusSession? {
|
||||
do {
|
||||
let data = try await req("/sessions/active")
|
||||
return try decode(FocusSession.self, from: data)
|
||||
} catch NetworkError.httpError(404, _) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func startSession(taskId: String?) async throws -> FocusSession {
|
||||
var dict: [String: Any] = ["platform": "mac"]
|
||||
if let tid = taskId { dict["task_id"] = tid }
|
||||
@@ -268,9 +337,35 @@ final class APIClient {
|
||||
_ = try await req("/distractions/app-activity", method: "POST", body: body)
|
||||
}
|
||||
|
||||
// MARK: - Distraction / Screenshot Analysis
|
||||
// Note: spec primary endpoint is POST /distractions/analyze-result (device-side VLM, JSON only).
|
||||
// Backend currently implements analyze-screenshot (legacy fallback) — using that until analyze-result is deployed.
|
||||
// MARK: - Distraction / VLM Analysis
|
||||
|
||||
/// Post a VLM analysis result (from GeminiVLMClient) to the backend.
|
||||
/// This updates the session checkpoint so the backend has the latest on_task / friction data.
|
||||
func postAnalysisResult(_ result: DistractionAnalysisResponse, sessionId: String) async throws {
|
||||
var payload: [String: Any] = [
|
||||
"session_id": sessionId,
|
||||
"on_task": result.onTask,
|
||||
"confidence": result.confidence,
|
||||
"vlm_summary": result.vlmSummary ?? "",
|
||||
"steps_completed": result.stepsCompleted,
|
||||
]
|
||||
if let stepId = result.currentStepId { payload["current_step_id"] = stepId }
|
||||
if let note = result.checkpointNoteUpdate { payload["checkpoint_note_update"] = note }
|
||||
if let app = result.appName { payload["app_name"] = app }
|
||||
if let nudge = result.gentleNudge { payload["gentle_nudge"] = nudge }
|
||||
if let friction = result.friction {
|
||||
payload["friction"] = [
|
||||
"type": friction.type,
|
||||
"confidence": friction.confidence,
|
||||
"description": friction.description as Any,
|
||||
"proposed_actions": friction.proposedActions.map {
|
||||
["label": $0.label, "action_type": $0.actionType, "details": $0.details as Any]
|
||||
},
|
||||
]
|
||||
}
|
||||
let body = try JSONSerialization.data(withJSONObject: payload)
|
||||
_ = try await req("/distractions/analyze-result", method: "POST", body: body)
|
||||
}
|
||||
|
||||
func analyzeScreenshot(
|
||||
imageData: Data,
|
||||
|
||||
Reference in New Issue
Block a user