680 lines
25 KiB
Swift
680 lines
25 KiB
Swift
import Foundation
|
|
|
|
// MARK: - API Response Model
|
|
|
|
public struct WindsurfGetPlanStatusResponse: Sendable, Equatable {
|
|
public let planStatus: PlanStatus?
|
|
|
|
public struct PlanStatus: Sendable, Equatable {
|
|
public let planInfo: PlanInfo?
|
|
public let planStart: Date?
|
|
public let planEnd: Date?
|
|
public let dailyQuotaRemainingPercent: Int?
|
|
public let weeklyQuotaRemainingPercent: Int?
|
|
public let dailyQuotaResetAtUnix: Int64?
|
|
public let weeklyQuotaResetAtUnix: Int64?
|
|
public let topUpStatus: TopUpStatus?
|
|
public let gracePeriodStatus: Int?
|
|
|
|
public struct PlanInfo: Sendable, Equatable {
|
|
public let planName: String?
|
|
public let teamsTier: Int?
|
|
}
|
|
|
|
public struct TopUpStatus: Sendable, Equatable {
|
|
public let topUpTransactionStatus: Int?
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Conversion to UsageSnapshot
|
|
|
|
extension WindsurfGetPlanStatusResponse {
|
|
public func toUsageSnapshot() -> UsageSnapshot {
|
|
var primary: RateWindow?
|
|
var secondary: RateWindow?
|
|
|
|
if let status = self.planStatus {
|
|
if let daily = status.dailyQuotaRemainingPercent {
|
|
let resetDate = status.dailyQuotaResetAtUnix.map {
|
|
Date(timeIntervalSince1970: TimeInterval($0))
|
|
}
|
|
primary = RateWindow(
|
|
usedPercent: max(0, min(100, 100 - Double(daily))),
|
|
windowMinutes: nil,
|
|
resetsAt: resetDate,
|
|
resetDescription: Self.formatResetDescription(resetDate))
|
|
}
|
|
|
|
if let weekly = status.weeklyQuotaRemainingPercent {
|
|
let resetDate = status.weeklyQuotaResetAtUnix.map {
|
|
Date(timeIntervalSince1970: TimeInterval($0))
|
|
}
|
|
secondary = RateWindow(
|
|
usedPercent: max(0, min(100, 100 - Double(weekly))),
|
|
windowMinutes: nil,
|
|
resetsAt: resetDate,
|
|
resetDescription: Self.formatResetDescription(resetDate))
|
|
}
|
|
}
|
|
|
|
var orgDescription: String?
|
|
if let endDate = self.planStatus?.planEnd {
|
|
let formatter = DateFormatter()
|
|
formatter.dateStyle = .medium
|
|
formatter.timeStyle = .none
|
|
orgDescription = "Expires \(formatter.string(from: endDate))"
|
|
}
|
|
|
|
let identity = ProviderIdentitySnapshot(
|
|
providerID: .windsurf,
|
|
accountEmail: nil,
|
|
accountOrganization: orgDescription,
|
|
loginMethod: self.planStatus?.planInfo?.planName)
|
|
|
|
return UsageSnapshot(
|
|
primary: primary,
|
|
secondary: secondary,
|
|
updatedAt: Date(),
|
|
identity: identity)
|
|
}
|
|
|
|
private static func formatResetDescription(_ date: Date?) -> String? {
|
|
guard let date else { return nil }
|
|
let now = Date()
|
|
let interval = date.timeIntervalSince(now)
|
|
guard interval > 0 else { return "Expired" }
|
|
|
|
let hours = Int(interval / 3600)
|
|
let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60)
|
|
|
|
if hours > 24 {
|
|
let days = hours / 24
|
|
let remainingHours = hours % 24
|
|
return "Resets in \(days)d \(remainingHours)h"
|
|
} else if hours > 0 {
|
|
return "Resets in \(hours)h \(minutes)m"
|
|
} else {
|
|
return "Resets in \(minutes)m"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Session Material
|
|
|
|
#if os(macOS)
|
|
|
|
struct WindsurfDevinSessionAuth: Codable, Equatable {
|
|
let sessionToken: String
|
|
let auth1Token: String
|
|
let accountID: String
|
|
let primaryOrgID: String
|
|
}
|
|
|
|
public enum WindsurfWebFetcherError: LocalizedError, Sendable {
|
|
case noSessionData
|
|
case invalidManualSession(String)
|
|
case apiCallFailed(String)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .noSessionData:
|
|
"No Windsurf web session found in Chromium localStorage. " +
|
|
"Sign in to app.devin.ai or windsurf.com in Chrome first."
|
|
case let .invalidManualSession(message):
|
|
"Invalid Windsurf session payload: \(message)"
|
|
case let .apiCallFailed(message):
|
|
"Windsurf API call failed: \(message)"
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum WindsurfWebFetcher {
|
|
private static let windsurfOrigin = "https://windsurf.com"
|
|
private static let windsurfProfileReferer = "https://windsurf.com/profile"
|
|
private static let getPlanStatusURL = "https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/GetPlanStatus"
|
|
|
|
public static func fetchUsage(
|
|
browserDetection: BrowserDetection,
|
|
cookieSource: ProviderCookieSource = .auto,
|
|
manualSessionInput: String? = nil,
|
|
timeout: TimeInterval = 15,
|
|
logger: ((String) -> Void)? = nil,
|
|
session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> UsageSnapshot
|
|
{
|
|
let log: (String) -> Void = { msg in logger?("[windsurf-web] \(msg)") }
|
|
|
|
if cookieSource == .manual {
|
|
guard let manualSessionInput,
|
|
!manualSessionInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
else {
|
|
throw WindsurfWebFetcherError.invalidManualSession("empty input")
|
|
}
|
|
log("Using manual Windsurf session bundle")
|
|
let auth = try self.parseManualSessionInput(manualSessionInput)
|
|
let response = try await self.fetchPlanStatus(auth: auth, timeout: timeout, transport: transport)
|
|
return response.toUsageSnapshot()
|
|
}
|
|
|
|
guard cookieSource != .off else {
|
|
throw WindsurfWebFetcherError.noSessionData
|
|
}
|
|
|
|
let preferredSessionInfos = WindsurfDevinSessionImporter.importPreferredSessions(
|
|
browserDetection: browserDetection,
|
|
logger: logger)
|
|
let sessionInfos = preferredSessionInfos.isEmpty
|
|
? WindsurfDevinSessionImporter.importFallbackSessions(
|
|
browserDetection: browserDetection,
|
|
logger: logger)
|
|
: preferredSessionInfos
|
|
guard !sessionInfos.isEmpty else {
|
|
throw WindsurfWebFetcherError.noSessionData
|
|
}
|
|
|
|
do {
|
|
return try await self.fetchUsage(
|
|
sessionInfos: sessionInfos,
|
|
timeout: timeout,
|
|
logger: log,
|
|
transport: transport)
|
|
} catch {
|
|
guard !preferredSessionInfos.isEmpty, self.isRecoverableImportedSessionError(error) else {
|
|
throw error
|
|
}
|
|
}
|
|
|
|
log("Chrome Windsurf sessions failed; trying fallback Chromium browser sessions")
|
|
let fallbackSessionInfos = WindsurfDevinSessionImporter.importFallbackSessions(
|
|
browserDetection: browserDetection,
|
|
logger: logger)
|
|
guard !fallbackSessionInfos.isEmpty else {
|
|
throw WindsurfWebFetcherError.noSessionData
|
|
}
|
|
return try await self.fetchUsage(
|
|
sessionInfos: fallbackSessionInfos,
|
|
timeout: timeout,
|
|
logger: log,
|
|
transport: transport)
|
|
}
|
|
|
|
static func parseManualSessionInput(_ raw: String) throws -> WindsurfDevinSessionAuth {
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else {
|
|
throw WindsurfWebFetcherError.invalidManualSession("empty input")
|
|
}
|
|
|
|
if let auth = self.parseJSONSessionInput(trimmed) {
|
|
return auth
|
|
}
|
|
|
|
if let auth = self.parseKeyValueSessionInput(trimmed) {
|
|
return auth
|
|
}
|
|
|
|
throw WindsurfWebFetcherError.invalidManualSession(
|
|
"expected JSON with devin_session_token, devin_auth1_token, devin_account_id, and devin_primary_org_id")
|
|
}
|
|
|
|
private static func parseJSONSessionInput(_ raw: String) -> WindsurfDevinSessionAuth? {
|
|
guard let data = raw.data(using: .utf8),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
else {
|
|
return nil
|
|
}
|
|
return self.sessionAuth(from: json)
|
|
}
|
|
|
|
private static func parseKeyValueSessionInput(_ raw: String) -> WindsurfDevinSessionAuth? {
|
|
let separators = CharacterSet(charactersIn: "\n,;")
|
|
let segments = raw
|
|
.trimmingCharacters(in: CharacterSet(charactersIn: "{}"))
|
|
.components(separatedBy: separators)
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty }
|
|
|
|
var values: [String: String] = [:]
|
|
for segment in segments {
|
|
let delimiter: Character? = segment.contains("=") ? "=" : (segment.contains(":") ? ":" : nil)
|
|
guard let delimiter, let index = segment.firstIndex(of: delimiter) else { continue }
|
|
let key = String(segment[..<index]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let value = String(segment[segment.index(after: index)...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
values[key] = value.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
|
|
}
|
|
|
|
guard !values.isEmpty else { return nil }
|
|
return self.sessionAuth(from: values)
|
|
}
|
|
|
|
private static func isRecoverableImportedSessionError(_ error: Error) -> Bool {
|
|
guard case let WindsurfWebFetcherError.apiCallFailed(message) = error else {
|
|
return false
|
|
}
|
|
|
|
return ["HTTP 400", "HTTP 401", "HTTP 403"].contains { message.hasPrefix($0) }
|
|
}
|
|
|
|
private static func fetchUsage(
|
|
sessionInfos: [WindsurfDevinSessionImporter.SessionInfo],
|
|
timeout: TimeInterval,
|
|
logger log: (String) -> Void,
|
|
transport: any ProviderHTTPTransport) async throws -> UsageSnapshot
|
|
{
|
|
var lastError: Error?
|
|
for sessionInfo in sessionInfos {
|
|
do {
|
|
log("Using devin session from \(sessionInfo.sourceLabel)")
|
|
let response = try await self.fetchPlanStatus(
|
|
auth: sessionInfo.session,
|
|
timeout: timeout,
|
|
transport: transport)
|
|
return response.toUsageSnapshot()
|
|
} catch {
|
|
guard self.isRecoverableImportedSessionError(error) else {
|
|
throw error
|
|
}
|
|
lastError = error
|
|
log("Windsurf devin session from \(sessionInfo.sourceLabel) failed; trying next imported session")
|
|
}
|
|
}
|
|
|
|
throw lastError ?? WindsurfWebFetcherError.noSessionData
|
|
}
|
|
|
|
private static func sessionAuth(from values: [String: Any]) -> WindsurfDevinSessionAuth? {
|
|
func stringValue(for keys: [String]) -> String? {
|
|
for key in keys {
|
|
if let value = values[key] as? String {
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !trimmed.isEmpty {
|
|
return trimmed
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
guard let sessionToken = stringValue(for: ["devin_session_token", "devinSessionToken", "sessionToken"]),
|
|
let auth1Token = stringValue(for: ["devin_auth1_token", "devinAuth1Token", "auth1Token"]),
|
|
let accountID = stringValue(for: ["devin_account_id", "devinAccountId", "accountID", "accountId"]),
|
|
let primaryOrgID = stringValue(for: [
|
|
"devin_primary_org_id",
|
|
"devinPrimaryOrgId",
|
|
"primaryOrgID",
|
|
"primaryOrgId",
|
|
])
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return WindsurfDevinSessionAuth(
|
|
sessionToken: sessionToken,
|
|
auth1Token: auth1Token,
|
|
accountID: accountID,
|
|
primaryOrgID: primaryOrgID)
|
|
}
|
|
|
|
private static func fetchPlanStatus(
|
|
auth: WindsurfDevinSessionAuth,
|
|
timeout: TimeInterval,
|
|
transport: any ProviderHTTPTransport) async throws -> WindsurfGetPlanStatusResponse
|
|
{
|
|
guard let url = URL(string: self.getPlanStatusURL) else {
|
|
throw WindsurfWebFetcherError.apiCallFailed("Invalid GetPlanStatus URL")
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = timeout
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/proto", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version")
|
|
self.applyWindsurfHeaders(to: &request, auth: auth)
|
|
request.httpBody = WindsurfPlanStatusProtoCodec.encodeRequest(
|
|
authToken: auth.sessionToken,
|
|
includeTopUpStatus: true)
|
|
|
|
let response: ProviderHTTPResponse
|
|
do {
|
|
response = try await transport.response(for: request)
|
|
} catch let error as URLError where error.code == .badServerResponse {
|
|
throw WindsurfWebFetcherError.apiCallFailed("Invalid response")
|
|
} catch {
|
|
throw error
|
|
}
|
|
|
|
guard response.statusCode == 200 else {
|
|
let body = String(data: response.data, encoding: .utf8)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let snippet = if let body, !body.isEmpty {
|
|
": \(body.prefix(200))"
|
|
} else {
|
|
": <binary \(response.data.count) bytes>"
|
|
}
|
|
throw WindsurfWebFetcherError.apiCallFailed("HTTP \(response.statusCode)\(snippet)")
|
|
}
|
|
|
|
do {
|
|
return try WindsurfPlanStatusProtoCodec.decodeResponse(response.data)
|
|
} catch {
|
|
throw WindsurfWebFetcherError.apiCallFailed("Parse error: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
private static func applyWindsurfHeaders(to request: inout URLRequest, auth: WindsurfDevinSessionAuth) {
|
|
request.setValue(self.windsurfOrigin, forHTTPHeaderField: "Origin")
|
|
request.setValue(self.windsurfProfileReferer, forHTTPHeaderField: "Referer")
|
|
request.setValue(auth.sessionToken, forHTTPHeaderField: "x-auth-token")
|
|
request.setValue(auth.sessionToken, forHTTPHeaderField: "x-devin-session-token")
|
|
request.setValue(auth.auth1Token, forHTTPHeaderField: "x-devin-auth1-token")
|
|
request.setValue(auth.accountID, forHTTPHeaderField: "x-devin-account-id")
|
|
request.setValue(auth.primaryOrgID, forHTTPHeaderField: "x-devin-primary-org-id")
|
|
}
|
|
}
|
|
|
|
enum WindsurfPlanStatusProtoCodec {
|
|
/// Field numbers come from Windsurf's bundled protobuf metadata in
|
|
/// `/Applications/Windsurf.app/.../extension.js` and were re-verified against live browser traffic on 2026-04-17.
|
|
struct Request: Equatable {
|
|
let authToken: String
|
|
let includeTopUpStatus: Bool
|
|
}
|
|
|
|
static func encodeRequest(authToken: String, includeTopUpStatus: Bool) -> Data {
|
|
var data = Data()
|
|
self.appendFieldKey(1, wireType: .lengthDelimited, to: &data)
|
|
self.appendString(authToken, to: &data)
|
|
self.appendFieldKey(2, wireType: .varint, to: &data)
|
|
self.appendVarint(includeTopUpStatus ? 1 : 0, to: &data)
|
|
return data
|
|
}
|
|
|
|
static func decodeRequest(_ data: Data) throws -> Request {
|
|
var reader = ProtoReader(data: data)
|
|
var authToken: String?
|
|
var includeTopUpStatus = false
|
|
|
|
while let field = try reader.nextField() {
|
|
switch (field.number, field.wireType) {
|
|
case (1, .lengthDelimited):
|
|
authToken = try reader.readString()
|
|
case (2, .varint):
|
|
includeTopUpStatus = try reader.readVarint() != 0
|
|
default:
|
|
try reader.skipFieldBody(wireType: field.wireType)
|
|
}
|
|
}
|
|
|
|
guard let authToken else {
|
|
throw WindsurfProtoError.missingField("auth_token")
|
|
}
|
|
|
|
return Request(authToken: authToken, includeTopUpStatus: includeTopUpStatus)
|
|
}
|
|
|
|
static func decodeResponse(_ data: Data) throws -> WindsurfGetPlanStatusResponse {
|
|
var reader = ProtoReader(data: data)
|
|
var planStatus: WindsurfGetPlanStatusResponse.PlanStatus?
|
|
|
|
while let field = try reader.nextField() {
|
|
switch (field.number, field.wireType) {
|
|
case (1, .lengthDelimited):
|
|
planStatus = try self.decodePlanStatus(from: reader.readLengthDelimitedData())
|
|
default:
|
|
try reader.skipFieldBody(wireType: field.wireType)
|
|
}
|
|
}
|
|
|
|
return WindsurfGetPlanStatusResponse(planStatus: planStatus)
|
|
}
|
|
|
|
private static func decodePlanStatus(from data: Data) throws -> WindsurfGetPlanStatusResponse.PlanStatus {
|
|
var reader = ProtoReader(data: data)
|
|
var planInfo: WindsurfGetPlanStatusResponse.PlanStatus.PlanInfo?
|
|
var planStart: Date?
|
|
var planEnd: Date?
|
|
var dailyQuotaRemainingPercent: Int?
|
|
var weeklyQuotaRemainingPercent: Int?
|
|
var dailyQuotaResetAtUnix: Int64?
|
|
var weeklyQuotaResetAtUnix: Int64?
|
|
var topUpStatus: WindsurfGetPlanStatusResponse.PlanStatus.TopUpStatus?
|
|
var gracePeriodStatus: Int?
|
|
|
|
while let field = try reader.nextField() {
|
|
switch (field.number, field.wireType) {
|
|
case (1, .lengthDelimited):
|
|
planInfo = try self.decodePlanInfo(from: reader.readLengthDelimitedData())
|
|
case (2, .lengthDelimited):
|
|
planStart = try self.decodeTimestamp(from: reader.readLengthDelimitedData())
|
|
case (3, .lengthDelimited):
|
|
planEnd = try self.decodeTimestamp(from: reader.readLengthDelimitedData())
|
|
case (10, .lengthDelimited):
|
|
topUpStatus = try self.decodeTopUpStatus(from: reader.readLengthDelimitedData())
|
|
case (12, .varint):
|
|
gracePeriodStatus = try Int(reader.readVarint())
|
|
case (14, .varint):
|
|
dailyQuotaRemainingPercent = try Int(reader.readVarint())
|
|
case (15, .varint):
|
|
weeklyQuotaRemainingPercent = try Int(reader.readVarint())
|
|
case (17, .varint):
|
|
dailyQuotaResetAtUnix = try Int64(reader.readVarint())
|
|
case (18, .varint):
|
|
weeklyQuotaResetAtUnix = try Int64(reader.readVarint())
|
|
default:
|
|
try reader.skipFieldBody(wireType: field.wireType)
|
|
}
|
|
}
|
|
|
|
return WindsurfGetPlanStatusResponse.PlanStatus(
|
|
planInfo: planInfo,
|
|
planStart: planStart,
|
|
planEnd: planEnd,
|
|
dailyQuotaRemainingPercent: dailyQuotaRemainingPercent,
|
|
weeklyQuotaRemainingPercent: weeklyQuotaRemainingPercent,
|
|
dailyQuotaResetAtUnix: dailyQuotaResetAtUnix,
|
|
weeklyQuotaResetAtUnix: weeklyQuotaResetAtUnix,
|
|
topUpStatus: topUpStatus,
|
|
gracePeriodStatus: gracePeriodStatus)
|
|
}
|
|
|
|
private static func decodePlanInfo(
|
|
from data: Data) throws -> WindsurfGetPlanStatusResponse.PlanStatus.PlanInfo
|
|
{
|
|
var reader = ProtoReader(data: data)
|
|
var planName: String?
|
|
var teamsTier: Int?
|
|
|
|
while let field = try reader.nextField() {
|
|
switch (field.number, field.wireType) {
|
|
case (1, .varint):
|
|
teamsTier = try Int(reader.readVarint())
|
|
case (2, .lengthDelimited):
|
|
planName = try reader.readString()
|
|
default:
|
|
try reader.skipFieldBody(wireType: field.wireType)
|
|
}
|
|
}
|
|
|
|
return WindsurfGetPlanStatusResponse.PlanStatus.PlanInfo(planName: planName, teamsTier: teamsTier)
|
|
}
|
|
|
|
private static func decodeTopUpStatus(
|
|
from data: Data) throws -> WindsurfGetPlanStatusResponse.PlanStatus.TopUpStatus
|
|
{
|
|
var reader = ProtoReader(data: data)
|
|
var topUpTransactionStatus: Int?
|
|
|
|
while let field = try reader.nextField() {
|
|
switch (field.number, field.wireType) {
|
|
case (1, .varint):
|
|
topUpTransactionStatus = try Int(reader.readVarint())
|
|
default:
|
|
try reader.skipFieldBody(wireType: field.wireType)
|
|
}
|
|
}
|
|
|
|
return WindsurfGetPlanStatusResponse.PlanStatus.TopUpStatus(
|
|
topUpTransactionStatus: topUpTransactionStatus)
|
|
}
|
|
|
|
private static func decodeTimestamp(from data: Data) throws -> Date {
|
|
var reader = ProtoReader(data: data)
|
|
var seconds: Int64 = 0
|
|
var nanos: Int32 = 0
|
|
|
|
while let field = try reader.nextField() {
|
|
switch (field.number, field.wireType) {
|
|
case (1, .varint):
|
|
seconds = try Int64(reader.readVarint())
|
|
case (2, .varint):
|
|
nanos = try Int32(reader.readVarint())
|
|
default:
|
|
try reader.skipFieldBody(wireType: field.wireType)
|
|
}
|
|
}
|
|
|
|
let timeInterval = TimeInterval(seconds) + (TimeInterval(nanos) / 1_000_000_000)
|
|
return Date(timeIntervalSince1970: timeInterval)
|
|
}
|
|
|
|
private static func appendString(_ string: String, to data: inout Data) {
|
|
let encoded = Data(string.utf8)
|
|
self.appendVarint(UInt64(encoded.count), to: &data)
|
|
data.append(encoded)
|
|
}
|
|
|
|
private static func appendFieldKey(_ fieldNumber: Int, wireType: ProtoWireType, to data: inout Data) {
|
|
self.appendVarint(UInt64((fieldNumber << 3) | Int(wireType.rawValue)), to: &data)
|
|
}
|
|
|
|
private static func appendVarint(_ value: UInt64, to data: inout Data) {
|
|
var remaining = value
|
|
while remaining >= 0x80 {
|
|
data.append(UInt8((remaining & 0x7F) | 0x80))
|
|
remaining >>= 7
|
|
}
|
|
data.append(UInt8(remaining))
|
|
}
|
|
}
|
|
|
|
enum WindsurfProtoError: LocalizedError {
|
|
case truncated
|
|
case invalidWireType(UInt64)
|
|
case invalidUTF8
|
|
case missingField(String)
|
|
case unsupportedWireType(ProtoWireType)
|
|
case malformedFieldKey
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .truncated:
|
|
"truncated protobuf payload"
|
|
case let .invalidWireType(rawValue):
|
|
"invalid wire type \(rawValue)"
|
|
case .invalidUTF8:
|
|
"invalid UTF-8 string"
|
|
case let .missingField(name):
|
|
"missing protobuf field \(name)"
|
|
case let .unsupportedWireType(type):
|
|
"unsupported protobuf wire type \(type.rawValue)"
|
|
case .malformedFieldKey:
|
|
"malformed protobuf field key"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum ProtoWireType: UInt64 {
|
|
case varint = 0
|
|
case fixed64 = 1
|
|
case lengthDelimited = 2
|
|
case startGroup = 3
|
|
case endGroup = 4
|
|
case fixed32 = 5
|
|
}
|
|
|
|
private struct ProtoField {
|
|
let number: Int
|
|
let wireType: ProtoWireType
|
|
}
|
|
|
|
private struct ProtoReader {
|
|
private let bytes: [UInt8]
|
|
private var index: Int = 0
|
|
|
|
init(data: Data) {
|
|
self.bytes = Array(data)
|
|
}
|
|
|
|
mutating func nextField() throws -> ProtoField? {
|
|
guard self.index < self.bytes.count else { return nil }
|
|
let key = try self.readVarint()
|
|
let number = Int(key >> 3)
|
|
guard number > 0 else {
|
|
throw WindsurfProtoError.malformedFieldKey
|
|
}
|
|
guard let wireType = ProtoWireType(rawValue: key & 0x07) else {
|
|
throw WindsurfProtoError.invalidWireType(key & 0x07)
|
|
}
|
|
return ProtoField(number: number, wireType: wireType)
|
|
}
|
|
|
|
mutating func readVarint() throws -> UInt64 {
|
|
var result: UInt64 = 0
|
|
var shift: UInt64 = 0
|
|
|
|
while self.index < self.bytes.count {
|
|
let byte = self.bytes[self.index]
|
|
self.index += 1
|
|
|
|
result |= UInt64(byte & 0x7F) << shift
|
|
if byte & 0x80 == 0 {
|
|
return result
|
|
}
|
|
|
|
shift += 7
|
|
if shift >= 64 {
|
|
throw WindsurfProtoError.truncated
|
|
}
|
|
}
|
|
|
|
throw WindsurfProtoError.truncated
|
|
}
|
|
|
|
mutating func readLengthDelimitedData() throws -> Data {
|
|
let length = try Int(self.readVarint())
|
|
guard length >= 0, self.index + length <= self.bytes.count else {
|
|
throw WindsurfProtoError.truncated
|
|
}
|
|
|
|
let chunk = Data(self.bytes[self.index..<(self.index + length)])
|
|
self.index += length
|
|
return chunk
|
|
}
|
|
|
|
mutating func readString() throws -> String {
|
|
let data = try self.readLengthDelimitedData()
|
|
guard let string = String(data: data, encoding: .utf8) else {
|
|
throw WindsurfProtoError.invalidUTF8
|
|
}
|
|
return string
|
|
}
|
|
|
|
mutating func skipFieldBody(wireType: ProtoWireType) throws {
|
|
switch wireType {
|
|
case .varint:
|
|
_ = try self.readVarint()
|
|
case .fixed64:
|
|
guard self.index + 8 <= self.bytes.count else { throw WindsurfProtoError.truncated }
|
|
self.index += 8
|
|
case .lengthDelimited:
|
|
_ = try self.readLengthDelimitedData()
|
|
case .fixed32:
|
|
guard self.index + 4 <= self.bytes.count else { throw WindsurfProtoError.truncated }
|
|
self.index += 4
|
|
case .startGroup, .endGroup:
|
|
throw WindsurfProtoError.unsupportedWireType(wireType)
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|