diff --git a/.gitignore b/.gitignore index 98cddb7..014912b 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ relay-server/k8s/secrets.yaml RxCode/GoogleService-Info.plist RxCodeMobile/GoogleService-Info.plist RxCodeAndroid/app/google-services.json +.env diff --git a/Packages/Sources/RxCodeCore/Secrets/SecretsCrypto.swift b/Packages/Sources/RxCodeCore/Secrets/SecretsCrypto.swift new file mode 100644 index 0000000..b9c218f --- /dev/null +++ b/Packages/Sources/RxCodeCore/Secrets/SecretsCrypto.swift @@ -0,0 +1,275 @@ +import CryptoKit +import Foundation + +/// Pure CryptoKit port of github-pm's `lib/secrets/crypto.ts`. Keeping this +/// byte-for-byte interoperable with the web app is the whole point: a secret +/// enrolled / encrypted in the browser must decrypt here and vice-versa. +/// +/// The scheme: +/// - A 32-byte **PRF output** is extracted from the user's passkey (done in the +/// app layer via `AuthenticationServices`; this type never touches passkeys). +/// - The **KEK** (key-encryption key) is `HKDF-SHA256(prf, salt: ∅, info: "…/kek")`. +/// - Each user has an **ECDH P-256 keypair**; the private key is exported as a +/// JWK, AES-GCM-wrapped under the KEK, and stored server-side. +/// - Each environment has a random 32-byte **DEK**; the owner wraps it under +/// their KEK (`wrapMode: "kek"`), and it can also be sealed to another user's +/// public key (`wrapMode: "hpke"`). +/// - Files are AES-GCM encrypted under the DEK. +/// +/// AES-GCM layout matches WebCrypto exactly: the `iv` (12 bytes) is stored +/// separately, and the `ciphertext` field is `rawCiphertext ‖ 16-byte tag` +/// (WebCrypto appends the tag; CryptoKit keeps them separate, so we concatenate +/// on the way out and split on the way in). +public enum SecretsCrypto { + + // MARK: - Constants (must match github-pm/lib/secrets/constants.ts) + + /// `github-pm-secrets-v1-prf-salt!!!` — the load-bearing PRF salt. Changing + /// it invalidates every wrapped private key. 32 bytes. + public static let prfSalt = Data("github-pm-secrets-v1-prf-salt!!!".utf8) + + /// HKDF `info` for deriving the KEK from PRF output. + public static let hkdfInfoKEK = Data("github-pm-secrets-v1/kek".utf8) + + /// HKDF `info` for deriving an AES-GCM key from an ECDH shared secret (HPKE). + public static let hkdfInfoHPKE = Data("github-pm-secrets-v1/hpke".utf8) + + /// WebAuthn relying-party identifier the passkeys are scoped to. + public static let webAuthnRPID = "rxlab.app" + + public enum CryptoError: Error, LocalizedError { + case badBase64(String) + case ciphertextTooShort + case invalidJWK + case prfUnavailable + + public var errorDescription: String? { + switch self { + case .badBase64(let field): return "Malformed base64 in \(field)." + case .ciphertextTooShort: return "Ciphertext is too short to contain an auth tag." + case .invalidJWK: return "Stored private key is not a valid P-256 JWK." + case .prfUnavailable: return "This passkey does not support the PRF extension." + } + } + } + + // MARK: - KEK + + /// `HKDF-SHA256(prfOutput, salt: ∅, info: "github-pm-secrets-v1/kek")` → 256-bit AES key. + public static func deriveKEK(prfOutput: Data) -> SymmetricKey { + HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: prfOutput), + salt: Data(), + info: hkdfInfoKEK, + outputByteCount: 32 + ) + } + + // MARK: - AES-GCM (WebCrypto-compatible serialization) + + /// Encrypts `plaintext`, returning base64 `ciphertext` (`raw ‖ tag`) + base64 `iv`. + public static func aesGcmEncrypt( + key: SymmetricKey, + plaintext: Data + ) throws -> (ciphertext: String, iv: String) { + let nonce = AES.GCM.Nonce() + let sealed = try AES.GCM.seal(plaintext, using: key, nonce: nonce) + var combined = Data(sealed.ciphertext) + combined.append(sealed.tag) + return (combined.base64EncodedString(), Data(nonce).base64EncodedString()) + } + + /// Decrypts a WebCrypto-style `{ciphertext: raw‖tag, iv}` pair. + public static func aesGcmDecrypt( + key: SymmetricKey, + ciphertextB64: String, + ivB64: String + ) throws -> Data { + guard let ctTag = Data(base64Encoded: ciphertextB64) else { + throw CryptoError.badBase64("ciphertext") + } + guard let iv = Data(base64Encoded: ivB64) else { + throw CryptoError.badBase64("iv") + } + guard ctTag.count >= 16 else { throw CryptoError.ciphertextTooShort } + let tag = Data(ctTag.suffix(16)) + let ct = Data(ctTag.prefix(ctTag.count - 16)) + let box = try AES.GCM.SealedBox( + nonce: try AES.GCM.Nonce(data: iv), + ciphertext: ct, + tag: tag + ) + return try AES.GCM.open(box, using: key) + } + + // MARK: - User keypair (ECDH P-256) + + public struct UserKeypair { + /// Base64 of the raw uncompressed public point (`0x04 ‖ X ‖ Y`, 65 bytes). + public let publicKeyB64: String + /// The private key, ready to wrap. + public let privateKey: P256.KeyAgreement.PrivateKey + } + + public static func generateUserKeypair() -> UserKeypair { + let pk = P256.KeyAgreement.PrivateKey() + return UserKeypair( + publicKeyB64: pk.publicKey.x963Representation.base64EncodedString(), + privateKey: pk + ) + } + + public static func importPublicKey(b64: String) throws -> P256.KeyAgreement.PublicKey { + guard let data = Data(base64Encoded: b64) else { throw CryptoError.badBase64("publicKey") } + return try P256.KeyAgreement.PublicKey(x963Representation: data) + } + + /// Serializes the private key as a WebCrypto-importable JWK (`kty/crv/x/y/d`). + public static func privateKeyJWK(_ pk: P256.KeyAgreement.PrivateKey) -> [String: Any] { + let pub = pk.publicKey.x963Representation // 0x04 ‖ X(32) ‖ Y(32) + let x = pub.subdata(in: 1..<33) + let y = pub.subdata(in: 33..<65) + let d = pk.rawRepresentation // 32-byte scalar + return [ + "kty": "EC", + "crv": "P-256", + "x": base64urlEncode(x), + "y": base64urlEncode(y), + "d": base64urlEncode(d), + "ext": true, + ] + } + + public static func importPrivateKeyJWK(_ jwk: [String: Any]) throws -> P256.KeyAgreement.PrivateKey { + guard + let xS = jwk["x"] as? String, let x = base64urlDecode(xS), + let yS = jwk["y"] as? String, let y = base64urlDecode(yS), + let dS = jwk["d"] as? String, let d = base64urlDecode(dS), + x.count == 32, y.count == 32, d.count == 32 + else { throw CryptoError.invalidJWK } + var x963 = Data([0x04]) + x963.append(x) + x963.append(y) + x963.append(d) + return try P256.KeyAgreement.PrivateKey(x963Representation: x963) + } + + /// AES-GCM-wraps the private key (as JWK JSON) under the KEK. + public static func wrapPrivateKey( + _ pk: P256.KeyAgreement.PrivateKey, + kek: SymmetricKey + ) throws -> (wrappedPrivateKey: String, wrapIv: String) { + let json = try JSONSerialization.data( + withJSONObject: privateKeyJWK(pk), + options: [.sortedKeys] + ) + let enc = try aesGcmEncrypt(key: kek, plaintext: json) + return (wrappedPrivateKey: enc.ciphertext, wrapIv: enc.iv) + } + + public static func unwrapPrivateKey( + wrappedPrivateKey: String, + wrapIv: String, + kek: SymmetricKey + ) throws -> P256.KeyAgreement.PrivateKey { + let json = try aesGcmDecrypt(key: kek, ciphertextB64: wrappedPrivateKey, ivB64: wrapIv) + guard let jwk = try JSONSerialization.jsonObject(with: json) as? [String: Any] else { + throw CryptoError.invalidJWK + } + return try importPrivateKeyJWK(jwk) + } + + // MARK: - DEK (per-environment data key) + + /// Generates a fresh 32-byte DEK, returning both the key and its raw bytes + /// (the raw bytes are what get wrapped under a KEK / public key). + public static func generateDEK() -> (key: SymmetricKey, rawBytes: Data) { + let key = SymmetricKey(size: .bits256) + let raw = key.withUnsafeBytes { Data($0) } + return (key, raw) + } + + public static func importDEK(rawBytes: Data) -> SymmetricKey { + SymmetricKey(data: rawBytes) + } + + // MARK: - HPKE-style sealing (ECDH → HKDF → AES-GCM) + + private static func hpkeKey(shared: SharedSecret) -> SymmetricKey { + shared.hkdfDerivedSymmetricKey( + using: SHA256.self, + salt: Data(), + sharedInfo: hkdfInfoHPKE, + outputByteCount: 32 + ) + } + + /// Seals `plaintext` to `recipientPublicKey`, returning the ciphertext, iv + /// and the ephemeral public key (base64 raw point) needed to unseal. + public static func sealToPublicKey( + plaintext: Data, + recipientPublicKey: P256.KeyAgreement.PublicKey + ) throws -> (ciphertext: String, wrapIv: String, ephemeralPublicKey: String) { + let ephemeral = P256.KeyAgreement.PrivateKey() + let shared = try ephemeral.sharedSecretFromKeyAgreement(with: recipientPublicKey) + let aesKey = hpkeKey(shared: shared) + let enc = try aesGcmEncrypt(key: aesKey, plaintext: plaintext) + return ( + enc.ciphertext, + enc.iv, + ephemeral.publicKey.x963Representation.base64EncodedString() + ) + } + + public static func unsealWithPrivateKey( + ciphertext: String, + ivB64: String, + ephemeralPublicKeyB64: String, + recipientPrivateKey: P256.KeyAgreement.PrivateKey + ) throws -> Data { + let ephemeralPub = try importPublicKey(b64: ephemeralPublicKeyB64) + let shared = try recipientPrivateKey.sharedSecretFromKeyAgreement(with: ephemeralPub) + return try aesGcmDecrypt( + key: hpkeKey(shared: shared), + ciphertextB64: ciphertext, + ivB64: ivB64 + ) + } + + // MARK: - File-level helpers + + public static func encryptFile( + plaintext: String, + dek: SymmetricKey + ) throws -> (ciphertext: String, iv: String, size: Int) { + let bytes = Data(plaintext.utf8) + let enc = try aesGcmEncrypt(key: dek, plaintext: bytes) + return (enc.ciphertext, enc.iv, bytes.count) + } + + public static func decryptFile( + ciphertextB64: String, + ivB64: String, + dek: SymmetricKey + ) throws -> String { + let data = try aesGcmDecrypt(key: dek, ciphertextB64: ciphertextB64, ivB64: ivB64) + return String(decoding: data, as: UTF8.self) + } + + // MARK: - base64url (JWK fields) + + public static func base64urlEncode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + public static func base64urlDecode(_ s: String) -> Data? { + var str = s + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + while str.count % 4 != 0 { str += "=" } + return Data(base64Encoded: str) + } +} diff --git a/Packages/Sources/RxCodeCore/Secrets/SecretsFlows.swift b/Packages/Sources/RxCodeCore/Secrets/SecretsFlows.swift new file mode 100644 index 0000000..41c6239 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Secrets/SecretsFlows.swift @@ -0,0 +1,111 @@ +import CryptoKit +import Foundation + +/// High-level helpers that combine `SecretsCrypto` primitives with the DTO +/// models — mirrors github-pm's `lib/secrets/flows.ts`. The passkey-derived KEK +/// is always supplied by the caller (the app layer runs the passkey ceremony). +public enum SecretsFlows { + + public enum FlowError: Error, LocalizedError { + case missingWrapIv + case missingHPKEMaterial + case missingUserPrivateKey + case unknownWrapMode(String) + + public var errorDescription: String? { + switch self { + case .missingWrapIv: return "Wrapped key is missing its IV." + case .missingHPKEMaterial: return "Shared key is missing its ephemeral public key or IV." + case .missingUserPrivateKey: return "This environment was shared with you; your enrollment key is required to open it." + case .unknownWrapMode(let m): return "Unsupported wrap mode: \(m)." + } + } + } + + // MARK: - Enrollment + + public struct EnrollmentResult { + public let body: EnrollKeyBody + public let privateKey: P256.KeyAgreement.PrivateKey + } + + /// Generates a keypair and wraps the private key under `kek`. + public static func enroll(kek: SymmetricKey) throws -> EnrollmentResult { + let kp = SecretsCrypto.generateUserKeypair() + let wrap = try SecretsCrypto.wrapPrivateKey(kp.privateKey, kek: kek) + return EnrollmentResult( + body: EnrollKeyBody( + publicKey: kp.publicKeyB64, + wrappedPrivateKey: wrap.wrappedPrivateKey, + wrapIv: wrap.wrapIv + ), + privateKey: kp.privateKey + ) + } + + // MARK: - Environment creation + + public struct OwnerEnvironmentKey { + public let body: EnvironmentKeyBody + public let dek: SymmetricKey + } + + /// Generates a fresh DEK and wraps it under the owner's KEK (`wrapMode: "kek"`). + public static func buildOwnerEnvironmentKey(kek: SymmetricKey) throws -> OwnerEnvironmentKey { + let dek = SecretsCrypto.generateDEK() + let wrap = try SecretsCrypto.aesGcmEncrypt(key: kek, plaintext: dek.rawBytes) + return OwnerEnvironmentKey( + body: EnvironmentKeyBody( + wrapMode: "kek", + wrappedDek: wrap.ciphertext, + wrapIv: wrap.iv, + ephemeralPublicKey: nil + ), + dek: dek.key + ) + } + + // MARK: - Unwrapping a DEK from a stored environment key + + /// Resolves a `SecretsEnvironmentKey` row into a usable DEK. `kek` is the + /// caller's passkey-derived key; `userPrivateKey` is only needed for + /// HPKE-shared environments (unwrap it from the user's enrollment record + /// first via ``unwrapUserPrivateKey(_:kek:)``). + public static func unwrapDEK( + envKey: SecretsEnvironmentKey, + kek: SymmetricKey, + userPrivateKey: P256.KeyAgreement.PrivateKey? + ) throws -> SymmetricKey { + switch envKey.wrapMode { + case "kek": + guard let iv = envKey.wrapIv else { throw FlowError.missingWrapIv } + let raw = try SecretsCrypto.aesGcmDecrypt( + key: kek, ciphertextB64: envKey.wrappedDek, ivB64: iv + ) + return SecretsCrypto.importDEK(rawBytes: raw) + case "hpke": + guard let priv = userPrivateKey else { throw FlowError.missingUserPrivateKey } + guard let iv = envKey.wrapIv, let eph = envKey.ephemeralPublicKey else { + throw FlowError.missingHPKEMaterial + } + let raw = try SecretsCrypto.unsealWithPrivateKey( + ciphertext: envKey.wrappedDek, + ivB64: iv, + ephemeralPublicKeyB64: eph, + recipientPrivateKey: priv + ) + return SecretsCrypto.importDEK(rawBytes: raw) + default: + throw FlowError.unknownWrapMode(envKey.wrapMode) + } + } + + /// Unwraps the user's ECDH private key from their enrollment record. + public static func unwrapUserPrivateKey( + _ userKey: SecretsUserKey, + kek: SymmetricKey + ) throws -> P256.KeyAgreement.PrivateKey? { + guard let wrapped = userKey.wrappedPrivateKey, let iv = userKey.wrapIv else { return nil } + return try SecretsCrypto.unwrapPrivateKey(wrappedPrivateKey: wrapped, wrapIv: iv, kek: kek) + } +} diff --git a/Packages/Sources/RxCodeCore/Secrets/SecretsModels.swift b/Packages/Sources/RxCodeCore/Secrets/SecretsModels.swift new file mode 100644 index 0000000..457112a --- /dev/null +++ b/Packages/Sources/RxCodeCore/Secrets/SecretsModels.swift @@ -0,0 +1,210 @@ +import Foundation + +// Codable DTOs mirroring github-pm's `/api/v1/secrets/*` JSON shapes. +// Field names match the server responses; extra fields decode harmlessly. + +// MARK: - Enrollment / user key + +public struct SecretsUserKey: Codable, Sendable { + public let enrolled: Bool + public let publicKey: String? + public let wrappedPrivateKey: String? + public let wrapIv: String? + public let algorithm: String? + + public init( + enrolled: Bool, + publicKey: String? = nil, + wrappedPrivateKey: String? = nil, + wrapIv: String? = nil, + algorithm: String? = nil + ) { + self.enrolled = enrolled + self.publicKey = publicKey + self.wrappedPrivateKey = wrappedPrivateKey + self.wrapIv = wrapIv + self.algorithm = algorithm + } +} + +public struct EnrollKeyBody: Codable, Sendable { + public let publicKey: String + public let wrappedPrivateKey: String + public let wrapIv: String + public let algorithm: String + + public init(publicKey: String, wrappedPrivateKey: String, wrapIv: String, algorithm: String = "ECDH-P256") { + self.publicKey = publicKey + self.wrappedPrivateKey = wrappedPrivateKey + self.wrapIv = wrapIv + self.algorithm = algorithm + } +} + +// MARK: - Repositories (new `/repositories/all` endpoint) + +public struct SecretsManagedRepo: Codable, Sendable, Identifiable, Hashable { + public let id: Int // GitHub repository id + public let fullName: String + public let name: String + public let owner: String + public let isPrivate: Bool + public let defaultBranch: String + public let secretsRepoId: String? // internal secrets UUID when managed + public let installationId: Int + public let environmentsCount: Int + public let filesCount: Int + public let isCurrent: Bool + + enum CodingKeys: String, CodingKey { + case id, fullName, name, owner + case isPrivate = "private" + case defaultBranch, secretsRepoId, installationId + case environmentsCount, filesCount, isCurrent + } + + public var isManaged: Bool { secretsRepoId != nil } +} + +public struct SecretsManagedRepoPage: Codable, Sendable { + public let items: [SecretsManagedRepo] + public let pagination: Pagination + + public struct Pagination: Codable, Sendable { + public let nextCursor: String? + public let hasMore: Bool + } +} + +// MARK: - Repository secrets status (batch) + +/// One repo's secrets-management status from `POST /repositories/status`. +public struct SecretsRepoStatus: Codable, Sendable, Hashable { + public let fullName: String + public let secretsRepoId: String? + public let isManaged: Bool + public let environmentsCount: Int + public let filesCount: Int +} + +public struct SecretsRepoStatusList: Codable, Sendable { + public let items: [SecretsRepoStatus] +} + +public struct SecretsRepoStatusRequest: Codable, Sendable { + public let repositories: [String] + + public init(repositories: [String]) { + self.repositories = repositories + } +} + +public struct AddSecretsRepoBody: Codable, Sendable { + public let installationId: Int + public let repositoryId: Int + public let repositoryFullName: String + + public init(installationId: Int, repositoryId: Int, repositoryFullName: String) { + self.installationId = installationId + self.repositoryId = repositoryId + self.repositoryFullName = repositoryFullName + } +} + +// MARK: - Environments + +public struct SecretsEnvironment: Codable, Sendable, Identifiable, Hashable { + public let id: String + public let name: String + public let filesCount: Int? + + public init(id: String, name: String, filesCount: Int? = nil) { + self.id = id + self.name = name + self.filesCount = filesCount + } +} + +public struct SecretsEnvironmentList: Codable, Sendable { + public let items: [SecretsEnvironment] +} + +public struct EnvironmentKeyBody: Codable, Sendable { + public let wrapMode: String + public let wrappedDek: String + public let wrapIv: String? + public let ephemeralPublicKey: String? + + public init(wrapMode: String, wrappedDek: String, wrapIv: String?, ephemeralPublicKey: String?) { + self.wrapMode = wrapMode + self.wrappedDek = wrappedDek + self.wrapIv = wrapIv + self.ephemeralPublicKey = ephemeralPublicKey + } +} + +public struct CreateEnvironmentBody: Codable, Sendable { + public let name: String + public let ownerKey: EnvironmentKeyBody + + public init(name: String, ownerKey: EnvironmentKeyBody) { + self.name = name + self.ownerKey = ownerKey + } +} + +// MARK: - Files + +public struct SecretsFileMeta: Codable, Sendable, Identifiable, Hashable { + public let id: String + public let filename: String + public let size: Int? + public let encryptedByUserId: String? +} + +public struct SecretsFileList: Codable, Sendable { + public let items: [SecretsFileMeta] +} + +public struct UpsertFileBody: Codable, Sendable { + public let filename: String + public let ciphertext: String + public let iv: String + public let size: Int? + + public init(filename: String, ciphertext: String, iv: String, size: Int?) { + self.filename = filename + self.ciphertext = ciphertext + self.iv = iv + self.size = size + } +} + +// MARK: - Download bundle + +public struct SecretsEnvironmentKey: Codable, Sendable { + public let wrapMode: String // "kek" | "hpke" + public let wrappedDek: String + public let wrapIv: String? + public let ephemeralPublicKey: String? +} + +public struct SecretsBundleFile: Codable, Sendable, Identifiable, Hashable { + public let filename: String + public let ciphertext: String + public let iv: String + public let size: Int? + + public var id: String { filename } +} + +public struct SecretsBundle: Codable, Sendable { + public let environmentKey: SecretsEnvironmentKey + public let files: [SecretsBundleFile] +} + +// MARK: - Misc + +public struct SecretsIDResponse: Codable, Sendable { + public let id: String +} diff --git a/Packages/Sources/RxCodeCore/Utilities/AnalyticsService.swift b/Packages/Sources/RxCodeCore/Utilities/AnalyticsService.swift index 7ac8785..1bb0fdd 100644 --- a/Packages/Sources/RxCodeCore/Utilities/AnalyticsService.swift +++ b/Packages/Sources/RxCodeCore/Utilities/AnalyticsService.swift @@ -47,4 +47,7 @@ public enum AnalyticsEvent: String, Sendable { // Misc case settingsOpened = "settings_opened" case newProjectStarted = "new_project_started" + + // Errors + case passkeySignInError = "passkey_sign_in_error" } diff --git a/Packages/Sources/RxCodeCore/Utilities/GitURLHelpers.swift b/Packages/Sources/RxCodeCore/Utilities/GitURLHelpers.swift index 09165c9..98c6b2e 100644 --- a/Packages/Sources/RxCodeCore/Utilities/GitURLHelpers.swift +++ b/Packages/Sources/RxCodeCore/Utilities/GitURLHelpers.swift @@ -1,7 +1,5 @@ import Foundation -#if os(macOS) - /// Extracts "owner/repo" from a GitHub remote URL. /// Supports HTTPS and SSH formats. /// Returns nil for non-GitHub URLs. @@ -23,6 +21,8 @@ public func gitHubWebURL(forOwnerRepo ownerRepo: String) -> URL? { URL(string: "https://github.com/\(ownerRepo)") } +#if os(macOS) + public func detectGitHubOwnerRepo(at path: String) -> String? { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/git") diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+RemoteManagement.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+RemoteManagement.swift index a5db0be..118b16a 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload+RemoteManagement.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+RemoteManagement.swift @@ -498,6 +498,18 @@ public struct MobileBranchBriefing: Codable, Sendable, Identifiable, Equatable { } } +public struct MobileProjectCIStatus: Codable, Sendable, Identifiable, Equatable { + public var id: UUID { projectId } + + public let projectId: UUID + public let status: ProjectCIStatus + + public init(projectId: UUID, status: ProjectCIStatus) { + self.projectId = projectId + self.status = status + } +} + public struct MobileThreadSummary: Codable, Sendable, Identifiable, Equatable { public var id: String { sessionId } @@ -698,4 +710,3 @@ public struct MobileSettingsUpdatePayload: Codable, Sendable { self.autoPreviewSettings = autoPreviewSettings } } - diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index 31d1257..a117278 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -332,6 +332,9 @@ public struct SnapshotPayload: Codable, Sendable { public let sessions: [SessionSummary] public let branchBriefings: [MobileBranchBriefing]? public let threadSummaries: [MobileThreadSummary]? + /// Current GitHub Actions CI status per desktop project. `nil` when the + /// desktop predates CI-status sync. + public let ciStatuses: [MobileProjectCIStatus]? public let settings: MobileSettingsSnapshot? public let activeSessionID: String? public let activeSessionMessages: [ChatMessage]? @@ -372,6 +375,7 @@ public struct SnapshotPayload: Codable, Sendable { sessions: [SessionSummary], branchBriefings: [MobileBranchBriefing]? = nil, threadSummaries: [MobileThreadSummary]? = nil, + ciStatuses: [MobileProjectCIStatus]? = nil, settings: MobileSettingsSnapshot? = nil, activeSessionID: String? = nil, activeSessionMessages: [ChatMessage]? = nil, @@ -388,6 +392,7 @@ public struct SnapshotPayload: Codable, Sendable { self.sessions = sessions self.branchBriefings = branchBriefings self.threadSummaries = threadSummaries + self.ciStatuses = ciStatuses self.settings = settings self.activeSessionID = activeSessionID self.activeSessionMessages = activeSessionMessages @@ -402,7 +407,7 @@ public struct SnapshotPayload: Codable, Sendable { } private enum CodingKeys: String, CodingKey { - case projects, sessions, branchBriefings, threadSummaries, settings + case projects, sessions, branchBriefings, threadSummaries, ciStatuses, settings case activeSessionID, activeSessionMessages, activeSessionHasMore, projectBranches case usage, hostMetrics, runProfiles, runTasks, webProxy, seq } @@ -413,6 +418,7 @@ public struct SnapshotPayload: Codable, Sendable { sessions = try c.decode([SessionSummary].self, forKey: .sessions) branchBriefings = try c.decodeIfPresent([MobileBranchBriefing].self, forKey: .branchBriefings) threadSummaries = try c.decodeIfPresent([MobileThreadSummary].self, forKey: .threadSummaries) + ciStatuses = try c.decodeIfPresent([MobileProjectCIStatus].self, forKey: .ciStatuses) settings = try c.decodeIfPresent(MobileSettingsSnapshot.self, forKey: .settings) activeSessionID = try c.decodeIfPresent(String.self, forKey: .activeSessionID) activeSessionMessages = try c.decodeIfPresent([ChatMessage].self, forKey: .activeSessionMessages) diff --git a/Packages/Tests/RxCodeCoreTests/SecretsCryptoTests.swift b/Packages/Tests/RxCodeCoreTests/SecretsCryptoTests.swift new file mode 100644 index 0000000..5f8bf7e --- /dev/null +++ b/Packages/Tests/RxCodeCoreTests/SecretsCryptoTests.swift @@ -0,0 +1,133 @@ +import CryptoKit +import Foundation +import Testing +@testable import RxCodeCore + +/// Cross-implementation interop tests. The vectors below were produced by +/// running github-pm's exact WebCrypto algorithm (`lib/secrets/crypto.ts`) in +/// Node. If these pass, a secret encrypted in the web app decrypts here and +/// vice-versa. See `/tmp/secrets_vectors.mjs` for the generator. +@Suite("Secrets crypto interop") +struct SecretsCryptoTests { + + // PRF output = bytes 0..31. + let prfBase64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + let kekRawBase64 = "ULZP/vEtqjzXo4BBS9LgP3+ArlkcvarPQh5oz9+UOn8=" + let aesIvBase64 = "ZGVmZ2hpamtsbW5v" + let aesCiphertextBase64 = "oMVg1z0otjpPgx+gu9Vp871a7BzkTrRWgAAUADGTGCn50M0c" + let plaintext = "HELLO=world\nFOO=bar\n" + + // HPKE vector. + let recipientPublicKeyBase64 = "BAdm7iOjfExHY0laeUByH7D1i/BU6sNQa0rBmmx+RnwmcKrtopQzEjuXoUmq7+ogv+nxdmQrwkuDnGLHmokQEA4=" + let recipientJWK: [String: Any] = [ + "kty": "EC", "crv": "P-256", + "x": "B2buI6N8TEdjSVp5QHIfsPWL8FTqw1BrSsGabH5GfCY", + "y": "cKrtopQzEjuXoUmq7-ogv-nxdmQrwkuDnGLHmokQEA4", + "d": "dBSLtp7PjaVJb9TG_HT2i8KPQX4teVcXXivvX2gXwXo", + ] + let hpkeEphemeralPublicKeyBase64 = "BEqBsBWx/ZTkwMrTFhgrr3QqfCF2KgOMK/0XBhskDwAs879yGNm+sbpggasWCAerBX8LW+2vtWUa4Esnj8COoRU=" + let hpkeIvBase64 = "BwcHBwcHBwcHBwcH" + let hpkeCiphertextBase64 = "emwdBPA8uXNXZOzWZ7qHPrjd25HXp2lIPGndMx0D9KtK7IVa" + + private func data(_ b64: String) -> Data { Data(base64Encoded: b64)! } + + @Test("PRF salt is the documented 32-byte constant") + func prfSaltConstant() { + #expect(SecretsCrypto.prfSalt.count == 32) + #expect(SecretsCrypto.prfSalt == Data("github-pm-secrets-v1-prf-salt!!!".utf8)) + } + + @Test("KEK derivation matches WebCrypto HKDF (known answer)") + func kekKnownAnswer() { + let kek = SecretsCrypto.deriveKEK(prfOutput: data(prfBase64)) + let raw = kek.withUnsafeBytes { Data($0) } + #expect(raw == data(kekRawBase64)) + } + + @Test("AES-GCM decrypts WebCrypto-produced ciphertext (tag layout)") + func aesGcmDecryptsWebVector() throws { + let kek = SecretsCrypto.deriveKEK(prfOutput: data(prfBase64)) + let out = try SecretsCrypto.aesGcmDecrypt( + key: kek, ciphertextB64: aesCiphertextBase64, ivB64: aesIvBase64 + ) + #expect(String(decoding: out, as: UTF8.self) == plaintext) + } + + @Test("AES-GCM round-trips") + func aesGcmRoundTrip() throws { + let key = SymmetricKey(size: .bits256) + let enc = try SecretsCrypto.aesGcmEncrypt(key: key, plaintext: Data(plaintext.utf8)) + let dec = try SecretsCrypto.aesGcmDecrypt(key: key, ciphertextB64: enc.ciphertext, ivB64: enc.iv) + #expect(String(decoding: dec, as: UTF8.self) == plaintext) + } + + @Test("Imports a WebCrypto JWK private key and reproduces its public point") + func jwkImport() throws { + let priv = try SecretsCrypto.importPrivateKeyJWK(recipientJWK) + #expect(priv.publicKey.x963Representation == data(recipientPublicKeyBase64)) + } + + @Test("JWK export/import round-trips") + func jwkRoundTrip() throws { + let kp = SecretsCrypto.generateUserKeypair() + let jwk = SecretsCrypto.privateKeyJWK(kp.privateKey) + let reimported = try SecretsCrypto.importPrivateKeyJWK(jwk) + #expect(reimported.rawRepresentation == kp.privateKey.rawRepresentation) + } + + @Test("HPKE unseal of WebCrypto-sealed payload") + func hpkeUnsealWebVector() throws { + let priv = try SecretsCrypto.importPrivateKeyJWK(recipientJWK) + let out = try SecretsCrypto.unsealWithPrivateKey( + ciphertext: hpkeCiphertextBase64, + ivB64: hpkeIvBase64, + ephemeralPublicKeyB64: hpkeEphemeralPublicKeyBase64, + recipientPrivateKey: priv + ) + #expect(String(decoding: out, as: UTF8.self) == plaintext) + } + + @Test("HPKE seal/unseal round-trips") + func hpkeRoundTrip() throws { + let recipient = P256.KeyAgreement.PrivateKey() + let sealed = try SecretsCrypto.sealToPublicKey( + plaintext: Data(plaintext.utf8), + recipientPublicKey: recipient.publicKey + ) + let out = try SecretsCrypto.unsealWithPrivateKey( + ciphertext: sealed.ciphertext, + ivB64: sealed.wrapIv, + ephemeralPublicKeyB64: sealed.ephemeralPublicKey, + recipientPrivateKey: recipient + ) + #expect(String(decoding: out, as: UTF8.self) == plaintext) + } + + @Test("Private key wrap/unwrap under KEK round-trips") + func wrapUnwrapPrivateKey() throws { + let kek = SecretsCrypto.deriveKEK(prfOutput: data(prfBase64)) + let kp = SecretsCrypto.generateUserKeypair() + let wrap = try SecretsCrypto.wrapPrivateKey(kp.privateKey, kek: kek) + let unwrapped = try SecretsCrypto.unwrapPrivateKey( + wrappedPrivateKey: wrap.wrappedPrivateKey, wrapIv: wrap.wrapIv, kek: kek + ) + #expect(unwrapped.rawRepresentation == kp.privateKey.rawRepresentation) + } + + @Test("DEK owner-wrap + unwrap round-trips, file decrypts") + func dekFlow() throws { + let kek = SecretsCrypto.deriveKEK(prfOutput: data(prfBase64)) + let owner = try SecretsFlows.buildOwnerEnvironmentKey(kek: kek) + let file = try SecretsCrypto.encryptFile(plaintext: plaintext, dek: owner.dek) + + let envKey = SecretsEnvironmentKey( + wrapMode: owner.body.wrapMode, + wrappedDek: owner.body.wrappedDek, + wrapIv: owner.body.wrapIv, + ephemeralPublicKey: owner.body.ephemeralPublicKey + ) + let dek = try SecretsFlows.unwrapDEK(envKey: envKey, kek: kek, userPrivateKey: nil) + let decrypted = try SecretsCrypto.decryptFile(ciphertextB64: file.ciphertext, ivB64: file.iv, dek: dek) + #expect(decrypted == plaintext) + } +} diff --git a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift index 8c4f0d3..5110e6c 100644 --- a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift +++ b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift @@ -136,6 +136,28 @@ struct PayloadTests { updatedAt: Date(timeIntervalSince1970: 11) ) ], + ciStatuses: [ + MobileProjectCIStatus( + projectId: projectId, + status: ProjectCIStatus( + owner: "rxlab", + repo: "rxcode", + branch: "main", + found: true, + overallState: .failure, + lastUpdated: "2026-05-31T00:00:00Z", + headSha: "abc123", + prNumber: 42, + workflows: [], + failing: [ + CIFailingWorkflow( + workflowName: "Tests", + htmlUrl: "https://github.com/rxlab/rxcode/actions/runs/1" + ) + ] + ) + ) + ], settings: settings ) ) @@ -149,6 +171,8 @@ struct PayloadTests { #expect(snapshot.branchBriefings?.first?.briefing == "Current work summary") #expect(snapshot.threadSummaries?.first?.title == "Fix sync") + #expect(snapshot.ciStatuses?.first?.status.overallState == .failure) + #expect(snapshot.ciStatuses?.first?.status.prNumber == 42) #expect(snapshot.settings?.selectedAgentProvider == .codex) #expect(snapshot.settings?.permissionMode == .acceptEdits) } diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 03669a9..abedadd 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -1510,8 +1510,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/rxtech-lab/RxAuthSwift"; requirement = { - branch = main; - kind = branch; + kind = exactVersion; + version = 1.1.1; }; }; E6A001002F8A000100000001 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { diff --git a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4e1c56b..4dc60b7 100644 --- a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/rxtech-lab/RxAuthSwift", "state" : { - "branch" : "main", - "revision" : "31e20effc3215c7656ec1543a27a1dcc2a9b0ccf" + "revision" : "f1bc8a1004c58f7eae628eaf8ae705e4f8c21c51", + "version" : "1.1.1" } }, { diff --git a/RxCode.xcodeproj/xcshareddata/xcschemes/RxCodeMobile.xcscheme b/RxCode.xcodeproj/xcshareddata/xcschemes/RxCodeMobile.xcscheme index eb5f117..fdc7361 100644 --- a/RxCode.xcodeproj/xcshareddata/xcschemes/RxCodeMobile.xcscheme +++ b/RxCode.xcodeproj/xcshareddata/xcschemes/RxCodeMobile.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> [MobileProjectCIStatus] { + let knownProjectIds = Set(projects.map(\.id)) + return ciStatusByProject + .filter { knownProjectIds.contains($0.key) } + .map { MobileProjectCIStatus(projectId: $0.key, status: $0.value) } + .sorted { lhs, rhs in + let leftName = projects.first(where: { $0.id == lhs.projectId })?.name ?? "" + let rightName = projects.first(where: { $0.id == rhs.projectId })?.name ?? "" + return leftName.localizedCaseInsensitiveCompare(rightName) == .orderedAscending + } + } + func mobileSettingsSnapshot() -> MobileSettingsSnapshot { let sections = availableAgentModelSections().map { AgentModelSection( @@ -871,4 +884,3 @@ extension AppState { } } - diff --git a/RxCode/App/AppState+MobileSync.swift b/RxCode/App/AppState+MobileSync.swift index 1852ca8..26d4be3 100644 --- a/RxCode/App/AppState+MobileSync.swift +++ b/RxCode/App/AppState+MobileSync.swift @@ -401,6 +401,7 @@ extension AppState { _ = autoPreviewSettings _ = branchBriefingRevision _ = threadSummaryRevision + _ = ciStatusRevision _ = projects.count _ = allSessionSummaries.count _ = latestRateLimitUsage diff --git a/RxCode/App/AppState+Secrets.swift b/RxCode/App/AppState+Secrets.swift new file mode 100644 index 0000000..8ca2146 --- /dev/null +++ b/RxCode/App/AppState+Secrets.swift @@ -0,0 +1,158 @@ +#if os(macOS) +import CryptoKit +import Foundation +import RxCodeCore + +/// High-level secrets intents: orchestrate the passkey-derived KEK +/// (`secretsKeyVault`), the crypto (`SecretsCrypto`/`SecretsFlows`), and the +/// network (`secrets`). Views call these and never touch crypto directly. +extension AppState { + + // MARK: - Enrollment + + /// Refreshes `secretsEnrolled` from the backend. Leaves it `nil` (unknown) + /// on transport errors so the UI can distinguish "not enrolled" from "can't + /// tell yet". + func refreshSecretsEnrollment() async { + guard isSignedIn else { secretsEnrolled = nil; return } + do { + let key = try await secrets.getUserKey() + secretsEnrolled = key.enrolled + } catch { + secretsEnrolled = nil + } + } + + /// Generates the user's keypair, wraps the private key under the + /// passkey-derived KEK, and publishes it. Prompts for the passkey. + func enrollSecrets() async throws { + let kek = try await secretsKeyVault.kek() + let result = try await SecretsFlows.enroll(kek: kek) + try await secrets.putUserKey(result.body) + secretsEnrolled = true + } + + // MARK: - Status (batch) + + /// Refreshes `secretsStatusByRepo` for every open project's GitHub repo so + /// the sidebar can show "Download Secret" only where secrets exist. Leaves + /// the previous cache untouched on transient errors. + func refreshSecretsStatuses() async { + guard isSignedIn else { secretsStatusByRepo = [:]; return } + let repos = Array(Set(projects.compactMap(\.gitHubRepo))) + guard !repos.isEmpty else { secretsStatusByRepo = [:]; return } + do { + let statuses = try await secrets.statuses(forRepos: repos) + secretsStatusByRepo = Dictionary( + statuses.map { ($0.fullName.lowercased(), $0) }, + uniquingKeysWith: { _, last in last } + ) + } catch { + // Keep the prior cache; a failed poll shouldn't hide existing buttons. + } + } + + /// Whether the project's linked repo has secrets configured (managed). + func projectHasSecrets(_ project: Project) -> Bool { + guard let repo = project.gitHubRepo else { return false } + return secretsStatusByRepo[repo.lowercased()]?.isManaged ?? false + } + + // MARK: - Repository management + + /// Ensures the repo is registered for secrets management, returning its + /// internal secrets id (adds it on first use). + func ensureSecretsRepoManaged(_ repo: SecretsManagedRepo) async throws -> String { + if let id = repo.secretsRepoId { return id } + let result = try await secrets.addRepository( + AddSecretsRepoBody( + installationId: repo.installationId, + repositoryId: repo.id, + repositoryFullName: repo.fullName + ) + ) + return result.id + } + + // MARK: - Environments + + /// Creates a new environment with a fresh DEK wrapped under the owner's KEK. + func createSecretEnvironment(repo: String, name: String) async throws { + let kek = try await secretsKeyVault.kek() + let owner = try SecretsFlows.buildOwnerEnvironmentKey(kek: kek) + _ = try await secrets.createEnvironment( + repo: repo, + body: CreateEnvironmentBody(name: name, ownerKey: owner.body) + ) + } + + // MARK: - File encryption / decryption + + /// Resolves an environment's wrapped DEK into a usable key, unwrapping the + /// user's private key first when the environment was shared via HPKE. + private func resolveDEK(for envKey: SecretsEnvironmentKey) async throws -> SymmetricKey { + let kek = try await secretsKeyVault.kek() + if envKey.wrapMode == "hpke" { + let userKey = try await secrets.getUserKey() + let priv = try SecretsFlows.unwrapUserPrivateKey(userKey, kek: kek) + return try SecretsFlows.unwrapDEK(envKey: envKey, kek: kek, userPrivateKey: priv) + } + return try SecretsFlows.unwrapDEK(envKey: envKey, kek: kek, userPrivateKey: nil) + } + + /// Builds an upload body by encrypting `content` under the environment DEK. + func encryptSecretFile( + forEnvironmentKey envKey: SecretsEnvironmentKey, + filename: String, + content: String + ) async throws -> UpsertFileBody { + let dek = try await resolveDEK(for: envKey) + let enc = try SecretsCrypto.encryptFile(plaintext: content, dek: dek) + return UpsertFileBody(filename: filename, ciphertext: enc.ciphertext, iv: enc.iv, size: enc.size) + } + + /// Decrypts a single file given its environment key + ciphertext. + func decryptSecretFile( + envKey: SecretsEnvironmentKey, + ciphertext: String, + iv: String + ) async throws -> String { + let dek = try await resolveDEK(for: envKey) + return try SecretsCrypto.decryptFile(ciphertextB64: ciphertext, ivB64: iv, dek: dek) + } + + /// Decrypts every file in a bundle to plaintext. + func decryptSecretBundle( + _ bundle: SecretsBundle + ) async throws -> [(filename: String, content: String)] { + let dek = try await resolveDEK(for: bundle.environmentKey) + return try bundle.files.map { file in + (file.filename, try SecretsCrypto.decryptFile(ciphertextB64: file.ciphertext, ivB64: file.iv, dek: dek)) + } + } + + // MARK: - Download + + /// Downloads + decrypts an environment and writes its files into + /// `directory`. Returns the filenames written (skips existing files unless + /// `overwrite`). Prompts for the passkey. + @discardableResult + func downloadSecrets( + repo: String, + env: String, + to directory: URL, + overwrite: Bool + ) async throws -> [String] { + let bundle = try await secrets.bundle(repo: repo, env: env) + let files = try await decryptSecretBundle(bundle) + var written: [String] = [] + for file in files { + let dest = directory.appendingPathComponent(file.filename) + if !overwrite, FileManager.default.fileExists(atPath: dest.path) { continue } + try Data(file.content.utf8).write(to: dest, options: .atomic) + written.append(file.filename) + } + return written + } +} +#endif diff --git a/RxCode/App/AppState+SessionLifecycle.swift b/RxCode/App/AppState+SessionLifecycle.swift index be825e5..7c3fff6 100644 --- a/RxCode/App/AppState+SessionLifecycle.swift +++ b/RxCode/App/AppState+SessionLifecycle.swift @@ -595,10 +595,16 @@ extension AppState { } func lastAssistantResponseText(in messages: [ChatMessage]) -> String { - guard let message = messages.last(where: { $0.role == .assistant && !$0.isError }) else { - return "" + // Walk back to the most recent assistant message that actually has text. + // A turn often ends on a tool-call-only (or thinking-only) assistant + // message whose `content` — the joined text blocks — is empty. Taking + // `messages.last` blindly would return "" and collapse the notification + // body to the "Response complete" fallback, hiding the real summary. + for message in messages.reversed() where message.role == .assistant && !message.isError { + let text = message.content.trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { return text } } - return message.content.trimmingCharacters(in: .whitespacesAndNewlines) + return "" } func lastUserMessageText(in messages: [ChatMessage]) -> String { diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index e629c40..993cd55 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -438,6 +438,11 @@ final class AppState { /// The CI poller loop. Cancelled on teardown / restarted on sign-in. @ObservationIgnored var ciStatusPollerTask: Task? + /// Latest secrets-management status keyed by lowercased `owner/repo`, + /// refreshed in `AppState+Secrets.swift`. Gates the per-project "Download + /// Secret" affordance so it only shows where secrets exist. Memory only. + var secretsStatusByRepo: [String: SecretsRepoStatus] = [:] + /// Per-project signature of the last CI failure we already notified / auto-fixed, /// so a steady-state red branch doesn't re-fire every 30s. Keyed by project id; /// persisted to UserDefaults so a fix isn't re-triggered across relaunches. @@ -902,6 +907,11 @@ final class AppState { let rxAuth = RxAuthService.shared let autopilot: AutopilotService + let secrets: SecretsService + /// Passkey-derived KEK cache for the secrets feature (macOS only). + let secretsKeyVault = SecretsKeyVault() + /// Cached enrollment status for the secrets feature: `nil` = unknown. + var secretsEnrolled: Bool? let permission = PermissionServer() let metaStore = SessionMetaStore() let cliStore: CLISessionStore @@ -1040,6 +1050,7 @@ final class AppState { self.mcp = MCPService(claudeService: claude) self.threadStore = ThreadStore.make() self.autopilot = AutopilotService(rxAuth: RxAuthService.shared) + self.secrets = SecretsService(rxAuth: RxAuthService.shared) self.runService.onTasksChanged = { [weak self] in Task { @MainActor [weak self] in self?.broadcastMobileRunTasks() diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 89ba7bf..6536ff8 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -240,6 +240,12 @@ } } } + }, + "%lld B" : { + + }, + "%lld bytes" : { + }, "%lld changed" : { "localizations" : { @@ -299,6 +305,16 @@ } } }, + "%lld env · %lld secrets" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld env · %2$lld secrets" + } + } + } + }, "%lld files" : { "localizations" : { "zh-Hans" : { @@ -784,6 +800,9 @@ } } } + }, + "Add Secret" : { + }, "Add server" : { "localizations" : { @@ -969,6 +988,9 @@ } } } + }, + "All secrets in this environment will be permanently deleted." : { + }, "All sessions in the current project will be deleted. This action cannot be undone." : { "localizations" : { @@ -1675,6 +1697,9 @@ } } } + }, + "Checking enrollment…" : { + }, "Checking..." : { "localizations" : { @@ -2300,6 +2325,9 @@ } } } + }, + "Create" : { + }, "Create and checkout" : { "localizations" : { @@ -2330,6 +2358,9 @@ } } } + }, + "Create your encryption key. You'll authenticate with your passkey; the key is derived from it and used to protect your secrets." : { + }, "Creating…" : { "localizations" : { @@ -2340,6 +2371,9 @@ } } } + }, + "Current" : { + }, "Current branch" : { "localizations" : { @@ -2414,6 +2448,12 @@ } } } + }, + "Decrypting…" : { + + }, + "Decrypts with your passkey and writes the .env file(s) into the project folder." : { + }, "Default" : { "localizations" : { @@ -2596,6 +2636,9 @@ } } } + }, + "Delete Environment" : { + }, "Delete Hook" : { "localizations" : { @@ -2668,6 +2711,9 @@ } } } + }, + "Delete Secret" : { + }, "Delete Session" : { "localizations" : { @@ -2678,6 +2724,9 @@ } } } + }, + "Deleting \"%@\"…" : { + }, "Deny" : { "localizations" : { @@ -2841,6 +2890,9 @@ } } } + }, + "Download" : { + }, "Download APK" : { "localizations" : { @@ -2871,6 +2923,9 @@ } } } + }, + "Download Secret" : { + }, "Duplicate Hook" : { "localizations" : { @@ -3080,6 +3135,12 @@ } } } + }, + "Encrypt & Upload" : { + + }, + "Encryption enabled" : { + }, "Endpoint" : { "localizations" : { @@ -3090,6 +3151,9 @@ } } } + }, + "Enroll with Passkey" : { + }, "Enter a new name for this device." : { "localizations" : { @@ -3359,6 +3423,9 @@ } } } + }, + "Filename" : { + }, "Files" : { "localizations" : { @@ -4075,6 +4142,9 @@ } } } + }, + "Letters, numbers, dot, dash and underscore. Max 32 characters." : { + }, "Lifecycle" : { "localizations" : { @@ -4105,6 +4175,9 @@ } } } + }, + "Load more" : { + }, "Loading catalog..." : { "localizations" : { @@ -4361,6 +4434,12 @@ } } } + }, + "Manage Secrets" : { + + }, + "Manage your repositories, environments, and secrets." : { + }, "Manual key/value pairs" : { "localizations" : { @@ -4657,6 +4736,9 @@ } } } + }, + "Name (e.g. prod)" : { + }, "New Chat" : { "localizations" : { @@ -4689,6 +4771,9 @@ } } } + }, + "New Environment" : { + }, "New Hook" : { "extractionState" : "stale", @@ -4741,6 +4826,9 @@ } } } + }, + "No .env files found in the project folder." : { + }, "No active runs" : { "localizations" : { @@ -4896,6 +4984,12 @@ } } } + }, + "No environments found for this repository. Add secrets from Settings → Secrets first." : { + + }, + "No environments yet. Create one to store secrets." : { + }, "No hooks yet" : { "localizations" : { @@ -5065,6 +5159,9 @@ }, "No repos match “%@”" : { + }, + "No repositories found." : { + }, "No results for '%@'" : { "localizations" : { @@ -5109,6 +5206,9 @@ } } } + }, + "No secrets yet. Add a .env file or enter values manually." : { + }, "No servers" : { "localizations" : { @@ -5215,6 +5315,12 @@ }, "Not signed in" : { + }, + "Not yet managed" : { + + }, + "Nothing written (files already exist)." : { + }, "Notifications" : { "localizations" : { @@ -5354,6 +5460,9 @@ } } } + }, + "Open on GitHub" : { + }, "Open Project" : { "localizations" : { @@ -5384,6 +5493,9 @@ } } } + }, + "Open Pull Request" : { + }, "Open RxCode" : { "localizations" : { @@ -5497,6 +5609,12 @@ } } } + }, + "Overwrite existing files" : { + + }, + "overwrites" : { + }, "Package" : { @@ -6837,6 +6955,9 @@ } } } + }, + "Search repositories" : { + }, "Search skills..." : { "localizations" : { @@ -6879,6 +7000,9 @@ } } } + }, + "Secrets" : { + }, "Select a Project" : { "localizations" : { @@ -7124,6 +7248,9 @@ } } } + }, + "Set up encryption" : { + }, "Set up your first MCP server" : { "localizations" : { @@ -7499,7 +7626,6 @@ } }, "Source" : { - "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -7558,6 +7684,9 @@ } } } + }, + "Store .env files end-to-end encrypted with your passkey. Secrets are encrypted on this device and never leave it unencrypted." : { + }, "streaming stops" : { "extractionState" : "stale", @@ -7767,6 +7896,9 @@ } } } + }, + "This project isn't linked to a GitHub repository, so it has no secrets to download." : { + }, "This session will be deleted. This action cannot be undone." : { "localizations" : { @@ -8258,6 +8390,9 @@ }, "When CI fails on a project's current branch, automatically start a thread so an agent can fix it. CI failures are always notified; this only controls the automatic fix." : { + }, + "Will overwrite the existing %@." : { + }, "Wipe cached embeddings and re-embed every thread for semantic search. Use this if global search results look stale or empty." : { "localizations" : { @@ -8291,6 +8426,9 @@ } } } + }, + "Wrote %@ to the project." : { + }, "wss://relay.example.com" : { "localizations" : { diff --git a/RxCode/Services/NotificationService.swift b/RxCode/Services/NotificationService.swift index 063843c..d390472 100644 --- a/RxCode/Services/NotificationService.swift +++ b/RxCode/Services/NotificationService.swift @@ -277,6 +277,11 @@ final class NotificationService: NSObject { // Notification banners (the APNs alert and the macOS local banner) render // Markdown syntax literally, so strip it from the assistant-summary body. let cleanBody = stripMarkdown(body) + if cleanBody.isEmpty { + logger.warning("[Notification] response-complete body empty after strip (incomingLen=\(body.count, privacy: .public)) — substituting \"Response complete\" fallback session=\(sessionId, privacy: .public)") + } else { + logger.info("[Notification] response-complete cleanBodyLen=\(cleanBody.count, privacy: .public) session=\(sessionId, privacy: .public)") + } await fanoutToMobile(.init( kind: .responseComplete, title: title, diff --git a/RxCode/Services/RxAuthService.swift b/RxCode/Services/RxAuthService.swift index fc440a2..e409ab1 100644 --- a/RxCode/Services/RxAuthService.swift +++ b/RxCode/Services/RxAuthService.swift @@ -18,9 +18,24 @@ final class RxAuthService { static let redirectURI = "rxcode://oauth-callback" static let issuer = "https://auth.rxlab.app" + /// Keychain service shared with RxAuthSwift's `KeychainTokenStorage`. The + /// SDK stores `access_token`, `refresh_token`, and `expires_at` under this + /// service; we read those items directly for the fast-path token check. + static let keychainService = "com.rxtech.rxcode.rxauth" + let manager: OAuthManager private let logger = Logger(subsystem: "com.claudework", category: "RxAuthService") + /// In-flight token refresh shared by every concurrent `accessToken()` + /// caller. The rxauth server rotates the refresh token on each use, so two + /// refreshes firing in parallel would race — one rotates the token out + /// from under the other, the loser 401s, retries, and refreshes again. On + /// first sign-in a burst of callers (repo list, installation list, CI + /// poller, mobile sync) hits the network at once; without coalescing that + /// burst turns into an endless refresh/retry storm that surfaces as + /// "infinite loading" while reading repos. One shared task fixes that. + private var refreshTask: Task? + init() { let configuration = RxAuthConfiguration( issuer: Self.issuer, @@ -40,7 +55,7 @@ final class RxAuthService { // Must match the `webcredentials:rxlab.app` entitlement and the // AASA file served at https://rxlab.app/.well-known/apple-app-site-association. passkeyRelyingPartyIdentifier: "rxlab.app", - keychainServiceName: "com.rxtech.rxcode.rxauth" + keychainServiceName: Self.keychainService ) self.manager = OAuthManager(configuration: configuration) } @@ -48,27 +63,61 @@ final class RxAuthService { var isAuthenticated: Bool { manager.authState == .authenticated } var user: User? { manager.currentUser } - /// Returns a current bearer token, refreshing first if it's expired. - /// Returns `nil` when the user is signed out or refresh failed. + /// Returns a current bearer token, refreshing first only if the cached + /// one is missing or near expiry. Returns `nil` when the user is signed + /// out or refresh failed. + /// + /// Note: `OAuthManager.refreshTokenIfNeeded()` refreshes *unconditionally* + /// despite its name, so we gate it ourselves with the keychain `expires_at` + /// to avoid a token rotation + userinfo round trip on every autopilot call. func accessToken() async -> String? { - let clock = ContinuousClock() - let start = clock.now + // Fast path — a cached, not-yet-expiring token needs no network hop. + if let cached = KeychainBackedTokenReader.readAccessToken(service: Self.keychainService), + !Self.accessTokenIsExpiring(service: Self.keychainService) { + return cached + } + do { - // RxAuthSwift reads/writes its own keychain item internally here; a - // long elapsed time below points the keychain prompt at the SDK - // rather than our `KeychainHelper.read` further down. - try await manager.refreshTokenIfNeeded() + try await refreshSharedToken() } catch { logger.warning("RxAuth refresh failed: \(error.localizedDescription, privacy: .public)") return nil } - let refreshElapsed = clock.now - start - let ms = Double(refreshElapsed.components.attoseconds) / 1e15 - + Double(refreshElapsed.components.seconds) * 1e3 + return KeychainBackedTokenReader.readAccessToken(service: Self.keychainService) + } + + /// Run at most one `refreshTokenIfNeeded()` at a time; concurrent callers + /// await the same in-flight task instead of each kicking off a competing + /// (refresh-token-rotating) refresh. All access is `@MainActor`-isolated, + /// so the check-then-store below is atomic up to the first suspension. + private func refreshSharedToken() async throws { + if let existing = refreshTask { + try await existing.value + return + } + let clock = ContinuousClock() + let start = clock.now + let task = Task { @MainActor [manager] in + try await manager.refreshTokenIfNeeded() + } + refreshTask = task + defer { refreshTask = nil } + try await task.value + let elapsed = clock.now - start + let ms = Double(elapsed.components.attoseconds) / 1e15 + + Double(elapsed.components.seconds) * 1e3 logger.debug("accessToken: refreshTokenIfNeeded returned in \(ms, privacy: .public)ms") - return KeychainBackedTokenReader.readAccessToken( - service: "com.rxtech.rxcode.rxauth" - ) + } + + /// Mirror RxAuthSwift's `KeychainTokenStorage.isTokenExpired()`: treat the + /// token as expiring within 10 minutes of its stored expiry, and as + /// expired when no expiry is recorded. + private static func accessTokenIsExpiring(service: String) -> Bool { + guard + let timestamp = KeychainHelper.readString(service: service, account: "expires_at"), + let seconds = Double(timestamp) + else { return true } + return Date(timeIntervalSince1970: seconds).timeIntervalSinceNow < 600 } func signIn() async throws { diff --git a/RxCode/Services/Secrets/SecretsPasskeyAuthenticator.swift b/RxCode/Services/Secrets/SecretsPasskeyAuthenticator.swift new file mode 100644 index 0000000..91b047a --- /dev/null +++ b/RxCode/Services/Secrets/SecretsPasskeyAuthenticator.swift @@ -0,0 +1,147 @@ +#if os(macOS) +import AppKit +import AuthenticationServices +import CryptoKit +import Foundation +import os +import RxCodeCore + +/// Runs a passkey assertion **purely to unlock the WebAuthn PRF extension**, so +/// we can derive the secrets KEK from the user's existing `rxlab.app` passkey. +/// This is the piece `RxAuthSwift` does not provide — it never sets the `prf` +/// extension. The assertion's signature is not verified by anyone; the only +/// thing we want back is the PRF output bytes. +/// +/// Mirrors the delegate/anchor structure of RxAuthSwift's +/// `MacOSPasskeyAuthenticator`. +@MainActor +final class SecretsPasskeyAuthenticator: NSObject { + + enum PasskeyError: LocalizedError { + case cancelled + case prfUnavailable + case failed(String) + + var errorDescription: String? { + switch self { + case .cancelled: + return "Passkey authentication was cancelled." + case .prfUnavailable: + return "This passkey can't unlock secrets — it doesn't support the PRF extension. Use the passkey you enrolled with." + case .failed(let detail): + return "Passkey authentication failed: \(detail)" + } + } + } + + private var continuation: CheckedContinuation? + private var retainedSelf: SecretsPasskeyAuthenticator? + private let logger = Logger(subsystem: "com.claudework", category: "SecretsPasskey") + + /// Performs a discoverable assertion with the PRF extension evaluating + /// `salt`, and returns the 32-byte PRF output. + func evaluatePRF( + salt: Data = SecretsCrypto.prfSalt, + relyingPartyIdentifier: String = SecretsCrypto.webAuthnRPID + ) async throws -> Data { + var challengeBytes = Data(count: 32) + challengeBytes.withUnsafeMutableBytes { buffer in + _ = SecRandomCopyBytes(kSecRandomDefault, 32, buffer.baseAddress!) + } + let challenge = challengeBytes + + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + self.retainedSelf = self + + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: relyingPartyIdentifier + ) + let request = provider.createCredentialAssertionRequest(challenge: challenge) + request.prf = .inputValues( + ASAuthorizationPublicKeyCredentialPRFAssertionInput.InputValues(saltInput1: salt) + ) + + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } + } + + private func complete(with result: Result) { + guard let continuation else { return } + self.continuation = nil + self.retainedSelf = nil + continuation.resume(with: result) + } +} + +extension SecretsPasskeyAuthenticator: ASAuthorizationControllerDelegate { + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + guard let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion else { + complete(with: .failure(PasskeyError.failed("Unexpected passkey credential"))) + return + } + guard let prf = credential.prf?.first else { + complete(with: .failure(PasskeyError.prfUnavailable)) + return + } + // PRF output is exposed as a SymmetricKey; we need its raw bytes for HKDF. + let prfBytes = prf.withUnsafeBytes { Data($0) } + complete(with: .success(prfBytes)) + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Error + ) { + let nsError = error as NSError + if nsError.domain == ASAuthorizationError.errorDomain, + ASAuthorizationError.Code(rawValue: nsError.code) == .canceled { + complete(with: .failure(PasskeyError.cancelled)) + } else { + complete(with: .failure(PasskeyError.failed(error.localizedDescription))) + } + } +} + +extension SecretsPasskeyAuthenticator: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + NSApplication.shared.keyWindow + ?? NSApplication.shared.mainWindow + ?? ASPresentationAnchor() + } +} + +/// Owns the passkey-derived KEK and caches it for a few minutes so a burst of +/// encrypt/decrypt operations only prompts the user once (matches the web app's +/// 5-minute `cachedKek`). The KEK never persists anywhere. +@MainActor +final class SecretsKeyVault { + private var cachedKEK: SymmetricKey? + private var cachedAt: Date? + private let ttl: TimeInterval = 5 * 60 + + /// Returns the KEK, running a passkey PRF ceremony if the cache is cold. + func kek() async throws -> SymmetricKey { + if let cachedKEK, let cachedAt, Date().timeIntervalSince(cachedAt) < ttl { + return cachedKEK + } + let authenticator = SecretsPasskeyAuthenticator() + let prf = try await authenticator.evaluatePRF() + let kek = SecretsCrypto.deriveKEK(prfOutput: prf) + cachedKEK = kek + cachedAt = Date() + return kek + } + + func clear() { + cachedKEK = nil + cachedAt = nil + } +} +#endif diff --git a/RxCode/Services/Secrets/SecretsService.swift b/RxCode/Services/Secrets/SecretsService.swift new file mode 100644 index 0000000..bb50be6 --- /dev/null +++ b/RxCode/Services/Secrets/SecretsService.swift @@ -0,0 +1,240 @@ +import Foundation +import RxCodeCore +import os + +/// Talks to github-pm's end-to-end-encrypted secrets API (same host as +/// `AutopilotService`, `https://autopilot.rxlab.app`) using the rxauth bearer. +/// All ciphertext is produced/consumed by `SecretsCrypto`; this layer only +/// moves opaque blobs. +@MainActor +final class SecretsService { + + enum ServiceError: LocalizedError { + case notAuthenticated + case invalidResponse + case apiError(Int, String) + case decodingError(String) + + var errorDescription: String? { + switch self { + case .notAuthenticated: + return "Not signed in. Please sign in with rxlab." + case .invalidResponse: + return "Received an invalid response from the secrets service." + case .apiError(let code, let detail): + return "Secrets service error (\(code)): \(detail)" + case .decodingError(let detail): + return "Failed to decode secrets response: \(detail)" + } + } + } + + private let rxAuth: RxAuthService + private let logger = Logger(subsystem: "com.claudework", category: "SecretsService") + private let session: URLSession = .shared + + init(rxAuth: RxAuthService) { + self.rxAuth = rxAuth + } + + var baseURL: URL { + if let override = Bundle.main.object(forInfoDictionaryKey: "SecretsBaseURL") as? String, + !override.isEmpty, let url = URL(string: override) { + return url + } + if let override = Bundle.main.object(forInfoDictionaryKey: "AutopilotBaseURL") as? String, + !override.isEmpty, let url = URL(string: override) { + return url + } + return URL(string: "https://autopilot.rxlab.app")! + } + + // MARK: - Enrollment + + func getUserKey() async throws -> SecretsUserKey { + try await get(url: url("/api/v1/secrets/users/me/key")) + } + + func putUserKey(_ body: EnrollKeyBody) async throws { + let _: Ignored = try await send(method: "PUT", url: url("/api/v1/secrets/users/me/key"), body: body) + } + + // MARK: - Repositories + + func listManagedRepositories( + currentRepo: String? = nil, + search: String? = nil, + cursor: String? = nil, + pageSize: Int? = nil + ) async throws -> SecretsManagedRepoPage { + var items: [URLQueryItem] = [] + if let currentRepo, !currentRepo.isEmpty { items.append(.init(name: "currentRepo", value: currentRepo)) } + if let trimmed = search?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty { + items.append(.init(name: "search", value: trimmed)) + } + if let cursor, !cursor.isEmpty { items.append(.init(name: "cursor", value: cursor)) } + if let pageSize { items.append(.init(name: "pageSize", value: String(pageSize))) } + return try await get(url: url("/api/v1/secrets/repositories/all", query: items)) + } + + func addRepository(_ body: AddSecretsRepoBody) async throws -> SecretsIDResponse { + try await send(method: "POST", url: url("/api/v1/secrets/repositories"), body: body) + } + + /// Batch-fetches secrets-management status for `repos` (each `owner/repo`). + /// Returns one entry per requested repo; unmanaged repos report `isManaged: + /// false`. Used to gate per-project "Download Secret" affordances. + func statuses(forRepos repos: [String]) async throws -> [SecretsRepoStatus] { + guard !repos.isEmpty else { return [] } + let list: SecretsRepoStatusList = try await send( + method: "POST", + url: url("/api/v1/secrets/repositories/status"), + body: SecretsRepoStatusRequest(repositories: repos) + ) + return list.items + } + + // MARK: - Environments + + func listEnvironments(repo: String) async throws -> SecretsEnvironmentList { + try await get(url: url("/api/v1/secrets/repositories/\(seg(repo))/environments")) + } + + func createEnvironment(repo: String, body: CreateEnvironmentBody) async throws -> SecretsIDResponse { + try await send(method: "POST", url: url("/api/v1/secrets/repositories/\(seg(repo))/environments"), body: body) + } + + func deleteEnvironment(repo: String, envId: String) async throws { + let _: Ignored = try await send( + method: "DELETE", + url: url("/api/v1/secrets/repositories/\(seg(repo))/environments/\(seg(envId))") + ) + } + + // MARK: - Files + + func listFiles(repo: String, envId: String) async throws -> SecretsFileList { + try await get(url: url("/api/v1/secrets/repositories/\(seg(repo))/environments/\(seg(envId))/files")) + } + + func upsertFile(repo: String, envId: String, body: UpsertFileBody) async throws -> SecretsIDResponse { + try await send( + method: "POST", + url: url("/api/v1/secrets/repositories/\(seg(repo))/environments/\(seg(envId))/files"), + body: body + ) + } + + func deleteFile(repo: String, envId: String, fileId: String) async throws { + let _: Ignored = try await send( + method: "DELETE", + url: url("/api/v1/secrets/repositories/\(seg(repo))/environments/\(seg(envId))/files/\(seg(fileId))") + ) + } + + // MARK: - Download bundle + + /// `env` may be an environment name or UUID. + func bundle(repo: String, env: String) async throws -> SecretsBundle { + try await get(url: url("/api/v1/secrets/repositories/\(seg(repo))/environments/\(seg(env))/bundle")) + } + + // MARK: - URL building + + /// Percent-encodes a single path segment, including any `/` in an + /// `owner/repo` identifier so it stays one segment. + private func seg(_ value: String) -> String { + var allowed = CharacterSet.urlPathAllowed + allowed.remove("/") + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } + + private func url(_ path: String, query: [URLQueryItem] = []) -> URL { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + components.percentEncodedPath = (components.percentEncodedPath) + path + if !query.isEmpty { components.queryItems = query } + return components.url! + } + + // MARK: - Transport (mirrors AutopilotService) + + private struct Ignored: Decodable {} + + private func get(url: URL) async throws -> T { + try await performWithRetry { token in + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return request + } + } + + private func send(method: String, url: URL, body: Body) async throws -> T { + let payload: Data + do { + payload = try JSONEncoder().encode(body) + } catch { + throw ServiceError.decodingError(error.localizedDescription) + } + return try await performWithRetry { token in + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.httpBody = payload + return request + } + } + + private func send(method: String, url: URL) async throws -> T { + try await performWithRetry { token in + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return request + } + } + + private func performWithRetry(_ build: (String) -> URLRequest) async throws -> T { + guard let token = await rxAuth.accessToken() else { + throw ServiceError.notAuthenticated + } + let request = build(token) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw ServiceError.invalidResponse } + + if http.statusCode == 401 { + guard let refreshed = await rxAuth.accessToken() else { + NotificationCenter.default.post(name: .rxAuthSessionExpired, object: nil) + throw ServiceError.notAuthenticated + } + let retried = build(refreshed) + let (data2, response2) = try await session.data(for: retried) + guard let http2 = response2 as? HTTPURLResponse else { throw ServiceError.invalidResponse } + if http2.statusCode == 401 { + NotificationCenter.default.post(name: .rxAuthSessionExpired, object: nil) + throw ServiceError.notAuthenticated + } + return try decode(data: data2, response: http2) + } + return try decode(data: data, response: http) + } + + private func decode(data: Data, response: HTTPURLResponse) throws -> T { + guard (200..<300).contains(response.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "no body" + throw ServiceError.apiError(response.statusCode, body) + } + if T.self == Ignored.self { + return Ignored() as! T + } + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw ServiceError.decodingError(error.localizedDescription) + } + } +} diff --git a/RxCode/Views/Onboarding/RxAuthSignInView.swift b/RxCode/Views/Onboarding/RxAuthSignInView.swift index 8c4c854..091de1f 100644 --- a/RxCode/Views/Onboarding/RxAuthSignInView.swift +++ b/RxCode/Views/Onboarding/RxAuthSignInView.swift @@ -28,6 +28,10 @@ struct RxAuthSignInView: View { }, onAuthFailed: { error in Self.logger.error("rxauth sign-in failed: \(String(describing: error), privacy: .public) — manager.errorMessage=\(manager.errorMessage ?? "", privacy: .public)") + AnalyticsService.shared.log(.passkeySignInError, parameters: [ + "message": manager.errorMessage ?? error.localizedDescription, + "error": String(describing: error), + ]) } ) .onAppear { diff --git a/RxCode/Views/Secrets/AddSecretSheet.swift b/RxCode/Views/Secrets/AddSecretSheet.swift new file mode 100644 index 0000000..4b5fded --- /dev/null +++ b/RxCode/Views/Secrets/AddSecretSheet.swift @@ -0,0 +1,265 @@ +import RxCodeCore +import SwiftUI +import UniformTypeIdentifiers + +/// Adds one or more encrypted secret files to an environment from three +/// sources: a detected local `.env`, a picked file, or manually entered values. +struct AddSecretSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + let repoIdentifier: String + let envId: String + let environmentKey: SecretsEnvironmentKey + var projectPath: String? + var existingFilenames: Set + var onSaved: () -> Void + + enum Source: String, CaseIterable, Identifiable { + case detected = "Detected" + case file = "File" + case manual = "Manual" + var id: String { rawValue } + } + + @State private var source: Source = .manual + @State private var detected: [DetectedEnv] = [] + @State private var selectedDetected: Set = [] + @State private var showFileImporter = false + @State private var pickedFilename = "" + @State private var pickedContent = "" + @State private var manualFilename = ".env" + @State private var manualRows: [ManualRow] = [ManualRow()] + + @State private var isSaving = false + @State private var errorMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Add Secret").font(.headline) + + Picker("Source", selection: $source) { + ForEach(availableSources) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + + Group { + switch source { + case .detected: detectedView + case .file: fileView + case .manual: manualView + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.callout) + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + Button { + Task { await save() } + } label: { + if isSaving { ProgressView().controlSize(.small) } else { Text("Encrypt & Upload") } + } + .keyboardShortcut(.defaultAction) + .disabled(isSaving || !canSave) + } + } + .padding(20) + .frame(width: 520, height: 460) + .onAppear { + detected = DetectedEnv.scan(directory: projectPath) + selectedDetected = Set(detected.map(\.filename)) + source = detected.isEmpty ? .manual : .detected + } + } + + private var availableSources: [Source] { + detected.isEmpty ? [.file, .manual] : Source.allCases + } + + // MARK: - Detected + + @ViewBuilder private var detectedView: some View { + if detected.isEmpty { + Text("No .env files found in the project folder.") + .foregroundStyle(.secondary) + } else { + List { + ForEach(detected) { env in + Toggle(isOn: Binding( + get: { selectedDetected.contains(env.filename) }, + set: { on in + if on { selectedDetected.insert(env.filename) } + else { selectedDetected.remove(env.filename) } + } + )) { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 6) { + Text(env.filename) + if existingFilenames.contains(env.filename) { + Text("overwrites").font(.caption2).foregroundStyle(.orange) + } + } + Text("\(env.byteCount) bytes").font(.caption).foregroundStyle(.secondary) + } + } + } + } + } + } + + // MARK: - File picker + + @ViewBuilder private var fileView: some View { + VStack(alignment: .leading, spacing: 8) { + Button { + showFileImporter = true + } label: { + Label(pickedFilename.isEmpty ? "Choose File…" : pickedFilename, systemImage: "folder") + } + if !pickedContent.isEmpty { + Text("\(pickedContent.utf8.count) bytes").font(.caption).foregroundStyle(.secondary) + if existingFilenames.contains(pickedFilename) { + Text("Will overwrite the existing \(pickedFilename).") + .font(.caption).foregroundStyle(.orange) + } + } + } + .fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.data, .text, .plainText]) { result in + switch result { + case .success(let url): + let didAccess = url.startAccessingSecurityScopedResource() + defer { if didAccess { url.stopAccessingSecurityScopedResource() } } + if let data = try? Data(contentsOf: url) { + pickedFilename = url.lastPathComponent + pickedContent = String(decoding: data, as: UTF8.self) + } else { + errorMessage = "Couldn't read the selected file." + } + case .failure(let error): + errorMessage = error.localizedDescription + } + } + } + + // MARK: - Manual + + @ViewBuilder private var manualView: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Filename").foregroundStyle(.secondary) + TextField(".env", text: $manualFilename).textFieldStyle(.roundedBorder) + } + Divider() + ScrollView { + VStack(spacing: 6) { + ForEach($manualRows) { $row in + HStack(spacing: 6) { + TextField("KEY", text: $row.key).textFieldStyle(.roundedBorder) + Text("=").foregroundStyle(.secondary) + TextField("value", text: $row.value).textFieldStyle(.roundedBorder) + Button { + manualRows.removeAll { $0.id == row.id } + if manualRows.isEmpty { manualRows = [ManualRow()] } + } label: { Image(systemName: "minus.circle") } + .buttonStyle(.plain).foregroundStyle(.secondary) + } + } + } + } + Button { manualRows.append(ManualRow()) } label: { + Label("Add Variable", systemImage: "plus") + } + .buttonStyle(.plain) + } + } + + // MARK: - Save + + private var canSave: Bool { + switch source { + case .detected: return !selectedDetected.isEmpty + case .file: return !pickedFilename.isEmpty && !pickedContent.isEmpty + case .manual: + return !manualFilename.trimmingCharacters(in: .whitespaces).isEmpty + && manualRows.contains { !$0.key.trimmingCharacters(in: .whitespaces).isEmpty } + } + } + + /// The `(filename, content)` pairs to upload for the current source. + private func pendingUploads() -> [(filename: String, content: String)] { + switch source { + case .detected: + return detected + .filter { selectedDetected.contains($0.filename) } + .map { ($0.filename, $0.content) } + case .file: + return [(pickedFilename, pickedContent)] + case .manual: + let content = manualRows + .filter { !$0.key.trimmingCharacters(in: .whitespaces).isEmpty } + .map { "\($0.key.trimmingCharacters(in: .whitespaces))=\($0.value)" } + .joined(separator: "\n") + "\n" + return [(manualFilename.trimmingCharacters(in: .whitespaces), content)] + } + } + + private func save() async { + isSaving = true + errorMessage = nil + defer { isSaving = false } + do { + for upload in pendingUploads() { + let body = try await appState.encryptSecretFile( + forEnvironmentKey: environmentKey, + filename: upload.filename, + content: upload.content + ) + _ = try await appState.secrets.upsertFile(repo: repoIdentifier, envId: envId, body: body) + } + onSaved() + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} + +// MARK: - Supporting types + +private struct ManualRow: Identifiable { + let id = UUID() + var key = "" + var value = "" +} + +struct DetectedEnv: Identifiable { + let filename: String + let content: String + var id: String { filename } + var byteCount: Int { content.utf8.count } + + /// Scans `directory` for files whose name starts with `.env`. Uses the + /// path-based listing because `.env` is a hidden dotfile. + static func scan(directory: String?) -> [DetectedEnv] { + guard let directory else { return [] } + guard let names = try? FileManager.default.contentsOfDirectory(atPath: directory) else { return [] } + return names + .filter { $0 == ".env" || $0.hasPrefix(".env") } + .sorted() + .compactMap { name in + let path = (directory as NSString).appendingPathComponent(name) + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir), !isDir.boolValue else { + return nil + } + guard let data = FileManager.default.contents(atPath: path) else { return nil } + return DetectedEnv(filename: name, content: String(decoding: data, as: UTF8.self)) + } + } +} diff --git a/RxCode/Views/Secrets/SecretsDownloadSheet.swift b/RxCode/Views/Secrets/SecretsDownloadSheet.swift new file mode 100644 index 0000000..2163147 --- /dev/null +++ b/RxCode/Views/Secrets/SecretsDownloadSheet.swift @@ -0,0 +1,116 @@ +import RxCodeCore +import SwiftUI + +/// Per-project "Download Secret" flow: pick an environment for the project's +/// repository, then decrypt (passkey) and write the files into the project. +struct SecretsDownloadSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + let project: Project + + @State private var environments: [SecretsEnvironment] = [] + @State private var selectedEnvId: String? + @State private var overwrite = true + @State private var isLoading = false + @State private var isDownloading = false + @State private var errorMessage: String? + @State private var written: [String]? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Download Secret").font(.headline) + + if let repo = project.gitHubRepo { + Text(repo).font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary) + bodyContent(repo: repo) + } else { + Text("This project isn't linked to a GitHub repository, so it has no secrets to download.") + .foregroundStyle(.secondary) + } + + if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.callout) + } + if let written { + if written.isEmpty { + Label("Nothing written (files already exist).", systemImage: "info.circle") + .font(.callout).foregroundStyle(.secondary) + } else { + Label("Wrote \(written.joined(separator: ", ")) to the project.", systemImage: "checkmark.circle") + .font(.callout).foregroundStyle(.green) + } + } + + Spacer(minLength: 0) + + HStack { + Spacer() + Button(written == nil ? "Cancel" : "Done") { dismiss() } + if project.gitHubRepo != nil, written == nil { + Button { + Task { await download() } + } label: { + if isDownloading { ProgressView().controlSize(.small) } else { Text("Download") } + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(isDownloading || selectedEnvId == nil) + } + } + } + .padding(20) + .frame(width: 460, height: 320) + .task { await load() } + } + + @ViewBuilder + private func bodyContent(repo: String) -> some View { + if isLoading { + ProgressView().frame(maxWidth: .infinity) + } else if environments.isEmpty { + Text("No environments found for this repository. Add secrets from Settings → Secrets first.") + .foregroundStyle(.secondary) + } else { + Picker("Environment", selection: $selectedEnvId) { + ForEach(environments) { env in + Text(env.name).tag(Optional(env.id)) + } + } + Toggle("Overwrite existing files", isOn: $overwrite) + Text("Decrypts with your passkey and writes the .env file(s) into the project folder.") + .font(.caption).foregroundStyle(.secondary) + } + } + + private func load() async { + guard let repo = project.gitHubRepo else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + environments = try await appState.secrets.listEnvironments(repo: repo).items + selectedEnvId = environments.first?.id + } catch { + errorMessage = error.localizedDescription + } + } + + private func download() async { + guard let repo = project.gitHubRepo, let envId = selectedEnvId else { return } + isDownloading = true + errorMessage = nil + defer { isDownloading = false } + do { + let files = try await appState.downloadSecrets( + repo: repo, + env: envId, + to: URL(fileURLWithPath: project.path, isDirectory: true), + overwrite: overwrite + ) + written = files + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCode/Views/Secrets/SecretsEnvironmentDetailView.swift b/RxCode/Views/Secrets/SecretsEnvironmentDetailView.swift new file mode 100644 index 0000000..d1b690c --- /dev/null +++ b/RxCode/Views/Secrets/SecretsEnvironmentDetailView.swift @@ -0,0 +1,232 @@ +import RxCodeCore +import SwiftUI + +/// Files within an environment. Loads the encrypted bundle (env key + file +/// ciphertexts) up front; decryption is deferred until the user views a file. +struct SecretsEnvironmentDetailView: View { + @Environment(AppState.self) private var appState + + let route: SecretsEnvRoute + var projectPath: String? + + @State private var environmentKey: SecretsEnvironmentKey? + @State private var files: [SecretsBundleFile] = [] + @State private var isLoading = false + @State private var errorMessage: String? + + @State private var showAdd = false + @State private var editingFile: SecretsBundleFile? + @State private var fileToDelete: SecretsBundleFile? + @State private var deletingFilename: String? + + var body: some View { + List { + if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.callout) + } + ForEach(files) { file in + Button { editingFile = file } label: { + HStack { + Image(systemName: "doc.text").foregroundStyle(.secondary) + Text(file.filename) + Spacer() + if let size = file.size { + Text("\(size) B").foregroundStyle(.secondary).font(.caption) + } + Image(systemName: "chevron.right").foregroundStyle(.tertiary).font(.caption) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .contextMenu { + Button(role: .destructive) { fileToDelete = file } label: { + Label("Delete Secret", systemImage: "trash") + } + } + } + if files.isEmpty, !isLoading { + VStack(alignment: .leading, spacing: 10) { + Text("No secrets yet. Add a .env file or enter values manually.") + .foregroundStyle(.secondary) + Button { showAdd = true } label: { + Label("Add Secret", systemImage: "plus") + } + .buttonStyle(.borderedProminent) + .disabled(environmentKey == nil) + } + } + } + .overlay { if isLoading, files.isEmpty { ProgressView() } } + .overlay { + if let deletingFilename { + ZStack { + Color.black.opacity(0.2).ignoresSafeArea() + VStack(spacing: 12) { + ProgressView() + Text("Deleting \"\(deletingFilename)\"…") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(24) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + .transition(.opacity) + } + } + .animation(.default, value: deletingFilename) + .navigationTitle(route.env.name) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { showAdd = true } label: { Label("Add Secret", systemImage: "plus") } + .disabled(environmentKey == nil) + } + } + .sheet(isPresented: $showAdd) { + if let environmentKey { + AddSecretSheet( + repoIdentifier: route.repoIdentifier, + envId: route.env.id, + environmentKey: environmentKey, + projectPath: projectPath, + existingFilenames: Set(files.map(\.filename)) + ) { + Task { await load() } + } + } + } + .sheet(item: $editingFile) { file in + if let environmentKey { + SecretsFileEditor( + repoIdentifier: route.repoIdentifier, + envId: route.env.id, + environmentKey: environmentKey, + file: file + ) { + Task { await load() } + } + } + } + .confirmationDialog( + "Delete \"\(fileToDelete?.filename ?? "")\"?", + isPresented: Binding(get: { fileToDelete != nil }, set: { if !$0 { fileToDelete = nil } }), + titleVisibility: .visible + ) { + // Capture the target synchronously: dismissing the dialog clears + // `fileToDelete` before the async Task body runs, so reading it + // inside `deleteFile()` would see nil and silently no-op. + Button("Delete", role: .destructive) { + let file = fileToDelete + Task { await deleteFile(file) } + } + Button("Cancel", role: .cancel) { fileToDelete = nil } + } + .task { await load() } + } + + private func load() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let bundle = try await appState.secrets.bundle(repo: route.repoIdentifier, env: route.env.id) + environmentKey = bundle.environmentKey + files = bundle.files + } catch { + errorMessage = error.localizedDescription + } + } + + private func deleteFile(_ file: SecretsBundleFile?) async { + guard let file else { return } + fileToDelete = nil + deletingFilename = file.filename + defer { deletingFilename = nil } + // Look up the file id via the metadata listing (bundle has no id). + do { + let metas = try await appState.secrets.listFiles(repo: route.repoIdentifier, envId: route.env.id).items + guard let meta = metas.first(where: { $0.filename == file.filename }) else { return } + try await appState.secrets.deleteFile(repo: route.repoIdentifier, envId: route.env.id, fileId: meta.id) + await load() + } catch { + errorMessage = error.localizedDescription + } + } +} + +// MARK: - File editor + +/// View / edit one secret file's plaintext. Decrypts on appear (passkey), saves +/// by re-encrypting under the environment DEK. +struct SecretsFileEditor: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + let repoIdentifier: String + let envId: String + let environmentKey: SecretsEnvironmentKey + let file: SecretsBundleFile + var onSaved: () -> Void + + @State private var content = "" + @State private var isLoading = true + @State private var isSaving = false + @State private var errorMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(file.filename).font(.headline) + if isLoading { + ProgressView("Decrypting…").frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + TextEditor(text: $content) + .font(.system(.body, design: .monospaced)) + .border(Color(NSColor.separatorColor)) + } + if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.callout) + } + HStack { + Spacer() + Button("Cancel") { dismiss() } + Button { + Task { await save() } + } label: { + if isSaving { ProgressView().controlSize(.small) } else { Text("Save") } + } + .keyboardShortcut(.defaultAction) + .disabled(isLoading || isSaving) + } + } + .padding(20) + .frame(width: 520, height: 440) + .task { await decrypt() } + } + + private func decrypt() async { + isLoading = true + defer { isLoading = false } + do { + content = try await appState.decryptSecretFile( + envKey: environmentKey, ciphertext: file.ciphertext, iv: file.iv + ) + } catch { + errorMessage = error.localizedDescription + } + } + + private func save() async { + isSaving = true + errorMessage = nil + defer { isSaving = false } + do { + let body = try await appState.encryptSecretFile( + forEnvironmentKey: environmentKey, filename: file.filename, content: content + ) + _ = try await appState.secrets.upsertFile(repo: repoIdentifier, envId: envId, body: body) + onSaved() + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCode/Views/Secrets/SecretsManageSheet.swift b/RxCode/Views/Secrets/SecretsManageSheet.swift new file mode 100644 index 0000000..9135e44 --- /dev/null +++ b/RxCode/Views/Secrets/SecretsManageSheet.swift @@ -0,0 +1,173 @@ +import RxCodeCore +import SwiftUI + +// MARK: - Routes + +struct SecretsEnvRoute: Hashable { + let repoIdentifier: String // internal secrets UUID + let repoFullName: String + let env: SecretsEnvironment +} + +// MARK: - Manage sheet + +/// Top-level "Manage Secrets" sheet: lists every accessible repo (current repo +/// pinned on top, server-sorted), drilling into environments and files. +struct SecretsManageSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + /// Optional project context: pins this repo to the top and enables local + /// `.env` detection when adding secrets. + var currentRepoFullName: String? + var currentProjectPath: String? + + @State private var repos: [SecretsManagedRepo] = [] + @State private var search = "" + @State private var nextCursor: String? + @State private var hasMore = false + @State private var isLoading = false + @State private var errorMessage: String? + @State private var searchTask: Task? + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + content + } + .navigationTitle("Manage Secrets") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + .navigationDestination(for: SecretsManagedRepo.self) { repo in + SecretsRepoDetailView(repo: repo, projectPath: projectPath(for: repo)) + } + .navigationDestination(for: SecretsEnvRoute.self) { route in + SecretsEnvironmentDetailView(route: route, projectPath: pathIfCurrent(route.repoFullName)) + } + } + .frame(width: 560, height: 560) + .task { await reload() } + } + + @ViewBuilder + private var content: some View { + List { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Search repositories", text: $search) + .textFieldStyle(.plain) + } + if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.callout) + } + ForEach(repos) { repo in + NavigationLink(value: repo) { + SecretsRepoRow(repo: repo) + } + } + if hasMore { + Button { + Task { await loadMore() } + } label: { + HStack { Spacer(); Text("Load more"); Spacer() } + } + .disabled(isLoading) + } + if repos.isEmpty, !isLoading, errorMessage == nil { + Text("No repositories found.") + .foregroundStyle(.secondary) + } + } + .overlay { + if isLoading, repos.isEmpty { ProgressView() } + } + .onChange(of: search) { _, _ in + searchTask?.cancel() + searchTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + await reload() + } + } + } + + private func projectPath(for repo: SecretsManagedRepo) -> String? { + pathIfCurrent(repo.fullName) + } + + private func pathIfCurrent(_ fullName: String) -> String? { + guard let currentRepoFullName, fullName == currentRepoFullName else { return nil } + return currentProjectPath + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let page = try await appState.secrets.listManagedRepositories( + currentRepo: currentRepoFullName, + search: search + ) + repos = page.items + nextCursor = page.pagination.nextCursor + hasMore = page.pagination.hasMore + } catch { + errorMessage = error.localizedDescription + repos = [] + hasMore = false + } + } + + private func loadMore() async { + guard let cursor = nextCursor else { return } + isLoading = true + defer { isLoading = false } + do { + let page = try await appState.secrets.listManagedRepositories( + currentRepo: currentRepoFullName, + search: search, + cursor: cursor + ) + repos.append(contentsOf: page.items) + nextCursor = page.pagination.nextCursor + hasMore = page.pagination.hasMore + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct SecretsRepoRow: View { + let repo: SecretsManagedRepo + + var body: some View { + HStack(spacing: 10) { + Image(systemName: repo.isPrivate ? "lock.fill" : "book.closed") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(repo.fullName).font(.body) + if repo.isCurrent { + Text("Current") + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 1) + .background(Color.accentColor.opacity(0.15)) + .clipShape(Capsule()) + } + } + if repo.isManaged { + Text("\(repo.environmentsCount) env · \(repo.filesCount) secrets") + .font(.caption).foregroundStyle(.secondary) + } else { + Text("Not yet managed").font(.caption).foregroundStyle(.tertiary) + } + } + Spacer() + } + .padding(.vertical, 2) + } +} diff --git a/RxCode/Views/Secrets/SecretsRepoDetailView.swift b/RxCode/Views/Secrets/SecretsRepoDetailView.swift new file mode 100644 index 0000000..dd8e9a6 --- /dev/null +++ b/RxCode/Views/Secrets/SecretsRepoDetailView.swift @@ -0,0 +1,138 @@ +import RxCodeCore +import SwiftUI + +/// Environments within a repo. Creating the first environment lazily registers +/// the repo for secrets management. +struct SecretsRepoDetailView: View { + @Environment(AppState.self) private var appState + + let repo: SecretsManagedRepo + var projectPath: String? + + @State private var repoId: String? + @State private var environments: [SecretsEnvironment] = [] + @State private var isLoading = false + @State private var errorMessage: String? + + @State private var showCreate = false + @State private var newName = "" + @State private var isMutating = false + @State private var envToDelete: SecretsEnvironment? + + var body: some View { + List { + if let errorMessage { + Text(errorMessage).foregroundStyle(.red).font(.callout) + } + ForEach(environments) { env in + NavigationLink(value: SecretsEnvRoute( + repoIdentifier: repoId ?? repo.fullName, + repoFullName: repo.fullName, + env: env + )) { + HStack { + Image(systemName: "shippingbox") + .foregroundStyle(.secondary) + Text(env.name) + Spacer() + if let count = env.filesCount { + Text("\(count)").foregroundStyle(.secondary).font(.caption) + } + } + } + .contextMenu { + Button(role: .destructive) { envToDelete = env } label: { + Label("Delete Environment", systemImage: "trash") + } + } + } + if environments.isEmpty, !isLoading { + VStack(alignment: .leading, spacing: 10) { + Text("No environments yet. Create one to store secrets.") + .foregroundStyle(.secondary) + Button { newName = ""; showCreate = true } label: { + Label("New Environment", systemImage: "plus") + } + .buttonStyle(.borderedProminent) + } + } + } + .overlay { if isLoading, environments.isEmpty { ProgressView() } } + .navigationTitle(repo.name) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { newName = ""; showCreate = true } label: { + Label("New Environment", systemImage: "plus") + } + } + } + .alert("New Environment", isPresented: $showCreate) { + TextField("Name (e.g. prod)", text: $newName) + Button("Create") { Task { await createEnvironment() } } + .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty) + Button("Cancel", role: .cancel) {} + } message: { + Text("Letters, numbers, dot, dash and underscore. Max 32 characters.") + } + .confirmationDialog( + "Delete \"\(envToDelete?.name ?? "")\"?", + isPresented: Binding(get: { envToDelete != nil }, set: { if !$0 { envToDelete = nil } }), + titleVisibility: .visible + ) { + // Capture synchronously: dialog dismissal clears `envToDelete` + // before the async Task body runs. + Button("Delete", role: .destructive) { + let env = envToDelete + Task { await deleteEnvironment(env) } + } + Button("Cancel", role: .cancel) { envToDelete = nil } + } message: { + Text("All secrets in this environment will be permanently deleted.") + } + .task { await load() } + } + + private func load() async { + guard let id = repoId ?? repo.secretsRepoId else { + // Unmanaged repo — nothing stored yet. + environments = [] + return + } + repoId = id + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + environments = try await appState.secrets.listEnvironments(repo: id).items + } catch { + errorMessage = error.localizedDescription + } + } + + private func createEnvironment() async { + let name = newName.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return } + isMutating = true + errorMessage = nil + defer { isMutating = false } + do { + let id = try await appState.ensureSecretsRepoManaged(repo) + repoId = id + try await appState.createSecretEnvironment(repo: id, name: name) + await load() + } catch { + errorMessage = error.localizedDescription + } + } + + private func deleteEnvironment(_ env: SecretsEnvironment?) async { + guard let env, let id = repoId else { return } + envToDelete = nil + do { + try await appState.secrets.deleteEnvironment(repo: id, envId: env.id) + await load() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCode/Views/Settings/AutopilotSettingsTab.swift b/RxCode/Views/Settings/AutopilotSettingsTab.swift index ba51265..682595c 100644 --- a/RxCode/Views/Settings/AutopilotSettingsTab.swift +++ b/RxCode/Views/Settings/AutopilotSettingsTab.swift @@ -9,12 +9,20 @@ struct AutopilotSettingsTab: View { @State private var showSignIn = false @State private var isSigningOut = false + @State private var showManageSecrets = false + @State private var isEnrollingSecrets = false + @State private var secretsError: String? + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { headerSection Divider() accountSection + if appState.isSignedIn { + Divider() + secretsSection + } } .padding(24) .frame(maxWidth: .infinity, alignment: .leading) @@ -23,6 +31,110 @@ struct AutopilotSettingsTab: View { RxAuthSignInView() .environment(appState) } + .sheet(isPresented: $showManageSecrets, onDismiss: { + Task { await appState.refreshSecretsStatuses() } + }) { + SecretsManageSheet() + .environment(appState) + } + .task { await appState.refreshSecretsEnrollment() } + } + + // MARK: - Secrets Section + + private var secretsSection: some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text("Secrets") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + Text("Store .env files end-to-end encrypted with your passkey. Secrets are encrypted on this device and never leave it unencrypted.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + switch appState.secretsEnrolled { + case .none: + secretsCard { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Checking enrollment…").foregroundStyle(.secondary) + } + } + case .some(false): + secretsEnrollCard + case .some(true): + secretsManageCard + } + + if let secretsError { + Text(secretsError).foregroundStyle(.red).font(.system(size: ClaudeTheme.size(11))) + } + } + } + + private var secretsEnrollCard: some View { + secretsCard { + VStack(alignment: .leading, spacing: 10) { + Text("Set up encryption") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + Text("Create your encryption key. You'll authenticate with your passkey; the key is derived from it and used to protect your secrets.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Button { + Task { await enrollSecrets() } + } label: { + if isEnrollingSecrets { ProgressView().controlSize(.small) } else { Text("Enroll with Passkey") } + } + .buttonStyle(.borderedProminent) + .disabled(isEnrollingSecrets) + } + } + } + + private var secretsManageCard: some View { + secretsCard { + HStack(spacing: 12) { + Image(systemName: "key.fill") + .font(.system(size: ClaudeTheme.size(18))) + .foregroundStyle(ClaudeTheme.accent) + VStack(alignment: .leading, spacing: 2) { + Text("Encryption enabled") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + Text("Manage your repositories, environments, and secrets.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + Button("Manage Secrets") { showManageSecrets = true } + .buttonStyle(.borderedProminent) + } + } + } + + private func secretsCard(@ViewBuilder _ content: () -> Content) -> some View { + content() + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) + ) + } + + private func enrollSecrets() async { + isEnrollingSecrets = true + secretsError = nil + defer { isEnrollingSecrets = false } + do { + try await appState.enrollSecrets() + } catch { + secretsError = error.localizedDescription + } } private var headerSection: some View { diff --git a/RxCode/Views/Sidebar/BriefingView.swift b/RxCode/Views/Sidebar/BriefingView.swift index 9c11ff5..0150eb5 100644 --- a/RxCode/Views/Sidebar/BriefingView.swift +++ b/RxCode/Views/Sidebar/BriefingView.swift @@ -466,7 +466,7 @@ struct BriefingView: View { copyButton(for: group) if let project { - cardMenu(for: project) + cardMenu(for: group, project: project) } } } @@ -533,7 +533,28 @@ struct BriefingView: View { return lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) } - private func cardMenu(for project: Project) -> some View { + /// GitHub destination for a briefing card's "Open on GitHub" action. Prefers + /// the pull request associated with the branch (mirroring the menu bar + /// extra), and falls back to the repository page when no PR is known. + private func gitHubURL(for group: BriefingGroup, project: Project) -> URL? { + let _ = appState.ciStatusRevision + if currentBranchByProject[group.projectId] == group.branch, + let status = appState.ciStatusByProject[group.projectId], + let prNumber = status.prNumber { + return URL(string: "https://github.com/\(status.owner)/\(status.repo)/pull/\(prNumber)") + } + guard let ownerRepo = project.gitHubRepo else { return nil } + return gitHubWebURL(forOwnerRepo: ownerRepo) + } + + /// True when the GitHub action for this card points at a pull request. + private func gitHubURLIsPullRequest(for group: BriefingGroup) -> Bool { + let _ = appState.ciStatusRevision + return currentBranchByProject[group.projectId] == group.branch + && appState.ciStatusByProject[group.projectId]?.prNumber != nil + } + + private func cardMenu(for group: BriefingGroup, project: Project) -> some View { Menu { Button { if windowState.selectedProject?.id != project.id { @@ -551,6 +572,16 @@ struct BriefingView: View { } label: { Label("Open Project", systemImage: "folder") } + + if let url = gitHubURL(for: group, project: project) { + Divider() + Link(destination: url) { + Label( + gitHubURLIsPullRequest(for: group) ? "Open Pull Request" : "Open on GitHub", + systemImage: "arrow.up.forward.square" + ) + } + } } label: { Image(systemName: "ellipsis") .font(.system(size: 11, weight: .semibold)) diff --git a/RxCode/Views/Sidebar/ProjectTreeView.swift b/RxCode/Views/Sidebar/ProjectTreeView.swift index d08dccc..cc66dca 100644 --- a/RxCode/Views/Sidebar/ProjectTreeView.swift +++ b/RxCode/Views/Sidebar/ProjectTreeView.swift @@ -23,6 +23,7 @@ struct ProjectTreeView: View { @State private var archiveSession: ChatSession? = nil @State private var showAllChatsSheet = false + @State private var downloadSecretProject: Project? = nil var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -125,6 +126,10 @@ struct ProjectTreeView: View { .sheet(isPresented: $showAllChatsSheet) { AllChatsHistorySheet(isPresented: $showAllChatsSheet) } + .sheet(item: $downloadSecretProject) { project in + SecretsDownloadSheet(project: project) + .environment(appState) + } .onChange(of: windowState.selectedProject?.id) { _, newId in if let newId { expandedProjectIds.insert(newId) } } @@ -133,6 +138,9 @@ struct ProjectTreeView: View { expandedProjectIds.insert(id) } } + .task(id: appState.projects.map { $0.gitHubRepo ?? "" }.joined(separator: ",")) { + await appState.refreshSecretsStatuses() + } } // MARK: - Header @@ -253,7 +261,9 @@ private struct SummarySidebarSection: View { onNewChat: { appState.selectProject(project, in: windowState) appState.startNewChat(in: windowState) - } + }, + onDownloadSecret: { downloadSecretProject = project }, + hasSecrets: appState.projectHasSecrets(project) ) if expandedProjectIds.contains(project.id) { @@ -297,6 +307,8 @@ private struct ProjectTreeRow: View { let onRename: () -> Void let onDelete: () -> Void let onNewChat: () -> Void + let onDownloadSecret: () -> Void + let hasSecrets: Bool @State private var isHovered = false @State private var showLocationPopover = false @@ -367,6 +379,12 @@ private struct ProjectTreeRow: View { Label("Open in New Window", systemImage: "macwindow.badge.plus") } Divider() + if hasSecrets { + Button { onDownloadSecret() } label: { + Label("Download Secret", systemImage: "key.fill") + } + Divider() + } Button { onRename() } label: { Label("Rename Project", systemImage: "pencil") } @@ -427,6 +445,14 @@ private struct ProjectTreeRow: View { Label("Open in New Window", systemImage: "macwindow.badge.plus") } Divider() + if hasSecrets { + Button { + onDownloadSecret() + } label: { + Label("Download Secret", systemImage: "key.fill") + } + Divider() + } Button { onRename() } label: { diff --git a/RxCodeMobile/Resources/Localizable.xcstrings b/RxCodeMobile/Resources/Localizable.xcstrings index fbf4339..3fdd9fb 100644 --- a/RxCodeMobile/Resources/Localizable.xcstrings +++ b/RxCodeMobile/Resources/Localizable.xcstrings @@ -3,6 +3,9 @@ "strings" : { "" : { + }, + "-- --port 3000" : { + }, "%@ · %lld model%@" : { "localizations" : { @@ -1457,6 +1460,9 @@ } } } + }, + "Custom Script" : { + }, "Custom Sources" : { "localizations" : { @@ -2126,6 +2132,22 @@ } } }, + "Failing" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Failing" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "失败" + } + } + } + }, "Fair" : { "localizations" : { "en" : { @@ -3112,6 +3134,22 @@ } } }, + "No status" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No status" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无状态" + } + } + } + }, "No summary available yet" : { "localizations" : { "en" : { @@ -3297,6 +3335,22 @@ } } }, + "Open failing CI run" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open failing CI run" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开失败的 CI 运行" + } + } + } + }, "Open in Browser" : { "localizations" : { "en" : { @@ -3312,6 +3366,12 @@ } } } + }, + "Open on GitHub" : { + + }, + "Open Pull Request" : { + }, "Opens todos and thread summary" : { "localizations" : { @@ -3331,6 +3391,15 @@ }, "Optional xcodebuild destination" : { + }, + "Package" : { + + }, + "Package Configuration" : { + + }, + "Package Manager" : { + }, "Pair New Mac" : { "localizations" : { @@ -3412,6 +3481,22 @@ } } }, + "Passing" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Passing" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通过" + } + } + } + }, "Permission needed" : { "localizations" : { "en" : { @@ -3497,6 +3582,9 @@ } } } + }, + "Project actions" : { + }, "Project root" : { @@ -3830,6 +3918,22 @@ } } }, + "Running" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Running" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "运行中" + } + } + } + }, "Runs" : { "localizations" : { "en" : { @@ -3979,6 +4083,9 @@ } } } + }, + "Script" : { + }, "Scroll to bottom" : { "localizations" : { diff --git a/RxCodeMobile/State/MobileAppState+Inbound.swift b/RxCodeMobile/State/MobileAppState+Inbound.swift index 552c8f3..b33eb34 100644 --- a/RxCodeMobile/State/MobileAppState+Inbound.swift +++ b/RxCodeMobile/State/MobileAppState+Inbound.swift @@ -77,6 +77,7 @@ extension MobileAppState { sessions = snap.sessions branchBriefings = snap.branchBriefings ?? [] threadSummaries = snap.threadSummaries ?? [] + ciStatusByProject = Dictionary(uniqueKeysWithValues: (snap.ciStatuses ?? []).map { ($0.projectId, $0.status) }) desktopSettings = snap.settings desktopUsage = snap.usage desktopHostMetrics = snap.hostMetrics diff --git a/RxCodeMobile/State/MobileAppState+Sync.swift b/RxCodeMobile/State/MobileAppState+Sync.swift index eba697a..b880fde 100644 --- a/RxCodeMobile/State/MobileAppState+Sync.swift +++ b/RxCodeMobile/State/MobileAppState+Sync.swift @@ -441,6 +441,7 @@ extension MobileAppState { sessions = [] branchBriefings = [] threadSummaries = [] + ciStatusByProject = [:] desktopSettings = nil desktopUsage = nil desktopHostMetrics = nil diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index b9d38f3..bf7a749 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -67,6 +67,7 @@ final class MobileAppState: ObservableObject { @Published var sessions: [SessionSummary] = [] @Published var branchBriefings: [MobileBranchBriefing] = [] @Published var threadSummaries: [MobileThreadSummary] = [] + @Published var ciStatusByProject: [UUID: ProjectCIStatus] = [:] @Published var desktopSettings: MobileSettingsSnapshot? /// Agent rate-limit usage mirrored from the paired desktop. `nil` until the /// first snapshot arrives, or when paired with a desktop that predates diff --git a/RxCodeMobile/Views/MobileBriefingComponents.swift b/RxCodeMobile/Views/MobileBriefingComponents.swift new file mode 100644 index 0000000..24a4c8d --- /dev/null +++ b/RxCodeMobile/Views/MobileBriefingComponents.swift @@ -0,0 +1,124 @@ +import SwiftUI +import RxCodeCore + +// MARK: - Flow Layout + +/// A layout that arranges views horizontally and wraps to the next line when needed. +struct BriefingFlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + return layout(sizes: sizes, containerWidth: proposal.width ?? .infinity).size + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + let offsets = layout(sizes: sizes, containerWidth: bounds.width).offsets + + for (index, subview) in subviews.enumerated() { + subview.place( + at: CGPoint(x: bounds.minX + offsets[index].x, y: bounds.minY + offsets[index].y), + proposal: ProposedViewSize(sizes[index]) + ) + } + } + + private func layout(sizes: [CGSize], containerWidth: CGFloat) -> (offsets: [CGPoint], size: CGSize) { + var offsets: [CGPoint] = [] + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var lineHeight: CGFloat = 0 + var maxWidth: CGFloat = 0 + + for size in sizes { + if currentX + size.width > containerWidth && currentX > 0 { + currentX = 0 + currentY += lineHeight + spacing + lineHeight = 0 + } + + offsets.append(CGPoint(x: currentX, y: currentY)) + lineHeight = max(lineHeight, size.height) + currentX += size.width + spacing + maxWidth = max(maxWidth, currentX - spacing) + } + + return (offsets, CGSize(width: maxWidth, height: currentY + lineHeight)) + } +} + +// MARK: - CI Status Chip + +struct MobileCIStatusChip: View { + let status: ProjectCIStatus + var compact: Bool = false + var linksFailingRun: Bool = false + + private var failingRunURL: URL? { + guard status.overallState == .failure, + let urlString = status.failing.first?.htmlUrl + else { return nil } + return URL(string: urlString) + } + + var body: some View { + if linksFailingRun, let url = failingRunURL { + Link(destination: url) { + label + } + .accessibilityLabel(Text("Open failing CI run", tableName: "Localizable")) + } else { + label + } + } + + private var label: some View { + HStack(spacing: compact ? 3 : 4) { + Image(systemName: status.overallState.mobileSFSymbolName) + .font(.system(size: compact ? 9 : 10, weight: .semibold)) + Text(status.overallState.mobileLabel) + .font(compact ? .system(size: 11, weight: .medium) : .caption.weight(.medium)) + .lineLimit(1) + } + .foregroundStyle(status.overallState.mobileDisplayColor) + .padding(.horizontal, compact ? 0 : 8) + .padding(.vertical, compact ? 0 : 4) + .background { + if !compact { + Capsule() + .fill(status.overallState.mobileDisplayColor.opacity(0.12)) + } + } + .accessibilityElement(children: .combine) + } +} + +private extension CIOverallState { + var mobileDisplayColor: Color { + switch self { + case .success: .green + case .failure: .red + case .pending: .yellow + case .unknown: .secondary + } + } + + var mobileSFSymbolName: String { + switch self { + case .success: "checkmark.circle.fill" + case .failure: "xmark.circle.fill" + case .pending: "clock.fill" + case .unknown: "questionmark.circle" + } + } + + var mobileLabel: LocalizedStringKey { + switch self { + case .success: "Passing" + case .failure: "Failing" + case .pending: "Running" + case .unknown: "No status" + } + } +} diff --git a/RxCodeMobile/Views/MobileBriefingDetailView.swift b/RxCodeMobile/Views/MobileBriefingDetailView.swift index 1cbf1e6..c8fd03d 100644 --- a/RxCodeMobile/Views/MobileBriefingDetailView.swift +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -39,6 +39,24 @@ struct MobileBriefingDetailView: View { .accessibilityLabel("New Thread") .accessibilityIdentifier("briefing-detail-new-thread") } + + if let gitHubURL { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Link(destination: gitHubURL) { + Label( + gitHubURLIsPullRequest ? "Open Pull Request" : "Open on GitHub", + systemImage: "arrow.up.forward.square" + ) + } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 16, weight: .medium)) + } + .accessibilityLabel("Project actions") + .accessibilityIdentifier("briefing-detail-actions") + } + } } .sheet(isPresented: $showingNewThread) { NewThreadSheet( @@ -76,10 +94,33 @@ struct MobileBriefingDetailView: View { state.projects.first(where: { $0.id == groupKey.projectId })?.name ?? "Unknown Project" } + /// GitHub destination for the "Open on GitHub" action. Prefers the pull + /// request associated with the branch (mirroring the menu bar extra), and + /// falls back to the repository page when no PR is known. + private var gitHubURL: URL? { + if let status = ciStatus, let prNumber = status.prNumber { + return URL(string: "https://github.com/\(status.owner)/\(status.repo)/pull/\(prNumber)") + } + guard let repo = state.projects.first(where: { $0.id == groupKey.projectId })?.gitHubRepo else { + return nil + } + return gitHubWebURL(forOwnerRepo: repo) + } + + /// True when the GitHub action points at a pull request rather than the repo. + private var gitHubURLIsPullRequest: Bool { + ciStatus?.prNumber != nil + } + private var isUnknownBranch: Bool { groupKey.branch.lowercased() == "unknown" } + private var ciStatus: ProjectCIStatus? { + guard state.projectBranches[groupKey.projectId] == groupKey.branch else { return nil } + return state.ciStatusByProject[groupKey.projectId] + } + private func initializeGit() { guard !isInitializingGit else { return } isInitializingGit = true @@ -150,6 +191,10 @@ struct MobileBriefingDetailView: View { .background(.ultraThinMaterial, in: Capsule()) } + if let ciStatus { + MobileCIStatusChip(status: ciStatus, linksFailingRun: true) + } + // Updated time if let updatedAt = group?.updatedAt { Text(updatedAt.formatted(.relative(presentation: .named))) diff --git a/RxCodeMobile/Views/MobileBriefingView.swift b/RxCodeMobile/Views/MobileBriefingView.swift index 70ed02f..cb24470 100644 --- a/RxCodeMobile/Views/MobileBriefingView.swift +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -55,6 +55,7 @@ struct MobileBriefingView: View { group: group, projectName: projectsById[group.projectId]?.name ?? "Unknown Project", activeJobCount: activeJobCountByProject[group.projectId] ?? 0, + ciStatus: ciStatus(for: group), namespace: glassNamespace ) } @@ -195,6 +196,11 @@ struct MobileBriefingView: View { } } + private func ciStatus(for group: GroupedBriefing) -> ProjectCIStatus? { + guard state.projectBranches[group.projectId] == group.branch else { return nil } + return state.ciStatusByProject[group.projectId] + } + // MARK: - Filter menu @ViewBuilder @@ -301,6 +307,7 @@ struct BriefingListView: View { group: group, projectName: projectsById[group.projectId]?.name ?? "Unknown Project", activeJobCount: activeJobCountByProject[group.projectId] ?? 0, + ciStatus: ciStatus(for: group), isSelected: selectedGroup == group.key, namespace: glassNamespace ) @@ -388,6 +395,11 @@ struct BriefingListView: View { } } + private func ciStatus(for group: GroupedBriefing) -> ProjectCIStatus? { + guard state.projectBranches[group.projectId] == group.branch else { return nil } + return state.ciStatusByProject[group.projectId] + } + // MARK: - Filter Menu @ViewBuilder @@ -476,6 +488,7 @@ private struct BriefingListCard: View { let group: GroupedBriefing let projectName: String let activeJobCount: Int + let ciStatus: ProjectCIStatus? let isSelected: Bool let namespace: Namespace.ID @@ -511,7 +524,7 @@ private struct BriefingListCard: View { .foregroundStyle(.secondary) // Metadata - FlowLayout(spacing: 8) { + BriefingFlowLayout(spacing: 8) { if threadCount > 0 { HStack(spacing: 4) { Image(systemName: "bubble.left.and.bubble.right") @@ -533,6 +546,10 @@ private struct BriefingListCard: View { .foregroundStyle(.green) } + if let ciStatus { + MobileCIStatusChip(status: ciStatus, compact: true) + } + HStack(spacing: 4) { Image(systemName: "clock") .font(.system(size: 9)) @@ -606,59 +623,13 @@ private struct BriefingListCardButtonStyle: ButtonStyle { } } -// MARK: - Flow Layout - -/// A layout that arranges views horizontally and wraps to the next line when needed. -private struct FlowLayout: Layout { - var spacing: CGFloat = 8 - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - let sizes = subviews.map { $0.sizeThatFits(.unspecified) } - return layout(sizes: sizes, containerWidth: proposal.width ?? .infinity).size - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - let sizes = subviews.map { $0.sizeThatFits(.unspecified) } - let offsets = layout(sizes: sizes, containerWidth: bounds.width).offsets - - for (index, subview) in subviews.enumerated() { - subview.place( - at: CGPoint(x: bounds.minX + offsets[index].x, y: bounds.minY + offsets[index].y), - proposal: ProposedViewSize(sizes[index]) - ) - } - } - - private func layout(sizes: [CGSize], containerWidth: CGFloat) -> (offsets: [CGPoint], size: CGSize) { - var offsets: [CGPoint] = [] - var currentX: CGFloat = 0 - var currentY: CGFloat = 0 - var lineHeight: CGFloat = 0 - var maxWidth: CGFloat = 0 - - for size in sizes { - if currentX + size.width > containerWidth && currentX > 0 { - currentX = 0 - currentY += lineHeight + spacing - lineHeight = 0 - } - - offsets.append(CGPoint(x: currentX, y: currentY)) - lineHeight = max(lineHeight, size.height) - currentX += size.width + spacing - maxWidth = max(maxWidth, currentX - spacing) - } - - return (offsets, CGSize(width: maxWidth, height: currentY + lineHeight)) - } -} - // MARK: - Briefing Card private struct BriefingCard: View { let group: GroupedBriefing let projectName: String let activeJobCount: Int + let ciStatus: ProjectCIStatus? let namespace: Namespace.ID private var threadCount: Int { group.threads.count } @@ -687,7 +658,7 @@ private struct BriefingCard: View { } Spacer(minLength: 0) - + Image(systemName: "chevron.right") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.tertiary) @@ -723,6 +694,10 @@ private struct BriefingCard: View { ActiveJobsChip(count: activeJobCount) } + if let ciStatus { + MobileCIStatusChip(status: ciStatus) + } + // Thread count chip if threadCount > 0 { HStack(spacing: 4) {