Import Coucou 0.1.9 (Louis-CFM/coucou@83708fe), Mac app only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
#if PHONE_LINK
|
||||
import AppKit
|
||||
import CloudKit
|
||||
import Combine
|
||||
|
||||
// MARK: - Answer Claude's questions from the iPhone
|
||||
//
|
||||
// While Claude Code waits on an AskUserQuestion, the session record carries
|
||||
// the question and its choices (SessionPublisher). The iPhone answers with an
|
||||
// `Answer` record: the question's fingerprint and the labels picked. This Mac
|
||||
// looks for answers every 2 s, only while a question waits, and applies one
|
||||
// only if it matches the question still waiting and every label is one of its
|
||||
// choices. Answers are single use: deleted as soon as they are read.
|
||||
|
||||
@MainActor
|
||||
final class QuestionRelay {
|
||||
static let shared = QuestionRelay()
|
||||
|
||||
private let container = CKContainer(identifier: "iCloud.fr.louisraille.Coucou")
|
||||
private var database: CKDatabase { container.privateCloudDatabase }
|
||||
private var zoneID: CKRecordZone.ID { SessionSnapshot.zoneID }
|
||||
|
||||
private var cancellable: AnyCancellable?
|
||||
private var current: (fingerprint: String, since: Date)?
|
||||
private var pollTask: Task<Void, Never>?
|
||||
private var changeToken: CKServerChangeToken?
|
||||
|
||||
func start() {
|
||||
guard cancellable == nil else { return }
|
||||
// @Published sends the new value before the property changes: use the value passed along.
|
||||
cancellable = AppState.shared.$pendingQuestion
|
||||
.map { $0.map(QuestionPayload.init(ask:)) }
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] payload in
|
||||
MainActor.assumeIsolated { self?.pendingChanged(to: payload) }
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
cancellable = nil
|
||||
pendingChanged(to: nil)
|
||||
}
|
||||
|
||||
private func pendingChanged(to payload: QuestionPayload?) {
|
||||
let fingerprint = payload?.fingerprint
|
||||
guard current?.fingerprint != fingerprint else { return }
|
||||
pollTask?.cancel()
|
||||
pollTask = nil
|
||||
current = nil
|
||||
guard let fingerprint else { return }
|
||||
current = (fingerprint, Date())
|
||||
pollTask = Task { [weak self] in
|
||||
// The Mac gives the question back to the terminal after 120 s.
|
||||
for _ in 0..<62 {
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
if await self.checkAnswers(for: fingerprint) { return }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true once a matching answer was applied.
|
||||
private func checkAnswers(for fingerprint: String) async -> Bool {
|
||||
guard current?.fingerprint == fingerprint, let since = current?.since else { return true }
|
||||
var found: [(id: CKRecord.ID, fingerprint: String, selections: String)] = []
|
||||
do {
|
||||
var more = true
|
||||
while more {
|
||||
let changes = try await database.recordZoneChanges(inZoneWith: zoneID, since: changeToken)
|
||||
for (id, result) in changes.modificationResultsByID {
|
||||
guard case .success(let mod) = result, mod.record.recordType == "Answer" else { continue }
|
||||
let record = mod.record
|
||||
let answeredAt = record["answeredAt"] as? Date ?? .distantPast
|
||||
guard answeredAt >= since.addingTimeInterval(-5) else { continue }
|
||||
found.append((id, record["fingerprint"] as? String ?? "",
|
||||
record.encryptedValues["selections"] as? String ?? ""))
|
||||
}
|
||||
changeToken = changes.changeToken
|
||||
more = changes.moreComing
|
||||
}
|
||||
} catch let error as CKError where error.code == .changeTokenExpired {
|
||||
changeToken = nil
|
||||
return false
|
||||
} catch {
|
||||
log("answer check failed: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
guard !found.isEmpty else { return false }
|
||||
let ids = found.map { $0.id }
|
||||
Task { _ = try? await database.modifyRecords(saving: [], deleting: ids) }
|
||||
guard let match = found.first(where: { $0.fingerprint == fingerprint }) else {
|
||||
log("ignored \(found.count) answer(s) for another question")
|
||||
return false
|
||||
}
|
||||
apply(match.selections, fingerprint: fingerprint)
|
||||
return true
|
||||
}
|
||||
|
||||
private func apply(_ json: String, fingerprint: String) {
|
||||
// Check again right before answering the hook.
|
||||
guard let pending = AppState.shared.pendingQuestion else {
|
||||
log("answer arrived after the question was resolved, ignored")
|
||||
return
|
||||
}
|
||||
let payload = QuestionPayload(ask: pending)
|
||||
guard payload.fingerprint == fingerprint else {
|
||||
log("answer for an older question, ignored")
|
||||
return
|
||||
}
|
||||
guard let selections = QuestionPayload.decodeSelections(json), payload.accepts(selections) else {
|
||||
log("answer with unknown choices, ignored")
|
||||
return
|
||||
}
|
||||
log("answered from the iPhone")
|
||||
HookServer.shared.sendQuestionAnswers(AskQuestion.buildAnswers(questions: pending.questions, selections: selections))
|
||||
}
|
||||
|
||||
private func log(_ message: String) {
|
||||
CloudProbe.shared.log("[question] \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
extension QuestionPayload {
|
||||
init(ask: AskQuestion) {
|
||||
self.init(items: ask.questions.map { item in
|
||||
Item(question: item.question, header: item.header,
|
||||
options: item.options.map { Option(label: $0.label, description: $0.description) },
|
||||
multiSelect: item.multiSelect)
|
||||
})
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user