A comment is a last resort, and these were not one: section banners, doc headers naming what the declaration below already names, and notes saying in prose what the next line says in code. What is left is what the code cannot show -- an ordering that matters, a platform quirk, why the obvious way fails.
1883 lines
74 KiB
Swift
1883 lines
74 KiB
Swift
import Foundation
|
|
import Frida
|
|
import Observation
|
|
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
@Observable
|
|
@MainActor
|
|
public final class CollaborationSession {
|
|
public enum Status: Equatable, Sendable {
|
|
case disconnected
|
|
case connecting
|
|
case joined(labID: String)
|
|
case error(message: String)
|
|
}
|
|
|
|
public struct UserInfo: Identifiable, Hashable, Sendable, Codable {
|
|
public let id: String
|
|
public let name: String
|
|
public let avatarURL: URL?
|
|
|
|
public init(id: String, name: String, avatarURL: URL?) {
|
|
self.id = id
|
|
self.name = name
|
|
self.avatarURL = avatarURL
|
|
}
|
|
|
|
public static func fromJSON(_ obj: [String: Any]) -> UserInfo? {
|
|
guard let id = obj["id"] as? String, let name = obj["name"] as? String else { return nil }
|
|
let avatarURL = (obj["avatar"] as? String).flatMap(URL.init(string:))
|
|
return UserInfo(id: id, name: name, avatarURL: avatarURL)
|
|
}
|
|
}
|
|
|
|
public struct Member: Identifiable, Hashable, Sendable {
|
|
public enum Role: String, Sendable { case owner, member }
|
|
public enum Presence: String, Sendable { case online, offline }
|
|
|
|
public let user: UserInfo
|
|
public var role: Role
|
|
public var presence: Presence
|
|
public let joinedAt: String
|
|
public var lastSeenAt: String
|
|
|
|
public var id: String { user.id }
|
|
|
|
public static func fromJSON(_ obj: [String: Any]) -> Member? {
|
|
guard let userObj = obj["user"] as? [String: Any],
|
|
let user = UserInfo.fromJSON(userObj),
|
|
let roleRaw = obj["role"] as? String,
|
|
let role = Role(rawValue: roleRaw),
|
|
let presenceRaw = obj["presence"] as? String,
|
|
let presence = Presence(rawValue: presenceRaw),
|
|
let joinedAt = obj["joined_at"] as? String,
|
|
let lastSeenAt = obj["last_seen_at"] as? String
|
|
else { return nil }
|
|
return Member(user: user, role: role, presence: presence, joinedAt: joinedAt, lastSeenAt: lastSeenAt)
|
|
}
|
|
}
|
|
|
|
public struct Session: Identifiable, Sendable {
|
|
public enum Phase: String, Sendable {
|
|
case attaching
|
|
case attached
|
|
case detached
|
|
}
|
|
|
|
public let id: UUID
|
|
public let host: UserInfo
|
|
public let deviceID: String
|
|
public let deviceName: String
|
|
public let pid: UInt
|
|
public let processName: String
|
|
public var phase: Phase
|
|
public var armingState: ProcessSession.ArmingState
|
|
public let createdAt: String
|
|
public var lastSeenAt: String
|
|
public var modules: [ProcessModule]
|
|
public var threads: [ProcessThread]
|
|
public var replCells: [REPLCell]
|
|
public var instruments: [InstrumentInstance]
|
|
public var insights: [AddressInsight]
|
|
public var traces: [ITrace]
|
|
public var processInfo: ProcessSession.ProcessInfo?
|
|
public var moduleAnalyses: [ModuleAnalysis]
|
|
public var moduleSymbols: [SessionOp.UpdateModuleSymbols.Entry]
|
|
|
|
public init(
|
|
id: UUID,
|
|
host: UserInfo,
|
|
deviceID: String,
|
|
deviceName: String,
|
|
pid: UInt,
|
|
processName: String,
|
|
phase: Phase,
|
|
armingState: ProcessSession.ArmingState = .unarmed,
|
|
createdAt: String,
|
|
lastSeenAt: String,
|
|
modules: [ProcessModule],
|
|
threads: [ProcessThread] = [],
|
|
replCells: [REPLCell] = [],
|
|
instruments: [InstrumentInstance] = [],
|
|
insights: [AddressInsight] = [],
|
|
traces: [ITrace] = [],
|
|
processInfo: ProcessSession.ProcessInfo? = nil,
|
|
moduleAnalyses: [ModuleAnalysis] = [],
|
|
moduleSymbols: [SessionOp.UpdateModuleSymbols.Entry] = []
|
|
) {
|
|
self.id = id
|
|
self.host = host
|
|
self.deviceID = deviceID
|
|
self.deviceName = deviceName
|
|
self.pid = pid
|
|
self.processName = processName
|
|
self.phase = phase
|
|
self.armingState = armingState
|
|
self.createdAt = createdAt
|
|
self.lastSeenAt = lastSeenAt
|
|
self.modules = modules
|
|
self.threads = threads
|
|
self.replCells = replCells
|
|
self.instruments = instruments
|
|
self.insights = insights
|
|
self.traces = traces
|
|
self.processInfo = processInfo
|
|
self.moduleAnalyses = moduleAnalyses
|
|
self.moduleSymbols = moduleSymbols
|
|
}
|
|
|
|
public static func fromJSON(_ obj: [String: Any]) -> Session? {
|
|
guard let idStr = obj["id"] as? String,
|
|
let id = UUID(uuidString: idStr),
|
|
let hostObj = obj["host"] as? [String: Any],
|
|
let host = UserInfo.fromJSON(hostObj),
|
|
let deviceObj = obj["device"] as? [String: Any],
|
|
let deviceID = deviceObj["id"] as? String,
|
|
let deviceName = deviceObj["name"] as? String,
|
|
let processObj = obj["process"] as? [String: Any],
|
|
let processName = processObj["name"] as? String,
|
|
let phaseRaw = obj["phase"] as? String,
|
|
let phase = Phase(rawValue: phaseRaw),
|
|
let createdAt = obj["created_at"] as? String,
|
|
let lastSeenAt = obj["last_seen_at"] as? String
|
|
else { return nil }
|
|
|
|
let pid: UInt
|
|
if let v = processObj["pid"] as? Int { pid = UInt(v) }
|
|
else if let v = processObj["pid"] as? UInt { pid = v }
|
|
else if let v = processObj["pid"] as? NSNumber { pid = v.uintValue }
|
|
else { return nil }
|
|
|
|
let moduleObjs = (obj["modules"] as? [[String: Any]]) ?? []
|
|
let modules = moduleObjs.compactMap(ProcessModule.fromJSON)
|
|
|
|
let threadObjs = (obj["threads"] as? [[String: Any]]) ?? []
|
|
let threads = threadObjs.compactMap(ProcessThread.fromJSON)
|
|
|
|
let cellObjs = (obj["repl_cells"] as? [[String: Any]]) ?? []
|
|
let cells = cellObjs.compactMap(REPLCell.fromWireJSON)
|
|
|
|
let instrumentObjs = (obj["instruments"] as? [[String: Any]]) ?? []
|
|
let instruments = instrumentObjs.compactMap(InstrumentInstance.fromWireJSON)
|
|
|
|
let insightObjs = (obj["insights"] as? [[String: Any]]) ?? []
|
|
let insights = insightObjs.compactMap(AddressInsight.fromWireJSON)
|
|
|
|
let traceObjs = (obj["traces"] as? [[String: Any]]) ?? []
|
|
let traces = traceObjs.compactMap(ITrace.fromWireJSON)
|
|
|
|
let processInfo: ProcessSession.ProcessInfo?
|
|
if let infoObj = obj["process_info"] as? [String: Any],
|
|
let platform = infoObj["platform"] as? String,
|
|
let arch = infoObj["arch"] as? String,
|
|
let pointerSize = infoObj["pointer_size"] as? Int,
|
|
let identity = infoObj["identity"] as? String
|
|
{
|
|
processInfo = ProcessSession.ProcessInfo(
|
|
platform: platform, arch: arch, pointerSize: pointerSize, identity: identity
|
|
)
|
|
} else {
|
|
processInfo = nil
|
|
}
|
|
|
|
let analysisObjs = (obj["module_analyses"] as? [[String: Any]]) ?? []
|
|
let moduleAnalyses = analysisObjs.compactMap(ModuleAnalysis.fromWireJSON)
|
|
|
|
let symbolObjs = (obj["module_symbols"] as? [[String: Any]]) ?? []
|
|
let moduleSymbols: [SessionOp.UpdateModuleSymbols.Entry] = symbolObjs.compactMap { entry in
|
|
guard let modulePath = entry["module_path"] as? String,
|
|
let name = entry["name"] as? String
|
|
else { return nil }
|
|
let rawOffset: Int64
|
|
if let n = entry["offset"] as? Int64 { rawOffset = n }
|
|
else if let n = entry["offset"] as? Int { rawOffset = Int64(n) }
|
|
else { return nil }
|
|
return .init(modulePath: modulePath, offset: UInt64(bitPattern: rawOffset), name: name)
|
|
}
|
|
|
|
let armingState: ProcessSession.ArmingState
|
|
if let stateObj = obj["arming_state"] as? [String: Any],
|
|
let parsed = decodeArmingState(stateObj) {
|
|
armingState = parsed
|
|
} else {
|
|
armingState = .unarmed
|
|
}
|
|
|
|
return Session(
|
|
id: id,
|
|
host: host,
|
|
deviceID: deviceID,
|
|
deviceName: deviceName,
|
|
pid: pid,
|
|
processName: processName,
|
|
phase: phase,
|
|
armingState: armingState,
|
|
createdAt: createdAt,
|
|
lastSeenAt: lastSeenAt,
|
|
modules: modules,
|
|
threads: threads,
|
|
replCells: cells,
|
|
instruments: instruments,
|
|
insights: insights,
|
|
traces: traces,
|
|
processInfo: processInfo,
|
|
moduleAnalyses: moduleAnalyses,
|
|
moduleSymbols: moduleSymbols
|
|
)
|
|
}
|
|
}
|
|
|
|
public struct ChatMessage: Identifiable, Hashable, Sendable {
|
|
public let id: UUID
|
|
public let text: String
|
|
public let sender: UserInfo
|
|
public let isLocal: Bool
|
|
public let timestamp: Date
|
|
|
|
public init(id: UUID = UUID(), text: String, sender: UserInfo, isLocal: Bool, timestamp: Date = .now) {
|
|
self.id = id
|
|
self.text = text
|
|
self.sender = sender
|
|
self.isLocal = isLocal
|
|
self.timestamp = timestamp
|
|
}
|
|
|
|
public static func fromJSON(_ obj: [String: Any], localUser: UserInfo) -> ChatMessage? {
|
|
guard let idString = obj["id"] as? String,
|
|
let id = UUID(uuidString: idString),
|
|
let text = obj["text"] as? String,
|
|
let senderObj = obj["user"] as? [String: Any],
|
|
let sender = UserInfo.fromJSON(senderObj),
|
|
let timestampString = obj["timestamp"] as? String,
|
|
let timestamp = parseTimestamp(timestampString)
|
|
else { return nil }
|
|
return ChatMessage(id: id, text: text, sender: sender, isLocal: sender.id == localUser.id, timestamp: timestamp)
|
|
}
|
|
|
|
private static func parseTimestamp(_ s: String) -> Date? {
|
|
timestampWithFractionalSeconds.date(from: s) ?? timestampWithoutFractionalSeconds.date(from: s)
|
|
}
|
|
|
|
private nonisolated(unsafe) static let timestampWithFractionalSeconds: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return f
|
|
}()
|
|
|
|
private nonisolated(unsafe) static let timestampWithoutFractionalSeconds: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime]
|
|
return f
|
|
}()
|
|
}
|
|
|
|
private let deviceManager: DeviceManager
|
|
private let store: ProjectStore
|
|
private let portalAddress: String
|
|
private let portalCertificate: String
|
|
|
|
private(set) public var status: Status = .disconnected
|
|
private(set) public var labID: String?
|
|
private(set) public var labTitle: String?
|
|
private(set) public var labPictureData: Data?
|
|
private(set) public var labPictureContentType: String?
|
|
private(set) public var localUser: UserInfo?
|
|
private var pendingJoinLabID: String?
|
|
private var pendingCreateOrJoinDispatched: Bool = false
|
|
private(set) public var members: [Member] = []
|
|
private(set) public var chatMessages: [ChatMessage] = []
|
|
private(set) public var vapidPublicKey: String?
|
|
private(set) public var registeredPushPlatforms: Set<String> = []
|
|
public var isHost = false
|
|
|
|
public var isOwner: Bool {
|
|
guard let localUser else { return false }
|
|
return members.contains { $0.user.id == localUser.id && $0.role == .owner }
|
|
}
|
|
|
|
public func isSelf(_ userID: String) -> Bool {
|
|
userID == localUser?.id
|
|
}
|
|
|
|
private var portalDevice: Device?
|
|
private var portalBusTask: Task<Void, Never>?
|
|
|
|
private let _statusChanges = AsyncEventSource<Status>()
|
|
public var statusChanges: AsyncStream<Status> { _statusChanges.makeStream() }
|
|
|
|
public var onNotebookSnapshot: (([NotebookEntry]) -> Void)?
|
|
public var onEntryUpserted: ((NotebookEntry) -> Void)?
|
|
public var onEntryRemoved: ((UUID) -> Void)?
|
|
public var onEntryRepositioned: ((UUID, Double) -> Void)?
|
|
public var onOpRejected: ((UUID, String) -> Void)?
|
|
public var onMemberAdded: ((Member) -> Void)?
|
|
public var onMemberRemoved: ((String) -> Void)?
|
|
public var onMemberRoleChanged: ((String, Member.Role) -> Void)?
|
|
public var onMemberPresenceChanged: ((String, Member.Presence) -> Void)?
|
|
public var onSessionsSnapshot: (([Session]) -> Void)?
|
|
public var onSessionAdded: ((Session) -> Void)?
|
|
public var onSessionPhaseChanged: ((UUID, Session.Phase, String?) -> Void)?
|
|
public var onSessionArmingChanged: ((UUID, ProcessSession.ArmingState) -> Void)?
|
|
public var onSessionModulesUpdated: ((UUID, ModuleDelta) -> Void)?
|
|
public var onSessionThreadsUpdated: ((UUID, ThreadDelta) -> Void)?
|
|
public var onSessionHostChanged: ((UUID, UserInfo, String, String, UInt, String) -> Void)?
|
|
public var onSessionReplCellAdded: ((UUID, REPLCell) -> Void)?
|
|
public var onSessionReplEvalRequested: ((UUID, String, REPLLanguage, UUID) -> Void)?
|
|
public var onSessionInstrumentAdded: ((UUID, InstrumentInstance) -> Void)?
|
|
public var onSessionInstrumentUpdated: ((UUID, InstrumentInstance) -> Void)?
|
|
public var onSessionInstrumentRemoved: ((UUID, UUID) -> Void)?
|
|
public var onSessionInstrumentSetStateRequested: ((UUID, UUID, InstrumentState) -> Void)?
|
|
public var onSessionInstrumentRemoveRequested: ((UUID, UUID) -> Void)?
|
|
public var onSessionInstrumentAddRequested: ((UUID, InstrumentKind, String, Data) -> Void)?
|
|
public var onSessionInstrumentUpdateConfigRequested: ((UUID, UUID, Data) -> Void)?
|
|
public var onSessionInsightAdded: ((UUID, AddressInsight) -> Void)?
|
|
public var onSessionInsightUpdated: ((UUID, AddressInsight) -> Void)?
|
|
public var onSessionInsightRemoved: ((UUID, UUID) -> Void)?
|
|
public var onSessionProcessInfoUpdated: ((UUID, ProcessSession.ProcessInfo) -> Void)?
|
|
public var onSessionModuleAnalysisUpdated: ((UUID, ModuleAnalysis) -> Void)?
|
|
public var onSessionModuleSymbolsUpdated: ((UUID, [SessionOp.UpdateModuleSymbols.Entry]) -> Void)?
|
|
public var onSessionTraceUpserted: ((UUID, ITrace) -> Void)?
|
|
public var onSessionTraceRemoved: ((UUID, UUID) -> Void)?
|
|
public var onSessionTraceDataProgressed: ((UUID, UUID, Int) -> Void)?
|
|
public var onSessionEventReceived: ((UUID, RuntimeEvent) -> Void)?
|
|
public var onSessionWidgetUpdateReceived: ((UUID, WidgetUpdate) -> Void)?
|
|
public var onSessionWidgetActionRequested: ((UUID, UUID, String, String, String?) -> Void)?
|
|
public var onReplEvalTimedOut: ((UUID) -> Void)?
|
|
public var onSessionRemoved: ((UUID) -> Void)?
|
|
public var onCustomInstrumentOpReceived: ((CustomInstrumentOp) -> Void)?
|
|
public var onMissionOpReceived: ((MissionOp) -> Void)?
|
|
public var onAddressNoteOpReceived: ((AddressNoteOp) -> Void)?
|
|
public var onMissionSnapshot: ((MissionSnapshot) -> Void)?
|
|
public var onCustomInstrumentSnapshot: (([CustomInstrumentBundle]) -> Void)?
|
|
public var onWidgetStatesSnapshot: (([WidgetStateSnapshot]) -> Void)?
|
|
public var onSessionOpRejected: ((UUID, String) -> Void)?
|
|
public var onChatMessageReceived: ((ChatMessage) -> Void)?
|
|
public var onChatMessageChange: ((ChatMessageChange) -> Void)?
|
|
|
|
public enum ChatMessageChange: Sendable {
|
|
case reset
|
|
case appended(ChatMessage)
|
|
case replaced(id: UUID, with: ChatMessage)
|
|
case removed(id: UUID)
|
|
}
|
|
public var onAuthRejected: ((AuthFailure) async -> Void)?
|
|
|
|
private var nextRequestId = 0
|
|
private var pendingRequests: [String: (Result<JSONObject, AuthFailure>) -> Void] = [:]
|
|
|
|
public init(
|
|
deviceManager: DeviceManager,
|
|
store: ProjectStore,
|
|
portalAddress: String,
|
|
portalCertificate: String
|
|
) {
|
|
self.deviceManager = deviceManager
|
|
self.store = store
|
|
self.portalAddress = portalAddress
|
|
self.portalCertificate = portalCertificate
|
|
}
|
|
|
|
public func start(token: String, existingLabID: String?) async {
|
|
guard case .disconnected = status else { return }
|
|
setStatus(.connecting)
|
|
|
|
do {
|
|
let device = try await deviceManager.addRemoteDevice(
|
|
address: portalAddress,
|
|
certificate: portalCertificate,
|
|
origin: nil,
|
|
token: token,
|
|
keepaliveInterval: nil
|
|
)
|
|
portalDevice = device
|
|
|
|
let busEvents = device.bus.events
|
|
portalBusTask?.cancel()
|
|
portalBusTask = Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
for await event in busEvents {
|
|
await self.handleBusEvent(event)
|
|
}
|
|
}
|
|
|
|
try await device.bus.attach()
|
|
|
|
if let existingLabID {
|
|
isHost = false
|
|
pendingJoinLabID = existingLabID
|
|
} else {
|
|
isHost = true
|
|
}
|
|
triggerJoinOrCreateIfReady()
|
|
} catch {
|
|
if let failure = AuthFailure.fromError(error), failure.isAuthRejection {
|
|
setStatus(.error(message: failure.message))
|
|
await onAuthRejected?(failure)
|
|
} else {
|
|
setStatus(.error(message: error.localizedDescription))
|
|
}
|
|
}
|
|
}
|
|
|
|
public func stop() async {
|
|
try? await deviceManager.removeRemoteDevice(address: portalAddress)
|
|
|
|
portalBusTask?.cancel()
|
|
portalBusTask = nil
|
|
portalDevice = nil
|
|
|
|
setStatus(.disconnected)
|
|
labID = nil
|
|
labTitle = nil
|
|
labPictureData = nil
|
|
labPictureContentType = nil
|
|
localUser = nil
|
|
pendingJoinLabID = nil
|
|
pendingCreateOrJoinDispatched = false
|
|
members = []
|
|
chatMessages = []
|
|
onChatMessageChange?(.reset)
|
|
vapidPublicKey = nil
|
|
registeredPushPlatforms = []
|
|
pendingRequests.removeAll()
|
|
}
|
|
|
|
private func sendRequest(
|
|
to path: String,
|
|
type: String,
|
|
payload: JSONObject = [:],
|
|
data: [UInt8]? = nil,
|
|
onResult: @escaping (Result<JSONObject, AuthFailure>) -> Void
|
|
) {
|
|
guard let device = portalDevice else { return }
|
|
nextRequestId += 1
|
|
let id = "r\(nextRequestId)"
|
|
pendingRequests[id] = onResult
|
|
var msg: JSONObject = ["to": path, "type": type, "id": id]
|
|
if !payload.isEmpty { msg["payload"] = payload }
|
|
device.bus.post(msg, data: data)
|
|
}
|
|
|
|
private func sendNotification(
|
|
to path: String,
|
|
type: String,
|
|
payload: JSONObject,
|
|
data: [UInt8]? = nil
|
|
) {
|
|
guard let device = portalDevice else { return }
|
|
var msg: JSONObject = ["to": path, "type": type]
|
|
if !payload.isEmpty { msg["payload"] = payload }
|
|
device.bus.post(msg, data: data)
|
|
}
|
|
|
|
public func sendChat(_ text: String) async throws {
|
|
guard case .joined(let labID) = status, let localUser else {
|
|
throw AuthFailure(domain: "client", code: "not-joined", message: "Not in a lab")
|
|
}
|
|
let optimistic = ChatMessage(text: text, sender: localUser, isLocal: true)
|
|
chatMessages.append(optimistic)
|
|
onChatMessageChange?(.appended(optimistic))
|
|
do {
|
|
let confirmed = try await requestAddChat(labID: labID, message: optimistic, localUser: localUser)
|
|
replaceChatMessage(id: optimistic.id, with: confirmed)
|
|
} catch {
|
|
removeChatMessage(id: optimistic.id)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private func requestAddChat(labID: String, message: ChatMessage, localUser: UserInfo) async throws -> ChatMessage {
|
|
try await withCheckedThrowingContinuation { cont in
|
|
sendRequest(
|
|
to: "/labs/\(labID)/chat/messages",
|
|
type: ".add",
|
|
payload: ["messages": [["id": message.id.uuidString, "text": message.text]]]
|
|
) { result in
|
|
switch result {
|
|
case .success(let payload):
|
|
guard let msgs = payload["messages"] as? [JSONObject],
|
|
let first = msgs.first,
|
|
let parsed = ChatMessage.fromJSON(first, localUser: localUser)
|
|
else {
|
|
cont.resume(throwing: AuthFailure(
|
|
domain: "portal",
|
|
code: "bad-response",
|
|
message: "Missing 'messages' in chat response"
|
|
))
|
|
return
|
|
}
|
|
cont.resume(returning: parsed)
|
|
case .failure(let err):
|
|
cont.resume(throwing: err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func replaceChatMessage(id: UUID, with replacement: ChatMessage) {
|
|
guard let index = chatMessages.firstIndex(where: { $0.id == id }) else { return }
|
|
chatMessages[index] = replacement
|
|
onChatMessageChange?(.replaced(id: id, with: replacement))
|
|
}
|
|
|
|
private func removeChatMessage(id: UUID) {
|
|
guard let index = chatMessages.firstIndex(where: { $0.id == id }) else { return }
|
|
chatMessages.remove(at: index)
|
|
onChatMessageChange?(.removed(id: id))
|
|
}
|
|
|
|
/// True once this project has ever joined or created a lab. Local-only
|
|
/// projects skip the outbox entirely so they never touch that table.
|
|
public var isCollaborative: Bool {
|
|
(try? store.fetchCollaborationState())?.labID != nil
|
|
}
|
|
|
|
public func enqueueAdd(_ entry: NotebookEntry) {
|
|
guard isCollaborative else { return }
|
|
let op = NotebookOp.add(.init(entry: entry))
|
|
try? store.saveOutboxOp(op)
|
|
sendOpIfJoined(op)
|
|
}
|
|
|
|
public func enqueueUpdate(
|
|
entryID: UUID,
|
|
title: String? = nil,
|
|
details: String? = nil,
|
|
processName: String? = nil,
|
|
pharoSnapshot: PharoSnapshot? = nil
|
|
) {
|
|
guard isCollaborative else { return }
|
|
let op = NotebookOp.update(.init(
|
|
entryID: entryID,
|
|
title: title,
|
|
details: details,
|
|
processName: processName,
|
|
pharoSnapshot: pharoSnapshot
|
|
))
|
|
try? store.saveOutboxOp(op)
|
|
sendOpIfJoined(op)
|
|
}
|
|
|
|
public func enqueueRemove(entryID: UUID) {
|
|
guard isCollaborative else { return }
|
|
let op = NotebookOp.remove(.init(entryID: entryID))
|
|
try? store.saveOutboxOp(op)
|
|
sendOpIfJoined(op)
|
|
}
|
|
|
|
public func sendCustomInstrumentOpIfJoined(_ op: CustomInstrumentOp) {
|
|
guard isCollaborative else { return }
|
|
guard case .joined(let labID) = status else { return }
|
|
sendNotification(
|
|
to: "/labs/\(labID)/custom-instruments",
|
|
type: "+op",
|
|
payload: op.toJSON()
|
|
)
|
|
}
|
|
|
|
public func enqueueReorder(entryID: UUID, position: Double) {
|
|
guard isCollaborative else { return }
|
|
let op = NotebookOp.reorder(.init(entryID: entryID, position: position))
|
|
try? store.saveOutboxOp(op)
|
|
sendOpIfJoined(op)
|
|
}
|
|
|
|
public func enqueueMissionUpsert(_ mission: Mission) {
|
|
enqueueMissionOp(.missionUpsert(.init(mission: mission)))
|
|
}
|
|
|
|
public func enqueueMissionRemove(missionID: UUID) {
|
|
enqueueMissionOp(.missionRemove(.init(missionID: missionID)))
|
|
}
|
|
|
|
public func enqueueMissionTurn(_ turn: MissionTurn) {
|
|
enqueueMissionOp(.turnAppend(.init(turn: turn)))
|
|
}
|
|
|
|
public func enqueueMissionAction(_ action: MissionAction) {
|
|
enqueueMissionOp(.actionUpsert(.init(action: action)))
|
|
}
|
|
|
|
public func enqueueMissionFinding(_ finding: MissionFinding) {
|
|
enqueueMissionOp(.findingUpsert(.init(finding: finding)))
|
|
}
|
|
|
|
public func enqueueMissionFindingRemove(missionID: UUID, findingID: UUID) {
|
|
enqueueMissionOp(.findingRemove(.init(missionID: missionID, findingID: findingID)))
|
|
}
|
|
|
|
public func enqueueMissionEvidence(missionID: UUID, evidence: MissionEvidence) {
|
|
enqueueMissionOp(.evidenceAdd(.init(missionID: missionID, evidence: evidence)))
|
|
}
|
|
|
|
private func enqueueMissionOp(_ op: MissionOp) {
|
|
guard isCollaborative else { return }
|
|
try? store.saveMissionOutboxOp(op)
|
|
sendMissionOpIfJoined(op)
|
|
}
|
|
|
|
private func sendMissionOpIfJoined(_ op: MissionOp) {
|
|
guard case .joined(let labID) = status else { return }
|
|
sendMissionOpOverWire(op, labID: labID)
|
|
}
|
|
|
|
private func sendMissionOpOverWire(_ op: MissionOp, labID: String) {
|
|
sendNotification(
|
|
to: "/labs/\(labID)/missions",
|
|
type: "+op",
|
|
payload: op.toJSON()
|
|
)
|
|
}
|
|
|
|
public func enqueueAddressNoteUpsert(_ note: AddressNote) {
|
|
enqueueAddressNoteOp(.noteUpsert(.init(note: note)))
|
|
}
|
|
|
|
public func enqueueAddressNoteRemove(noteID: UUID) {
|
|
enqueueAddressNoteOp(.noteRemove(.init(noteID: noteID)))
|
|
}
|
|
|
|
public func enqueueAddressNoteMessageAppend(_ message: AddressNoteMessage) {
|
|
enqueueAddressNoteOp(.messageAppend(.init(message: message)))
|
|
}
|
|
|
|
public func enqueueAddressNoteMessageEdit(noteID: UUID, messageID: UUID, bodyMarkdown: String) {
|
|
enqueueAddressNoteOp(.messageEdit(.init(noteID: noteID, messageID: messageID, bodyMarkdown: bodyMarkdown)))
|
|
}
|
|
|
|
public func enqueueAddressNoteMessageRemove(noteID: UUID, messageID: UUID) {
|
|
enqueueAddressNoteOp(.messageRemove(.init(noteID: noteID, messageID: messageID)))
|
|
}
|
|
|
|
private func enqueueAddressNoteOp(_ op: AddressNoteOp) {
|
|
guard isCollaborative else { return }
|
|
try? store.saveAddressNoteOutboxOp(op)
|
|
sendAddressNoteOpIfJoined(op)
|
|
}
|
|
|
|
private func sendAddressNoteOpIfJoined(_ op: AddressNoteOp) {
|
|
guard case .joined(let labID) = status else { return }
|
|
sendAddressNoteOpOverWire(op, labID: labID)
|
|
}
|
|
|
|
private func sendAddressNoteOpOverWire(_ op: AddressNoteOp, labID: String) {
|
|
sendNotification(
|
|
to: "/labs/\(labID)/address-notes",
|
|
type: "+op",
|
|
payload: op.toJSON()
|
|
)
|
|
}
|
|
|
|
/// Resend every op still in the outbox. Called after a successful
|
|
/// join/create so unsynced mutations propagate. The server dedupes by
|
|
/// `op_id`, so redundant replays are safe.
|
|
public func replayOutbox() {
|
|
guard case .joined(let labID) = status else { return }
|
|
let notebookOps = (try? store.fetchOutboxOps()) ?? []
|
|
for op in notebookOps {
|
|
sendOpOverWire(op, labID: labID)
|
|
}
|
|
let sessionOps = (try? store.fetchSessionOutboxOps()) ?? []
|
|
for op in sessionOps {
|
|
sendSessionOpOverWire(op, labID: labID)
|
|
}
|
|
let customOps = (try? store.fetchCustomInstrumentOutboxOps()) ?? []
|
|
for op in customOps {
|
|
sendNotification(
|
|
to: "/labs/\(labID)/custom-instruments",
|
|
type: "+op",
|
|
payload: op.toJSON()
|
|
)
|
|
}
|
|
let missionOps = (try? store.fetchMissionOutboxOps()) ?? []
|
|
for op in missionOps {
|
|
sendMissionOpOverWire(op, labID: labID)
|
|
}
|
|
let addressNoteOps = (try? store.fetchAddressNoteOutboxOps()) ?? []
|
|
for op in addressNoteOps {
|
|
sendAddressNoteOpOverWire(op, labID: labID)
|
|
}
|
|
}
|
|
|
|
private func sendOpIfJoined(_ op: NotebookOp) {
|
|
guard case .joined(let labID) = status else { return }
|
|
sendOpOverWire(op, labID: labID)
|
|
}
|
|
|
|
private func sendOpOverWire(_ op: NotebookOp, labID: String) {
|
|
var binary: [UInt8]? = nil
|
|
if case let .add(add) = op, let bin = add.entry.binaryData {
|
|
binary = [UInt8](bin)
|
|
}
|
|
sendNotification(
|
|
to: "/labs/\(labID)/notebook/entries",
|
|
type: "+op",
|
|
payload: op.toJSON(),
|
|
data: binary
|
|
)
|
|
}
|
|
|
|
public func registerPushSubscriptions(_ subs: [JSONObject]) {
|
|
guard let localUser else { return }
|
|
sendNotification(
|
|
to: "/users/\(localUser.id)/push_subscriptions",
|
|
type: "+add",
|
|
payload: ["subscriptions": subs]
|
|
)
|
|
for s in subs {
|
|
if let platform = s["platform"] as? String {
|
|
registeredPushPlatforms.insert(platform)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func unregisterPushSubscriptions(_ subs: [JSONObject]) {
|
|
guard let localUser else { return }
|
|
sendNotification(
|
|
to: "/users/\(localUser.id)/push_subscriptions",
|
|
type: "+remove",
|
|
payload: ["subscriptions": subs]
|
|
)
|
|
}
|
|
|
|
public struct PushEnrollmentTicket: Sendable {
|
|
public let token: String
|
|
public let vapidPublicKey: String
|
|
}
|
|
|
|
public func requestPushEnrollmentToken() async throws -> PushEnrollmentTicket {
|
|
guard let userID = localUser?.id else {
|
|
throw AuthFailure(
|
|
domain: "client",
|
|
code: "not-authenticated",
|
|
message: "No authenticated user",
|
|
)
|
|
}
|
|
return try await withCheckedThrowingContinuation { cont in
|
|
sendRequest(
|
|
to: "/users/\(userID)/push_enrollment_tokens",
|
|
type: ".create"
|
|
) { result in
|
|
switch result {
|
|
case .success(let payload):
|
|
guard let token = payload["token"] as? String,
|
|
let vapid = payload["vapid_public_key"] as? String
|
|
else {
|
|
cont.resume(throwing: AuthFailure(
|
|
domain: "portal",
|
|
code: "bad-response",
|
|
message: "Missing fields in enrollment response",
|
|
))
|
|
return
|
|
}
|
|
cont.resume(returning: PushEnrollmentTicket(
|
|
token: token, vapidPublicKey: vapid
|
|
))
|
|
case .failure(let err):
|
|
cont.resume(throwing: err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Rename the active lab. Owner-only — the server rejects everyone
|
|
/// else with `forbidden`. Optimistically updates `labTitle` on success
|
|
/// so the UI doesn't wait for the broadcast echo to reflect the new
|
|
/// value.
|
|
public func setLabTitle(_ title: String) async {
|
|
guard case .joined(let labID) = status else { return }
|
|
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
|
sendRequest(
|
|
to: "/labs/\(labID)",
|
|
type: ".set",
|
|
payload: ["title": trimmed]
|
|
) { [weak self] result in
|
|
if case .success = result {
|
|
self?.labTitle = trimmed
|
|
}
|
|
cont.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Upload a new lab picture. Owner-only. `contentType` is one of
|
|
/// image/png, image/jpeg, image/webp, image/gif. Data is capped at
|
|
/// 512 KiB by the server. Updates `labPictureData` optimistically on
|
|
/// success.
|
|
public func setLabPicture(_ data: Data, contentType: String) async {
|
|
guard case .joined(let labID) = status else { return }
|
|
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
|
sendRequest(
|
|
to: "/labs/\(labID)/picture",
|
|
type: ".set",
|
|
payload: ["content_type": contentType],
|
|
data: [UInt8](data)
|
|
) { [weak self] result in
|
|
if case .success = result {
|
|
self?.labPictureData = data
|
|
self?.labPictureContentType = contentType
|
|
}
|
|
cont.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Suggested title for a freshly-created lab, built from the local
|
|
/// weekday and time-of-day plus a randomly-picked verb, e.g.
|
|
/// "Monday morning reversing", "Friday evening spelunking". Generated
|
|
/// client-side so the weekday/time reflect the owner's timezone, not
|
|
/// the server's.
|
|
public static func initialLabTitle(at date: Date = .now) -> String {
|
|
var cal = Calendar(identifier: .gregorian)
|
|
cal.locale = Locale(identifier: "en_US_POSIX")
|
|
let weekdays = [
|
|
"Sunday", "Monday", "Tuesday", "Wednesday",
|
|
"Thursday", "Friday", "Saturday",
|
|
]
|
|
let weekday = weekdays[cal.component(.weekday, from: date) - 1]
|
|
let hour = cal.component(.hour, from: date)
|
|
let timeOfDay: String
|
|
switch hour {
|
|
case 4..<12: timeOfDay = "morning"
|
|
case 12..<17: timeOfDay = "afternoon"
|
|
case 17..<21: timeOfDay = "evening"
|
|
default: timeOfDay = "night"
|
|
}
|
|
let verbs = [
|
|
"reversing", "tracing", "hacking", "spelunking",
|
|
"poking", "sleuthing", "dissecting",
|
|
]
|
|
return "\(weekday) \(timeOfDay) \(verbs.randomElement()!)"
|
|
}
|
|
|
|
public func setMemberRole(userID: String, role: Member.Role) async {
|
|
guard case .joined(let labID) = status else { return }
|
|
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
|
sendRequest(
|
|
to: "/labs/\(labID)/members/\(userID)/role",
|
|
type: ".set",
|
|
payload: ["role": role.rawValue]
|
|
) { _ in cont.resume() }
|
|
}
|
|
}
|
|
|
|
public func removeMembers(_ userIDs: [String]) async {
|
|
guard case .joined(let labID) = status else { return }
|
|
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
|
sendRequest(
|
|
to: "/labs/\(labID)/members",
|
|
type: ".remove",
|
|
payload: ["user_ids": userIDs]
|
|
) { _ in cont.resume() }
|
|
}
|
|
}
|
|
|
|
public func leaveLab() async {
|
|
guard case .joined(let labID) = status else { return }
|
|
let succeeded: Bool = await withCheckedContinuation { cont in
|
|
sendRequest(to: "/labs/\(labID)", type: ".leave") { result in
|
|
if case .success = result {
|
|
cont.resume(returning: true)
|
|
} else {
|
|
cont.resume(returning: false)
|
|
}
|
|
}
|
|
}
|
|
guard succeeded else { return }
|
|
var collabState = try! store.fetchCollaborationState()
|
|
collabState.labID = nil
|
|
try! store.save(collabState)
|
|
await stop()
|
|
}
|
|
|
|
@discardableResult
|
|
public func enqueueAddSession(
|
|
sessionID: UUID,
|
|
deviceID: String,
|
|
deviceName: String,
|
|
pid: UInt,
|
|
processName: String,
|
|
createdAt: String = ISO8601DateFormatter().string(from: Date())
|
|
) -> SessionOp? {
|
|
guard let localUser else { return nil }
|
|
let op = SessionOp.add(.init(
|
|
sessionID: sessionID,
|
|
host: localUser,
|
|
deviceID: deviceID,
|
|
deviceName: deviceName,
|
|
pid: pid,
|
|
processName: processName,
|
|
createdAt: createdAt
|
|
))
|
|
enqueueSessionOp(op)
|
|
return op
|
|
}
|
|
|
|
public func enqueueUpdateSessionPhase(
|
|
sessionID: UUID,
|
|
phase: Session.Phase,
|
|
reason: String? = nil,
|
|
lastSeenAt: String = ISO8601DateFormatter().string(from: Date())
|
|
) {
|
|
enqueueSessionOp(.updatePhase(.init(
|
|
sessionID: sessionID,
|
|
phase: phase,
|
|
reason: reason,
|
|
lastSeenAt: lastSeenAt
|
|
)))
|
|
}
|
|
|
|
public func enqueueUpdateSessionArming(
|
|
sessionID: UUID,
|
|
armingState: ProcessSession.ArmingState
|
|
) {
|
|
enqueueSessionOp(.updateArming(.init(
|
|
sessionID: sessionID,
|
|
armingState: armingState
|
|
)))
|
|
}
|
|
|
|
public func enqueueUpdateSessionModules(sessionID: UUID, delta: ModuleDelta) {
|
|
guard !delta.isEmpty else { return }
|
|
enqueueSessionOp(.updateModules(.init(
|
|
sessionID: sessionID,
|
|
delta: delta
|
|
)))
|
|
}
|
|
|
|
public func enqueueUpdateSessionThreads(sessionID: UUID, delta: ThreadDelta) {
|
|
guard !delta.isEmpty else { return }
|
|
enqueueSessionOp(.updateThreads(.init(
|
|
sessionID: sessionID,
|
|
delta: delta
|
|
)))
|
|
}
|
|
|
|
public func enqueueRemoveSession(sessionID: UUID) {
|
|
enqueueSessionOp(.remove(.init(sessionID: sessionID)))
|
|
}
|
|
|
|
public func requestClaimHost(
|
|
sessionID: UUID,
|
|
deviceID: String,
|
|
deviceName: String,
|
|
pid: UInt,
|
|
processName: String
|
|
) async throws {
|
|
guard case .joined(let labID) = status, let localUser else {
|
|
throw AuthFailure(domain: "client", code: "not-joined", message: "Not in a lab")
|
|
}
|
|
let op = SessionOp.claimHost(.init(
|
|
sessionID: sessionID,
|
|
host: localUser,
|
|
deviceID: deviceID,
|
|
deviceName: deviceName,
|
|
pid: pid,
|
|
processName: processName
|
|
))
|
|
try await withCheckedThrowingContinuation { cont in
|
|
sendRequest(
|
|
to: "/labs/\(labID)/sessions/\(sessionID.uuidString)",
|
|
type: ".claim-host",
|
|
payload: op.toJSON()
|
|
) { result in
|
|
switch result {
|
|
case .success:
|
|
cont.resume(returning: ())
|
|
case .failure(let err):
|
|
cont.resume(throwing: err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public func enqueueAddReplCell(sessionID: UUID, cell: REPLCell) {
|
|
var wireCell = cell
|
|
wireCell.sessionID = sessionID
|
|
enqueueSessionOp(.addReplCell(.init(sessionID: sessionID, cell: wireCell)))
|
|
}
|
|
|
|
public func enqueueAddInstrument(sessionID: UUID, instance: InstrumentInstance, runtimeStatus: InstrumentStatus? = nil) {
|
|
var wireInst = instance
|
|
wireInst.sessionID = sessionID
|
|
enqueueSessionOp(.addInstrument(.init(sessionID: sessionID, instance: wireInst, runtimeStatus: runtimeStatus)))
|
|
}
|
|
|
|
public func enqueueUpdateInstrument(sessionID: UUID, instance: InstrumentInstance, runtimeStatus: InstrumentStatus? = nil) {
|
|
var wireInst = instance
|
|
wireInst.sessionID = sessionID
|
|
enqueueSessionOp(.updateInstrument(.init(sessionID: sessionID, instance: wireInst, runtimeStatus: runtimeStatus)))
|
|
}
|
|
|
|
public func enqueueRemoveInstrument(sessionID: UUID, instanceID: UUID) {
|
|
enqueueSessionOp(.removeInstrument(.init(sessionID: sessionID, instanceID: instanceID)))
|
|
}
|
|
|
|
public func enqueueAddInsight(sessionID: UUID, insight: AddressInsight) {
|
|
var wireInsight = insight
|
|
wireInsight.sessionID = sessionID
|
|
enqueueSessionOp(.addInsight(.init(sessionID: sessionID, insight: wireInsight)))
|
|
}
|
|
|
|
public func enqueueUpdateInsight(sessionID: UUID, insight: AddressInsight) {
|
|
var wireInsight = insight
|
|
wireInsight.sessionID = sessionID
|
|
enqueueSessionOp(.updateInsight(.init(sessionID: sessionID, insight: wireInsight)))
|
|
}
|
|
|
|
public func enqueueRemoveInsight(sessionID: UUID, insightID: UUID) {
|
|
enqueueSessionOp(.removeInsight(.init(sessionID: sessionID, insightID: insightID)))
|
|
}
|
|
|
|
public func enqueueUpdateProcessInfo(sessionID: UUID, info: ProcessSession.ProcessInfo) {
|
|
enqueueSessionOp(.updateProcessInfo(.init(sessionID: sessionID, info: info)))
|
|
}
|
|
|
|
public func enqueueUpdateModuleSymbols(sessionID: UUID, entries: [SessionOp.UpdateModuleSymbols.Entry]) {
|
|
guard !entries.isEmpty else { return }
|
|
enqueueSessionOp(.updateModuleSymbols(.init(sessionID: sessionID, entries: entries)))
|
|
}
|
|
|
|
public func enqueueUpdateModuleAnalysis(sessionID: UUID, analysis: ModuleAnalysis) {
|
|
var wire = analysis
|
|
wire.sessionID = sessionID
|
|
enqueueSessionOp(.updateModuleAnalysis(.init(sessionID: sessionID, analysis: wire)))
|
|
}
|
|
|
|
public func publishMemoryPage(sessionID: UUID, _ page: MemoryPagePublish) {
|
|
guard case .joined(let labID) = status else { return }
|
|
var payload: JSONObject = [
|
|
"op_id": UUID().uuidString,
|
|
"kind": "publish-memory-page",
|
|
]
|
|
switch page.region {
|
|
case .module(let uuid, let offset):
|
|
payload["region"] = "module"
|
|
payload["module_uuid"] = uuid
|
|
payload["offset"] = Int64(bitPattern: offset)
|
|
case .anonymous(let identity, let address):
|
|
payload["region"] = "anonymous"
|
|
payload["process_identity"] = identity
|
|
payload["address"] = Int64(bitPattern: address)
|
|
}
|
|
sendNotification(
|
|
to: "/labs/\(labID)/sessions/\(sessionID.uuidString)",
|
|
type: "+op",
|
|
payload: payload,
|
|
data: [UInt8](page.bytes)
|
|
)
|
|
}
|
|
|
|
public func fetchMemoryPage(sessionID: UUID, region: MemoryPageRegion) async -> Data? {
|
|
guard case .joined(let labID) = status else { return nil }
|
|
var payload: JSONObject = [:]
|
|
switch region {
|
|
case .module(let uuid, let offset):
|
|
payload["region"] = "module"
|
|
payload["module_uuid"] = uuid
|
|
payload["offset"] = Int64(bitPattern: offset)
|
|
case .anonymous(let identity, let address):
|
|
payload["region"] = "anonymous"
|
|
payload["process_identity"] = identity
|
|
payload["address"] = Int64(bitPattern: address)
|
|
}
|
|
return await withCheckedContinuation { continuation in
|
|
sendRequest(
|
|
to: "/labs/\(labID)/sessions/\(sessionID.uuidString)",
|
|
type: "+fetch-memory-page",
|
|
payload: payload
|
|
) { [weak self] result in
|
|
switch result {
|
|
case .success(let reply):
|
|
let present = (reply["present"] as? Bool) ?? false
|
|
let bytes = self?.lastMessageData ?? []
|
|
continuation.resume(returning: present ? Data(bytes) : nil)
|
|
case .failure:
|
|
continuation.resume(returning: nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public func uploadTraceData(sessionID: UUID, traceID: UUID, offset: Int, chunk: Data) {
|
|
guard case .joined(let labID) = status else { return }
|
|
sendNotification(
|
|
to: "/labs/\(labID)/sessions/\(sessionID.uuidString)",
|
|
type: "+op",
|
|
payload: [
|
|
"op_id": UUID().uuidString,
|
|
"kind": "set-trace-data",
|
|
"trace_id": traceID.uuidString,
|
|
"offset": offset,
|
|
],
|
|
data: [UInt8](chunk)
|
|
)
|
|
}
|
|
|
|
public func fetchTraceData(
|
|
sessionID: UUID,
|
|
traceID: UUID,
|
|
offset: Int = 0,
|
|
length: Int? = nil
|
|
) async throws -> (data: Data, totalSize: Int) {
|
|
guard case .joined(let labID) = status else {
|
|
throw LumaCoreError.invalidOperation("Not joined to a lab")
|
|
}
|
|
var payload: JSONObject = ["offset": offset]
|
|
if let length { payload["length"] = length }
|
|
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
sendRequest(
|
|
to: "/labs/\(labID)/sessions/\(sessionID.uuidString)/traces/\(traceID.uuidString)",
|
|
type: "+fetch",
|
|
payload: payload
|
|
) { [weak self] result in
|
|
switch result {
|
|
case .success(let payload):
|
|
let total = (payload["total_size"] as? Int) ?? 0
|
|
let bytes = self?.lastMessageData ?? []
|
|
continuation.resume(returning: (Data(bytes), total))
|
|
case .failure(let failure):
|
|
continuation.resume(throwing: failure)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public func enqueueUpsertTrace(sessionID: UUID, trace: ITrace) {
|
|
var wireTrace = trace
|
|
wireTrace.sessionID = sessionID
|
|
enqueueSessionOp(.upsertTrace(.init(sessionID: sessionID, trace: wireTrace)))
|
|
}
|
|
|
|
public func enqueueRemoveTrace(sessionID: UUID, traceID: UUID) {
|
|
enqueueSessionOp(.removeTrace(.init(sessionID: sessionID, traceID: traceID)))
|
|
}
|
|
|
|
public func sendEvent(sessionID: UUID, event: RuntimeEvent) {
|
|
guard case .joined(let labID) = status,
|
|
let wirePayload = event.toWireJSON()
|
|
else { return }
|
|
sendNotification(
|
|
to: "/labs/\(labID)/sessions/\(sessionID.uuidString)/events",
|
|
type: "+event",
|
|
payload: wirePayload
|
|
)
|
|
}
|
|
|
|
public func sendWidgetUpdate(sessionID: UUID, update: WidgetUpdate) {
|
|
guard case .joined(let labID) = status else { return }
|
|
sendNotification(
|
|
to: "/labs/\(labID)/sessions/\(sessionID.uuidString)/widget-updates",
|
|
type: "+widget-update",
|
|
payload: update.toWireJSON()
|
|
)
|
|
}
|
|
|
|
public func sendWidgetAction(
|
|
sessionID: UUID,
|
|
instanceID: UUID,
|
|
widget: String,
|
|
action: String,
|
|
item: String?
|
|
) {
|
|
guard case .joined(let labID) = status else { return }
|
|
var payload: [String: Any] = [
|
|
"instance_id": instanceID.uuidString,
|
|
"widget": widget,
|
|
"action": action,
|
|
]
|
|
if let item { payload["item"] = item }
|
|
sendNotification(
|
|
to: "/labs/\(labID)/sessions/\(sessionID.uuidString)/widget-actions",
|
|
type: "+widget-action",
|
|
payload: payload
|
|
)
|
|
}
|
|
|
|
|
|
public func sendReplEvalRequest(sessionID: UUID, code: String, language: REPLLanguage, cellID: UUID) {
|
|
guard case .joined(let labID) = status else { return }
|
|
sendNotification(
|
|
to: "/labs/\(labID)/sessions/\(sessionID.uuidString)/repl/cells/\(cellID.uuidString)",
|
|
type: "+eval",
|
|
payload: ["code": code, "language": language.rawValue]
|
|
)
|
|
schedulePendingReplTimeout(cellID: cellID)
|
|
}
|
|
|
|
private static let replEvalTimeout: UInt64 = 30_000_000_000
|
|
|
|
@ObservationIgnored private var pendingReplTimeouts: [UUID: Task<Void, Never>] = [:]
|
|
|
|
private func schedulePendingReplTimeout(cellID: UUID) {
|
|
pendingReplTimeouts[cellID]?.cancel()
|
|
pendingReplTimeouts[cellID] = Task { @MainActor [weak self] in
|
|
try? await Task.sleep(nanoseconds: Self.replEvalTimeout)
|
|
guard !Task.isCancelled, let self else { return }
|
|
self.pendingReplTimeouts.removeValue(forKey: cellID)
|
|
self.onReplEvalTimedOut?(cellID)
|
|
}
|
|
}
|
|
|
|
private func cancelPendingReplTimeout(cellID: UUID) {
|
|
pendingReplTimeouts.removeValue(forKey: cellID)?.cancel()
|
|
}
|
|
|
|
public func sendInstrumentSetState(sessionID: UUID, instanceID: UUID, state: InstrumentState) {
|
|
guard case .joined(let labID) = status else { return }
|
|
let verb: String = state == .enabled ? "+enable" : "+disable"
|
|
sendNotification(
|
|
to: "/labs/\(labID)/sessions/\(sessionID.uuidString)/instruments/\(instanceID.uuidString)",
|
|
type: verb,
|
|
payload: [:]
|
|
)
|
|
}
|
|
|
|
public func sendInstrumentRemove(sessionID: UUID, instanceID: UUID) {
|
|
guard case .joined(let labID) = status else { return }
|
|
sendNotification(
|
|
to: "/labs/\(labID)/sessions/\(sessionID.uuidString)/instruments/\(instanceID.uuidString)",
|
|
type: "+remove",
|
|
payload: [:]
|
|
)
|
|
}
|
|
|
|
public func sendInstrumentAdd(
|
|
sessionID: UUID,
|
|
kind: InstrumentKind,
|
|
sourceIdentifier: String,
|
|
configJSON: Data
|
|
) {
|
|
guard case .joined(let labID) = status else { return }
|
|
sendNotification(
|
|
to: "/labs/\(labID)/sessions/\(sessionID.uuidString)/instruments",
|
|
type: "+add",
|
|
payload: [
|
|
"kind": kind.rawValue,
|
|
"source_identifier": sourceIdentifier,
|
|
"config_json": configJSON.base64EncodedString(),
|
|
]
|
|
)
|
|
}
|
|
|
|
public func sendInstrumentUpdateConfig(
|
|
sessionID: UUID,
|
|
instanceID: UUID,
|
|
configJSON: Data
|
|
) {
|
|
guard case .joined(let labID) = status else { return }
|
|
sendNotification(
|
|
to: "/labs/\(labID)/sessions/\(sessionID.uuidString)/instruments/\(instanceID.uuidString)",
|
|
type: "+update-config",
|
|
payload: ["config_json": configJSON.base64EncodedString()]
|
|
)
|
|
}
|
|
|
|
private func enqueueSessionOp(_ op: SessionOp) {
|
|
guard isCollaborative else { return }
|
|
try? store.saveSessionOutboxOp(op)
|
|
sendSessionOpIfJoined(op)
|
|
}
|
|
|
|
private func sendSessionOpIfJoined(_ op: SessionOp) {
|
|
guard case .joined(let labID) = status else { return }
|
|
sendSessionOpOverWire(op, labID: labID)
|
|
}
|
|
|
|
private func sendSessionOpOverWire(_ op: SessionOp, labID: String) {
|
|
sendNotification(
|
|
to: "/labs/\(labID)/sessions/\(op.sessionID.uuidString)",
|
|
type: "+op",
|
|
payload: op.toJSON()
|
|
)
|
|
}
|
|
|
|
private func applySessionOp(sessionID: UUID, payload: JSONObject) {
|
|
guard let op = SessionOp.fromJSON(payload, sessionID: sessionID) else { return }
|
|
switch op {
|
|
case .add(let a):
|
|
let session = Session(
|
|
id: a.sessionID,
|
|
host: a.host,
|
|
deviceID: a.deviceID,
|
|
deviceName: a.deviceName,
|
|
pid: a.pid,
|
|
processName: a.processName,
|
|
phase: .attaching,
|
|
createdAt: a.createdAt,
|
|
lastSeenAt: a.createdAt,
|
|
modules: []
|
|
)
|
|
onSessionAdded?(session)
|
|
|
|
case .updatePhase(let u):
|
|
onSessionPhaseChanged?(u.sessionID, u.phase, u.reason)
|
|
|
|
case .updateProcessInfo(let u):
|
|
onSessionProcessInfoUpdated?(u.sessionID, u.info)
|
|
|
|
case .updateArming(let u):
|
|
onSessionArmingChanged?(u.sessionID, u.armingState)
|
|
|
|
case .updateModules(let u):
|
|
onSessionModulesUpdated?(u.sessionID, u.delta)
|
|
|
|
case .updateModuleAnalysis(let u):
|
|
onSessionModuleAnalysisUpdated?(u.sessionID, u.analysis)
|
|
|
|
case .updateModuleSymbols(let u):
|
|
onSessionModuleSymbolsUpdated?(u.sessionID, u.entries)
|
|
|
|
case .updateThreads(let u):
|
|
onSessionThreadsUpdated?(u.sessionID, u.delta)
|
|
|
|
case .claimHost(let c):
|
|
onSessionHostChanged?(
|
|
c.sessionID, c.host, c.deviceID, c.deviceName, c.pid, c.processName
|
|
)
|
|
|
|
case .addReplCell(let a):
|
|
cancelPendingReplTimeout(cellID: a.cell.id)
|
|
onSessionReplCellAdded?(a.sessionID, a.cell)
|
|
|
|
case .addInstrument(let a):
|
|
onSessionInstrumentAdded?(a.sessionID, a.instance)
|
|
|
|
case .updateInstrument(let u):
|
|
onSessionInstrumentUpdated?(u.sessionID, u.instance)
|
|
|
|
case .removeInstrument(let r):
|
|
onSessionInstrumentRemoved?(r.sessionID, r.instanceID)
|
|
|
|
case .addInsight(let a):
|
|
onSessionInsightAdded?(a.sessionID, a.insight)
|
|
|
|
case .updateInsight(let u):
|
|
onSessionInsightUpdated?(u.sessionID, u.insight)
|
|
|
|
case .removeInsight(let r):
|
|
onSessionInsightRemoved?(r.sessionID, r.insightID)
|
|
|
|
case .upsertTrace(let u):
|
|
onSessionTraceUpserted?(u.sessionID, u.trace)
|
|
|
|
case .removeTrace(let r):
|
|
onSessionTraceRemoved?(r.sessionID, r.traceID)
|
|
|
|
case .traceDataProgressed(let t):
|
|
onSessionTraceDataProgressed?(t.sessionID, t.traceID, t.totalSize)
|
|
|
|
case .remove(let r):
|
|
onSessionRemoved?(r.sessionID)
|
|
}
|
|
try? store.removeSessionOutboxOp(opID: op.opID)
|
|
}
|
|
|
|
private func triggerJoinOrCreateIfReady() {
|
|
guard !pendingCreateOrJoinDispatched else { return }
|
|
guard localUser != nil else { return }
|
|
pendingCreateOrJoinDispatched = true
|
|
if let labID = pendingJoinLabID {
|
|
pendingJoinLabID = nil
|
|
joinLab(labID: labID)
|
|
} else {
|
|
createLab()
|
|
}
|
|
}
|
|
|
|
private func createLab() {
|
|
setStatus(.connecting)
|
|
let initialTitle = Self.initialLabTitle()
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
guard let creatorAvatar = await self.fetchCreatorAvatar() else {
|
|
self.setStatus(.error(message: "Couldn't fetch your GitHub avatar to use as the lab picture."))
|
|
return
|
|
}
|
|
self.sendCreateRequest(
|
|
title: initialTitle,
|
|
pictureData: creatorAvatar.data,
|
|
pictureContentType: creatorAvatar.contentType
|
|
)
|
|
}
|
|
}
|
|
|
|
private func sendCreateRequest(title: String, pictureData: Data, pictureContentType: String) {
|
|
sendRequest(
|
|
to: "/labs",
|
|
type: ".create",
|
|
payload: ["title": title, "picture": ["content_type": pictureContentType]],
|
|
data: [UInt8](pictureData)
|
|
) { [weak self] result in
|
|
guard let self else { return }
|
|
switch result {
|
|
case .success(let payload):
|
|
guard let labObj = payload["lab"] as? JSONObject,
|
|
let labID = labObj["id"] as? String,
|
|
let localUser = self.localUser
|
|
else { Task { await self.stop() }; return }
|
|
self.labID = labID
|
|
self.labTitle = (labObj["title"] as? String) ?? title
|
|
self.labPictureData = pictureData
|
|
self.labPictureContentType = pictureContentType
|
|
// Persist labID first so isCollaborative flips true before
|
|
// we fan existing entries into the outbox.
|
|
var collabState = try! self.store.fetchCollaborationState()
|
|
collabState.labID = labID
|
|
try! self.store.save(collabState)
|
|
self.setStatus(.joined(labID: labID))
|
|
let now = ISO8601DateFormatter().string(from: Date())
|
|
self.members = [Member(
|
|
user: localUser,
|
|
role: .owner,
|
|
presence: .online,
|
|
joinedAt: now,
|
|
lastSeenAt: now
|
|
)]
|
|
let entries = (try? self.store.fetchNotebookEntries()) ?? []
|
|
let ops: [NotebookOp] = entries.map { .add(.init(entry: $0)) }
|
|
try? self.store.saveOutboxOps(ops)
|
|
self.replayOutbox()
|
|
self.onSessionsSnapshot?([])
|
|
case .failure(let failure):
|
|
self.setStatus(.error(message: failure.message))
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AvatarBytes {
|
|
let data: Data
|
|
let contentType: String
|
|
}
|
|
|
|
private static let allowedAvatarContentTypes: Set<String> = [
|
|
"image/png", "image/jpeg", "image/webp", "image/gif",
|
|
]
|
|
|
|
private func fetchCreatorAvatar() async -> AvatarBytes? {
|
|
guard let baseURL = localUser?.avatarURL,
|
|
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
|
|
return nil
|
|
}
|
|
var items = components.queryItems ?? []
|
|
items.removeAll { $0.name == "s" }
|
|
items.append(URLQueryItem(name: "s", value: "256"))
|
|
components.queryItems = items
|
|
guard let url = components.url else { return nil }
|
|
let backoffs: [UInt64] = [0, 500_000_000, 2_000_000_000]
|
|
for delay in backoffs {
|
|
if delay > 0 {
|
|
try? await Task.sleep(nanoseconds: delay)
|
|
}
|
|
do {
|
|
let (data, response) = try await URLSession.shared.data(from: url)
|
|
guard let http = response as? HTTPURLResponse,
|
|
(200..<300).contains(http.statusCode) else { continue }
|
|
let raw = http.value(forHTTPHeaderField: "Content-Type") ?? ""
|
|
let contentType = raw.split(separator: ";").first.map { String($0).trimmingCharacters(in: .whitespaces) } ?? raw
|
|
guard Self.allowedAvatarContentTypes.contains(contentType) else { return nil }
|
|
return AvatarBytes(data: data, contentType: contentType)
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func joinLab(labID: String) {
|
|
setStatus(.connecting)
|
|
sendRequest(to: "/labs/\(labID)", type: ".join") { [weak self] result in
|
|
guard let self else { return }
|
|
switch result {
|
|
case .success(let payload):
|
|
self.ingestJoinSnapshot(payload: payload, labID: labID)
|
|
self.replayOutbox()
|
|
case .failure(let failure):
|
|
self.setStatus(.error(message: failure.message))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func ingestJoinSnapshot(payload: JSONObject, labID: String) {
|
|
guard let localUser = self.localUser,
|
|
let memberDicts = payload["members"] as? [JSONObject],
|
|
let chatObj = payload["chat"] as? JSONObject,
|
|
let chatMsgs = chatObj["messages"] as? [JSONObject],
|
|
let notebookObj = payload["notebook"] as? JSONObject,
|
|
let notebookEntries = notebookObj["entries"] as? [JSONObject]
|
|
else { Task { await self.stop() }; return }
|
|
|
|
let binaryIndices = notebookObj["binary_indices"] as? [Any] ?? []
|
|
|
|
self.labID = labID
|
|
if let labObj = payload["lab"] as? JSONObject {
|
|
if let title = labObj["title"] as? String {
|
|
self.labTitle = title
|
|
}
|
|
if let picture = labObj["picture"] as? JSONObject,
|
|
let contentType = picture["content_type"] as? String,
|
|
let offset = picture["offset"] as? Int,
|
|
let length = picture["length"] as? Int,
|
|
let all = lastMessageData,
|
|
offset >= 0, offset + length <= all.count {
|
|
self.labPictureData = Data(all[offset..<offset + length])
|
|
self.labPictureContentType = contentType
|
|
} else {
|
|
self.labPictureData = nil
|
|
self.labPictureContentType = nil
|
|
}
|
|
}
|
|
setStatus(.joined(labID: labID))
|
|
members = memberDicts.compactMap(Member.fromJSON)
|
|
chatMessages = chatMsgs.compactMap { ChatMessage.fromJSON($0, localUser: localUser) }
|
|
onChatMessageChange?(.reset)
|
|
|
|
var collabState = try! store.fetchCollaborationState()
|
|
collabState.labID = labID
|
|
try! store.save(collabState)
|
|
|
|
// Note: .join response data (binary blob) is fetched separately via
|
|
// the last-data on the message; see handleBusEvent.
|
|
var snapshot: [NotebookEntry] = []
|
|
for (i, obj) in notebookEntries.enumerated() {
|
|
let bin: [UInt8]? = extractBinary(indices: binaryIndices, at: i, from: lastMessageData)
|
|
guard let entry = NotebookEntry.fromJSON(obj, binaryData: bin) else {
|
|
continue
|
|
}
|
|
snapshot.append(entry)
|
|
}
|
|
onNotebookSnapshot?(snapshot)
|
|
|
|
let sessionDicts = (payload["sessions"] as? [JSONObject]) ?? []
|
|
let sessions = sessionDicts.compactMap(Session.fromJSON)
|
|
onSessionsSnapshot?(sessions)
|
|
|
|
let customDicts = (payload["custom_instruments"] as? [JSONObject]) ?? []
|
|
let customBundles = customDicts.compactMap(CustomInstrumentBundle.fromJSON)
|
|
onCustomInstrumentSnapshot?(customBundles)
|
|
|
|
let widgetStateDicts = (payload["widget_states"] as? [JSONObject]) ?? []
|
|
let widgetSnapshots = widgetStateDicts.compactMap(WidgetStateSnapshot.fromWireJSON)
|
|
onWidgetStatesSnapshot?(widgetSnapshots)
|
|
|
|
let missionsArr = (payload["missions"] as? [JSONObject]) ?? []
|
|
let turnsArr = (payload["mission_turns"] as? [JSONObject]) ?? []
|
|
let actionsArr = (payload["mission_actions"] as? [JSONObject]) ?? []
|
|
let findingsArr = (payload["mission_findings"] as? [JSONObject]) ?? []
|
|
let evidenceArr = (payload["mission_evidence"] as? [JSONObject]) ?? []
|
|
let missionSnapshot = MissionSnapshot(
|
|
missions: missionsArr.compactMap(Mission.fromWireJSON),
|
|
turns: turnsArr.compactMap(MissionTurn.fromWireJSON),
|
|
actions: actionsArr.compactMap(MissionAction.fromWireJSON),
|
|
findings: findingsArr.compactMap(MissionFinding.fromWireJSON),
|
|
evidence: evidenceArr.compactMap(MissionEvidence.fromWireJSON)
|
|
)
|
|
onMissionSnapshot?(missionSnapshot)
|
|
}
|
|
|
|
private var lastMessageData: [UInt8]? = nil
|
|
|
|
private func extractBinary(indices: [Any], at i: Int, from data: [UInt8]?) -> [UInt8]? {
|
|
guard i < indices.count else { return nil }
|
|
guard let idx = indices[i] as? JSONObject,
|
|
let start = idx["start"] as? Int,
|
|
let length = idx["length"] as? Int,
|
|
let data = data,
|
|
start + length <= data.count
|
|
else { return nil }
|
|
return Array(data[start..<start + length])
|
|
}
|
|
|
|
private func handleBusEvent(_ event: Bus.Event) async {
|
|
switch event {
|
|
case .detached:
|
|
await stop()
|
|
|
|
case .message(message: let anyValue, let data):
|
|
guard let dict = anyValue as? JSONObject,
|
|
let type = dict["type"] as? String
|
|
else { await stop(); return }
|
|
|
|
let id = dict["id"] as? String
|
|
let payload = (dict["payload"] as? JSONObject) ?? [:]
|
|
let errorObj = dict["error"] as? JSONObject
|
|
|
|
if type == "+result" || type == "+error" {
|
|
guard let id, let cont = pendingRequests.removeValue(forKey: id) else { return }
|
|
if type == "+result" {
|
|
self.lastMessageData = data
|
|
cont(.success(payload))
|
|
self.lastMessageData = nil
|
|
} else {
|
|
let code = errorObj?["code"] as? String ?? "unknown"
|
|
let msg = errorObj?["message"] as? String ?? "request failed"
|
|
cont(.failure(AuthFailure(domain: "portal", code: code, message: msg)))
|
|
}
|
|
return
|
|
}
|
|
|
|
guard let from = dict["from"] as? String else { return }
|
|
lastMessageData = data
|
|
handleNotification(from: from, type: type, payload: payload, data: data)
|
|
lastMessageData = nil
|
|
}
|
|
}
|
|
|
|
private func handleNotification(from: String, type: String, payload: JSONObject, data: [UInt8]?) {
|
|
let segs = from.hasPrefix("/") ? from.dropFirst()
|
|
.split(separator: "/", omittingEmptySubsequences: true).map(String.init) : []
|
|
|
|
switch (type, segs) {
|
|
case ("+welcome", ["session"]):
|
|
if let userObj = payload["user"] as? JSONObject, let u = UserInfo.fromJSON(userObj) {
|
|
localUser = u
|
|
triggerJoinOrCreateIfReady()
|
|
}
|
|
if let push = payload["push"] as? JSONObject {
|
|
if let key = push["vapid_public_key"] as? String {
|
|
vapidPublicKey = key
|
|
}
|
|
if let list = push["registered"] as? [String] {
|
|
registeredPushPlatforms = Set(list)
|
|
}
|
|
}
|
|
|
|
case ("+update", let s) where s.count == 2 && s[0] == "labs":
|
|
if let title = payload["title"] as? String {
|
|
labTitle = title
|
|
}
|
|
|
|
case ("+update", let s) where s.count == 3 && s[0] == "users" && s[2] == "push_subscriptions":
|
|
if let list = payload["registered"] as? [String] {
|
|
registeredPushPlatforms = Set(list)
|
|
}
|
|
|
|
case ("+update", let s) where s.count == 3 && s[0] == "labs" && s[2] == "picture":
|
|
if let contentType = payload["content_type"] as? String,
|
|
let bytes = data, !bytes.isEmpty {
|
|
labPictureData = Data(bytes)
|
|
labPictureContentType = contentType
|
|
}
|
|
|
|
case ("+add", let s) where s.count == 3 && s[0] == "labs" && s[2] == "members":
|
|
guard let arr = payload["members"] as? [JSONObject] else { return }
|
|
for obj in arr {
|
|
guard let member = Member.fromJSON(obj) else { continue }
|
|
if !members.contains(where: { $0.user.id == member.user.id }) {
|
|
members.append(member)
|
|
onMemberAdded?(member)
|
|
}
|
|
}
|
|
|
|
case ("+remove", let s) where s.count == 3 && s[0] == "labs" && s[2] == "members":
|
|
guard let ids = payload["user_ids"] as? [String] else { return }
|
|
for userID in ids {
|
|
members.removeAll { $0.user.id == userID }
|
|
onMemberRemoved?(userID)
|
|
}
|
|
|
|
case ("+role-changed", let s) where s.count == 3 && s[0] == "labs" && s[2] == "members":
|
|
guard let userID = payload["user_id"] as? String,
|
|
let roleRaw = payload["role"] as? String,
|
|
let role = Member.Role(rawValue: roleRaw),
|
|
let idx = members.firstIndex(where: { $0.user.id == userID })
|
|
else { return }
|
|
members[idx].role = role
|
|
onMemberRoleChanged?(userID, role)
|
|
|
|
case ("+presence", let s) where s.count == 3 && s[0] == "labs" && s[2] == "members":
|
|
guard let changes = payload["changes"] as? [JSONObject] else { return }
|
|
for change in changes {
|
|
guard let userID = change["user_id"] as? String,
|
|
let presenceRaw = change["presence"] as? String,
|
|
let presence = Member.Presence(rawValue: presenceRaw),
|
|
let lastSeen = change["last_seen_at"] as? String,
|
|
let idx = members.firstIndex(where: { $0.user.id == userID })
|
|
else { continue }
|
|
members[idx].presence = presence
|
|
members[idx].lastSeenAt = lastSeen
|
|
onMemberPresenceChanged?(userID, presence)
|
|
}
|
|
|
|
case ("+op", let s) where s.count == 4 && s[0] == "labs" && s[2] == "notebook" && s[3] == "entries":
|
|
guard let kind = payload["kind"] as? String else { return }
|
|
let opID = (payload["op_id"] as? String).flatMap(UUID.init(uuidString:))
|
|
switch kind {
|
|
case "add":
|
|
if let entryObj = payload["entry"] as? JSONObject,
|
|
let entry = NotebookEntry.fromJSON(entryObj, binaryData: data) {
|
|
onEntryUpserted?(entry)
|
|
}
|
|
case "update":
|
|
if let entryObj = payload["entry"] as? JSONObject,
|
|
let entry = NotebookEntry.fromJSON(entryObj, binaryData: nil) {
|
|
onEntryUpserted?(entry)
|
|
}
|
|
case "remove":
|
|
if let idStr = payload["entry_id"] as? String,
|
|
let id = UUID(uuidString: idStr) {
|
|
onEntryRemoved?(id)
|
|
}
|
|
case "reorder":
|
|
if let idStr = payload["entry_id"] as? String,
|
|
let id = UUID(uuidString: idStr),
|
|
let position = (payload["position"] as? Double)
|
|
?? (payload["position"] as? NSNumber)?.doubleValue {
|
|
onEntryRepositioned?(id, position)
|
|
}
|
|
default:
|
|
return
|
|
}
|
|
if let opID {
|
|
try? store.removeOutboxOp(opID: opID)
|
|
}
|
|
|
|
case ("+op-rejected", let s) where s.count == 4 && s[0] == "labs" && s[2] == "notebook" && s[3] == "entries":
|
|
guard let idStr = payload["op_id"] as? String,
|
|
let opID = UUID(uuidString: idStr) else { return }
|
|
let reason = (payload["reason"] as? String) ?? "rejected"
|
|
try? store.removeOutboxOp(opID: opID)
|
|
onOpRejected?(opID, reason)
|
|
|
|
case ("+op", let s) where s.count == 3 && s[0] == "labs" && s[2] == "custom-instruments":
|
|
guard let op = CustomInstrumentOp.fromJSON(payload) else { return }
|
|
onCustomInstrumentOpReceived?(op)
|
|
try? store.removeCustomInstrumentOutboxOp(opID: op.opID)
|
|
|
|
case ("+op-rejected", let s) where s.count == 3 && s[0] == "labs" && s[2] == "custom-instruments":
|
|
guard let idStr = payload["op_id"] as? String,
|
|
let opID = UUID(uuidString: idStr) else { return }
|
|
try? store.removeCustomInstrumentOutboxOp(opID: opID)
|
|
|
|
case ("+op", let s) where s.count == 3 && s[0] == "labs" && s[2] == "missions":
|
|
guard let op = MissionOp.fromJSON(payload) else { return }
|
|
onMissionOpReceived?(op)
|
|
try? store.removeMissionOutboxOp(opID: op.opID)
|
|
|
|
case ("+op-rejected", let s) where s.count == 3 && s[0] == "labs" && s[2] == "missions":
|
|
guard let idStr = payload["op_id"] as? String,
|
|
let opID = UUID(uuidString: idStr) else { return }
|
|
try? store.removeMissionOutboxOp(opID: opID)
|
|
|
|
case ("+op", let s) where s.count == 3 && s[0] == "labs" && s[2] == "address-notes":
|
|
guard let op = AddressNoteOp.fromJSON(payload) else { return }
|
|
onAddressNoteOpReceived?(op)
|
|
try? store.removeAddressNoteOutboxOp(opID: op.opID)
|
|
|
|
case ("+op-rejected", let s) where s.count == 3 && s[0] == "labs" && s[2] == "address-notes":
|
|
guard let idStr = payload["op_id"] as? String,
|
|
let opID = UUID(uuidString: idStr) else { return }
|
|
try? store.removeAddressNoteOutboxOp(opID: opID)
|
|
|
|
case ("+op", let s) where s.count == 4 && s[0] == "labs" && s[2] == "sessions":
|
|
guard let sessionID = UUID(uuidString: s[3]) else { return }
|
|
applySessionOp(sessionID: sessionID, payload: payload)
|
|
|
|
case ("+op-rejected", let s) where s.count == 4 && s[0] == "labs" && s[2] == "sessions":
|
|
guard let idStr = payload["op_id"] as? String,
|
|
let opID = UUID(uuidString: idStr) else { return }
|
|
let reason = (payload["reason"] as? String) ?? "rejected"
|
|
try? store.removeSessionOutboxOp(opID: opID)
|
|
onSessionOpRejected?(opID, reason)
|
|
|
|
case ("+eval", let s)
|
|
where s.count == 7 && s[0] == "labs" && s[2] == "sessions" && s[4] == "repl" && s[5] == "cells":
|
|
guard let sessionID = UUID(uuidString: s[3]),
|
|
let cellID = UUID(uuidString: s[6]),
|
|
let code = payload["code"] as? String
|
|
else { return }
|
|
let language = (payload["language"] as? String).flatMap(REPLLanguage.init(rawValue:)) ?? .javascript
|
|
onSessionReplEvalRequested?(sessionID, code, language, cellID)
|
|
|
|
case ("+enable", let s)
|
|
where s.count == 6 && s[0] == "labs" && s[2] == "sessions" && s[4] == "instruments":
|
|
guard let sessionID = UUID(uuidString: s[3]),
|
|
let instanceID = UUID(uuidString: s[5])
|
|
else { return }
|
|
onSessionInstrumentSetStateRequested?(sessionID, instanceID, .enabled)
|
|
|
|
case ("+disable", let s)
|
|
where s.count == 6 && s[0] == "labs" && s[2] == "sessions" && s[4] == "instruments":
|
|
guard let sessionID = UUID(uuidString: s[3]),
|
|
let instanceID = UUID(uuidString: s[5])
|
|
else { return }
|
|
onSessionInstrumentSetStateRequested?(sessionID, instanceID, .disabled)
|
|
|
|
case ("+remove", let s)
|
|
where s.count == 6 && s[0] == "labs" && s[2] == "sessions" && s[4] == "instruments":
|
|
guard let sessionID = UUID(uuidString: s[3]),
|
|
let instanceID = UUID(uuidString: s[5])
|
|
else { return }
|
|
onSessionInstrumentRemoveRequested?(sessionID, instanceID)
|
|
|
|
case ("+add", let s)
|
|
where s.count == 5 && s[0] == "labs" && s[2] == "sessions" && s[4] == "instruments":
|
|
guard let sessionID = UUID(uuidString: s[3]),
|
|
let kindRaw = payload["kind"] as? String,
|
|
let kind = InstrumentKind(rawValue: kindRaw),
|
|
let sourceIdentifier = payload["source_identifier"] as? String,
|
|
let configB64 = payload["config_json"] as? String,
|
|
let configJSON = Data(base64Encoded: configB64)
|
|
else { return }
|
|
onSessionInstrumentAddRequested?(sessionID, kind, sourceIdentifier, configJSON)
|
|
|
|
case ("+update-config", let s)
|
|
where s.count == 6 && s[0] == "labs" && s[2] == "sessions" && s[4] == "instruments":
|
|
guard let sessionID = UUID(uuidString: s[3]),
|
|
let instanceID = UUID(uuidString: s[5]),
|
|
let configB64 = payload["config_json"] as? String,
|
|
let configJSON = Data(base64Encoded: configB64)
|
|
else { return }
|
|
onSessionInstrumentUpdateConfigRequested?(sessionID, instanceID, configJSON)
|
|
|
|
case ("+event", let s)
|
|
where s.count == 5 && s[0] == "labs" && s[2] == "sessions" && s[4] == "events":
|
|
guard let sessionID = UUID(uuidString: s[3]),
|
|
let event = RuntimeEvent.fromWireJSON(payload)
|
|
else { return }
|
|
onSessionEventReceived?(sessionID, event)
|
|
|
|
case ("+widget-update", let s)
|
|
where s.count == 5 && s[0] == "labs" && s[2] == "sessions" && s[4] == "widget-updates":
|
|
guard let sessionID = UUID(uuidString: s[3]),
|
|
let update = WidgetUpdate.fromWireJSON(payload)
|
|
else { return }
|
|
onSessionWidgetUpdateReceived?(sessionID, update)
|
|
|
|
case ("+widget-action", let s)
|
|
where s.count == 5 && s[0] == "labs" && s[2] == "sessions" && s[4] == "widget-actions":
|
|
guard let sessionID = UUID(uuidString: s[3]),
|
|
let instanceStr = payload["instance_id"] as? String,
|
|
let instanceID = UUID(uuidString: instanceStr),
|
|
let widget = payload["widget"] as? String,
|
|
let action = payload["action"] as? String
|
|
else { return }
|
|
onSessionWidgetActionRequested?(sessionID, instanceID, widget, action, payload["item"] as? String)
|
|
|
|
case ("+add", let s) where s.count == 4 && s[0] == "labs" && s[2] == "chat" && s[3] == "messages":
|
|
guard let localUser, let msgs = payload["messages"] as? [JSONObject] else { return }
|
|
for m in msgs {
|
|
if let message = ChatMessage.fromJSON(m, localUser: localUser) {
|
|
chatMessages.append(message)
|
|
onChatMessageReceived?(message)
|
|
onChatMessageChange?(.appended(message))
|
|
}
|
|
}
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func setStatus(_ newStatus: Status) {
|
|
status = newStatus
|
|
_statusChanges.yield(newStatus)
|
|
}
|
|
}
|