chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
|
||||
public enum DevinProviderDescriptor {
|
||||
public static let descriptor: ProviderDescriptor = Self.makeDescriptor()
|
||||
|
||||
static func makeDescriptor() -> ProviderDescriptor {
|
||||
ProviderDescriptor(
|
||||
id: .devin,
|
||||
metadata: ProviderMetadata(
|
||||
id: .devin,
|
||||
displayName: "Devin",
|
||||
sessionLabel: "Daily",
|
||||
weeklyLabel: "Weekly",
|
||||
opusLabel: nil,
|
||||
supportsOpus: false,
|
||||
supportsCredits: false,
|
||||
creditsHint: "",
|
||||
toggleTitle: "Show Devin usage",
|
||||
cliName: "devin",
|
||||
defaultEnabled: false,
|
||||
isPrimaryProvider: false,
|
||||
usesAccountFallback: false,
|
||||
browserCookieOrder: ProviderBrowserCookieDefaults.devinCookieImportOrder,
|
||||
dashboardURL: "https://app.devin.ai",
|
||||
subscriptionDashboardURL: "https://app.devin.ai/settings/usage",
|
||||
statusPageURL: nil),
|
||||
branding: ProviderBranding(
|
||||
iconStyle: .devin,
|
||||
iconResourceName: "ProviderIcon-devin",
|
||||
color: ProviderColor(red: 70 / 255, green: 180 / 255, blue: 130 / 255)),
|
||||
tokenCost: ProviderTokenCostConfig(
|
||||
supportsTokenCost: false,
|
||||
noDataMessage: { "Devin cost summary is not supported." }),
|
||||
fetchPlan: ProviderFetchPlan(
|
||||
sourceModes: [.auto, .web],
|
||||
pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [DevinWebFetchStrategy()] })),
|
||||
cli: ProviderCLIConfig(
|
||||
name: "devin",
|
||||
versionDetector: nil))
|
||||
}
|
||||
}
|
||||
|
||||
struct DevinWebFetchStrategy: ProviderFetchStrategy {
|
||||
let id: String = "devin.web"
|
||||
let kind: ProviderFetchKind = .web
|
||||
|
||||
func isAvailable(_ context: ProviderFetchContext) async -> Bool {
|
||||
let settings = context.settings?.devin
|
||||
let source = settings?.cookieSource ?? .auto
|
||||
guard source != .off else { return false }
|
||||
if source == .manual {
|
||||
return DevinUsageFetcher.manualAuth(from: Self.bearerTokenOverride(context: context)) != nil
|
||||
}
|
||||
#if os(macOS)
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
|
||||
let fetcher = DevinUsageFetcher(browserDetection: context.browserDetection)
|
||||
let settings = context.settings?.devin
|
||||
let logger: ((String) -> Void)? = context.verbose
|
||||
? { msg in CodexBarLog.logger(LogCategories.devin).verbose(msg) }
|
||||
: nil
|
||||
let snapshot = try await fetcher.fetch(
|
||||
bearerTokenOverride: settings?.cookieSource == .manual ? Self.bearerTokenOverride(context: context) : nil,
|
||||
organizationOverride: Self.organizationOverride(context: context),
|
||||
timeout: context.webTimeout,
|
||||
logger: logger)
|
||||
return self.makeResult(
|
||||
usage: snapshot.toUsageSnapshot(),
|
||||
sourceLabel: "web")
|
||||
}
|
||||
|
||||
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
private static func bearerTokenOverride(context: ProviderFetchContext) -> String? {
|
||||
context.env["DEVIN_BEARER_TOKEN"]
|
||||
?? context.env["DEVIN_AUTHORIZATION"]
|
||||
?? context.settings?.devin?.manualBearerToken
|
||||
}
|
||||
|
||||
private static func organizationOverride(context: ProviderFetchContext) -> String? {
|
||||
context.env["DEVIN_ORGANIZATION"]
|
||||
?? context.env["DEVIN_ORG"]
|
||||
?? context.settings?.devin?.organization
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import Foundation
|
||||
#if os(macOS)
|
||||
import SweetCookieKit
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
enum DevinSessionImporter {
|
||||
nonisolated(unsafe) static var importSessionOverrideForTesting:
|
||||
((BrowserDetection, String?, ((String) -> Void)?) -> SessionInfo?)?
|
||||
|
||||
private static let storageOrigin = "https://app.devin.ai"
|
||||
private static let externalOrgPrefix = "last-internal-org-for-external-org-v1-"
|
||||
|
||||
struct SessionInfo: Equatable {
|
||||
let accessToken: String
|
||||
let organization: String?
|
||||
let internalOrganizationID: String?
|
||||
let sourceLabel: String
|
||||
}
|
||||
|
||||
struct LocalStorageCandidate {
|
||||
let label: String
|
||||
let url: URL
|
||||
}
|
||||
|
||||
static func importSession(
|
||||
browserDetection: BrowserDetection,
|
||||
organizationOverride: String? = nil,
|
||||
logger: ((String) -> Void)? = nil) -> SessionInfo?
|
||||
{
|
||||
if let override = self.importSessionOverrideForTesting {
|
||||
return override(browserDetection, organizationOverride, logger)
|
||||
}
|
||||
|
||||
let sessions = self.importSessions(
|
||||
browserDetection: browserDetection,
|
||||
organizationOverride: organizationOverride,
|
||||
logger: logger)
|
||||
return sessions.first
|
||||
}
|
||||
|
||||
static func importSessions(
|
||||
browserDetection: BrowserDetection,
|
||||
organizationOverride: String? = nil,
|
||||
logger: ((String) -> Void)? = nil) -> [SessionInfo]
|
||||
{
|
||||
if let override = self.importSessionOverrideForTesting {
|
||||
return override(browserDetection, organizationOverride, logger).map { [$0] } ?? []
|
||||
}
|
||||
|
||||
let log: (String) -> Void = { msg in logger?("[devin-storage] \(msg)") }
|
||||
let candidates = self.chromeLocalStorageCandidates(browserDetection: browserDetection)
|
||||
if !candidates.isEmpty {
|
||||
log("Chrome local storage candidates: \(candidates.count)")
|
||||
}
|
||||
|
||||
var sessions: [SessionInfo] = []
|
||||
for candidate in candidates {
|
||||
let storage = self.readLocalStorage(from: candidate.url, logger: log)
|
||||
guard let session = self.session(
|
||||
from: storage,
|
||||
organizationOverride: organizationOverride,
|
||||
sourceLabel: candidate.label)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
log(
|
||||
"Found Devin session in \(candidate.label); " +
|
||||
"organization=\(session.organization != nil), internalOrganizationID=" +
|
||||
"\(session.internalOrganizationID != nil)")
|
||||
sessions.append(session)
|
||||
}
|
||||
sessions = self.rankSessions(self.deduplicateSessions(sessions))
|
||||
|
||||
if sessions.isEmpty {
|
||||
log("No Devin session found in browser local storage")
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
static func session(
|
||||
from storage: [String: String],
|
||||
organizationOverride: String? = nil,
|
||||
sourceLabel: String) -> SessionInfo?
|
||||
{
|
||||
guard let accessToken = self.accessToken(from: storage) else {
|
||||
return nil
|
||||
}
|
||||
let organizationInfo = self.organizationInfo(from: storage, organizationOverride: organizationOverride)
|
||||
return SessionInfo(
|
||||
accessToken: accessToken,
|
||||
organization: organizationInfo.organization,
|
||||
internalOrganizationID: organizationInfo.internalOrganizationID,
|
||||
sourceLabel: sourceLabel)
|
||||
}
|
||||
|
||||
static func accessToken(from storage: [String: String]) -> String? {
|
||||
for (key, value) in storage where self.isAuth1StorageKey(key) {
|
||||
guard let json = self.jsonObject(from: value),
|
||||
let token = self.findAuth1Token(in: json)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
for (key, value) in storage where self.isAuth0StorageKey(key) {
|
||||
guard let json = self.jsonObject(from: value),
|
||||
let token = self.findAccessToken(in: json)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
for value in storage.values {
|
||||
guard let json = self.jsonObject(from: value),
|
||||
let token = self.findAccessToken(in: json)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func deduplicateSessions(_ sessions: [SessionInfo]) -> [SessionInfo] {
|
||||
var order: [String] = []
|
||||
var bestByToken: [String: SessionInfo] = [:]
|
||||
for session in sessions {
|
||||
if let existing = bestByToken[session.accessToken] {
|
||||
if self.organizationScore(session) > self.organizationScore(existing) {
|
||||
bestByToken[session.accessToken] = session
|
||||
}
|
||||
} else {
|
||||
order.append(session.accessToken)
|
||||
bestByToken[session.accessToken] = session
|
||||
}
|
||||
}
|
||||
return order.compactMap { bestByToken[$0] }
|
||||
}
|
||||
|
||||
static func rankSessions(_ sessions: [SessionInfo]) -> [SessionInfo] {
|
||||
sessions.enumerated()
|
||||
.sorted { lhs, rhs in
|
||||
let lhsScore = self.organizationScore(lhs.element)
|
||||
let rhsScore = self.organizationScore(rhs.element)
|
||||
return lhsScore == rhsScore ? lhs.offset < rhs.offset : lhsScore > rhsScore
|
||||
}
|
||||
.map(\.element)
|
||||
}
|
||||
|
||||
private static func organizationScore(_ session: SessionInfo) -> Int {
|
||||
(session.organization == nil ? 0 : 1) + (session.internalOrganizationID == nil ? 0 : 2)
|
||||
}
|
||||
|
||||
static func organizationInfo(
|
||||
from storage: [String: String],
|
||||
organizationOverride: String?) -> (organization: String?, internalOrganizationID: String?)
|
||||
{
|
||||
let override = DevinUsageFetcher.normalizedOrganization(organizationOverride)
|
||||
let overrideSlug = override.flatMap(self.slug(fromNormalizedOrganization:))
|
||||
var firstInternalOrgID: String?
|
||||
|
||||
for (key, value) in storage where self.isExternalOrgStorageKey(key) {
|
||||
let suffix = self.externalOrgSlug(from: key)
|
||||
let orgID = self.cleanedOrgID(value)
|
||||
if firstInternalOrgID == nil {
|
||||
firstInternalOrgID = orgID
|
||||
}
|
||||
if let overrideSlug, suffix == overrideSlug {
|
||||
return (override, orgID)
|
||||
}
|
||||
if override == nil, suffix != "null" {
|
||||
return ("org/\(suffix)", orgID)
|
||||
}
|
||||
}
|
||||
|
||||
if let inferred = self.inferredOrganizationInfo(from: storage, override: override) {
|
||||
return inferred
|
||||
}
|
||||
|
||||
if let override {
|
||||
return (override, firstInternalOrgID ?? self.orgID(fromNormalizedOrganization: override))
|
||||
}
|
||||
|
||||
return (firstInternalOrgID.map { "organizations/\($0)" }, firstInternalOrgID)
|
||||
}
|
||||
|
||||
static func decodedStorageValue(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return trimmed }
|
||||
if let data = trimmed.data(using: .utf8),
|
||||
let decoded = try? JSONDecoder().decode(String.self, from: data)
|
||||
{
|
||||
return decoded.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func chromeLocalStorageCandidates(browserDetection: BrowserDetection) -> [LocalStorageCandidate] {
|
||||
let installedBrowsers = self.localStorageBrowsers(browserDetection: browserDetection)
|
||||
let roots = ChromiumProfileLocator
|
||||
.roots(for: installedBrowsers, homeDirectories: BrowserCookieClient.defaultHomeDirectories())
|
||||
.map { (url: $0.url, labelPrefix: $0.labelPrefix) }
|
||||
|
||||
var candidates: [LocalStorageCandidate] = []
|
||||
for root in roots {
|
||||
candidates.append(contentsOf: self.chromeProfileLocalStorageDirs(
|
||||
root: root.url,
|
||||
labelPrefix: root.labelPrefix))
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
static func localStorageBrowsers(browserDetection: BrowserDetection) -> [Browser] {
|
||||
let order = ProviderDefaults.metadata[.devin]?.browserCookieOrder ?? [.chrome]
|
||||
return order.browsersWithProfileData(using: browserDetection)
|
||||
}
|
||||
|
||||
private static func chromeProfileLocalStorageDirs(root: URL, labelPrefix: String) -> [LocalStorageCandidate] {
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(
|
||||
at: root,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: [.skipsHiddenFiles])
|
||||
else { return [] }
|
||||
|
||||
return entries.filter { url in
|
||||
guard let isDir = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory), isDir else {
|
||||
return false
|
||||
}
|
||||
let name = url.lastPathComponent
|
||||
return name == "Default" || name.hasPrefix("Profile ") || name.hasPrefix("user-")
|
||||
}
|
||||
.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||
.compactMap { dir in
|
||||
let levelDBURL = dir.appendingPathComponent("Local Storage").appendingPathComponent("leveldb")
|
||||
guard FileManager.default.fileExists(atPath: levelDBURL.path) else { return nil }
|
||||
return LocalStorageCandidate(label: "\(labelPrefix) \(dir.lastPathComponent)", url: levelDBURL)
|
||||
}
|
||||
}
|
||||
|
||||
private static func readLocalStorage(from levelDBURL: URL, logger: ((String) -> Void)?) -> [String: String] {
|
||||
var storage: [String: String] = [:]
|
||||
let entries = SweetCookieKit.ChromiumLocalStorageReader.readEntries(
|
||||
for: self.storageOrigin,
|
||||
in: levelDBURL,
|
||||
logger: logger)
|
||||
for entry in entries {
|
||||
storage[entry.key] = self.decodedStorageValue(entry.value)
|
||||
}
|
||||
|
||||
let textEntries = SweetCookieKit.ChromiumLocalStorageReader.readTextEntries(
|
||||
in: levelDBURL,
|
||||
logger: logger)
|
||||
for entry in textEntries where storage[entry.key] == nil {
|
||||
if self.isUsefulStorageKey(entry.key) {
|
||||
storage[entry.key] = self.decodedStorageValue(entry.value)
|
||||
}
|
||||
}
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
private static func jsonObject(from raw: String) -> Any? {
|
||||
guard let data = raw.data(using: .utf8) else { return nil }
|
||||
return try? JSONSerialization.jsonObject(with: data)
|
||||
}
|
||||
|
||||
private static func findAuth1Token(in object: Any) -> String? {
|
||||
guard let dictionary = object as? [String: Any],
|
||||
let token = dictionary["token"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.hasPrefix("auth1_") && value.count > 20 ? value : nil
|
||||
}
|
||||
|
||||
private static func findAccessToken(in object: Any) -> String? {
|
||||
if let dictionary = object as? [String: Any] {
|
||||
for key in ["access_token", "accessToken"] {
|
||||
if let value = dictionary[key] as? String,
|
||||
self.looksLikeToken(value)
|
||||
{
|
||||
return value
|
||||
}
|
||||
}
|
||||
for value in dictionary.values {
|
||||
if let found = self.findAccessToken(in: value) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let array = object as? [Any] {
|
||||
for value in array {
|
||||
if let found = self.findAccessToken(in: value) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func looksLikeToken(_ raw: String) -> Bool {
|
||||
let value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.count > 20 && (value.hasPrefix("eyJ") || value.contains("."))
|
||||
}
|
||||
|
||||
private static func isAuth1StorageKey(_ key: String) -> Bool {
|
||||
key.hasSuffix("auth1_session")
|
||||
}
|
||||
|
||||
private static func isAuth0StorageKey(_ key: String) -> Bool {
|
||||
key.contains("auth0spajs@@::")
|
||||
}
|
||||
|
||||
private static func isExternalOrgStorageKey(_ key: String) -> Bool {
|
||||
key.contains(self.externalOrgPrefix)
|
||||
}
|
||||
|
||||
private static func isUsefulStorageKey(_ key: String) -> Bool {
|
||||
self.isAuth1StorageKey(key) ||
|
||||
self.isAuth0StorageKey(key) ||
|
||||
self.isExternalOrgStorageKey(key) ||
|
||||
key.contains("post-auth-v") ||
|
||||
key.contains("member-info-v") ||
|
||||
key.contains("feature-flags-cache:org-") ||
|
||||
key.contains("feature-flags-cache:org_")
|
||||
}
|
||||
|
||||
private static func inferredOrganizationInfo(
|
||||
from storage: [String: String],
|
||||
override: String?) -> (organization: String?, internalOrganizationID: String?)?
|
||||
{
|
||||
let overrideSlug = override.flatMap(self.slug(fromNormalizedOrganization:))
|
||||
let overrideOrgID = override.flatMap(self.orgID(fromNormalizedOrganization:))
|
||||
var fallbackSlug: String?
|
||||
var fallbackInternalOrgID: String?
|
||||
|
||||
for (key, value) in storage {
|
||||
let object = self.jsonObject(from: value)
|
||||
let internalOrgID = self.cleanedOrgID(self.firstString(
|
||||
in: object,
|
||||
matching: ["internalOrgId", "internal_org_id", "org_id", "orgId"]))
|
||||
?? self.internalOrgIDFromStorageKey(key)
|
||||
let slug = self.cleanedSlug(
|
||||
self.slugFromPostAuthKey(key) ??
|
||||
self.firstString(in: object, matching: ["orgName", "org_name", "externalOrgId", "external_org_id"]))
|
||||
|
||||
if let overrideOrgID, internalOrgID == overrideOrgID {
|
||||
return (override, internalOrgID)
|
||||
}
|
||||
if let overrideSlug, slug == overrideSlug {
|
||||
return (override, internalOrgID)
|
||||
}
|
||||
|
||||
if fallbackSlug == nil, let slug {
|
||||
fallbackSlug = slug
|
||||
}
|
||||
if fallbackInternalOrgID == nil, let internalOrgID {
|
||||
fallbackInternalOrgID = internalOrgID
|
||||
}
|
||||
}
|
||||
|
||||
if let override, fallbackInternalOrgID != nil {
|
||||
return (override, fallbackInternalOrgID)
|
||||
}
|
||||
|
||||
if let fallbackSlug {
|
||||
return ("org/\(fallbackSlug)", fallbackInternalOrgID)
|
||||
}
|
||||
if let fallbackInternalOrgID {
|
||||
return ("organizations/\(fallbackInternalOrgID)", fallbackInternalOrgID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func externalOrgSlug(from key: String) -> String {
|
||||
guard let range = key.range(of: self.externalOrgPrefix) else { return key }
|
||||
return String(key[range.upperBound...])
|
||||
}
|
||||
|
||||
private static func cleanedOrgID(_ raw: String) -> String? {
|
||||
let value = self.decodedStorageValue(raw)
|
||||
guard DevinUsageFetcher.isInternalOrganizationID(value) else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
private static func cleanedOrgID(_ raw: String?) -> String? {
|
||||
guard let raw else { return nil }
|
||||
return self.cleanedOrgID(raw)
|
||||
}
|
||||
|
||||
private static func cleanedSlug(_ raw: String?) -> String? {
|
||||
guard let raw else { return nil }
|
||||
let value = self.decodedStorageValue(raw)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty, value != "null", !DevinUsageFetcher.isInternalOrganizationID(value) else {
|
||||
return nil
|
||||
}
|
||||
if value.hasPrefix("org/") {
|
||||
return String(value.dropFirst(4))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private static func slugFromPostAuthKey(_ key: String) -> String? {
|
||||
guard let range = key.range(of: "-org_name-") else { return nil }
|
||||
return String(key[range.upperBound...])
|
||||
}
|
||||
|
||||
private static func internalOrgIDFromStorageKey(_ key: String) -> String? {
|
||||
guard let range = key.range(of: #"org[-_][A-Za-z0-9]{8,}"#, options: .regularExpression) else {
|
||||
return nil
|
||||
}
|
||||
return self.cleanedOrgID(String(key[range]))
|
||||
}
|
||||
|
||||
private static func firstString(in object: Any?, matching keys: Set<String>) -> String? {
|
||||
if let dictionary = object as? [String: Any] {
|
||||
for (key, value) in dictionary {
|
||||
if keys.contains(key), let string = value as? String, !string.isEmpty {
|
||||
return string
|
||||
}
|
||||
if let found = self.firstString(in: value, matching: keys) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let array = object as? [Any] {
|
||||
for value in array {
|
||||
if let found = self.firstString(in: value, matching: keys) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func slug(fromNormalizedOrganization organization: String) -> String? {
|
||||
guard organization.hasPrefix("org/") else { return nil }
|
||||
return String(organization.dropFirst(4))
|
||||
}
|
||||
|
||||
private static func orgID(fromNormalizedOrganization organization: String) -> String? {
|
||||
guard organization.hasPrefix("organizations/") else { return nil }
|
||||
return String(organization.dropFirst("organizations/".count))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,271 @@
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
public struct DevinUsageFetcher: Sendable {
|
||||
private static let log = CodexBarLog.logger(LogCategories.devin)
|
||||
private static let baseURL = URL(string: "https://app.devin.ai")!
|
||||
private static let defaultUserAgent =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
|
||||
|
||||
public struct RequestAuth: Sendable, Equatable {
|
||||
public let bearerToken: String
|
||||
public let organization: String?
|
||||
public let internalOrganizationID: String?
|
||||
public let sourceLabel: String
|
||||
|
||||
public init(
|
||||
bearerToken: String,
|
||||
organization: String?,
|
||||
internalOrganizationID: String?,
|
||||
sourceLabel: String)
|
||||
{
|
||||
self.bearerToken = bearerToken
|
||||
self.organization = organization
|
||||
self.internalOrganizationID = internalOrganizationID
|
||||
self.sourceLabel = sourceLabel
|
||||
}
|
||||
}
|
||||
|
||||
public let browserDetection: BrowserDetection
|
||||
|
||||
public init(browserDetection: BrowserDetection) {
|
||||
self.browserDetection = browserDetection
|
||||
}
|
||||
|
||||
public func fetch(
|
||||
bearerTokenOverride: String? = nil,
|
||||
organizationOverride: String? = nil,
|
||||
timeout: TimeInterval = 15,
|
||||
logger: ((String) -> Void)? = nil,
|
||||
now: Date = Date(),
|
||||
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> DevinUsageSnapshot
|
||||
{
|
||||
let auths = try self.resolveAuths(
|
||||
bearerTokenOverride: bearerTokenOverride,
|
||||
organizationOverride: organizationOverride,
|
||||
logger: logger)
|
||||
var lastError: Error?
|
||||
for auth in auths {
|
||||
do {
|
||||
return try await Self.fetchQuotaUsage(
|
||||
auth: auth,
|
||||
organizationOverride: organizationOverride,
|
||||
timeout: timeout,
|
||||
logger: logger,
|
||||
now: now,
|
||||
transport: transport)
|
||||
} catch {
|
||||
lastError = error
|
||||
logger?("[devin] Session from \(auth.sourceLabel) failed: \(error.localizedDescription)")
|
||||
if auth.sourceLabel == "manual" || !Self.shouldTryNextSession(after: error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError ?? DevinUsageError.noSession
|
||||
}
|
||||
|
||||
public static func fetchQuotaUsage(
|
||||
auth: RequestAuth,
|
||||
organizationOverride: String? = nil,
|
||||
timeout: TimeInterval = 15,
|
||||
logger: ((String) -> Void)? = nil,
|
||||
now: Date = Date(),
|
||||
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> DevinUsageSnapshot
|
||||
{
|
||||
let organization = self.normalizedOrganization(organizationOverride) ??
|
||||
self.normalizedOrganization(auth.organization)
|
||||
guard let organization else {
|
||||
throw DevinUsageError.missingOrganization
|
||||
}
|
||||
|
||||
var lastError: Error?
|
||||
for path in self.candidatePaths(
|
||||
organization: organization,
|
||||
internalOrganizationID: auth.internalOrganizationID)
|
||||
{
|
||||
let data: Data
|
||||
do {
|
||||
data = try await self.fetch(
|
||||
path: path,
|
||||
auth: auth,
|
||||
timeout: timeout,
|
||||
transport: transport)
|
||||
} catch {
|
||||
lastError = error
|
||||
logger?("[devin] /api/\(path) failed: \(error.localizedDescription)")
|
||||
if case DevinUsageError.invalidCredentials = error {
|
||||
throw error
|
||||
}
|
||||
continue
|
||||
}
|
||||
logger?("[devin] Fetched quota usage from /api/\(path)")
|
||||
return try DevinUsageParser.parse(data, organization: organization, now: now)
|
||||
}
|
||||
|
||||
throw lastError ?? DevinUsageError.apiError("No Devin quota endpoint succeeded.")
|
||||
}
|
||||
|
||||
public static func manualAuth(from raw: String?, organization: String? = nil) -> RequestAuth? {
|
||||
guard var token = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
if token.lowercased().hasPrefix("authorization:") {
|
||||
token = token.dropHeaderName().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
if token.lowercased().hasPrefix("bearer ") {
|
||||
token = String(token.dropFirst(7)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
guard !token.isEmpty else { return nil }
|
||||
return RequestAuth(
|
||||
bearerToken: token,
|
||||
organization: self.normalizedOrganization(organization),
|
||||
internalOrganizationID: self.internalOrganizationID(from: organization),
|
||||
sourceLabel: "manual")
|
||||
}
|
||||
|
||||
private func resolveAuths(
|
||||
bearerTokenOverride: String?,
|
||||
organizationOverride: String?,
|
||||
logger: ((String) -> Void)?) throws -> [RequestAuth]
|
||||
{
|
||||
if let manual = Self.manualAuth(from: bearerTokenOverride, organization: organizationOverride) {
|
||||
logger?("[devin] Using manual Bearer token")
|
||||
return [manual]
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
let normalizedOrganizationOverride = Self.normalizedOrganization(organizationOverride)
|
||||
let sessions = DevinSessionImporter.importSessions(
|
||||
browserDetection: self.browserDetection,
|
||||
organizationOverride: normalizedOrganizationOverride,
|
||||
logger: logger)
|
||||
guard !sessions.isEmpty else {
|
||||
throw DevinUsageError.noSession
|
||||
}
|
||||
logger?("[devin] Found \(sessions.count) browser session(s)")
|
||||
return sessions.map { session in
|
||||
RequestAuth(
|
||||
bearerToken: session.accessToken,
|
||||
organization: normalizedOrganizationOverride ?? Self.normalizedOrganization(session.organization),
|
||||
internalOrganizationID: session.internalOrganizationID,
|
||||
sourceLabel: session.sourceLabel)
|
||||
}
|
||||
#else
|
||||
throw DevinUsageError.noSession
|
||||
#endif
|
||||
}
|
||||
|
||||
static func shouldTryNextSession(after error: Error) -> Bool {
|
||||
switch error {
|
||||
case DevinUsageError.invalidCredentials, DevinUsageError.apiError, DevinUsageError.missingOrganization:
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private static func fetch(
|
||||
path: String,
|
||||
auth: RequestAuth,
|
||||
timeout: TimeInterval,
|
||||
transport: any ProviderHTTPTransport) async throws -> Data
|
||||
{
|
||||
let url = self.baseURL.appending(path: "api/\(path)")
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = timeout
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
|
||||
request.setValue(self.defaultUserAgent, forHTTPHeaderField: "User-Agent")
|
||||
request.setValue("Bearer \(auth.bearerToken)", forHTTPHeaderField: "Authorization")
|
||||
if let internalOrganizationID = auth.internalOrganizationID {
|
||||
request.setValue(internalOrganizationID, forHTTPHeaderField: "x-cog-org-id")
|
||||
}
|
||||
let response = try await transport.response(for: request)
|
||||
guard response.statusCode == 200 else {
|
||||
let body = String(data: response.data.prefix(200), encoding: .utf8) ?? "<binary>"
|
||||
if response.statusCode == 401 || response.statusCode == 403 {
|
||||
throw DevinUsageError.invalidCredentials
|
||||
}
|
||||
Self.log.error("Devin API returned \(response.statusCode): \(body)")
|
||||
throw DevinUsageError.apiError("HTTP \(response.statusCode)")
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
private static func candidatePaths(organization: String, internalOrganizationID: String?) -> [String] {
|
||||
var paths: [String] = []
|
||||
let normalized = self.normalizedOrganization(organization) ?? organization
|
||||
if let internalOrganizationID {
|
||||
paths.append("\(internalOrganizationID)/billing/quota/usage")
|
||||
}
|
||||
paths.append("\(normalized)/billing/quota/usage")
|
||||
if normalized.hasPrefix("org/") {
|
||||
let slug = String(normalized.dropFirst(4))
|
||||
paths.append("\(slug)/billing/quota/usage")
|
||||
}
|
||||
if !normalized.hasPrefix("org/"), !normalized.hasPrefix("organizations/") {
|
||||
paths.append("org/\(normalized)/billing/quota/usage")
|
||||
}
|
||||
if let internalOrganizationID {
|
||||
paths.append("organizations/\(internalOrganizationID)/billing/quota/usage")
|
||||
}
|
||||
return paths.removingDuplicates()
|
||||
}
|
||||
|
||||
public static func normalizedOrganization(_ raw: String?) -> String? {
|
||||
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
if let url = URL(string: value),
|
||||
let host = url.host?.lowercased(),
|
||||
host == "devin.ai" || host.hasSuffix(".devin.ai")
|
||||
{
|
||||
let components = url.path.split(separator: "/").map(String.init)
|
||||
if components.count >= 2, components[0] == "org" {
|
||||
value = "org/\(components[1])"
|
||||
} else if components.count >= 2, components[0] == "organizations" {
|
||||
value = "organizations/\(components[1])"
|
||||
}
|
||||
}
|
||||
value = value.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
if value.hasPrefix("org/") || value.hasPrefix("organizations/") {
|
||||
return value
|
||||
}
|
||||
if self.isInternalOrganizationID(value) {
|
||||
return "organizations/\(value)"
|
||||
}
|
||||
return "org/\(value)"
|
||||
}
|
||||
|
||||
private static func internalOrganizationID(from raw: String?) -> String? {
|
||||
guard let normalized = self.normalizedOrganization(raw),
|
||||
normalized.hasPrefix("organizations/")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return String(normalized.dropFirst("organizations/".count))
|
||||
}
|
||||
|
||||
static func isInternalOrganizationID(_ value: String) -> Bool {
|
||||
value.hasPrefix("org-") || value.hasPrefix("org_")
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
fileprivate func dropHeaderName() -> String {
|
||||
guard let index = self.firstIndex(of: ":") else { return self }
|
||||
return String(self[self.index(after: index)...])
|
||||
}
|
||||
}
|
||||
|
||||
extension [String] {
|
||||
fileprivate func removingDuplicates() -> [String] {
|
||||
var seen = Set<String>()
|
||||
return self.filter { seen.insert($0).inserted }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import CoreFoundation
|
||||
import Foundation
|
||||
|
||||
public enum DevinUsageError: LocalizedError, Sendable {
|
||||
case noSession
|
||||
case missingOrganization
|
||||
case invalidCredentials
|
||||
case apiError(String)
|
||||
case parseFailed(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .noSession:
|
||||
"No Devin browser session found. Please log in to app.devin.ai or paste a Bearer token."
|
||||
case .missingOrganization:
|
||||
"No Devin organization was found. Open an app.devin.ai/org/... page " +
|
||||
"or set the organization in Devin settings."
|
||||
case .invalidCredentials:
|
||||
"Devin session token is invalid or expired."
|
||||
case let .apiError(message):
|
||||
"Devin API error: \(message)"
|
||||
case let .parseFailed(message):
|
||||
"Could not parse Devin usage: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct DevinQuotaWindow: Sendable, Equatable {
|
||||
public let usedPercent: Double
|
||||
public let resetsAt: Date?
|
||||
|
||||
public init(usedPercent: Double, resetsAt: Date? = nil) {
|
||||
self.usedPercent = min(100, max(0, usedPercent))
|
||||
self.resetsAt = resetsAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct DevinUsageSnapshot: Sendable, Equatable {
|
||||
public let daily: DevinQuotaWindow?
|
||||
public let weekly: DevinQuotaWindow?
|
||||
public let planName: String?
|
||||
public let organization: String?
|
||||
public let updatedAt: Date
|
||||
public let overageBalance: Double?
|
||||
|
||||
public init(
|
||||
daily: DevinQuotaWindow?,
|
||||
weekly: DevinQuotaWindow?,
|
||||
planName: String?,
|
||||
organization: String?,
|
||||
updatedAt: Date,
|
||||
overageBalance: Double? = nil)
|
||||
{
|
||||
self.daily = daily
|
||||
self.weekly = weekly
|
||||
self.planName = planName
|
||||
self.organization = organization
|
||||
self.updatedAt = updatedAt
|
||||
self.overageBalance = overageBalance
|
||||
}
|
||||
|
||||
public func toUsageSnapshot() -> UsageSnapshot {
|
||||
let primary = self.daily.map {
|
||||
RateWindow(
|
||||
usedPercent: $0.usedPercent,
|
||||
windowMinutes: 24 * 60,
|
||||
resetsAt: $0.resetsAt,
|
||||
resetDescription: "Daily")
|
||||
}
|
||||
let secondary = self.weekly.map {
|
||||
RateWindow(
|
||||
usedPercent: $0.usedPercent,
|
||||
windowMinutes: 7 * 24 * 60,
|
||||
resetsAt: $0.resetsAt,
|
||||
resetDescription: "Weekly")
|
||||
}
|
||||
let identity = ProviderIdentitySnapshot(
|
||||
providerID: .devin,
|
||||
accountEmail: nil,
|
||||
accountOrganization: self.organization,
|
||||
loginMethod: self.planName)
|
||||
let providerCost: ProviderCostSnapshot? = self.overageBalance.map {
|
||||
ProviderCostSnapshot(
|
||||
used: $0,
|
||||
limit: 0,
|
||||
currencyCode: "USD",
|
||||
period: "Extra usage balance",
|
||||
updatedAt: self.updatedAt)
|
||||
}
|
||||
return UsageSnapshot(
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
providerCost: providerCost,
|
||||
updatedAt: self.updatedAt,
|
||||
identity: identity)
|
||||
}
|
||||
}
|
||||
|
||||
public enum DevinUsageParser {
|
||||
public static func parse(_ data: Data, organization: String?, now: Date = Date()) throws -> DevinUsageSnapshot {
|
||||
let object = try JSONSerialization.jsonObject(with: data)
|
||||
return try self.parse(object, organization: organization, now: now)
|
||||
}
|
||||
|
||||
public static func parse(_ object: Any, organization: String?, now: Date = Date()) throws -> DevinUsageSnapshot {
|
||||
let current = (object as? [String: Any]).map(self.currentQuotaWindows)
|
||||
let daily = current?.daily ?? self.findWindow(in: object, matching: self.isDailyKey)
|
||||
let weekly = current?.weekly ?? self.findWindow(in: object, matching: self.isWeeklyKey)
|
||||
guard daily != nil || weekly != nil else {
|
||||
throw DevinUsageError.parseFailed("Missing Devin quota windows.")
|
||||
}
|
||||
|
||||
return DevinUsageSnapshot(
|
||||
daily: daily,
|
||||
weekly: weekly,
|
||||
planName: self.findPlanName(in: object),
|
||||
organization: self.displayOrganization(from: organization),
|
||||
updatedAt: now,
|
||||
overageBalance: self.findOverageBalance(in: object))
|
||||
}
|
||||
|
||||
private static func findOverageBalance(in object: Any) -> Double? {
|
||||
guard let dictionary = object as? [String: Any] else { return nil }
|
||||
if let value = self.nonnegativeFiniteDouble(dictionary["overage_balance"]) { return value }
|
||||
if let cents = self.nonnegativeFiniteDouble(dictionary["overage_balance_cents"]) { return cents / 100.0 }
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func nonnegativeFiniteDouble(_ value: Any?) -> Double? {
|
||||
guard let value = self.double(value), value.isFinite, value >= 0 else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
private static func currentQuotaWindows(_ dictionary: [String: Any])
|
||||
-> (daily: DevinQuotaWindow?, weekly: DevinQuotaWindow?)
|
||||
{
|
||||
let daily = self.currentQuotaWindow(
|
||||
percent: dictionary["daily_percentage"],
|
||||
resetsAt: dictionary["daily_reset_at"])
|
||||
let weekly = self.currentQuotaWindow(
|
||||
percent: dictionary["weekly_percentage"],
|
||||
resetsAt: dictionary["weekly_reset_at"])
|
||||
return (daily, weekly)
|
||||
}
|
||||
|
||||
private static func currentQuotaWindow(percent: Any?, resetsAt: Any?) -> DevinQuotaWindow? {
|
||||
guard let usedPercent = self.double(percent) else { return nil }
|
||||
return DevinQuotaWindow(
|
||||
usedPercent: usedPercent < 1 ? usedPercent * 100 : usedPercent,
|
||||
resetsAt: self.date(from: resetsAt))
|
||||
}
|
||||
|
||||
private static func findWindow(in object: Any, matching keyMatches: (String) -> Bool) -> DevinQuotaWindow? {
|
||||
if let dictionary = object as? [String: Any] {
|
||||
for (key, value) in dictionary where keyMatches(key) {
|
||||
if let window = self.window(from: value) {
|
||||
return window
|
||||
}
|
||||
}
|
||||
for value in dictionary.values {
|
||||
if let found = self.findWindow(in: value, matching: keyMatches) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let array = object as? [Any] {
|
||||
for value in array {
|
||||
if let found = self.findWindow(in: value, matching: keyMatches) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func window(from object: Any) -> DevinQuotaWindow? {
|
||||
guard let dictionary = object as? [String: Any] else {
|
||||
guard let percent = self.percent(from: object) else { return nil }
|
||||
return DevinQuotaWindow(usedPercent: percent, resetsAt: nil)
|
||||
}
|
||||
|
||||
if let percent = self.percent(from: dictionary) {
|
||||
return DevinQuotaWindow(
|
||||
usedPercent: percent,
|
||||
resetsAt: self.findResetDate(in: dictionary))
|
||||
}
|
||||
|
||||
if let nested = dictionary.values.lazy.compactMap({ self.window(from: $0) }).first {
|
||||
return nested
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func percent(from object: Any) -> Double? {
|
||||
if let number = self.double(object) {
|
||||
return number <= 1 ? number * 100 : number
|
||||
}
|
||||
guard let dictionary = object as? [String: Any] else { return nil }
|
||||
|
||||
let directKeys = [
|
||||
"used_percent",
|
||||
"usedPercent",
|
||||
"usage_percent",
|
||||
"usagePercent",
|
||||
"percent_used",
|
||||
"percentUsed",
|
||||
"percent",
|
||||
]
|
||||
for key in directKeys {
|
||||
if let value = self.double(dictionary[key]) {
|
||||
return value <= 1 ? value * 100 : value
|
||||
}
|
||||
}
|
||||
|
||||
let remainingKeys = ["remaining_percent", "remainingPercent", "percent_remaining", "percentRemaining"]
|
||||
for key in remainingKeys {
|
||||
if let value = self.double(dictionary[key]) {
|
||||
let percent = value <= 1 ? value * 100 : value
|
||||
return 100 - percent
|
||||
}
|
||||
}
|
||||
|
||||
let used = self.firstDouble(in: dictionary, keys: ["used", "usage", "used_count", "usedCount", "consumed"])
|
||||
let limit = self.firstDouble(in: dictionary, keys: ["limit", "quota", "total", "max", "available"])
|
||||
if let used, let limit, limit > 0 {
|
||||
return used / limit * 100
|
||||
}
|
||||
|
||||
let remaining = self.firstDouble(in: dictionary, keys: ["remaining", "left", "available"])
|
||||
if let remaining, let limit, limit > 0 {
|
||||
return (limit - remaining) / limit * 100
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func findPlanName(in object: Any) -> String? {
|
||||
if let dictionary = object as? [String: Any] {
|
||||
for key in ["plan_name", "planName", "plan", "tier", "subscription_tier", "subscriptionTier"] {
|
||||
if let value = dictionary[key] as? String,
|
||||
let cleaned = self.cleanDisplay(value)
|
||||
{
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
for value in dictionary.values {
|
||||
if let found = self.findPlanName(in: value) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let array = object as? [Any] {
|
||||
for value in array {
|
||||
if let found = self.findPlanName(in: value) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func findResetDate(in dictionary: [String: Any]) -> Date? {
|
||||
for (key, value) in dictionary where key.localizedCaseInsensitiveContains("reset") {
|
||||
if let date = self.date(from: value) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func date(from value: Any?) -> Date? {
|
||||
if let raw = value as? String {
|
||||
if let date = ISO8601DateFormatter().date(from: raw) {
|
||||
return date
|
||||
}
|
||||
if let number = Double(raw) {
|
||||
return self.date(from: number)
|
||||
}
|
||||
}
|
||||
if let number = self.double(value) {
|
||||
return self.date(from: number)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func date(from number: Double) -> Date? {
|
||||
guard number > 0 else { return nil }
|
||||
let seconds = number > 10_000_000_000 ? number / 1000 : number
|
||||
return Date(timeIntervalSince1970: seconds)
|
||||
}
|
||||
|
||||
private static func firstDouble(in dictionary: [String: Any], keys: [String]) -> Double? {
|
||||
for key in keys {
|
||||
if let value = self.double(dictionary[key]) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func double(_ value: Any?) -> Double? {
|
||||
switch value {
|
||||
case let number as NSNumber:
|
||||
CFGetTypeID(number) == CFBooleanGetTypeID() ? nil : number.doubleValue
|
||||
case let string as String:
|
||||
Double(string.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func isDailyKey(_ raw: String) -> Bool {
|
||||
let key = raw.lowercased()
|
||||
return !key.contains("hide") && (key.contains("daily") || key.contains("day"))
|
||||
}
|
||||
|
||||
private static func isWeeklyKey(_ raw: String) -> Bool {
|
||||
let key = raw.lowercased()
|
||||
return !key.contains("hide") && (key.contains("weekly") || key.contains("week"))
|
||||
}
|
||||
|
||||
private static func displayOrganization(from raw: String?) -> String? {
|
||||
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil }
|
||||
if raw.hasPrefix("org/") {
|
||||
return String(raw.dropFirst(4))
|
||||
}
|
||||
if raw.hasPrefix("organizations/") {
|
||||
return String(raw.dropFirst("organizations/".count))
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
private static func cleanDisplay(_ raw: String) -> String? {
|
||||
let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
return cleaned.split(separator: "_").flatMap { $0.split(separator: "-") }.map { part in
|
||||
part.prefix(1).uppercased() + String(part.dropFirst())
|
||||
}.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user