Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
shell: bash
run: |
audit_dir="$RUNNER_TEMP/oblien-sdk-audit"
npm install --prefix "$audit_dir" --ignore-scripts --no-audit --no-fund typescript@6.0.3 oblien@2.4.0
npm install --prefix "$audit_dir" --ignore-scripts --no-audit --no-fund typescript@6.0.3 oblien@2.8.0
NODE_PATH="$audit_dir/node_modules" node scripts/audit-typescript-sdk.cjs "$audit_dir/node_modules/oblien/dist"
NODE_PATH="$audit_dir/node_modules" node scripts/generate-platform-models.cjs "$audit_dir/node_modules/oblien/dist"
git diff --exit-code -- Sources/OblienKit/PlatformModels
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A Swift SDK for [Oblien](https://oblien.com), using Foundation, URLSession and async/await. Supports iOS 16+ and macOS 12+ without third-party dependencies.

The management and runtime APIs cover the portable surface of the TypeScript `oblien` SDK **2.4.0**. They also include the current disk and desktop installation APIs. See [TypeScript coverage](docs/TypeScriptCoverage.md) for the API mapping, native equivalents, verification, and platform boundaries.
The management and runtime APIs cover the portable surface of the TypeScript `oblien` SDK **2.8.0**. They also include the current disk and desktop installation APIs. See [TypeScript coverage](docs/TypeScriptCoverage.md) for the API mapping, native equivalents, verification, and platform boundaries.

## Install

Expand Down Expand Up @@ -106,7 +106,7 @@ swift test
NODE_PATH=/path/to/node_modules node scripts/audit-typescript-sdk.cjs /path/to/oblien/dist
```

The audit compares declarations and the reviewed Swift method mapping with a checked-in 2.4.0 snapshot. Optional live tests require `OBLIEN_LIVE_TOKEN_FILE`; see the coverage document before running them.
The audit compares declarations and the reviewed Swift method mapping with a checked-in 2.8.0 snapshot. Optional live tests require `OBLIEN_LIVE_TOKEN_FILE`; see the coverage document before running them.

## License

Expand Down
153 changes: 153 additions & 0 deletions Sources/OblienKit/AccessAPI.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import Foundation

public struct AccessResource: Codable, Sendable {
public var accountId: String
public var resourceType: String
public var resourceId: String?
public init(accountId: String, resourceType: String, resourceId: String? = nil) {
self.accountId = accountId; self.resourceType = resourceType; self.resourceId = resourceId
}
}
public struct AccountLabel: Codable, Sendable {
public let id: String
public let name: String
public var image: String?
}
public struct AccessibleAccount: Codable, Sendable {
public let id: String
public let name: String
public var image: String?
public let personal: Bool
public let role: String
public let permissions: [String]
public var membershipId: String?
}
public struct WorkspaceSharing: Codable, Sendable {
public let ownerId: String
public let actorId: String
public let role: String
public let permissions: [String]
public var owner: AccountLabel?
public var namespaceAccess: Bool?
public var namespaceId: String?
}
public struct AccessInvitation: Codable, Sendable, Identifiable {
public let id: String
public let owner: AccountLabel
public let resourceType: String
public let resourceId: String
public var resourceName: String?
public let role: String
public let expiresAt: String
public var accessExpiresAt: String?
}
public struct AccessMember: Codable, Sendable, Identifiable {
public let id: String
public let user: AccountLabel
public let role: String
public let version: String
public var expiresAt: String?
}
public struct AccessMembers: Codable, Sendable {
public let success: Bool
public let owner: AccessibleAccount
public let members: [AccessMember]
public let invitations: [AccessInvitation]
}
public struct SharedWorkspaceList: Codable, Sendable {
public let success: Bool
public let workspaces: [Workspace]
public var nextCursor: String?
}
public struct CreatedAccessInvitation: Codable, Sendable {
public let success: Bool
public let invitation: Invitation
public let invitationUrl: String
public struct Invitation: Codable, Sendable {
public let id: String
public let email: String
public let role: String
public let expiresAt: String
}
}
public struct AccessToken: Decodable, Sendable {
public let success: Bool
public let token: String
public let expiresAt: String
public let ttl: Int
public let scope: String
public let permissions: [String]
}
public struct AccessAudit: Codable, Sendable {
public let success: Bool
public let events: [Event]
public var nextCursor: String?
public struct Event: Codable, Sendable, Identifiable {
public let id: String
public let action: String
public let actorId: String
public let actorName: String
public let createdAt: String
}
}
public struct SharedSSHConnection: Decodable, Sendable {
public let success: Bool
public let delegated: Bool
public let expiresAt: String
public let ssh: DesktopSSHConnection.SSH
}

/// Identity-based sharing. Tokens and invitation URLs are secrets; keep them out of logs.
public struct AccessAPI: Sendable {
let transport: Transport
private func request<T: Decodable>(_ method: String, _ path: String, query: [String: String?] = [:], body: Data? = nil) async throws -> T {
try OblienJSON.decode(T.self, await transport.request(method, "/access" + path, query: query, body: body))
}
private func fields(_ resource: AccessResource) throws -> [String: JSONValue] {
try JSONDecoder().decode([String: JSONValue].self, from: OblienJSON.encode(resource))
}
private func query(_ resource: AccessResource) -> [String: String?] {
["account_id": resource.accountId, "resource_type": resource.resourceType, "resource_id": resource.resourceId]
}
public func accounts() async throws -> [AccessibleAccount] {
struct Result: Decodable { let accounts: [AccessibleAccount] }
let value: Result = try await request("GET", "/accounts"); return value.accounts
}
public func sharedWorkspaces(before: String? = nil, limit: Int? = nil) async throws -> SharedWorkspaceList {
try await request("GET", "/shared-workspaces", query: ["before": before, "limit": limit.map(String.init)])
}
public func invitations() async throws -> [AccessInvitation] {
struct Result: Decodable { let invitations: [AccessInvitation] }
let value: Result = try await request("GET", "/invitations"); return value.invitations
}
public func invite(_ resource: AccessResource, email: String, role: String? = nil, expiresAt: String? = nil) async throws -> CreatedAccessInvitation {
var body = try fields(resource); body["email"] = .string(email)
body["role"] = role.map(JSONValue.string); body["expires_at"] = expiresAt.map(JSONValue.string)
return try await request("POST", "/invitations", body: OblienJSON.encode(body))
}
public enum InvitationReference: Sendable { case id(String), token(String) }
public func accept(_ invitation: InvitationReference) async throws -> APIResponse {
let body: [String: String]
switch invitation { case .id(let id): body = ["id": id]; case .token(let token): body = ["token": token] }
return try await request("POST", "/invitations/accept", body: OblienJSON.encode(body))
}
public func revokeInvitation(_ id: String) async throws -> APIResponse { try await request("DELETE", "/invitations/" + id.pathEscaped) }
public func members(_ resource: AccessResource) async throws -> AccessMembers { try await request("GET", "/members", query: query(resource)) }
public func updateMember(_ id: String, role: String, version: String? = nil, expiresAt: JSONField<String>? = nil) async throws -> APIResponse {
struct Body: Encodable { let role: String; let version: String?; let expiresAt: JSONField<String>? }
return try await request("PATCH", "/members/" + id.pathEscaped, body: OblienJSON.encode(Body(role: role, version: version, expiresAt: expiresAt)))
}
public func removeMember(_ id: String, version: String? = nil) async throws -> APIResponse {
try await request("DELETE", "/members/" + id.pathEscaped, body: OblienJSON.encode(["version": version]))
}
public func issueToken(_ resource: AccessResource, role: String? = nil, ttl: Int? = nil, label: String? = nil) async throws -> AccessToken {
var body = try fields(resource); body["role"] = role.map(JSONValue.string)
body["ttl"] = ttl.map { .number(Double($0)) }; body["label"] = label.map(JSONValue.string)
return try await request("POST", "/tokens", body: OblienJSON.encode(body))
}
public func audit(_ resource: AccessResource, before: String? = nil) async throws -> AccessAudit {
var parameters = query(resource); parameters["before"] = before
return try await request("GET", "/audit", query: parameters)
}
}
extension OblienClient { public var access: AccessAPI { .init(transport: transport) } }
39 changes: 39 additions & 0 deletions Sources/OblienKit/BillingAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,33 @@ import Foundation
/// Namespace billing. Mutations require an admin or billing-scoped credential.
public struct BillingAPI: Sendable {
let transport: Transport
public func capacityCatalog() async throws -> CapacityCatalog { try await transport.api("GET", "/billing/capacity/catalog") }
public func capacity(_ namespace: String) async throws -> CapacityResponse {
try await transport.api("GET", "/billing/capacity", query: ["namespace": namespace])
}
public func previewCapacity(_ namespace: String, _ params: PreviewCapacityParams) async throws -> CapacityQuoteResponse {
try await transport.api("POST", "/billing/capacity/preview", body: APIJSON.encode(params, merging: ["namespace": .string(namespace)]))
}
public func confirmCapacity(_ namespace: String, _ params: ConfirmCapacityParams) async throws -> CapacityResponse {
try await transport.api("POST", "/billing/capacity/confirm", body: APIJSON.encode(params, merging: ["namespace": .string(namespace)]))
}
public func capacityCheckout(_ namespace: String, _ params: CapacityCheckoutParams) async throws -> CapacityCheckoutResponse {
try await transport.api("POST", "/billing/capacity/checkout", body: APIJSON.encode(params, merging: ["namespace": .string(namespace)]))
}
public func cancelCapacityChange(_ namespace: String, _ params: ConfirmCapacityParams) async throws -> CapacityResponse {
try await transport.api("POST", "/billing/capacity/change/cancel", body: APIJSON.encode(params, merging: ["namespace": .string(namespace)]))
}
public func setCapacityAutoRenew(_ namespace: String, autoRenew: Bool, idempotencyKey: String) async throws -> CapacityResponse {
struct Body: Encodable { let namespace: String; let autoRenew: Bool; let idempotencyKey: String }
return try await transport.api("POST", "/billing/capacity/renewal", body: APIJSON.encode(Body(namespace: namespace, autoRenew: autoRenew, idempotencyKey: idempotencyKey)))
}
public func previewNetworkTopup(_ namespace: String, unitAmount: Double, idempotencyKey: String) async throws -> CapacityQuoteResponse {
struct Body: Encodable { let namespace: String; let unitAmount: Double; let idempotencyKey: String }
return try await transport.api("POST", "/billing/capacity/network/preview", body: APIJSON.encode(Body(namespace: namespace, unitAmount: unitAmount, idempotencyKey: idempotencyKey)))
}
public func savings(namespace: String? = nil, workspaceId: String? = nil, month: String? = nil) async throws -> BillingSavings {
try await transport.api("GET", "/billing/savings", query: ["namespace": namespace, "workspaceId": workspaceId, "month": month])
}
public func catalog() async throws -> BillingCatalog { try await transport.api("GET", "/billing/catalog") }
public func checkout(_ params: CheckoutParams) async throws -> CheckoutResponse {
try await transport.api("POST", "/billing/checkout", body: APIJSON.encode(params), retrySafe: params.idempotencyKey?.isEmpty == false)
Expand All @@ -19,6 +46,18 @@ public struct BillingAPI: Sendable {
public func subscription(_ namespace: String) async throws -> BillingSubscriptionResponse {
try await transport.api("GET", "/billing/subscription", query: ["namespace": namespace])
}
public func previewPlanChange(_ namespace: String, _ params: PreviewPlanChangeParams) async throws -> BillingPlanChangeQuoteResponse {
try await transport.api("POST", "/billing/subscription/changes/preview", body: APIJSON.encode(params, merging: ["namespace": .string(namespace)]))
}
public func changePlan(_ namespace: String, _ params: ChangePlanParams) async throws -> BillingPlanChangeResponse {
try await transport.api("POST", "/billing/subscription/changes", body: APIJSON.encode(params, merging: ["namespace": .string(namespace)]))
}
public func planChange(_ namespace: String, changeId: String) async throws -> BillingPlanChangeResponse {
try await transport.api("GET", "/billing/subscription/changes/\(changeId.pathEscaped)", query: ["namespace": namespace])
}
public func cancelPlanChange(_ namespace: String, changeId: String, _ params: CancelPlanChangeParams) async throws -> BillingPlanChangeResponse {
try await transport.api("POST", "/billing/subscription/changes/\(changeId.pathEscaped)/cancel", body: APIJSON.encode(params, merging: ["namespace": .string(namespace)]))
}
public func cancelSubscription(_ namespace: String) async throws -> BillingSubscriptionResponse {
try await transport.api("POST", "/billing/subscription/cancel", body: APIJSON.encode(["namespace": namespace]))
}
Expand Down
6 changes: 5 additions & 1 deletion Sources/OblienKit/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ public struct OblienConfiguration: Sendable {
/// CDN edge upload API; receives only a CDN-scoped token, never account credentials.
public var cdnURL: URL
public var auth: OblienAuth
/// Account context for a personal login. The server verifies membership.
public var accountId: String?
public var maxRetries: Int
/// Gateway (runtime) JWT cache lifetime. Docs are contradictory (1h vs 30d); 55m is safe.
public var runtimeTokenTTL: TimeInterval
Expand All @@ -28,9 +30,11 @@ public struct OblienConfiguration: Sendable {
runtimeURL: URL = URL(string: "https://workspace.oblien.com")!,
maxRetries: Int = 3,
runtimeTokenTTL: TimeInterval = 55 * 60,
cdnURL: URL = URL(string: "https://cdn.oblien.com/api")!
cdnURL: URL = URL(string: "https://cdn.oblien.com/api")!,
accountId: String? = nil
) {
self.auth = auth
self.accountId = accountId
self.baseURL = baseURL
self.runtimeURL = runtimeURL
self.cdnURL = cdnURL
Expand Down
40 changes: 31 additions & 9 deletions Sources/OblienKit/DesktopAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,33 @@ public struct DesktopAPI: Sendable {
private var base: String { "/workspace/\(workspaceId.pathEscaped)/desktop" }

public func installation() async throws -> DesktopInstallation {
try OblienJSON.decode(DesktopInstallation.self, await transport.request("GET", base + "/installation"))
let value = try OblienJSON.decode(DesktopInstallation.self, await transport.request("GET", base + "/installation"))
if value.installed == true || value.phase == "installing" { await transport.invalidateRuntime(workspaceId) }
return value
}
/// Restarting a running workspace requires an explicit `restart: true` from the caller.
public func install(desktop: String? = nil, restart: Bool = false) async throws -> DesktopInstallation {
struct Body: Encodable { let desktop: String?; let restart: Bool }
return try OblienJSON.decode(DesktopInstallation.self, await transport.request("POST", base + "/install",
body: OblienJSON.encode(Body(desktop: desktop, restart: restart))))
do {
let value = try OblienJSON.decode(DesktopInstallation.self, await transport.request("POST", base + "/install",
body: OblienJSON.encode(Body(desktop: desktop, restart: restart))))
await transport.invalidateRuntime(workspaceId)
return value
} catch { await transport.invalidateRuntime(workspaceId); throw error }
}
/// Contains a temporary password; keep the grant in memory and never log or persist it.
public func sshConnection() async throws -> DesktopSSHConnection {
try OblienJSON.decode(DesktopSSHConnection.self, await transport.request("POST", base + "/ssh"))
public func sshConnection(sessionId: String? = nil) async throws -> DesktopSSHConnection {
if let sessionId { _ = try desktopSessionPath(sessionId) }
let body = try sessionId.map { try OblienJSON.encode(["session_id": $0]) }
return try OblienJSON.decode(DesktopSSHConnection.self, await transport.request("POST", base + "/ssh", body: body))
}
}

public struct RuntimeDesktopAPI: Sendable {
let runtime: RuntimeClient
public func status() async throws -> DesktopStatus {
try OblienJSON.decode(DesktopStatus.self, await runtime.perform("GET", "/desktop/status", root: true))
public func status(sessionId: String? = nil) async throws -> DesktopStatus {
let path = try sessionId.map { try desktopSessionPath($0) + "/status" } ?? "/desktop/status"
return try OblienJSON.decode(DesktopStatus.self, await runtime.perform("GET", path, root: true))
}
@discardableResult public func enable() async throws -> DesktopStatus {
try OblienJSON.decode(DesktopStatus.self, await runtime.perform("POST", "/desktop/enable", root: true))
Expand All @@ -39,9 +48,17 @@ public struct RuntimeDesktopAPI: Sendable {
}
/// Authenticated web viewer URL. Treat it as a credential and do not log or persist it.
/// Native viewers should use the management API's SSH grant instead.
public func url() async throws -> URL {
public func url(sessionId: String? = nil) async throws -> URL {
if let sessionId { _ = try desktopSessionPath(sessionId) }
var components = URLComponents(url: try await runtime.endpoint("/desktop", root: true), resolvingAgainstBaseURL: false)!
components.queryItems = [URLQueryItem(name: "token", value: try await runtime.token())]
let token = try await runtime.token()
var fragment = URLComponents()
fragment.queryItems = [URLQueryItem(name: "token", value: token)]
if let sessionId { fragment.queryItems?.append(.init(name: "session", value: sessionId)) }
components.percentEncodedFragment = fragment.percentEncodedQuery
if components.scheme != "https" || components.host != "workspace.oblien.com" || (components.port != nil && components.port != 443) {
components.queryItems = [.init(name: "token", value: token)]
}
return components.url!
}
}
Expand All @@ -54,11 +71,14 @@ public struct DesktopInstallation: Codable, Sendable {
public var error: String?
public var choices: [Choice]?
public var workloadId: String?
public var selected: String?
public var success: Bool?
public struct Choice: Codable, Sendable, Identifiable {
public let id: String
public let label: String
public var description: String?
public var minimumMemoryMb: Int?
public var prepared: Bool?
}
}

Expand All @@ -68,6 +88,7 @@ public struct DesktopStatus: Codable, Sendable {
public let available: Bool
public var credentials: Bool?
public var transports: [String]?
public var sessions: DesktopSessionCapabilities?
}
public struct DesktopCredentials: Codable, Sendable {
public let username: String
Expand All @@ -77,6 +98,7 @@ public struct DesktopSSHConnection: Codable, Sendable {
public let expiresAt: String
public let ssh: SSH
public let vnc: VNC
public var sessionId: String?
public struct SSH: Codable, Sendable {
public let host: String
public let port: Int
Expand Down
Loading
Loading