From 8a0683667968a5fc9ed63f1917f1d602a7b105f0 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:32:50 +0800 Subject: [PATCH 1/8] feat: add iOS autopilot support --- .../Protocol/Payload+Autopilot.swift | 437 +++++ .../Sources/RxCodeSync/Protocol/Payload.swift | 10 + .../Tests/RxCodeSyncTests/PayloadTests.swift | 78 + RxCode.xcodeproj/project.pbxproj | 28 + RxCode/App/AppState+Helpers.swift | 8 +- RxCode/App/AppState+Messaging.swift | 8 +- RxCode/App/AppState+MobileAutopilot.swift | 258 +++ RxCode/App/AppState+MobileSync.swift | 14 + RxCode/App/AppState+Stream.swift | 14 +- .../MobileSyncService+EventDispatch.swift | 8 + RxCode/Services/MobileSyncService.swift | 1 + RxCodeMobile/Resources/Localizable.xcstrings | 1598 +++++++++++++++-- RxCodeMobile/RxCodeMobile.entitlements | 2 + .../State/MobileAppState+Autopilot.swift | 367 ++++ .../State/MobileAppState+Inbound.swift | 5 + RxCodeMobile/State/MobileAppState+Sync.swift | 6 + RxCodeMobile/State/MobileAppState.swift | 13 + .../State/MobileSecretsKeyVault.swift | 144 ++ .../Autopilot/MobileAutomationView.swift | 129 ++ .../MobileAutopilotLoadingOverlay.swift | 42 + .../Autopilot/MobileAutopilotRepoPicker.swift | 134 ++ .../Views/Autopilot/MobileAutopilotView.swift | 164 ++ .../Views/Autopilot/MobileCIUpdatesView.swift | 275 +++ .../Views/Autopilot/MobileDocsView.swift | 369 ++++ .../Views/Autopilot/MobileReleaseView.swift | 319 ++++ .../Views/Autopilot/MobileRepoSetupView.swift | 294 +++ .../Views/Autopilot/MobileSecretsView.swift | 423 +++++ RxCodeMobile/Views/MobileSettingsView.swift | 7 +- 28 files changed, 5047 insertions(+), 108 deletions(-) create mode 100644 Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift create mode 100644 RxCode/App/AppState+MobileAutopilot.swift create mode 100644 RxCodeMobile/State/MobileAppState+Autopilot.swift create mode 100644 RxCodeMobile/State/MobileSecretsKeyVault.swift create mode 100644 RxCodeMobile/Views/Autopilot/MobileAutomationView.swift create mode 100644 RxCodeMobile/Views/Autopilot/MobileAutopilotLoadingOverlay.swift create mode 100644 RxCodeMobile/Views/Autopilot/MobileAutopilotRepoPicker.swift create mode 100644 RxCodeMobile/Views/Autopilot/MobileAutopilotView.swift create mode 100644 RxCodeMobile/Views/Autopilot/MobileCIUpdatesView.swift create mode 100644 RxCodeMobile/Views/Autopilot/MobileDocsView.swift create mode 100644 RxCodeMobile/Views/Autopilot/MobileReleaseView.swift create mode 100644 RxCodeMobile/Views/Autopilot/MobileRepoSetupView.swift create mode 100644 RxCodeMobile/Views/Autopilot/MobileSecretsView.swift diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift new file mode 100644 index 00000000..9d4575ed --- /dev/null +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift @@ -0,0 +1,437 @@ +import Foundation +import RxCodeCore + +// MARK: - Autopilot remote management (mobile → desktop) +// +// The desktop is the source of truth for every autopilot feature: it holds the +// rxlab bearer token and the passkey-derived secrets key, and is the only side +// that talks to `autopilot.rxlab.app`. Mobile mirrors the desktop's Autopilot +// settings tab by sending a single generic request/result pair over the +// E2E-encrypted relay channel; the desktop performs the action with its +// existing services and replies. +// +// One generic pair (`autopilotRequest` / `autopilotResult`) carries a `domain` +// + `operation` discriminator and a JSON `body` (a `RxCodeCore` DTO encoded to +// `Data`). Because `RxCodeCore` is linked on both sides, both encode/decode the +// same Codable model types — full type safety at the call sites with no +// per-operation `Payload` enum churn. + +/// Autopilot sub-feature a request targets. Used for logging/grouping; the +/// desktop dispatches on `AutopilotOp`. +public enum AutopilotDomain: String, Codable, Sendable { + case account + case automation + case repoSetup + case secrets + case ciUpdates + case docs + case release +} + +/// Every autopilot operation, globally unique so the desktop can switch on it +/// directly. Grouped by domain in declaration order. +public enum AutopilotOp: String, Codable, Sendable { + // Account + case accountStatus + + // Shared repo picker (accessible GitHub repos with installation ids, used + // by every "add repository" flow — mirrors the desktop add sheets which all + // source `secrets.listManagedRepositories`). + case listManagedRepos + + // Automation settings (schema-driven form) + case automationSchema + case automationValues + case automationSave + + // Repo setup templates + case repoSetupSchema + case repoSetupList + case repoSetupCreate + case repoSetupUpdate + case repoSetupDelete + + // Secrets — the desktop only relays opaque ciphertext; the phone derives the + // KEK from the iCloud-synced passkey (WebAuthn PRF) and does all + // encryption/decryption on-device, so plaintext never leaves the phone. + case secretsEnrollmentStatus + case secretsGetUserKey + case secretsListEnvironments + case secretsCreateEnvironment + case secretsDeleteEnvironment + case secretsListFiles + case secretsGetBundle + case secretsUpsertFile + case secretsDeleteFile + + // CI auto-update + case ciList + case ciAdd + case ciUpdateFrequency + case ciDelete + case ciHistory + case ciTrigger + case ciListPRs + case ciClosePR + + // Docs + case docsList + case docsAdd + case docsDelete + case docsListDocuments + case docsGetDocument + case docsUploadDocument + case docsDeleteDocument + case docsCreateUploadToken + case docsInstallGithubSecret + + // Release + case releaseList + case releaseAdd + case releaseDelete + case releaseListWorkflows + case releaseDispatch + case releaseInstallToken +} + +/// Mobile → desktop: a single autopilot operation. `body` is a JSON-encoded +/// request DTO (nil for parameterless operations). +public struct AutopilotRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let domain: String + public let operation: String + public let body: Data? + + public init(clientRequestID: UUID = UUID(), domain: AutopilotDomain, operation: AutopilotOp, body: Data? = nil) { + self.clientRequestID = clientRequestID + self.domain = domain.rawValue + self.operation = operation.rawValue + self.body = body + } +} + +/// Desktop → mobile: the result of an autopilot operation. `body` is a +/// JSON-encoded response DTO (nil for operations that return nothing, or on +/// failure). +public struct AutopilotResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let domain: String + public let operation: String + public let ok: Bool + public let errorMessage: String? + public let body: Data? + + public init( + clientRequestID: UUID, + domain: String, + operation: String, + ok: Bool, + errorMessage: String? = nil, + body: Data? = nil + ) { + self.clientRequestID = clientRequestID + self.domain = domain + self.operation = operation + self.ok = ok + self.errorMessage = errorMessage + self.body = body + } +} + +// MARK: - Account + +public struct AutopilotAccountStatus: Codable, Sendable { + public let isSignedIn: Bool + public let name: String? + public let email: String? + public let avatarURL: String? + + public init(isSignedIn: Bool, name: String? = nil, email: String? = nil, avatarURL: String? = nil) { + self.isSignedIn = isSignedIn + self.name = name + self.email = email + self.avatarURL = avatarURL + } +} + +// MARK: - Shared request bodies + +/// Search + cursor for the shared accessible-repos picker. +public struct AutopilotReposQuery: Codable, Sendable { + public let search: String? + public let cursor: String? + public init(search: String? = nil, cursor: String? = nil) { + self.search = search + self.cursor = cursor + } +} + +public struct AutopilotCursorQuery: Codable, Sendable { + public let cursor: String? + public init(cursor: String? = nil) { self.cursor = cursor } +} + +/// Generic single-id body (template id, watched-repo id, docs/release-repo id). +public struct AutopilotIDBody: Codable, Sendable { + public let id: String + public init(id: String) { self.id = id } +} + +/// Body addressing a docs/release repo by its internal id or `owner/repo`. +public struct AutopilotRepoIDBody: Codable, Sendable { + public let repoId: String + public init(repoId: String) { self.repoId = repoId } +} + +// MARK: - Repo setup bodies + +public struct AutopilotRepoSetupUpdateBody: Codable, Sendable { + public let id: String + public let input: RepoSetupTemplateInput + public init(id: String, input: RepoSetupTemplateInput) { + self.id = id + self.input = input + } +} + +// MARK: - Secrets bodies / results + +public struct AutopilotSecretsEnrollment: Codable, Sendable { + public let enrolled: Bool + public init(enrolled: Bool) { self.enrolled = enrolled } +} + +public struct AutopilotSecretsRepoBody: Codable, Sendable { + public let repo: String + public init(repo: String) { self.repo = repo } +} + +/// Create an environment from a key the phone already wrapped under its own +/// (passkey-derived) KEK. The desktop only forwards the body to the API; it +/// never derives a key or prompts for a passkey. +public struct AutopilotSecretsCreateEnvBody: Codable, Sendable { + public let repo: String + public let body: CreateEnvironmentBody + public init(repo: String, body: CreateEnvironmentBody) { + self.repo = repo + self.body = body + } +} + +public struct AutopilotSecretsEnvRefBody: Codable, Sendable { + public let repo: String + public let envId: String + public init(repo: String, envId: String) { + self.repo = repo + self.envId = envId + } +} + +/// Upsert a file the phone already encrypted under the environment DEK. The +/// desktop forwards the ciphertext body to the API verbatim. +public struct AutopilotSecretsUpsertFileBody: Codable, Sendable { + public let repo: String + public let envId: String + public let file: UpsertFileBody + public init(repo: String, envId: String, file: UpsertFileBody) { + self.repo = repo + self.envId = envId + self.file = file + } +} + +public struct AutopilotSecretsDeleteFileBody: Codable, Sendable { + public let repo: String + public let envId: String + public let fileId: String + public init(repo: String, envId: String, fileId: String) { + self.repo = repo + self.envId = envId + self.fileId = fileId + } +} + +/// One decrypted secret file. Produced on the phone after local decryption — it +/// is NOT a wire type and never crosses the relay. +public struct AutopilotSecretFilePlaintext: Sendable, Identifiable, Hashable { + public let filename: String + public let content: String + public var id: String { filename } + public init(filename: String, content: String) { + self.filename = filename + self.content = content + } +} + +// MARK: - CI auto-update bodies + +public struct AutopilotCIFrequencyBody: Codable, Sendable { + public let id: String + public let frequency: CIScanFrequency + public init(id: String, frequency: CIScanFrequency) { + self.id = id + self.frequency = frequency + } +} + +public struct AutopilotCIHistoryBody: Codable, Sendable { + public let id: String + public let limit: Int? + public init(id: String, limit: Int? = nil) { + self.id = id + self.limit = limit + } +} + +public struct AutopilotCIClosePRBody: Codable, Sendable { + public let id: String + public let prNumber: Int + public init(id: String, prNumber: Int) { + self.id = id + self.prNumber = prNumber + } +} + +// MARK: - Docs bodies + +public struct AutopilotDocsDocBody: Codable, Sendable { + public let repoId: String + public let docId: String + public init(repoId: String, docId: String) { + self.repoId = repoId + self.docId = docId + } +} + +public struct AutopilotDocsListDocumentsBody: Codable, Sendable { + public let repoId: String + public let cursor: String? + public init(repoId: String, cursor: String? = nil) { + self.repoId = repoId + self.cursor = cursor + } +} + +public struct AutopilotDocsUploadBody: Codable, Sendable { + public let repoId: String + public let docId: String + public let content: String + public let originalLink: String? + public init(repoId: String, docId: String, content: String, originalLink: String? = nil) { + self.repoId = repoId + self.docId = docId + self.content = content + self.originalLink = originalLink + } +} + +public struct AutopilotDocsCreateTokenBody: Codable, Sendable { + public let repoId: String + public let name: String? + public init(repoId: String, name: String? = nil) { + self.repoId = repoId + self.name = name + } +} + +// MARK: - Release bodies / results + +public struct AutopilotReleaseDispatchBody: Codable, Sendable { + public let repoId: String + public let request: ReleaseDispatchRequest + public init(repoId: String, request: ReleaseDispatchRequest) { + self.repoId = repoId + self.request = request + } +} + +public struct AutopilotReleaseTokenBody: Codable, Sendable { + public let repoId: String + public let value: String + public init(repoId: String, value: String) { + self.repoId = repoId + self.value = value + } +} + +/// Wire-friendly mirror of `ReleaseWorkflowInput` (which is `Decodable`-only and +/// so can't be re-encoded for the reply). +public struct MobileReleaseWorkflowInput: Codable, Sendable, Hashable, Identifiable { + public let name: String + public let type: String + public let description: String? + public let required: Bool + public let defaultString: String? + public let defaultBool: Bool? + public let options: [String]? + public var id: String { name } + + public init( + name: String, + type: String, + description: String? = nil, + required: Bool = false, + defaultString: String? = nil, + defaultBool: Bool? = nil, + options: [String]? = nil + ) { + self.name = name + self.type = type + self.description = description + self.required = required + self.defaultString = defaultString + self.defaultBool = defaultBool + self.options = options + } +} + +/// Wire-friendly mirror of `ReleaseWorkflow` (`Decodable`-only on the server DTO). +public struct MobileReleaseWorkflow: Codable, Sendable, Identifiable, Hashable { + public let id: String + public let workflowPath: String + public let workflowName: String? + public let isSelected: Bool + public let isReleaseWorkflow: Bool? + public let hasWorkflowDispatch: Bool + public let inputs: [MobileReleaseWorkflowInput]? + + public var displayName: String { + if let workflowName, !workflowName.isEmpty { return workflowName } + return workflowPath.split(separator: "/").last.map(String.init) ?? workflowPath + } + + public init( + id: String, + workflowPath: String, + workflowName: String? = nil, + isSelected: Bool, + isReleaseWorkflow: Bool? = nil, + hasWorkflowDispatch: Bool, + inputs: [MobileReleaseWorkflowInput]? = nil + ) { + self.id = id + self.workflowPath = workflowPath + self.workflowName = workflowName + self.isSelected = isSelected + self.isReleaseWorkflow = isReleaseWorkflow + self.hasWorkflowDispatch = hasWorkflowDispatch + self.inputs = inputs + } +} + +/// Array wrappers — keep result bodies as named objects for forward-compatible +/// decode and clearer logging than bare top-level JSON arrays. +public struct MobileReleaseWorkflowList: Codable, Sendable { + public let items: [MobileReleaseWorkflow] + public init(items: [MobileReleaseWorkflow]) { self.items = items } +} + +public struct CIUpdateRunHistoryList: Codable, Sendable { + public let items: [CIUpdateRunHistory] + public init(items: [CIUpdateRunHistory]) { self.items = items } +} + +public struct CIPullRequestList: Codable, Sendable { + public let items: [CIPullRequest] + public init(items: [CIPullRequest]) { self.items = items } +} diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index a1172780..3515ceed 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -64,6 +64,8 @@ public enum Payload: Sendable { case mcpConfigResult(MCPConfigResultPayload) case mcpMutationRequest(MCPMutationRequestPayload) case mcpMutationResult(MCPMutationResultPayload) + case autopilotRequest(AutopilotRequestPayload) + case autopilotResult(AutopilotResultPayload) case ping(PingPayload) case pong(PongPayload) case unknown(type: String) @@ -129,6 +131,8 @@ public extension Payload { case .mcpConfigResult: return "mcp_config_result" case .mcpMutationRequest: return "mcp_mutation_request" case .mcpMutationResult: return "mcp_mutation_result" + case .autopilotRequest: return "autopilot_request" + case .autopilotResult: return "autopilot_result" case .ping: return "ping" case .pong: return "pong" case .unknown(let type): return type @@ -748,6 +752,8 @@ extension Payload: Codable { case mcpConfigResult = "mcp_config_result" case mcpMutationRequest = "mcp_mutation_request" case mcpMutationResult = "mcp_mutation_result" + case autopilotRequest = "autopilot_request" + case autopilotResult = "autopilot_result" case ping case pong } @@ -817,6 +823,8 @@ extension Payload: Codable { case .mcpConfigResult: self = .mcpConfigResult(try container.decode(MCPConfigResultPayload.self, forKey: .data)) case .mcpMutationRequest: self = .mcpMutationRequest(try container.decode(MCPMutationRequestPayload.self, forKey: .data)) case .mcpMutationResult: self = .mcpMutationResult(try container.decode(MCPMutationResultPayload.self, forKey: .data)) + case .autopilotRequest: self = .autopilotRequest(try container.decode(AutopilotRequestPayload.self, forKey: .data)) + case .autopilotResult: self = .autopilotResult(try container.decode(AutopilotResultPayload.self, forKey: .data)) case .ping: self = .ping(try container.decode(PingPayload.self, forKey: .data)) case .pong: self = .pong(try container.decode(PongPayload.self, forKey: .data)) } @@ -882,6 +890,8 @@ extension Payload: Codable { case .mcpConfigResult(let p): try container.encode(TypeKey.mcpConfigResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .mcpMutationRequest(let p): try container.encode(TypeKey.mcpMutationRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .mcpMutationResult(let p): try container.encode(TypeKey.mcpMutationResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .autopilotRequest(let p): try container.encode(TypeKey.autopilotRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .autopilotResult(let p): try container.encode(TypeKey.autopilotResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .ping(let p): try container.encode(TypeKey.ping.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .pong(let p): try container.encode(TypeKey.pong.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .unknown(let type): try container.encode(type, forKey: .type) diff --git a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift index d67c3158..c064343b 100644 --- a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift +++ b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift @@ -415,4 +415,82 @@ struct PayloadTests { #expect(createResult.project?.id == projectID) #expect(createResult.project?.name == "RxCode") } + + @Test("autopilot request payload round trips with an encoded body") + func autopilotRequestRoundTrips() throws { + let requestID = UUID(uuidString: "AAAAAAAA-1111-2222-3333-444444444444")! + let innerBody = try JSONEncoder().encode(AutopilotSecretsEnvRefBody(repo: "octocat/hello", envId: "env-123")) + let request = Payload.autopilotRequest( + AutopilotRequestPayload( + clientRequestID: requestID, + domain: .secrets, + operation: .secretsGetBundle, + body: innerBody + ) + ) + + let data = try JSONEncoder().encode(request) + let decoded = try JSONDecoder().decode(Payload.self, from: data) + guard case .autopilotRequest(let req) = decoded else { + Issue.record("Expected autopilot request") + return + } + #expect(req.clientRequestID == requestID) + #expect(req.domain == AutopilotDomain.secrets.rawValue) + #expect(req.operation == AutopilotOp.secretsGetBundle.rawValue) + let innerDecoded = try JSONDecoder().decode(AutopilotSecretsEnvRefBody.self, from: #require(req.body)) + #expect(innerDecoded.repo == "octocat/hello") + #expect(innerDecoded.envId == "env-123") + } + + @Test("autopilot result payload round trips and preserves failure detail") + func autopilotResultRoundTrips() throws { + let requestID = UUID(uuidString: "BBBBBBBB-5555-6666-7777-888888888888")! + let workflows = MobileReleaseWorkflowList(items: [ + MobileReleaseWorkflow( + id: "wf-1", + workflowPath: ".github/workflows/release.yml", + workflowName: "Release", + isSelected: true, + isReleaseWorkflow: true, + hasWorkflowDispatch: true, + inputs: nil + ) + ]) + let okResult = Payload.autopilotResult( + AutopilotResultPayload( + clientRequestID: requestID, + domain: AutopilotDomain.release.rawValue, + operation: AutopilotOp.releaseListWorkflows.rawValue, + ok: true, + body: try JSONEncoder().encode(workflows) + ) + ) + let okData = try JSONEncoder().encode(okResult) + guard case .autopilotResult(let decodedOK) = try JSONDecoder().decode(Payload.self, from: okData) else { + Issue.record("Expected autopilot result") + return + } + #expect(decodedOK.ok) + let decodedWorkflows = try JSONDecoder().decode(MobileReleaseWorkflowList.self, from: #require(decodedOK.body)) + #expect(decodedWorkflows.items.first?.displayName == "Release") + + let failure = Payload.autopilotResult( + AutopilotResultPayload( + clientRequestID: requestID, + domain: AutopilotDomain.docs.rawValue, + operation: AutopilotOp.docsList.rawValue, + ok: false, + errorMessage: "Not signed in." + ) + ) + let failureData = try JSONEncoder().encode(failure) + guard case .autopilotResult(let decodedFailure) = try JSONDecoder().decode(Payload.self, from: failureData) else { + Issue.record("Expected autopilot result") + return + } + #expect(!decodedFailure.ok) + #expect(decodedFailure.errorMessage == "Not signed in.") + #expect(decodedFailure.body == nil) + } } diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 07bd07c8..5f9989de 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -42,11 +42,14 @@ DF462DCB2FC73FA8002D9562 /* RxAuthSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = DF462DCA2FC73FA8002D9562 /* RxAuthSwiftUI */; }; DF4652682FCD34B3002D9562 /* JSONSchemaForm in Frameworks */ = {isa = PBXBuildFile; productRef = DF4652672FCD34B3002D9562 /* JSONSchemaForm */; }; DF46526A2FCD34B3002D9562 /* JSONSchemaValidator in Frameworks */ = {isa = PBXBuildFile; productRef = DF4652692FCD34B3002D9562 /* JSONSchemaValidator */; }; + DF46683A2FCDB56B002D9562 /* JSONSchemaForm in Frameworks */ = {isa = PBXBuildFile; productRef = DF4668392FCDB56B002D9562 /* JSONSchemaForm */; }; + DF46683C2FCDB56B002D9562 /* JSONSchemaValidator in Frameworks */ = {isa = PBXBuildFile; productRef = DF46683B2FCDB56B002D9562 /* JSONSchemaValidator */; }; DFA0CCD12FB4CC01005991E1 /* PlanDecisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */; }; DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */; }; DFA0CCD42FB4CC01005991E1 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */; }; DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */; }; DFAA00012FCD34B3002D9562 /* JSONSchema in Frameworks */ = {isa = PBXBuildFile; productRef = DFAA00022FCD34B3002D9562 /* JSONSchema */; }; + DFAA01102FCD34B3002D9562 /* JSONSchema in Frameworks */ = {isa = PBXBuildFile; productRef = DFAA01112FCD34B3002D9562 /* JSONSchema */; }; E62000012FCB000100000001 /* MemoryIntentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62000002FCB000100000001 /* MemoryIntentTests.swift */; }; E6821AC12F7CEE7200829FC9 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = E6A001012F8A000100000001 /* SwiftTerm */; }; E6C001022F9B000100000001 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E6C001012F9B000100000001 /* Sparkle */; }; @@ -263,6 +266,9 @@ DF230B9F2FBC73BA008929A6 /* RxCodeChatKit in Frameworks */, FB0000070000000000000001 /* FirebaseAnalytics in Frameworks */, FB0000080000000000000001 /* FirebaseCrashlytics in Frameworks */, + DF46683C2FCDB56B002D9562 /* JSONSchemaValidator in Frameworks */, + DF46683A2FCDB56B002D9562 /* JSONSchemaForm in Frameworks */, + DFAA01102FCD34B3002D9562 /* JSONSchema in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -501,6 +507,9 @@ DF230BA22FBC73BA008929A6 /* RxCodeSync */, FB0000050000000000000001 /* FirebaseAnalytics */, FB0000060000000000000001 /* FirebaseCrashlytics */, + DF4668392FCDB56B002D9562 /* JSONSchemaForm */, + DF46683B2FCDB56B002D9562 /* JSONSchemaValidator */, + DFAA01112FCD34B3002D9562 /* JSONSchema */, ); productName = RxCodeMobile; productReference = DF230B4F2FBC7367008929A6 /* RxCodeMobile.app */; @@ -992,6 +1001,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = icon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = RxCodeMobile/RxCodeMobile.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = T7GYB573Y6; @@ -1012,6 +1022,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = app.rxlab.rxcodemobile; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -1029,6 +1040,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = icon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = RxCodeMobile/RxCodeMobile.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = T7GYB573Y6; @@ -1049,6 +1061,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = app.rxlab.rxcodemobile; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -1653,6 +1666,16 @@ package = DF4652662FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-jsonschema-form" */; productName = JSONSchemaValidator; }; + DF4668392FCDB56B002D9562 /* JSONSchemaForm */ = { + isa = XCSwiftPackageProductDependency; + package = DF4652662FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-jsonschema-form" */; + productName = JSONSchemaForm; + }; + DF46683B2FCDB56B002D9562 /* JSONSchemaValidator */ = { + isa = XCSwiftPackageProductDependency; + package = DF4652662FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-jsonschema-form" */; + productName = JSONSchemaValidator; + }; DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */ = { isa = XCSwiftPackageProductDependency; productName = RxCodeChatKit; @@ -1662,6 +1685,11 @@ package = DFAA00032FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-json-schema" */; productName = JSONSchema; }; + DFAA01112FCD34B3002D9562 /* JSONSchema */ = { + isa = XCSwiftPackageProductDependency; + package = DFAA00032FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-json-schema" */; + productName = JSONSchema; + }; E6A001012F8A000100000001 /* SwiftTerm */ = { isa = XCSwiftPackageProductDependency; package = E6A001002F8A000100000001 /* XCRemoteSwiftPackageReference "SwiftTerm" */; diff --git a/RxCode/App/AppState+Helpers.swift b/RxCode/App/AppState+Helpers.swift index 072162ac..a642e7f8 100644 --- a/RxCode/App/AppState+Helpers.swift +++ b/RxCode/App/AppState+Helpers.swift @@ -402,7 +402,9 @@ extension AppState { threadStore.clearQueue(sessionKey: key) if isStreaming(in: window) { - await cancelStreaming(in: window) + // Sending a queued message interrupts the current stream and starts + // a new turn — not a session stop, so skip the stop hooks. + await cancelStreaming(in: window, fireStopHooks: false) } // Restore the remaining items into memory + disk. @@ -443,7 +445,9 @@ extension AppState { threadStore.clearQueue(sessionKey: key) if isStreaming(in: window) { - await cancelStreaming(in: window) + // Sending the combined queue interrupts the current stream and + // starts a new turn — not a session stop, so skip the stop hooks. + await cancelStreaming(in: window, fireStopHooks: false) } let combinedText = snapshot diff --git a/RxCode/App/AppState+Messaging.swift b/RxCode/App/AppState+Messaging.swift index 95c49d61..64b902d1 100644 --- a/RxCode/App/AppState+Messaging.swift +++ b/RxCode/App/AppState+Messaging.swift @@ -18,7 +18,9 @@ extension AppState { guard !trimmed.isEmpty else { return } if isStreaming(in: window) { - await cancelStreaming(in: window) + // Interrupting to resend an edited message — a new turn starts + // immediately, so don't fire the session-stop hooks. + await cancelStreaming(in: window, fireStopHooks: false) } snapshot.removeSubrange((index + 1)...) @@ -221,7 +223,9 @@ extension AppState { } if isStreaming(in: window) { - await cancelStreaming(in: window) + // A new prompt interrupts the in-flight stream; the session isn't + // stopping (a new turn begins now), so skip the session-stop hooks. + await cancelStreaming(in: window, fireStopHooks: false) } let streamId = UUID() diff --git a/RxCode/App/AppState+MobileAutopilot.swift b/RxCode/App/AppState+MobileAutopilot.swift new file mode 100644 index 00000000..fd38c41b --- /dev/null +++ b/RxCode/App/AppState+MobileAutopilot.swift @@ -0,0 +1,258 @@ +import Foundation +import os +import RxAuthSwift +import RxCodeCore +import RxCodeSync +import SwiftUI + +/// Desktop-side handling of mobile autopilot requests. Mobile mirrors the +/// macOS Autopilot settings tab but never calls `autopilot.rxlab.app` itself — +/// every operation arrives here as a generic `AutopilotRequestPayload`, is +/// dispatched on its `AutopilotOp`, performed with the existing autopilot / +/// secrets / docs / release / CI services (the desktop holds the rxlab token +/// and the passkey-derived secrets key), and answered with an +/// `AutopilotResultPayload` echoing the same `clientRequestID`. +extension AppState { + + func handleMobileAutopilotRequest(_ request: AutopilotRequestPayload, fromHex: String) async { + guard let op = AutopilotOp(rawValue: request.operation) else { + await replyAutopilot(request, fromHex: fromHex, ok: false, + errorMessage: "Unknown autopilot operation \(request.operation).") + return + } + do { + let body = try await performAutopilotOperation(op, request: request) + await replyAutopilot(request, fromHex: fromHex, ok: true, body: body) + } catch { + logger.error("[MobileSync] autopilot op=\(request.operation, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") + await replyAutopilot(request, fromHex: fromHex, ok: false, errorMessage: error.localizedDescription) + } + } + + private func replyAutopilot( + _ request: AutopilotRequestPayload, + fromHex: String, + ok: Bool, + errorMessage: String? = nil, + body: Data? = nil + ) async { + let result = AutopilotResultPayload( + clientRequestID: request.clientRequestID, + domain: request.domain, + operation: request.operation, + ok: ok, + errorMessage: errorMessage, + body: body + ) + await MobileSyncService.shared.send(.autopilotResult(result), toHex: fromHex) + } + + /// Decodes the request body as `T`, throwing a descriptive error when the + /// body is missing for an operation that requires one. + private func decodeAutopilotBody(_ request: AutopilotRequestPayload, as type: T.Type) throws -> T { + guard let data = request.body else { + throw MobileRemoteConfigError.invalidRequest("Missing request body for \(request.operation).") + } + return try JSONDecoder().decode(T.self, from: data) + } + + /// Optional body variant — returns nil when absent or undecodable, used by + /// operations whose parameters are all optional (search/cursor queries). + private func optionalAutopilotBody(_ request: AutopilotRequestPayload, as type: T.Type) -> T? { + guard let data = request.body else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + private func performAutopilotOperation(_ op: AutopilotOp, request: AutopilotRequestPayload) async throws -> Data? { + let encoder = JSONEncoder() + switch op { + + // MARK: Account + case .accountStatus: + let status = AutopilotAccountStatus( + isSignedIn: isSignedIn, + name: rxUser?.name, + email: rxUser?.email, + avatarURL: rxUser?.image + ) + return try encoder.encode(status) + + // MARK: Shared repo picker + case .listManagedRepos: + let q = optionalAutopilotBody(request, as: AutopilotReposQuery.self) + let page = try await secrets.listManagedRepositories(search: q?.search, cursor: q?.cursor) + return try encoder.encode(page) + + // MARK: Automation settings + case .automationSchema: + return try encoder.encode(try await automationSchema()) + case .automationValues: + return try encoder.encode(try await automationValues()) + case .automationSave: + let values = try decodeAutopilotBody(request, as: PreferencesValues.self) + return try encoder.encode(try await saveAutomationValues(values.values)) + + // MARK: Repo setup templates + case .repoSetupSchema: + return try encoder.encode(try await repoSetupSchema()) + case .repoSetupList: + return try encoder.encode(RepoSetupTemplateList(items: try await repoSetupTemplates())) + case .repoSetupCreate: + let input = try decodeAutopilotBody(request, as: RepoSetupTemplateInput.self) + return try encoder.encode(try await createRepoSetupTemplate(input)) + case .repoSetupUpdate: + let body = try decodeAutopilotBody(request, as: AutopilotRepoSetupUpdateBody.self) + return try encoder.encode(try await updateRepoSetupTemplate(id: body.id, body.input)) + case .repoSetupDelete: + let body = try decodeAutopilotBody(request, as: AutopilotIDBody.self) + try await deleteRepoSetupTemplate(id: body.id) + return nil + + // MARK: Secrets — the desktop only relays opaque ciphertext; the phone + // derives the KEK from the synced passkey and does all crypto on-device. + case .secretsEnrollmentStatus: + let key = try await secrets.getUserKey() + return try encoder.encode(AutopilotSecretsEnrollment(enrolled: key.enrolled)) + case .secretsGetUserKey: + return try encoder.encode(try await secrets.getUserKey()) + case .secretsListEnvironments: + let body = try decodeAutopilotBody(request, as: AutopilotSecretsRepoBody.self) + return try encoder.encode(try await secrets.listEnvironments(repo: body.repo)) + case .secretsCreateEnvironment: + let body = try decodeAutopilotBody(request, as: AutopilotSecretsCreateEnvBody.self) + _ = try await secrets.createEnvironment(repo: body.repo, body: body.body) + return nil + case .secretsDeleteEnvironment: + let body = try decodeAutopilotBody(request, as: AutopilotSecretsEnvRefBody.self) + try await secrets.deleteEnvironment(repo: body.repo, envId: body.envId) + return nil + case .secretsListFiles: + let body = try decodeAutopilotBody(request, as: AutopilotSecretsEnvRefBody.self) + return try encoder.encode(try await secrets.listFiles(repo: body.repo, envId: body.envId)) + case .secretsGetBundle: + let body = try decodeAutopilotBody(request, as: AutopilotSecretsEnvRefBody.self) + return try encoder.encode(try await secrets.bundle(repo: body.repo, env: body.envId)) + case .secretsUpsertFile: + let body = try decodeAutopilotBody(request, as: AutopilotSecretsUpsertFileBody.self) + _ = try await secrets.upsertFile(repo: body.repo, envId: body.envId, body: body.file) + return nil + case .secretsDeleteFile: + let body = try decodeAutopilotBody(request, as: AutopilotSecretsDeleteFileBody.self) + try await secrets.deleteFile(repo: body.repo, envId: body.envId, fileId: body.fileId) + return nil + + // MARK: CI auto-update + case .ciList: + let q = optionalAutopilotBody(request, as: AutopilotCursorQuery.self) + return try encoder.encode(try await ciUpdates.listWatchedRepositories(cursor: q?.cursor)) + case .ciAdd: + let body = try decodeAutopilotBody(request, as: AddWatchedRepoBody.self) + _ = try await ciUpdates.addWatchedRepository(body) + return nil + case .ciUpdateFrequency: + let body = try decodeAutopilotBody(request, as: AutopilotCIFrequencyBody.self) + try await ciUpdates.updateScanFrequency(id: body.id, frequency: body.frequency) + return nil + case .ciDelete: + let body = try decodeAutopilotBody(request, as: AutopilotIDBody.self) + try await ciUpdates.deleteWatchedRepository(id: body.id) + return nil + case .ciHistory: + let body = try decodeAutopilotBody(request, as: AutopilotCIHistoryBody.self) + let items = try await ciUpdates.history(id: body.id, limit: body.limit) + return try encoder.encode(CIUpdateRunHistoryList(items: items)) + case .ciTrigger: + let body = try decodeAutopilotBody(request, as: AutopilotIDBody.self) + return try encoder.encode(try await ciUpdates.trigger(id: body.id)) + case .ciListPRs: + let body = try decodeAutopilotBody(request, as: AutopilotIDBody.self) + let items = try await ciUpdates.pullRequests(id: body.id) + return try encoder.encode(CIPullRequestList(items: items)) + case .ciClosePR: + let body = try decodeAutopilotBody(request, as: AutopilotCIClosePRBody.self) + try await ciUpdates.closePullRequest(id: body.id, prNumber: body.prNumber) + return nil + + // MARK: Docs + case .docsList: + let q = optionalAutopilotBody(request, as: AutopilotReposQuery.self) + return try encoder.encode(try await docs.listRepositories(search: q?.search, cursor: q?.cursor)) + case .docsAdd: + let body = try decodeAutopilotBody(request, as: AddDocsRepoBody.self) + _ = try await docs.addRepository(body) + return nil + case .docsDelete: + let body = try decodeAutopilotBody(request, as: AutopilotIDBody.self) + try await docs.deleteRepository(id: body.id) + return nil + case .docsListDocuments: + let body = try decodeAutopilotBody(request, as: AutopilotDocsListDocumentsBody.self) + return try encoder.encode(try await docs.listDocuments(repoId: body.repoId, cursor: body.cursor)) + case .docsGetDocument: + let body = try decodeAutopilotBody(request, as: AutopilotDocsDocBody.self) + return try encoder.encode(try await docs.getDocument(repoId: body.repoId, docId: body.docId)) + case .docsUploadDocument: + let body = try decodeAutopilotBody(request, as: AutopilotDocsUploadBody.self) + let result = try await docs.uploadDocuments( + repoId: body.repoId, + documents: [DocsDocumentUpload(docId: body.docId, content: body.content, originalLink: body.originalLink)] + ) + return try encoder.encode(result) + case .docsDeleteDocument: + let body = try decodeAutopilotBody(request, as: AutopilotDocsDocBody.self) + try await docs.deleteDocument(repoId: body.repoId, docId: body.docId) + return nil + case .docsCreateUploadToken: + let body = try decodeAutopilotBody(request, as: AutopilotDocsCreateTokenBody.self) + return try encoder.encode(try await docs.createUploadToken(repoId: body.repoId, name: body.name)) + case .docsInstallGithubSecret: + let body = try decodeAutopilotBody(request, as: AutopilotRepoIDBody.self) + return try encoder.encode(try await docs.installGithubSecret(repoId: body.repoId)) + + // MARK: Release + case .releaseList: + let q = optionalAutopilotBody(request, as: AutopilotCursorQuery.self) + return try encoder.encode(try await release.listRepositories(cursor: q?.cursor)) + case .releaseAdd: + let body = try decodeAutopilotBody(request, as: AddReleaseRepoBody.self) + _ = try await release.addRepository(body) + return nil + case .releaseDelete: + let body = try decodeAutopilotBody(request, as: AutopilotIDBody.self) + try await release.deleteRepository(id: body.id) + return nil + case .releaseListWorkflows: + let body = try decodeAutopilotBody(request, as: AutopilotRepoIDBody.self) + let workflows = try await release.listWorkflows(repoId: body.repoId) + let mapped = workflows.map { workflow in + MobileReleaseWorkflow( + id: workflow.id, + workflowPath: workflow.workflowPath, + workflowName: workflow.workflowName, + isSelected: workflow.isSelected, + isReleaseWorkflow: workflow.isReleaseWorkflow, + hasWorkflowDispatch: workflow.hasWorkflowDispatch, + inputs: workflow.inputs?.map { input in + MobileReleaseWorkflowInput( + name: input.name, + type: input.type, + description: input.description, + required: input.required, + defaultString: input.defaultString, + defaultBool: input.defaultBool, + options: input.options + ) + } + ) + } + return try encoder.encode(MobileReleaseWorkflowList(items: mapped)) + case .releaseDispatch: + let body = try decodeAutopilotBody(request, as: AutopilotReleaseDispatchBody.self) + return try encoder.encode(try await release.dispatch(repoId: body.repoId, body: body.request)) + case .releaseInstallToken: + let body = try decodeAutopilotBody(request, as: AutopilotReleaseTokenBody.self) + return try encoder.encode(try await release.installReleaseToken(repoId: body.repoId, value: body.value)) + } + } +} diff --git a/RxCode/App/AppState+MobileSync.swift b/RxCode/App/AppState+MobileSync.swift index 26d4be3d..229b0e86 100644 --- a/RxCode/App/AppState+MobileSync.swift +++ b/RxCode/App/AppState+MobileSync.swift @@ -384,6 +384,20 @@ extension AppState { } mobileSyncObservers.append(mcpMutationObserver) + let autopilotObserver = center.addObserver( + forName: .mobileSyncAutopilotRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? AutopilotRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileAutopilotRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(autopilotObserver) + observeMobileSnapshotInputs() } diff --git a/RxCode/App/AppState+Stream.swift b/RxCode/App/AppState+Stream.swift index 4fc7269c..4fa5aa26 100644 --- a/RxCode/App/AppState+Stream.swift +++ b/RxCode/App/AppState+Stream.swift @@ -412,7 +412,13 @@ extension AppState { stopFlushTimer(for: key) } - func cancelStreaming(in window: WindowState) async { + /// - Parameter fireStopHooks: When `true` (explicit Stop button), the + /// session-stop hooks fire because streaming genuinely stopped. When + /// `false` (the user sent a *new* message that interrupts the current + /// stream), the session isn't stopping — a new turn starts immediately — + /// so the stop hooks are skipped. The cancelled stream is still marked + /// handled either way so a trailing `.result` can't re-fire them. + func cancelStreaming(in window: WindowState, fireStopHooks: Bool = true) async { let key = window.currentSessionId ?? window.newSessionKey let streamToCancel = sessionStates[key]?.activeStreamId sessionStates[key]?.streamTask?.cancel() @@ -474,8 +480,12 @@ extension AppState { // Before-stop runs ahead of the save so its cards persist; after-stop // runs post-save and is shown only. let alreadyHandled = streamToCancel.map { stopHooksHandledStreamIds.contains($0) } ?? true + // Mark the stream handled regardless of `fireStopHooks` so a + // trailing `.result` event can't re-fire the stop hooks later. if let streamToCancel, !alreadyHandled { stopHooksHandledStreamIds.insert(streamToCancel) + } + if fireStopHooks, streamToCancel != nil, !alreadyHandled { await hookManager.dispatchBeforeSessionEnd(SessionEndPayload( project: project, sessionKey: key, @@ -489,7 +499,7 @@ extension AppState { if !messages.isEmpty { await saveSession(sessionId: key, projectId: project.id, messages: messages) } - if streamToCancel != nil, !alreadyHandled { + if fireStopHooks, streamToCancel != nil, !alreadyHandled { await hookManager.dispatchAfterSessionEnd(SessionEndPayload( project: project, sessionKey: key, diff --git a/RxCode/Services/MobileSyncService+EventDispatch.swift b/RxCode/Services/MobileSyncService+EventDispatch.swift index c2659051..74be21f2 100644 --- a/RxCode/Services/MobileSyncService+EventDispatch.swift +++ b/RxCode/Services/MobileSyncService+EventDispatch.swift @@ -423,6 +423,14 @@ extension MobileSyncService { object: nil, userInfo: ["from": inbound.fromHex, "payload": req] ) + case .autopilotRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "autopilot_request") else { return } + logger.info("[MobileSync] autopilot requested domain=\(req.domain, privacy: .public) op=\(req.operation, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncAutopilotRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) case .ping: guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "ping") else { return } Task { try? await activeClient.send(.pong(PongPayload()), toHex: inbound.fromHex) } diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index 44ba2250..4205e65c 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -902,4 +902,5 @@ extension Notification.Name { static let mobileSyncACPMutationRequested = Notification.Name("mobileSync.acpMutationRequested") static let mobileSyncMCPConfigRequested = Notification.Name("mobileSync.mcpConfigRequested") static let mobileSyncMCPMutationRequested = Notification.Name("mobileSync.mcpMutationRequested") + static let mobileSyncAutopilotRequested = Notification.Name("mobileSync.autopilotRequested") } diff --git a/RxCodeMobile/Resources/Localizable.xcstrings b/RxCodeMobile/Resources/Localizable.xcstrings index 3fdd9fb8..92ad3c15 100644 --- a/RxCodeMobile/Resources/Localizable.xcstrings +++ b/RxCodeMobile/Resources/Localizable.xcstrings @@ -6,6 +6,12 @@ }, "-- --port 3000" : { + }, + ".env" : { + + }, + "#%lld" : { + }, "%@ · %lld model%@" : { "localizations" : { @@ -34,22 +40,93 @@ } }, "%@. Tap to answer." : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@。点按以回答。" + } + } + } }, "%@. Tap to review." : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@。点按以查看。" + } + } + } }, "%lld" : { }, "%lld active" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 个活跃" + } + } + } }, "%lld active jobs" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 个活跃任务" + } + } + } + }, + "%lld document(s)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 个文档" + } + } + } + }, + "%lld environment(s), %lld file(s)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld environment(s), %2$lld file(s)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 个环境,%lld 个文件" + } + } + } + }, + "%lld outdated action(s)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 个过期 Action" + } + } + } }, "%lld queued" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 个排队" + } + } + } }, "%lld queued messages. Tap to view all." : { "localizations" : { @@ -150,6 +227,16 @@ } } }, + "Account" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "账户" + } + } + } + }, "Action" : { "localizations" : { "en" : { @@ -247,7 +334,14 @@ } }, "Add a QR code" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加二维码" + } + } + } }, "Add Argument" : { "localizations" : { @@ -265,6 +359,16 @@ } } }, + "Add Document" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加文档" + } + } + } + }, "Add Git Source" : { "localizations" : { "en" : { @@ -603,6 +707,19 @@ } } } + }, + "Automation" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动化" + } + } + } + }, + "Autopilot" : { + }, "Available in Registry" : { "localizations" : { @@ -652,6 +769,16 @@ } } }, + "Branch" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分支" + } + } + } + }, "Branch briefings collect recent thread summaries from your Mac into a quick mobile status view." : { "localizations" : { "en" : { @@ -748,6 +875,16 @@ } } }, + "Branch Ruleset (optional)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分支规则集(可选)" + } + } + } + }, "Branches" : { "localizations" : { "en" : { @@ -813,7 +950,14 @@ } }, "Browser Error" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "浏览器错误" + } + } + } }, "Cancel" : { "localizations" : { @@ -863,6 +1007,16 @@ } } }, + "Checking account…" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在检查账户…" + } + } + } + }, "Choose from Photos" : { "localizations" : { "en" : { @@ -911,6 +1065,19 @@ } } }, + "CI" : { + + }, + "CI Auto-Update" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 自动更新" + } + } + } + }, "Claude Code Usage" : { "localizations" : { "en" : { @@ -960,7 +1127,34 @@ } }, "Close Browser" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭浏览器" + } + } + } + }, + "Close PR" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭 PR" + } + } + } + }, + "Close pull request?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭拉取请求?" + } + } + } }, "Codex Usage" : { "localizations" : { @@ -1090,6 +1284,16 @@ } } }, + "Connect an online Mac to use Autopilot." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请连接一台在线的 Mac 以使用 Autopilot。" + } + } + } + }, "Connect to" : { "localizations" : { "en" : { @@ -1106,6 +1310,16 @@ } } }, + "Connect to an online Mac to manage Autopilot." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请连接一台在线的 Mac 以管理 Autopilot。" + } + } + } + }, "Connect to your Mac and try again." : { "localizations" : { "en" : { @@ -1218,6 +1432,16 @@ } } }, + "Control what autopilot does automatically and the templates applied to new repositories." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制 Autopilot 自动执行的操作,以及应用于新仓库的模板。" + } + } + } + }, "Couldn't connect to the relay from the QR code. Check the relay address and try again." : { "localizations" : { "en" : { @@ -1251,7 +1475,14 @@ } }, "Couldn't Load File" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法加载文件" + } + } + } }, "Couldn't load the selected image." : { "localizations" : { @@ -1381,6 +1612,16 @@ } } }, + "Create Release" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建发布" + } + } + } + }, "Created from %@" : { "localizations" : { "en" : { @@ -1445,6 +1686,16 @@ } } }, + "Current project" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前项目" + } + } + } + }, "Custom Scheme" : { "localizations" : { "en" : { @@ -1462,7 +1713,14 @@ } }, "Custom Script" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义脚本" + } + } + } }, "Custom Sources" : { "localizations" : { @@ -1528,59 +1786,119 @@ } } }, - "Delete" : { + "Default" : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Delete" - } - }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "删除" + "value" : "默认" } } } }, - "Delete this thread?" : { + "Default for new repositories" : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Delete this thread?" - } - }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "删除此对话?" + "value" : "新仓库的默认设置" } } } }, - "Deny" : { + "Delete" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Deny" + "value" : "Delete" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "拒绝" + "value" : "删除" } } } }, - "Describe a task, paste a snippet, or ask a question." : { + "Delete document?" : { "localizations" : { - "en" : { + "zh-Hans" : { "stringUnit" : { - "state" : "new", + "state" : "translated", + "value" : "删除文档?" + } + } + } + }, + "Delete environment?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除环境?" + } + } + } + }, + "Delete file?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除文件?" + } + } + } + }, + "Delete template?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除模板?" + } + } + } + }, + "Delete this thread?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete this thread?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除此对话?" + } + } + } + }, + "Deny" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Deny" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拒绝" + } + } + } + }, + "Describe a task, paste a snippet, or ask a question." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", "value" : "Describe a task, paste a snippet, or ask a question." } }, @@ -1607,6 +1925,9 @@ } } } + }, + "design/overview" : { + }, "Desktop Behavior" : { "localizations" : { @@ -1673,13 +1994,34 @@ } }, "Detected · Make" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已检测 · Make" + } + } + } }, "Detected · Scripts" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已检测 · Scripts" + } + } + } }, "Detected · Xcode" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已检测 · Xcode" + } + } + } }, "Device name" : { "localizations" : { @@ -1713,6 +2055,16 @@ } } }, + "Disabled" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已禁用" + } + } + } + }, "Disconnected" : { "localizations" : { "en" : { @@ -1729,6 +2081,46 @@ } } }, + "Dispatch" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "触发" + } + } + } + }, + "Document ID (slug)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文档 ID(slug)" + } + } + } + }, + "Documentation" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文档" + } + } + } + }, + "Documents" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文档" + } + } + } + }, "Done" : { "localizations" : { "en" : { @@ -1793,6 +2185,16 @@ } } }, + "Edit Template" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑模板" + } + } + } + }, "Empty Folder" : { "localizations" : { "en" : { @@ -1825,6 +2227,26 @@ } } }, + "Enabled" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已启用" + } + } + } + }, + "Encryption not set up" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未设置加密" + } + } + } + }, "Endpoint" : { "localizations" : { "en" : { @@ -1841,6 +2263,16 @@ } } }, + "Enroll with your passkey on your Mac to manage secrets." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在你的 Mac 上使用通行密钥注册以管理密钥。" + } + } + } + }, "Enter a GitHub repository URL." : { "localizations" : { "en" : { @@ -1873,6 +2305,16 @@ } } }, + "Error" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "错误" + } + } + } + }, "Extra High" : { "localizations" : { "en" : { @@ -2018,7 +2460,14 @@ } }, "Failed to request file: %@" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请求文件失败:%@" + } + } + } }, "Failed to request MCP servers: %@" : { "localizations" : { @@ -2165,7 +2614,14 @@ } }, "Favorites" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收藏" + } + } + } }, "File paths" : { "localizations" : { @@ -2184,7 +2640,34 @@ } }, "File preview truncated." : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件预览已截断。" + } + } + } + }, + "Filename" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件名" + } + } + } + }, + "Files" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件" + } + } + } }, "Filter Skills" : { "localizations" : { @@ -2249,6 +2732,29 @@ } } } + }, + "Form couldn't be rendered — edit raw JSON." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法渲染表单 — 请编辑原始 JSON。" + } + } + } + }, + "Frequency" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "频率" + } + } + } + }, + "ghp_… or github_pat_…" : { + }, "Git Source" : { "localizations" : { @@ -2299,7 +2805,14 @@ } }, "Go" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "前往" + } + } + } }, "Go to **Settings → Mobile** and tap *Pair new device*." : { "localizations" : { @@ -2317,6 +2830,16 @@ } } }, + "Hide values" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏值" + } + } + } + }, "High" : { "localizations" : { "en" : { @@ -2335,6 +2858,9 @@ }, "HTTP" : { + }, + "https://…" : { + }, "https://github.com/owner/repo" : { @@ -2356,10 +2882,24 @@ } }, "Initialize Git" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "初始化 Git" + } + } + } }, "Initializing…" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在初始化…" + } + } + } }, "Input" : { "localizations" : { @@ -2377,6 +2917,16 @@ } } }, + "Inputs" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输入" + } + } + } + }, "Install" : { "localizations" : { "en" : { @@ -2393,34 +2943,75 @@ } } }, - "Install skills and agents, and configure MCP servers on the active Mac." : { + "Install CI upload token" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装 CI 上传令牌" + } + } + } + }, + "Install RELEASE_TOKEN" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装 RELEASE_TOKEN" + } + } + } + }, + "Install skills and agents, and configure MCP servers on the active Mac." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Install skills and agents, and configure MCP servers on the active Mac." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在当前 Mac 上安装技能和代理,并配置 MCP 服务器。" + } + } + } + }, + "Install skills and agents, configure MCP servers, and manage Autopilot on the active Mac." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在当前 Mac 上安装技能和智能体、配置 MCP 服务器,并管理 Autopilot。" + } + } + } + }, + "Installed" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Install skills and agents, and configure MCP servers on the active Mac." + "value" : "Installed" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "在当前 Mac 上安装技能和代理,并配置 MCP 服务器。" + "value" : "已安装" } } } }, - "Installed" : { + "Installed as the repo's GitHub Actions secret so the release workflow can publish." : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Installed" - } - }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已安装" + "value" : "已作为仓库的 GitHub Actions 密钥安装,以便发布工作流可以发布。" } } } @@ -2473,6 +3064,16 @@ } } }, + "Latest: %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最新:%@" + } + } + } + }, "Live" : { "localizations" : { "en" : { @@ -2489,6 +3090,16 @@ } } }, + "Load more" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "加载更多" + } + } + } + }, "Loading changes…" : { "localizations" : { "en" : { @@ -2522,7 +3133,14 @@ } }, "Loading messages" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在加载消息" + } + } + } }, "Loading skills…" : { "localizations" : { @@ -2541,7 +3159,14 @@ } }, "Loading thread" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在加载会话" + } + } + } }, "Loading…" : { "localizations" : { @@ -2623,6 +3248,16 @@ } } }, + "Mac offline" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mac 离线" + } + } + } + }, "main" : { }, @@ -2705,6 +3340,19 @@ } } } + }, + "Manage end-to-end encrypted secrets, CI auto-update scans, indexed docs, and release workflows." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理端到端加密的密钥、CI 自动更新扫描、已索引的文档和发布工作流。" + } + } + } + }, + "Markdown" : { + }, "Marketplaces" : { "localizations" : { @@ -2802,6 +3450,16 @@ } } }, + "Merge Settings" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "合并设置" + } + } + } + }, "Message…" : { "localizations" : { "en" : { @@ -2819,7 +3477,24 @@ } }, "Messages you queue while a response is streaming appear here." : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在响应流式传输期间排队的消息会显示在这里。" + } + } + } + }, + "Mints a DOCS_UPLOAD_TOKEN and installs it as the repo's GitHub Actions secret so CI can publish docs automatically." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "生成 DOCS_UPLOAD_TOKEN 并将其作为仓库的 GitHub Actions 密钥安装,以便 CI 可以自动发布文档。" + } + } + } }, "Model" : { "localizations" : { @@ -2853,6 +3528,16 @@ } } }, + "Name (e.g. production)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "名称(例如 production)" + } + } + } + }, "New" : { "localizations" : { "en" : { @@ -2869,6 +3554,26 @@ } } }, + "New environment" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建环境" + } + } + } + }, + "New Template" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建模板" + } + } + } + }, "New Thread" : { "localizations" : { "en" : { @@ -2886,7 +3591,14 @@ } }, "Next change" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一处更改" + } + } + } }, "No agents found." : { "localizations" : { @@ -3016,6 +3728,46 @@ } } }, + "No documentation repositories. Tap + to add one." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有文档仓库。点按 + 添加。" + } + } + } + }, + "No documents indexed yet." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未索引任何文档。" + } + } + } + }, + "No environments yet. Tap + to add one." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还没有环境。点按 + 添加。" + } + } + } + }, + "No files yet. Tap + to add one." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还没有文件。点按 + 添加。" + } + } + } + }, "No Folders" : { "localizations" : { "en" : { @@ -3064,6 +3816,16 @@ } } }, + "No open auto-update pull requests." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有打开的自动更新拉取请求。" + } + } + } + }, "No Projects" : { "localizations" : { "en" : { @@ -3097,7 +3859,44 @@ } }, "No Queued Messages" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有排队的消息" + } + } + } + }, + "No release repositories. Tap + to add one." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有发布仓库。点按 + 添加。" + } + } + } + }, + "No repositories available to add." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有可添加的仓库。" + } + } + } + }, + "No repositories available." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有可用的仓库。" + } + } + } }, "No Run Profiles" : { "localizations" : { @@ -3115,8 +3914,25 @@ } } }, + "No scans yet." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未进行扫描。" + } + } + } + }, "No Selection" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未选择" + } + } + } }, "No skills found." : { "localizations" : { @@ -3183,7 +3999,24 @@ } }, "No summary yet. A summary is generated once the thread finishes a turn on your Mac." : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂无摘要。当会话在你的 Mac 上完成一轮对话后会生成摘要。" + } + } + } + }, + "No templates yet. Tap + to create one." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还没有模板。点按 + 创建。" + } + } + } }, "No Threads" : { "localizations" : { @@ -3196,29 +4029,56 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "没有对话" + "value" : "没有对话" + } + } + } + }, + "No threads yet" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No threads yet" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚无对话" + } + } + } + }, + "No todos for this thread." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此会话没有待办事项。" + } + } + } + }, + "No watched repositories. Tap + to add one." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有监视的仓库。点按 + 添加。" } } } }, - "No threads yet" : { + "No workflows scanned yet." : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "No threads yet" - } - }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "尚无对话" + "value" : "尚未扫描到工作流。" } } } - }, - "No todos for this thread." : { - }, "Normal" : { "localizations" : { @@ -3237,7 +4097,24 @@ } }, "Not connected to your Mac." : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未连接到你的 Mac。" + } + } + } + }, + "Not signed in" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未登录" + } + } + } }, "Nothing to Show" : { "localizations" : { @@ -3319,6 +4196,16 @@ } } }, + "Open" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开" + } + } + } + }, "Open **RxCode** on your Mac." : { "localizations" : { "en" : { @@ -3368,10 +4255,24 @@ } }, "Open on GitHub" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在 GitHub 上打开" + } + } + } }, "Open Pull Request" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开拉取请求" + } + } + } }, "Opens todos and thread summary" : { "localizations" : { @@ -3390,16 +4291,37 @@ } }, "Optional xcodebuild destination" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "可选的 xcodebuild 目标" + } + } + } }, "Package" : { }, "Package Configuration" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "软件包配置" + } + } + } }, "Package Manager" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "包管理器" + } + } + } }, "Pair New Mac" : { "localizations" : { @@ -3497,6 +4419,36 @@ } } }, + "Passkey authentication failed: %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通行密钥验证失败:%@" + } + } + } + }, + "Passkey authentication was cancelled." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通行密钥验证已取消。" + } + } + } + }, + "Paste a GitHub ruleset JSON with name, target, enforcement, and rules." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "粘贴包含 name、target、enforcement 和 rules 的 GitHub 规则集 JSON。" + } + } + } + }, "Permission needed" : { "localizations" : { "en" : { @@ -3546,10 +4498,24 @@ } }, "Previous change" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上一处更改" + } + } + } }, "Privacy Report" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐私报告" + } + } + } }, "Profiles" : { "localizations" : { @@ -3584,10 +4550,24 @@ } }, "Project actions" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "项目操作" + } + } + } }, "Project root" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "项目根目录" + } + } + } }, "Projects" : { "localizations" : { @@ -3621,6 +4601,16 @@ } } }, + "Pull Requests" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拉取请求" + } + } + } + }, "Queue a message..." : { "localizations" : { "en" : { @@ -3638,7 +4628,14 @@ } }, "Queued Messages" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "排队的消息" + } + } + } }, "Reconnecting in %llds" : { "localizations" : { @@ -3672,8 +4669,38 @@ } } }, - "Reload" : { + "Release" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发布" + } + } + } + }, + "RELEASE_TOKEN" : { + }, + "Releases" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发布" + } + } + } + }, + "Reload" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新加载" + } + } + } }, "Remove" : { "localizations" : { @@ -3708,10 +4735,24 @@ } }, "Remove agent?" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除智能体?" + } + } + } }, "Remove MCP server?" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除 MCP 服务器?" + } + } + } }, "Remove pairing?" : { "localizations" : { @@ -3729,6 +4770,16 @@ } } }, + "Remove repository?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除仓库?" + } + } + } + }, "Rename" : { "localizations" : { "en" : { @@ -3762,7 +4813,34 @@ } }, "Rename Thread" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重命名会话" + } + } + } + }, + "Repo Setup" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仓库设置" + } + } + } + }, + "Repositories" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仓库" + } + } + } }, "Request timed out. Check your Mac and try again." : { "localizations" : { @@ -3951,7 +5029,14 @@ } }, "RxCode keeps this browser session separate from your regular Safari data." : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "RxCode 会将此浏览器会话与你常用的 Safari 数据分开保存。" + } + } + } }, "rxcode/feature-name" : { @@ -3972,6 +5057,26 @@ } } }, + "Scan" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "扫描" + } + } + } + }, + "Scan History" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "扫描历史" + } + } + } + }, "Scan QR" : { "localizations" : { "en" : { @@ -4068,6 +5173,16 @@ } } }, + "Scanning…" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在扫描…" + } + } + } + }, "Scheme" : { "localizations" : { "en" : { @@ -4085,7 +5200,14 @@ } }, "Script" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "脚本" + } + } + } }, "Scroll to bottom" : { "localizations" : { @@ -4104,7 +5226,14 @@ } }, "Search or enter website name" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索或输入网站名称" + } + } + } }, "Search projects and threads" : { "localizations" : { @@ -4154,6 +5283,16 @@ } } }, + "Secrets" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "密钥" + } + } + } + }, "Securely link this device to RxCode in seconds." : { "localizations" : { "en" : { @@ -4171,13 +5310,34 @@ } }, "Select a briefing to view details" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择一个简报以查看详情" + } + } + } }, "Select a project" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择一个项目" + } + } + } }, "Select a thread" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择一个会话" + } + } + } }, "Select all that apply" : { "localizations" : { @@ -4291,6 +5451,16 @@ } } }, + "Sign in with rxlab on your Mac to enable Autopilot." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在你的 Mac 上使用 rxlab 登录以启用 Autopilot。" + } + } + } + }, "Skill operation failed." : { "localizations" : { "en" : { @@ -4355,6 +5525,16 @@ } } }, + "Source link (optional)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "来源链接(可选)" + } + } + } + }, "SSE" : { }, @@ -4441,6 +5621,26 @@ } } }, + "Stop watching?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "停止监视?" + } + } + } + }, + "Success" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "成功" + } + } + } + }, "Summarization" : { "localizations" : { "en" : { @@ -4506,10 +5706,24 @@ } }, "Switch to horizontal scroll" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "切换到水平滚动" + } + } + } }, "Switch to wrap" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "切换到自动换行" + } + } + } }, "Syncing settings…" : { "localizations" : { @@ -4559,6 +5773,26 @@ } } }, + "Template" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "模板" + } + } + } + }, + "Templates of GitHub merge settings and branch rulesets. The default template is applied to newly created repositories." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub 合并设置和分支规则集的模板。默认模板会应用于新创建的仓库。" + } + } + } + }, "The desktop sent an AskUserQuestion call this app could not parse." : { "localizations" : { "en" : { @@ -4575,6 +5809,36 @@ } } }, + "The Mac could not complete the request." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mac 无法完成该请求。" + } + } + } + }, + "The Mac returned an empty response." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mac 返回了空响应。" + } + } + } + }, + "The paired Mac changed before the request finished." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请求完成前已配对的 Mac 发生了变化。" + } + } + } + }, "Thermal" : { "localizations" : { "en" : { @@ -4592,7 +5856,14 @@ } }, "Thinking" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "思考中" + } + } + } }, "This location has no visible folders." : { "localizations" : { @@ -4626,6 +5897,26 @@ } } }, + "This passkey can't unlock secrets — it doesn't support the PRF extension. Use the passkey you enrolled with." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此通行密钥无法解锁密钥 — 它不支持 PRF 扩展。请使用你注册时使用的通行密钥。" + } + } + } + }, + "This permanently deletes the environment and its secret files." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这将永久删除该环境及其密钥文件。" + } + } + } + }, "This permanently deletes the thread and all of its messages. This cannot be undone." : { "localizations" : { "en" : { @@ -4722,6 +6013,26 @@ } } }, + "This removes the repository and its indexed documents from docs search." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这会从文档搜索中移除该仓库及其已索引的文档。" + } + } + } + }, + "This settings form couldn't be rendered. Edit the raw JSON instead." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法渲染此设置表单。请改为编辑原始 JSON。" + } + } + } + }, "This Turn" : { "localizations" : { "en" : { @@ -4755,10 +6066,24 @@ } }, "Thread name" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "会话名称" + } + } + } }, "Thread Summary" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "会话摘要" + } + } + } }, "Threads" : { "localizations" : { @@ -4777,10 +6102,24 @@ } }, "Todos" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "待办事项" + } + } + } }, "Todos & Summary" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "待办事项与摘要" + } + } + } }, "Tool" : { "localizations" : { @@ -4814,6 +6153,16 @@ } } }, + "Trigger scan now" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "立即触发扫描" + } + } + } + }, "Try Again" : { "localizations" : { "en" : { @@ -5006,6 +6355,16 @@ } } }, + "Upload" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上传" + } + } + } + }, "URL" : { }, @@ -5074,7 +6433,14 @@ } }, "Use Workspace" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用 Workspace" + } + } + } }, "Value" : { "localizations" : { @@ -5108,6 +6474,26 @@ } } }, + "View pull request" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "查看拉取请求" + } + } + } + }, + "View values" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "查看值" + } + } + } + }, "What should we build in %@?" : { "localizations" : { "en" : { @@ -5124,6 +6510,16 @@ } } }, + "Workflows" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "工作流" + } + } + } + }, "Working Directory" : { "localizations" : { "en" : { @@ -5222,4 +6618,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/RxCodeMobile/RxCodeMobile.entitlements b/RxCodeMobile/RxCodeMobile.entitlements index e7b7ab60..68082fb6 100644 --- a/RxCodeMobile/RxCodeMobile.entitlements +++ b/RxCodeMobile/RxCodeMobile.entitlements @@ -11,6 +11,8 @@ com.apple.developer.associated-domains applinks:code.rxlab.app + webcredentials:rxlab.app + webcredentials:rxlab.app?mode=developer keychain-access-groups diff --git a/RxCodeMobile/State/MobileAppState+Autopilot.swift b/RxCodeMobile/State/MobileAppState+Autopilot.swift new file mode 100644 index 00000000..8e72212c --- /dev/null +++ b/RxCodeMobile/State/MobileAppState+Autopilot.swift @@ -0,0 +1,367 @@ +import CryptoKit +import Foundation +import RxCodeCore +import RxCodeSync + +/// Errors surfaced by the autopilot remote-call layer. +enum AutopilotRemoteError: LocalizedError { + case notPaired + case timedOut + case desktopChanged + case server(String) + case emptyResponse + + var errorDescription: String? { + switch self { + case .notPaired: + return String(localized: "Connect an online Mac to use Autopilot.") + case .timedOut: + return String(localized: "Request timed out. Check your Mac and try again.") + case .desktopChanged: + return String(localized: "The paired Mac changed before the request finished.") + case .server(let message): + return message + case .emptyResponse: + return String(localized: "The Mac returned an empty response.") + } + } +} + +extension MobileAppState { + // MARK: - Generic autopilot round trip + + /// Sends one autopilot operation to the active desktop and awaits its + /// reply, correlated by `clientRequestID`. Throws `AutopilotRemoteError` + /// when not paired, on timeout, on desktop switch, or when the desktop + /// reports failure. + private func autopilotCall( + _ domain: AutopilotDomain, + _ op: AutopilotOp, + bodyData: Data? + ) async throws -> AutopilotResultPayload { + guard isPaired else { throw AutopilotRemoteError.notPaired } + let payload = AutopilotRequestPayload(domain: domain, operation: op, body: bodyData) + let desktop = pairedDesktopPubkey + return try await withCheckedThrowingContinuation { continuation in + pendingAutopilotRequests[payload.clientRequestID] = continuation + Task { [weak self] in + guard let self else { return } + do { + try await self.client.send(.autopilotRequest(payload), toHex: desktop) + self.scheduleTimeout(Self.autopilotTimeout) { state in + if let pending = state.pendingAutopilotRequests.removeValue(forKey: payload.clientRequestID) { + pending.resume(throwing: AutopilotRemoteError.timedOut) + } + } + } catch { + if let pending = self.pendingAutopilotRequests.removeValue(forKey: payload.clientRequestID) { + pending.resume(throwing: error) + } + } + } + } + } + + /// Autopilot requests can scan workflows / mint GitHub secrets on the + /// desktop, so they get a longer ceiling than the snappy config calls. + static let autopilotTimeout: Duration = .seconds(45) + + /// Send with an encodable body and decode a typed response. + func autopilotSend( + _ domain: AutopilotDomain, + _ op: AutopilotOp, + body: Req, + as: Res.Type + ) async throws -> Res { + let data = try JSONEncoder().encode(body) + return try await decodeResult(await autopilotCall(domain, op, bodyData: data), as: Res.self) + } + + /// Send with no body and decode a typed response. + func autopilotSend( + _ domain: AutopilotDomain, + _ op: AutopilotOp, + as: Res.Type + ) async throws -> Res { + try await decodeResult(await autopilotCall(domain, op, bodyData: nil), as: Res.self) + } + + /// Send with an encodable body and ignore the (empty) response. + func autopilotSendVoid( + _ domain: AutopilotDomain, + _ op: AutopilotOp, + body: Req + ) async throws { + let data = try JSONEncoder().encode(body) + try checkResult(await autopilotCall(domain, op, bodyData: data)) + } + + /// Send with no body and ignore the (empty) response. + func autopilotSendVoid(_ domain: AutopilotDomain, _ op: AutopilotOp) async throws { + try checkResult(await autopilotCall(domain, op, bodyData: nil)) + } + + private func decodeResult(_ result: AutopilotResultPayload, as: Res.Type) throws -> Res { + try checkResult(result) + guard let body = result.body else { throw AutopilotRemoteError.emptyResponse } + return try JSONDecoder().decode(Res.self, from: body) + } + + private func checkResult(_ result: AutopilotResultPayload) throws { + guard result.ok else { + throw AutopilotRemoteError.server(result.errorMessage ?? String(localized: "The Mac could not complete the request.")) + } + } + + // MARK: - Account + + @discardableResult + func loadAutopilotAccount() async throws -> AutopilotAccountStatus { + let status = try await autopilotSend(.account, .accountStatus, as: AutopilotAccountStatus.self) + autopilotAccount = status + return status + } + + // MARK: - Shared repo picker + + func listAutopilotRepos(search: String?, cursor: String? = nil) async throws -> SecretsManagedRepoPage { + try await autopilotSend(.account, .listManagedRepos, + body: AutopilotReposQuery(search: search, cursor: cursor), + as: SecretsManagedRepoPage.self) + } + + // MARK: - Automation settings + + func automationSchema() async throws -> SchemaEnvelope { + try await autopilotSend(.automation, .automationSchema, as: SchemaEnvelope.self) + } + + func automationValues() async throws -> PreferencesValues { + try await autopilotSend(.automation, .automationValues, as: PreferencesValues.self) + } + + @discardableResult + func saveAutomationValues(_ values: JSONValue) async throws -> PreferencesValues { + try await autopilotSend(.automation, .automationSave, + body: PreferencesValues(values: values), + as: PreferencesValues.self) + } + + // MARK: - Repo setup templates + + func repoSetupSchema() async throws -> SchemaEnvelope { + try await autopilotSend(.repoSetup, .repoSetupSchema, as: SchemaEnvelope.self) + } + + func repoSetupTemplates() async throws -> [RepoSetupTemplate] { + try await autopilotSend(.repoSetup, .repoSetupList, as: RepoSetupTemplateList.self).items + } + + @discardableResult + func createRepoSetupTemplate(_ input: RepoSetupTemplateInput) async throws -> RepoSetupTemplate { + try await autopilotSend(.repoSetup, .repoSetupCreate, body: input, as: RepoSetupTemplate.self) + } + + @discardableResult + func updateRepoSetupTemplate(id: String, _ input: RepoSetupTemplateInput) async throws -> RepoSetupTemplate { + try await autopilotSend(.repoSetup, .repoSetupUpdate, + body: AutopilotRepoSetupUpdateBody(id: id, input: input), + as: RepoSetupTemplate.self) + } + + func deleteRepoSetupTemplate(id: String) async throws { + try await autopilotSendVoid(.repoSetup, .repoSetupDelete, body: AutopilotIDBody(id: id)) + } + + // MARK: - Secrets (on-device passkey crypto) + // + // The desktop only relays opaque ciphertext. The phone derives the KEK from + // the iCloud-synced `rxlab.app` passkey (WebAuthn PRF) — the same credential + // and salt the Mac uses, so the KEK is identical — and does every + // encryption/decryption locally. Plaintext never crosses the relay. + + func secretsEnrollmentStatus() async throws -> Bool { + try await autopilotSend(.secrets, .secretsEnrollmentStatus, as: AutopilotSecretsEnrollment.self).enrolled + } + + private func secretsUserKey() async throws -> SecretsUserKey { + try await autopilotSend(.secrets, .secretsGetUserKey, as: SecretsUserKey.self) + } + + private func secretsBundle(repo: String, envId: String) async throws -> SecretsBundle { + try await autopilotSend(.secrets, .secretsGetBundle, + body: AutopilotSecretsEnvRefBody(repo: repo, envId: envId), + as: SecretsBundle.self) + } + + func listSecretEnvironments(repo: String) async throws -> [SecretsEnvironment] { + try await autopilotSend(.secrets, .secretsListEnvironments, + body: AutopilotSecretsRepoBody(repo: repo), + as: SecretsEnvironmentList.self).items + } + + /// Builds the owner environment key on-device (a fresh DEK wrapped under the + /// passkey-derived KEK), then asks the desktop to POST it. + func createSecretEnvironment(repo: String, name: String) async throws { + let kek = try await secretsKeyVault.kek() + let owner = try SecretsFlows.buildOwnerEnvironmentKey(kek: kek) + try await autopilotSendVoid(.secrets, .secretsCreateEnvironment, + body: AutopilotSecretsCreateEnvBody( + repo: repo, + body: CreateEnvironmentBody(name: name, ownerKey: owner.body))) + } + + func deleteSecretEnvironment(repo: String, envId: String) async throws { + try await autopilotSendVoid(.secrets, .secretsDeleteEnvironment, + body: AutopilotSecretsEnvRefBody(repo: repo, envId: envId)) + } + + func listSecretFiles(repo: String, envId: String) async throws -> [SecretsFileMeta] { + try await autopilotSend(.secrets, .secretsListFiles, + body: AutopilotSecretsEnvRefBody(repo: repo, envId: envId), + as: SecretsFileList.self).items + } + + /// Resolves the environment DEK on-device: derive the KEK from the passkey + /// and unwrap the DEK (unwrapping the user's private key first for + /// HPKE-shared environments). + private func resolveSecretsDEK(repo: String, envId: String) async throws -> (dek: SymmetricKey, bundle: SecretsBundle) { + let kek = try await secretsKeyVault.kek() + let bundle = try await secretsBundle(repo: repo, envId: envId) + var userPrivateKey: P256.KeyAgreement.PrivateKey? + if bundle.environmentKey.wrapMode == "hpke" { + let userKey = try await secretsUserKey() + userPrivateKey = try SecretsFlows.unwrapUserPrivateKey(userKey, kek: kek) + } + let dek = try SecretsFlows.unwrapDEK(envKey: bundle.environmentKey, kek: kek, userPrivateKey: userPrivateKey) + return (dek, bundle) + } + + func secretEnvironmentPlaintext(repo: String, envId: String) async throws -> [AutopilotSecretFilePlaintext] { + let (dek, bundle) = try await resolveSecretsDEK(repo: repo, envId: envId) + return try bundle.files.map { file in + AutopilotSecretFilePlaintext( + filename: file.filename, + content: try SecretsCrypto.decryptFile(ciphertextB64: file.ciphertext, ivB64: file.iv, dek: dek) + ) + } + } + + func upsertSecretFile(repo: String, envId: String, filename: String, content: String) async throws { + let (dek, _) = try await resolveSecretsDEK(repo: repo, envId: envId) + let enc = try SecretsCrypto.encryptFile(plaintext: content, dek: dek) + let file = UpsertFileBody(filename: filename, ciphertext: enc.ciphertext, iv: enc.iv, size: enc.size) + try await autopilotSendVoid(.secrets, .secretsUpsertFile, + body: AutopilotSecretsUpsertFileBody(repo: repo, envId: envId, file: file)) + } + + func deleteSecretFile(repo: String, envId: String, fileId: String) async throws { + try await autopilotSendVoid(.secrets, .secretsDeleteFile, + body: AutopilotSecretsDeleteFileBody(repo: repo, envId: envId, fileId: fileId)) + } + + // MARK: - CI auto-update + + func listWatchedRepos(cursor: String? = nil) async throws -> WatchedRepoPage { + try await autopilotSend(.ciUpdates, .ciList, body: AutopilotCursorQuery(cursor: cursor), as: WatchedRepoPage.self) + } + + func addWatchedRepo(_ body: AddWatchedRepoBody) async throws { + try await autopilotSendVoid(.ciUpdates, .ciAdd, body: body) + } + + func updateWatchedRepoFrequency(id: String, frequency: CIScanFrequency) async throws { + try await autopilotSendVoid(.ciUpdates, .ciUpdateFrequency, body: AutopilotCIFrequencyBody(id: id, frequency: frequency)) + } + + func deleteWatchedRepo(id: String) async throws { + try await autopilotSendVoid(.ciUpdates, .ciDelete, body: AutopilotIDBody(id: id)) + } + + func watchedRepoHistory(id: String, limit: Int? = nil) async throws -> [CIUpdateRunHistory] { + try await autopilotSend(.ciUpdates, .ciHistory, body: AutopilotCIHistoryBody(id: id, limit: limit), as: CIUpdateRunHistoryList.self).items + } + + func triggerWatchedRepoScan(id: String) async throws -> CITriggerResponse { + try await autopilotSend(.ciUpdates, .ciTrigger, body: AutopilotIDBody(id: id), as: CITriggerResponse.self) + } + + func watchedRepoPullRequests(id: String) async throws -> [CIPullRequest] { + try await autopilotSend(.ciUpdates, .ciListPRs, body: AutopilotIDBody(id: id), as: CIPullRequestList.self).items + } + + func closeWatchedRepoPR(id: String, prNumber: Int) async throws { + try await autopilotSendVoid(.ciUpdates, .ciClosePR, body: AutopilotCIClosePRBody(id: id, prNumber: prNumber)) + } + + // MARK: - Docs + + func listDocsRepos(search: String? = nil, cursor: String? = nil) async throws -> DocsRepoListResponse { + try await autopilotSend(.docs, .docsList, body: AutopilotReposQuery(search: search, cursor: cursor), as: DocsRepoListResponse.self) + } + + func addDocsRepo(_ body: AddDocsRepoBody) async throws { + try await autopilotSendVoid(.docs, .docsAdd, body: body) + } + + func deleteDocsRepo(id: String) async throws { + try await autopilotSendVoid(.docs, .docsDelete, body: AutopilotIDBody(id: id)) + } + + func listDocuments(repoId: String, cursor: String? = nil) async throws -> DocsDocumentList { + try await autopilotSend(.docs, .docsListDocuments, body: AutopilotDocsListDocumentsBody(repoId: repoId, cursor: cursor), as: DocsDocumentList.self) + } + + func getDocument(repoId: String, docId: String) async throws -> DocsDocumentDetail { + try await autopilotSend(.docs, .docsGetDocument, body: AutopilotDocsDocBody(repoId: repoId, docId: docId), as: DocsDocumentDetail.self) + } + + @discardableResult + func uploadDocument(repoId: String, docId: String, content: String, originalLink: String?) async throws -> DocsUploadResult { + try await autopilotSend(.docs, .docsUploadDocument, + body: AutopilotDocsUploadBody(repoId: repoId, docId: docId, content: content, originalLink: originalLink), + as: DocsUploadResult.self) + } + + func deleteDocument(repoId: String, docId: String) async throws { + try await autopilotSendVoid(.docs, .docsDeleteDocument, body: AutopilotDocsDocBody(repoId: repoId, docId: docId)) + } + + @discardableResult + func createDocsUploadToken(repoId: String, name: String?) async throws -> DocsUploadToken { + try await autopilotSend(.docs, .docsCreateUploadToken, body: AutopilotDocsCreateTokenBody(repoId: repoId, name: name), as: DocsUploadToken.self) + } + + @discardableResult + func installDocsGithubSecret(repoId: String) async throws -> DocsGithubSecretResult { + try await autopilotSend(.docs, .docsInstallGithubSecret, body: AutopilotRepoIDBody(repoId: repoId), as: DocsGithubSecretResult.self) + } + + // MARK: - Release + + func listReleaseRepos(cursor: String? = nil) async throws -> ReleaseRepoListResponse { + try await autopilotSend(.release, .releaseList, body: AutopilotCursorQuery(cursor: cursor), as: ReleaseRepoListResponse.self) + } + + func addReleaseRepo(_ body: AddReleaseRepoBody) async throws { + try await autopilotSendVoid(.release, .releaseAdd, body: body) + } + + func deleteReleaseRepo(id: String) async throws { + try await autopilotSendVoid(.release, .releaseDelete, body: AutopilotIDBody(id: id)) + } + + func listReleaseWorkflows(repoId: String) async throws -> [MobileReleaseWorkflow] { + try await autopilotSend(.release, .releaseListWorkflows, body: AutopilotRepoIDBody(repoId: repoId), as: MobileReleaseWorkflowList.self).items + } + + @discardableResult + func dispatchRelease(repoId: String, request: ReleaseDispatchRequest) async throws -> ReleaseDispatchResult { + try await autopilotSend(.release, .releaseDispatch, body: AutopilotReleaseDispatchBody(repoId: repoId, request: request), as: ReleaseDispatchResult.self) + } + + @discardableResult + func installReleaseToken(repoId: String, value: String) async throws -> ReleaseGithubSecretResult { + try await autopilotSend(.release, .releaseInstallToken, body: AutopilotReleaseTokenBody(repoId: repoId, value: value), as: ReleaseGithubSecretResult.self) + } +} diff --git a/RxCodeMobile/State/MobileAppState+Inbound.swift b/RxCodeMobile/State/MobileAppState+Inbound.swift index b33eb349..5e14c387 100644 --- a/RxCodeMobile/State/MobileAppState+Inbound.swift +++ b/RxCodeMobile/State/MobileAppState+Inbound.swift @@ -244,6 +244,11 @@ extension MobileAppState { case .mcpMutationResult(let result): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "mcp_mutation_result") else { return } applyMCPMutationResult(result) + case .autopilotResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "autopilot_result") else { return } + if let continuation = pendingAutopilotRequests.removeValue(forKey: result.clientRequestID) { + continuation.resume(returning: result) + } case .ping: guard pairedDesktops.contains(where: { $0.pubkeyHex == inbound.fromHex }) else { return } Task { try? await self.client.send(.pong(PongPayload()), toHex: inbound.fromHex) } diff --git a/RxCodeMobile/State/MobileAppState+Sync.swift b/RxCodeMobile/State/MobileAppState+Sync.swift index b880fde0..369be11b 100644 --- a/RxCodeMobile/State/MobileAppState+Sync.swift +++ b/RxCodeMobile/State/MobileAppState+Sync.swift @@ -493,5 +493,11 @@ extension MobileAppState { inFlightMCPMutations = [] lastMCPError = nil pendingMCPConfigRequestID = nil + autopilotAccount = nil + let pendingAutopilot = pendingAutopilotRequests + pendingAutopilotRequests = [:] + for continuation in pendingAutopilot.values { + continuation.resume(throwing: AutopilotRemoteError.desktopChanged) + } } } diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index bf7a7492..14137c6c 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -139,6 +139,19 @@ final class MobileAppState: ObservableObject { @Published var inFlightMCPMutations: Set = [] @Published var lastMCPError: String? var pendingMCPConfigRequestID: UUID? + + // MARK: - Remote desktop config: Autopilot + + /// Outstanding autopilot requests keyed by `clientRequestID`. Each entry is + /// resumed exactly once — by the matching `.autopilotResult`, by a timeout, + /// or by `clearDesktopMirror()` on unpair/desktop-switch. + var pendingAutopilotRequests: [UUID: CheckedContinuation] = [:] + /// Cached rxlab account status from the active desktop, shown on the + /// Autopilot screen header. `nil` until first loaded. + @Published var autopilotAccount: AutopilotAccountStatus? + /// Derives and caches the passkey PRF KEK so secrets are decrypted/encrypted + /// on-device — the desktop only relays opaque ciphertext. + let secretsKeyVault = MobileSecretsKeyVault() /// IDs of branch operations awaiting a `BranchOpResultPayload`. Used so the /// UI can render a spinner on the chip while the desktop runs git. @Published var inFlightBranchOps: Set = [] diff --git a/RxCodeMobile/State/MobileSecretsKeyVault.swift b/RxCodeMobile/State/MobileSecretsKeyVault.swift new file mode 100644 index 00000000..73c2c7d7 --- /dev/null +++ b/RxCodeMobile/State/MobileSecretsKeyVault.swift @@ -0,0 +1,144 @@ +import AuthenticationServices +import CryptoKit +import Foundation +import RxCodeCore +import UIKit +import os.log + +/// Runs a passkey assertion purely to unlock the WebAuthn PRF extension, so the +/// phone can derive the secrets KEK from the user's iCloud-synced `rxlab.app` +/// passkey — the same credential and PRF salt the desktop uses, producing the +/// identical KEK. The assertion signature is not verified by anyone; we only +/// want the PRF output bytes. iOS counterpart of the macOS +/// `SecretsPasskeyAuthenticator` (UIKit presentation anchor). +@MainActor +final class MobileSecretsPasskeyAuthenticator: NSObject { + + enum PasskeyError: LocalizedError { + case cancelled + case prfUnavailable + case failed(String) + + var errorDescription: String? { + switch self { + case .cancelled: + return String(localized: "Passkey authentication was cancelled.") + case .prfUnavailable: + return String(localized: "This passkey can't unlock secrets — it doesn't support the PRF extension. Use the passkey you enrolled with.") + case .failed(let detail): + return String(localized: "Passkey authentication failed: \(detail)") + } + } + } + + private var continuation: CheckedContinuation? + private var retainedSelf: MobileSecretsPasskeyAuthenticator? + private let logger = Logger(subsystem: "com.idealapp.RxCodeMobile", category: "SecretsPasskey") + + /// Performs a discoverable assertion with the PRF extension evaluating + /// `salt`, returning 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 MobileSecretsPasskeyAuthenticator: 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 + } + 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 MobileSecretsPasskeyAuthenticator: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + let scene = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + ?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + return scene?.keyWindow ?? scene?.windows.first ?? ASPresentationAnchor() + } +} + +/// Owns the passkey-derived KEK and caches it for a few minutes so a burst of +/// encrypt/decrypt operations only prompts once. The KEK never persists. iOS +/// counterpart of the macOS `SecretsKeyVault`. +@MainActor +final class MobileSecretsKeyVault { + 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 = MobileSecretsPasskeyAuthenticator() + let prf = try await authenticator.evaluatePRF() + let kek = SecretsCrypto.deriveKEK(prfOutput: prf) + cachedKEK = kek + cachedAt = Date() + return kek + } + + func clear() { + cachedKEK = nil + cachedAt = nil + } +} diff --git a/RxCodeMobile/Views/Autopilot/MobileAutomationView.swift b/RxCodeMobile/Views/Autopilot/MobileAutomationView.swift new file mode 100644 index 00000000..9d16dd41 --- /dev/null +++ b/RxCodeMobile/Views/Autopilot/MobileAutomationView.swift @@ -0,0 +1,129 @@ +import JSONSchema +import JSONSchemaForm +import RxCodeCore +import RxCodeSync +import SwiftUI + +/// Automation settings: fetches the automation JSON Schema + current values +/// from the paired Mac, renders them as a native form via `JSONSchemaForm`, and +/// saves edited values back. Mirrors the desktop `AutomationSettingsSheet`, +/// including the raw-JSON fallback when the schema can't be rendered. +struct MobileAutomationView: View { + @EnvironmentObject private var state: MobileAppState + + @State private var schema: JSONSchema? + @State private var uiSchema: [String: Any]? + @State private var formData: FormData = .object(properties: [:]) + @State private var controller = JSONSchemaFormController() + + @State private var fallbackJSON = "{}" + @State private var useFallback = false + + @State private var isLoading = true + @State private var loadError: String? + @State private var isSaving = false + @State private var errorMessage: String? + @State private var savedMessage: String? + + var body: some View { + Form { + if isLoading { + HStack(spacing: 8) { ProgressView(); Text("Loading…").foregroundStyle(.secondary) } + } else if let loadError { + VStack(spacing: 8) { + Label(loadError, systemImage: "exclamationmark.triangle").foregroundStyle(.orange) + Button("Retry") { Task { await load() } } + } + } else if useFallback { + Section { + Text("This settings form couldn't be rendered. Edit the raw JSON instead.") + .font(.caption).foregroundStyle(.secondary) + TextEditor(text: $fallbackJSON) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 280) + } + } else if let schema { + JSONSchemaForm( + schema: schema, + uiSchema: uiSchema, + formData: $formData, + liveValidate: true, + showSubmitButton: false, + controller: controller + ) + } + + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundStyle(.orange) + } + if let savedMessage { + Label(savedMessage, systemImage: "checkmark.circle").font(.footnote).foregroundStyle(.green) + } + } + .navigationTitle("Automation") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Save") { Task { await save() } } + .disabled(isSaving || isLoading || loadError != nil) + } + } + .task { await load() } + } + + private func load() async { + isLoading = true + loadError = nil + defer { isLoading = false } + do { + async let schemaCall = state.automationSchema() + async let valuesCall = state.automationValues() + let (env, prefs) = try await (schemaCall, valuesCall) + + uiSchema = env.uiSchema?.foundationObject as? [String: Any] + fallbackJSON = prefs.values.prettyJSONString + do { + let parsed = try JSONSchema(jsonString: env.schema.rawJSONString) + let seeded = try FormData.fromJSONString(prefs.values.rawJSONString) + formData = seeded.applyingDefaults(schema: parsed) + schema = parsed + useFallback = false + } catch { + useFallback = true + } + } catch { + loadError = error.localizedDescription + } + } + + private func save() async { + isSaving = true + errorMessage = nil + savedMessage = nil + defer { isSaving = false } + do { + let values: JSONValue + if useFallback { + guard let data = fallbackJSON.data(using: .utf8), + let parsed = try? JSONValue(jsonData: data) else { + errorMessage = "Settings must be valid JSON." + return + } + values = parsed + } else { + let ok = try await controller.submit() + guard ok else { + errorMessage = "Please fix the highlighted fields." + return + } + let data = try JSONEncoder().encode(formData) + values = try JSONValue(jsonData: data) + } + try await state.saveAutomationValues(values) + savedMessage = "Saved." + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCodeMobile/Views/Autopilot/MobileAutopilotLoadingOverlay.swift b/RxCodeMobile/Views/Autopilot/MobileAutopilotLoadingOverlay.swift new file mode 100644 index 00000000..d6c37981 --- /dev/null +++ b/RxCodeMobile/Views/Autopilot/MobileAutopilotLoadingOverlay.swift @@ -0,0 +1,42 @@ +import SwiftUI + +/// A centered, material-backed loading indicator shown over a screen while its +/// initial data is still being fetched from the paired Mac. Autopilot screens +/// load everything over the sync channel, so the first fetch can take a moment; +/// this overlay covers that gap instead of flashing an empty list/form. +struct MobileAutopilotLoadingOverlay: View { + var title: LocalizedStringKey = "Loading…" + + var body: some View { + VStack(spacing: 12) { + ProgressView() + .controlSize(.large) + Text(title) + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.regularMaterial) + .transition(.opacity) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(title)) + } +} + +extension View { + /// Overlays a loading indicator while `isLoading` is true. Gate this on the + /// *initial* load only (typically `isLoading && collection.isEmpty`) so it + /// doesn't cover content during pull-to-refresh. + @ViewBuilder + func mobileAutopilotLoadingOverlay( + _ isLoading: Bool, + title: LocalizedStringKey = "Loading…" + ) -> some View { + overlay { + if isLoading { + MobileAutopilotLoadingOverlay(title: title) + } + } + .animation(.easeInOut(duration: 0.2), value: isLoading) + } +} diff --git a/RxCodeMobile/Views/Autopilot/MobileAutopilotRepoPicker.swift b/RxCodeMobile/Views/Autopilot/MobileAutopilotRepoPicker.swift new file mode 100644 index 00000000..462dcdb3 --- /dev/null +++ b/RxCodeMobile/Views/Autopilot/MobileAutopilotRepoPicker.swift @@ -0,0 +1,134 @@ +import RxCodeCore +import RxCodeSync +import SwiftUI + +/// Picks an accessible GitHub repository to register for a feature (docs, +/// release, CI). Mirrors the desktop add-repo sheets, which all source the +/// accessible-repo list (carrying installation + repository ids) from the +/// secrets `repositories/all` listing. The picked repo is handed back via +/// `onSelect`; the caller performs the feature-specific registration. +struct MobileAutopilotRepoPicker: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + + let title: String + /// Full names already registered, hidden from the picker. + let existingRepoFullNames: Set + let onSelect: (SecretsManagedRepo) async -> Void + + @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 addingFullName: String? + @State private var errorMessage: String? + @State private var searchTask: Task? + + private var visibleRepos: [SecretsManagedRepo] { + repos.filter { !existingRepoFullNames.contains($0.fullName.lowercased()) } + } + + var body: some View { + NavigationStack { + List { + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundStyle(.orange) + } + ForEach(visibleRepos) { repo in + Button { + Task { await select(repo) } + } label: { + HStack(spacing: 10) { + Image(systemName: repo.isPrivate ? "lock.fill" : "book.closed") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(repo.fullName).foregroundStyle(.primary) + if repo.isCurrent { + Text("Current project").font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + if addingFullName == repo.fullName { + ProgressView() + } else { + Image(systemName: "plus.circle").foregroundStyle(.tint) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(addingFullName != nil) + } + if hasMore { + Button { + Task { await loadMore() } + } label: { + HStack { Spacer(); Text("Load more"); Spacer() } + } + .disabled(isLoading) + } + if visibleRepos.isEmpty, !isLoading, errorMessage == nil { + Text("No repositories available to add.") + .foregroundStyle(.secondary).font(.callout) + } + } + .mobileAutopilotLoadingOverlay(isLoading && repos.isEmpty) + .searchable(text: $search) + .onChange(of: search) { _, _ in + searchTask?.cancel() + searchTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + await reload() + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .task { await reload() } + } + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let page = try await state.listAutopilotRepos(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 state.listAutopilotRepos(search: search, cursor: cursor) + repos.append(contentsOf: page.items) + nextCursor = page.pagination.nextCursor + hasMore = page.pagination.hasMore + } catch { + errorMessage = error.localizedDescription + } + } + + private func select(_ repo: SecretsManagedRepo) async { + addingFullName = repo.fullName + defer { addingFullName = nil } + await onSelect(repo) + dismiss() + } +} diff --git a/RxCodeMobile/Views/Autopilot/MobileAutopilotView.swift b/RxCodeMobile/Views/Autopilot/MobileAutopilotView.swift new file mode 100644 index 00000000..6d2ee8c5 --- /dev/null +++ b/RxCodeMobile/Views/Autopilot/MobileAutopilotView.swift @@ -0,0 +1,164 @@ +import RxCodeCore +import RxCodeSync +import SwiftUI + +/// Mobile mirror of the desktop "Autopilot" settings tab. Every action is +/// performed by the paired Mac over the sync channel, so the screen requires an +/// online desktop; when offline it shows a notice and disables navigation. +struct MobileAutopilotView: View { + @EnvironmentObject private var state: MobileAppState + + @State private var account: AutopilotAccountStatus? + @State private var isLoading = true + @State private var loadError: String? + + private var isOnline: Bool { + state.isPaired && state.connectionState == .connected + } + + var body: some View { + Form { + if !isOnline { + offlineSection + } + + accountSection + + if account?.isSignedIn == true { + Section { + NavigationLink { + MobileAutomationView() + } label: { + Label("Automation", systemImage: "wand.and.stars") + } + NavigationLink { + MobileRepoSetupView() + } label: { + Label("Repo Setup", systemImage: "slider.horizontal.3") + } + } header: { + Text("Configuration") + } footer: { + Text("Control what autopilot does automatically and the templates applied to new repositories.") + } + + Section { + NavigationLink { + MobileSecretsView() + } label: { + Label("Secrets", systemImage: "key.fill") + } + NavigationLink { + MobileCIUpdatesView() + } label: { + Label("CI Auto-Update", systemImage: "arrow.triangle.2.circlepath") + } + NavigationLink { + MobileDocsView() + } label: { + Label("Documentation", systemImage: "books.vertical.fill") + } + NavigationLink { + MobileReleaseView() + } label: { + Label("Releases", systemImage: "tag.fill") + } + } header: { + Text("Repositories") + } footer: { + Text("Manage end-to-end encrypted secrets, CI auto-update scans, indexed docs, and release workflows.") + } + } else if !isLoading, loadError == nil, isOnline { + Section { + Text("Sign in with rxlab on your Mac to enable Autopilot.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + if let loadError { + Section { + Label(loadError, systemImage: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(.orange) + } + } + } + .navigationTitle("Autopilot") + .navigationBarTitleDisplayMode(.inline) + .disabled(!isOnline) + .mobileAutopilotLoadingOverlay(isOnline && isLoading && account == nil) + .refreshable { await load() } + .task { await load() } + } + + private var offlineSection: some View { + Section { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Mac offline") + Text("Connect to an online Mac to manage Autopilot.") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "wifi.slash").foregroundStyle(.orange) + } + } + } + + @ViewBuilder + private var accountSection: some View { + Section("Account") { + if isLoading { + HStack(spacing: 8) { + ProgressView() + Text("Checking account…").foregroundStyle(.secondary) + } + } else if let account, account.isSignedIn { + HStack(spacing: 12) { + avatar(account.avatarURL) + VStack(alignment: .leading, spacing: 2) { + Text(account.name ?? "rxlab account").font(.body) + if let email = account.email { + Text(email).font(.caption).foregroundStyle(.secondary) + } + } + } + } else { + Label("Not signed in", systemImage: "person.crop.circle.badge.questionmark") + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private func avatar(_ urlString: String?) -> some View { + if let urlString, let url = URL(string: urlString) { + AsyncImage(url: url) { image in + image.resizable().scaledToFill() + } placeholder: { + Image(systemName: "person.crop.circle.fill").foregroundStyle(.secondary) + } + .frame(width: 32, height: 32) + .clipShape(Circle()) + } else { + Image(systemName: "person.crop.circle.fill") + .font(.title) + .foregroundStyle(.secondary) + .frame(width: 32, height: 32) + } + } + + private func load() async { + guard isOnline else { isLoading = false; return } + isLoading = true + loadError = nil + defer { isLoading = false } + do { + account = try await state.loadAutopilotAccount() + } catch { + loadError = error.localizedDescription + } + } +} diff --git a/RxCodeMobile/Views/Autopilot/MobileCIUpdatesView.swift b/RxCodeMobile/Views/Autopilot/MobileCIUpdatesView.swift new file mode 100644 index 00000000..51d49451 --- /dev/null +++ b/RxCodeMobile/Views/Autopilot/MobileCIUpdatesView.swift @@ -0,0 +1,275 @@ +import RxCodeCore +import RxCodeSync +import SwiftUI + +/// CI auto-update: watched repositories and their scan schedule, history, and +/// auto-update pull requests. +struct MobileCIUpdatesView: View { + @EnvironmentObject private var state: MobileAppState + + @State private var repos: [WatchedRepo] = [] + @State private var isLoading = true + @State private var errorMessage: String? + @State private var showingAdd = false + @State private var pendingDelete: WatchedRepo? + + var body: some View { + List { + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundStyle(.orange) + } + ForEach(repos) { repo in + NavigationLink { + MobileCIRepoDetailView(repo: repo) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(repo.fullName) + Text(repo.scanFrequency.displayName) + .font(.caption).foregroundStyle(.secondary) + } + } + .swipeActions { + Button("Remove", role: .destructive) { pendingDelete = repo } + } + } + if repos.isEmpty, !isLoading { + Text("No watched repositories. Tap + to add one.").foregroundStyle(.secondary) + } + } + .navigationTitle("CI Auto-Update") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showingAdd = true } label: { Image(systemName: "plus") } + } + } + .sheet(isPresented: $showingAdd) { + MobileAutopilotRepoPicker( + title: "Watch Repository", + existingRepoFullNames: Set(repos.map { $0.fullName.lowercased() }) + ) { repo in + await add(repo) + } + .environmentObject(state) + .mobileSheetPresentation() + } + .alert( + "Stop watching?", + isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } }) + ) { + Button("Cancel", role: .cancel) {} + Button("Remove", role: .destructive) { + if let repo = pendingDelete { Task { await delete(repo) } } + pendingDelete = nil + } + } + .mobileAutopilotLoadingOverlay(isLoading && repos.isEmpty) + .refreshable { await reload() } + .task { await reload() } + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + repos = try await state.listWatchedRepos().repositories + } catch { + errorMessage = error.localizedDescription + } + } + + private func add(_ repo: SecretsManagedRepo) async { + do { + try await state.addWatchedRepo(AddWatchedRepoBody( + installationId: repo.installationId, + repositoryId: repo.id, + repositoryFullName: repo.fullName, + scanFrequency: .weekly + )) + await reload() + } catch { + errorMessage = error.localizedDescription + } + } + + private func delete(_ repo: WatchedRepo) async { + do { + try await state.deleteWatchedRepo(id: repo.id) + await reload() + } catch { + errorMessage = error.localizedDescription + } + } +} + +struct MobileCIRepoDetailView: View { + @EnvironmentObject private var state: MobileAppState + let repo: WatchedRepo + + @State private var frequency: CIScanFrequency + @State private var history: [CIUpdateRunHistory] = [] + @State private var pullRequests: [CIPullRequest] = [] + @State private var isLoading = true + @State private var isTriggering = false + @State private var statusMessage: String? + @State private var errorMessage: String? + @State private var pendingClose: CIPullRequest? + + init(repo: WatchedRepo) { + self.repo = repo + _frequency = State(initialValue: repo.scanFrequency) + } + + var body: some View { + List { + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundStyle(.orange) + } + if let statusMessage { + Label(statusMessage, systemImage: "checkmark.circle").font(.footnote).foregroundStyle(.green) + } + + Section("Scan") { + Picker("Frequency", selection: $frequency) { + ForEach(CIScanFrequency.allCases) { freq in + Text(freq.displayName).tag(freq) + } + } + .onChange(of: frequency) { _, value in + Task { await updateFrequency(value) } + } + Button { + Task { await trigger() } + } label: { + if isTriggering { + HStack { ProgressView(); Text("Scanning…") } + } else { + Label("Trigger scan now", systemImage: "arrow.triangle.2.circlepath") + } + } + .disabled(isTriggering) + } + + Section("Scan History") { + if history.isEmpty, !isLoading { + Text("No scans yet.").foregroundStyle(.secondary) + } + ForEach(history) { run in + VStack(alignment: .leading, spacing: 2) { + HStack { + Image(systemName: run.isSuccess ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(run.isSuccess ? .green : .red) + Text(run.isSuccess ? "Success" : "Error") + Spacer() + if let date = run.startedDate { + Text(date, format: .relative(presentation: .named)) + .font(.caption).foregroundStyle(.secondary) + } + } + if let outdated = run.outdatedActionsCount { + Text("\(outdated) outdated action(s)").font(.caption).foregroundStyle(.secondary) + } + if let message = run.errorMessage { + Text(message).font(.caption).foregroundStyle(.red).lineLimit(2) + } + if let url = run.prUrl, let link = URL(string: url) { + Link("View pull request", destination: link).font(.caption) + } + } + } + } + + Section("Pull Requests") { + if pullRequests.isEmpty, !isLoading { + Text("No open auto-update pull requests.").foregroundStyle(.secondary) + } + ForEach(pullRequests) { pr in + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(pr.title ?? "PR #\(pr.number ?? 0)") + Spacer() + if let url = pr.htmlURL, let link = URL(string: url) { + Link("Open", destination: link).font(.caption) + } + } + if let number = pr.number { + Text("#\(number)").font(.caption).foregroundStyle(.secondary) + } + } + .swipeActions { + Button("Close", role: .destructive) { pendingClose = pr } + } + } + } + } + .navigationTitle(repo.fullName) + .navigationBarTitleDisplayMode(.inline) + .alert( + "Close pull request?", + isPresented: Binding(get: { pendingClose != nil }, set: { if !$0 { pendingClose = nil } }) + ) { + Button("Cancel", role: .cancel) {} + Button("Close PR", role: .destructive) { + if let pr = pendingClose, let number = pr.number { + Task { await closePR(number) } + } + pendingClose = nil + } + } + .mobileAutopilotLoadingOverlay(isLoading && history.isEmpty && pullRequests.isEmpty) + .refreshable { await reload() } + .task { await reload() } + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + async let h = state.watchedRepoHistory(id: repo.id, limit: 50) + async let p = state.watchedRepoPullRequests(id: repo.id) + history = try await h + pullRequests = try await p + } catch { + errorMessage = error.localizedDescription + } + } + + private func updateFrequency(_ value: CIScanFrequency) async { + do { + try await state.updateWatchedRepoFrequency(id: repo.id, frequency: value) + } catch { + errorMessage = error.localizedDescription + } + } + + private func trigger() async { + isTriggering = true + statusMessage = nil + errorMessage = nil + defer { isTriggering = false } + do { + let result = try await state.triggerWatchedRepoScan(id: repo.id) + if result.success { + statusMessage = result.prUrl != nil ? "Scan opened a pull request." : "Scan complete — no changes needed." + } else { + errorMessage = result.error ?? "Scan failed." + } + await reload() + } catch { + errorMessage = error.localizedDescription + } + } + + private func closePR(_ number: Int) async { + do { + try await state.closeWatchedRepoPR(id: repo.id, prNumber: number) + await reload() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCodeMobile/Views/Autopilot/MobileDocsView.swift b/RxCodeMobile/Views/Autopilot/MobileDocsView.swift new file mode 100644 index 00000000..dcd3576f --- /dev/null +++ b/RxCodeMobile/Views/Autopilot/MobileDocsView.swift @@ -0,0 +1,369 @@ +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +/// Documentation management: docs-indexed repositories, their documents, and CI +/// upload-token / GitHub-secret setup. +struct MobileDocsView: View { + @EnvironmentObject private var state: MobileAppState + + @State private var repos: [DocsRepo] = [] + @State private var search = "" + @State private var isLoading = true + @State private var errorMessage: String? + @State private var showingAdd = false + @State private var pendingDelete: DocsRepo? + @State private var searchTask: Task? + + var body: some View { + List { + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundStyle(.orange) + } + ForEach(repos) { repo in + NavigationLink { + MobileDocsRepoDetailView(repo: repo) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(repo.repositoryFullName) + if let count = repo.documentsCount { + Text("\(count) document(s)").font(.caption).foregroundStyle(.secondary) + } + } + } + .swipeActions { + Button("Remove", role: .destructive) { pendingDelete = repo } + } + } + if repos.isEmpty, !isLoading { + Text("No documentation repositories. Tap + to add one.").foregroundStyle(.secondary) + } + } + .navigationTitle("Documentation") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $search) + .onChange(of: search) { _, _ in + searchTask?.cancel() + searchTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + await reload() + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showingAdd = true } label: { Image(systemName: "plus") } + } + } + .sheet(isPresented: $showingAdd) { + MobileAutopilotRepoPicker( + title: "Add Docs Repository", + existingRepoFullNames: Set(repos.map { $0.repositoryFullName.lowercased() }) + ) { repo in + await add(repo) + } + .environmentObject(state) + .mobileSheetPresentation() + } + .alert( + "Remove repository?", + isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } }) + ) { + Button("Cancel", role: .cancel) {} + Button("Remove", role: .destructive) { + if let repo = pendingDelete { Task { await delete(repo) } } + pendingDelete = nil + } + } message: { + Text("This removes the repository and its indexed documents from docs search.") + } + .mobileAutopilotLoadingOverlay(isLoading && repos.isEmpty) + .refreshable { await reload() } + .task { await reload() } + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + repos = try await state.listDocsRepos(search: search).items + } catch { + errorMessage = error.localizedDescription + } + } + + private func add(_ repo: SecretsManagedRepo) async { + do { + try await state.addDocsRepo(AddDocsRepoBody( + installationId: repo.installationId, + repositoryId: repo.id, + repositoryFullName: repo.fullName + )) + await reload() + } catch { + errorMessage = error.localizedDescription + } + } + + private func delete(_ repo: DocsRepo) async { + do { + try await state.deleteDocsRepo(id: repo.id) + await reload() + } catch { + errorMessage = error.localizedDescription + } + } +} + +struct MobileDocsRepoDetailView: View { + @EnvironmentObject private var state: MobileAppState + let repo: DocsRepo + + @State private var documents: [DocsDocument] = [] + @State private var isLoading = true + @State private var errorMessage: String? + @State private var statusMessage: String? + @State private var isInstallingSecret = false + @State private var showingAddDocument = false + @State private var viewingDocument: DocsDocument? + @State private var pendingDelete: DocsDocument? + + var body: some View { + List { + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundStyle(.orange) + } + if let statusMessage { + Label(statusMessage, systemImage: "checkmark.circle").font(.footnote).foregroundStyle(.green) + } + + Section { + Button { + Task { await installSecret() } + } label: { + if isInstallingSecret { + HStack { ProgressView(); Text("Installing…") } + } else { + Label("Install CI upload token", systemImage: "key.horizontal") + } + } + .disabled(isInstallingSecret) + } header: { + Text("CI") + } footer: { + Text("Mints a DOCS_UPLOAD_TOKEN and installs it as the repo's GitHub Actions secret so CI can publish docs automatically.") + } + + Section("Documents") { + if documents.isEmpty, !isLoading { + Text("No documents indexed yet.").foregroundStyle(.secondary) + } + ForEach(documents) { doc in + Button { + viewingDocument = doc + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(doc.title ?? doc.docId).foregroundStyle(.primary) + HStack(spacing: 6) { + Text(doc.docId).font(.caption).foregroundStyle(.secondary) + if let status = doc.embeddingStatus { + Text(status).font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 1) + .background(Color.secondary.opacity(0.15), in: Capsule()) + .foregroundStyle(.secondary) + } + } + } + } + .swipeActions { + Button("Delete", role: .destructive) { pendingDelete = doc } + } + } + } + } + .navigationTitle(repo.repositoryFullName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showingAddDocument = true } label: { Image(systemName: "plus") } + } + } + .sheet(isPresented: $showingAddDocument) { + MobileDocsUploadView(repoId: repo.id) { await reload() } + .environmentObject(state) + .mobileSheetPresentation() + } + .sheet(item: $viewingDocument) { doc in + MobileDocumentView(repoId: repo.id, docId: doc.docId) + .environmentObject(state) + .mobileSheetPresentation() + } + .alert( + "Delete document?", + isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } }) + ) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + if let doc = pendingDelete { Task { await delete(doc) } } + pendingDelete = nil + } + } + .mobileAutopilotLoadingOverlay(isLoading && documents.isEmpty) + .refreshable { await reload() } + .task { await reload() } + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + documents = try await state.listDocuments(repoId: repo.id).items + } catch { + errorMessage = error.localizedDescription + } + } + + private func installSecret() async { + isInstallingSecret = true + statusMessage = nil + errorMessage = nil + defer { isInstallingSecret = false } + do { + let result = try await state.installDocsGithubSecret(repoId: repo.id) + statusMessage = "Installed \(result.secretName)." + } catch { + errorMessage = error.localizedDescription + } + } + + private func delete(_ doc: DocsDocument) async { + do { + try await state.deleteDocument(repoId: repo.id, docId: doc.docId) + await reload() + } catch { + errorMessage = error.localizedDescription + } + } +} + +/// Read-only markdown view of a single document's current version. +struct MobileDocumentView: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + let repoId: String + let docId: String + + @State private var content: String = "" + @State private var isLoading = true + @State private var errorMessage: String? + + var body: some View { + NavigationStack { + ScrollView { + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange).padding() + } else if !isLoading { + MarkdownContentView(text: content) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle(docId) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .mobileAutopilotLoadingOverlay(isLoading) + .task { await load() } + } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + let detail = try await state.getDocument(repoId: repoId, docId: docId) + content = detail.currentVersion?.content ?? "(no content)" + } catch { + errorMessage = error.localizedDescription + } + } +} + +/// Upload (create/update) a document by pasting markdown. +struct MobileDocsUploadView: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + let repoId: String + let onUploaded: () async -> Void + + @State private var docId = "" + @State private var content = "" + @State private var originalLink = "" + @State private var isSaving = false + @State private var errorMessage: String? + + var body: some View { + NavigationStack { + Form { + Section("Document ID (slug)") { + TextField("design/overview", text: $docId) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + Section("Source link (optional)") { + TextField("https://…", text: $originalLink) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + } + Section("Markdown") { + TextEditor(text: $content) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 240) + } + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundStyle(.orange) + } + } + .navigationTitle("Add Document") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Upload") { Task { await upload() } } + .disabled(isSaving || docId.trimmingCharacters(in: .whitespaces).isEmpty || content.isEmpty) + } + } + } + } + + private func upload() async { + isSaving = true + errorMessage = nil + defer { isSaving = false } + let link = originalLink.trimmingCharacters(in: .whitespacesAndNewlines) + do { + _ = try await state.uploadDocument( + repoId: repoId, + docId: docId.trimmingCharacters(in: .whitespacesAndNewlines), + content: content, + originalLink: link.isEmpty ? nil : link + ) + await onUploaded() + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCodeMobile/Views/Autopilot/MobileReleaseView.swift b/RxCodeMobile/Views/Autopilot/MobileReleaseView.swift new file mode 100644 index 00000000..26e737bb --- /dev/null +++ b/RxCodeMobile/Views/Autopilot/MobileReleaseView.swift @@ -0,0 +1,319 @@ +import RxCodeCore +import RxCodeSync +import SwiftUI + +/// Release management: registered release repositories, their scanned +/// workflows, the RELEASE_TOKEN secret, and triggering releases. +struct MobileReleaseView: View { + @EnvironmentObject private var state: MobileAppState + + @State private var repos: [ReleaseRepo] = [] + @State private var isLoading = true + @State private var errorMessage: String? + @State private var showingAdd = false + @State private var pendingDelete: ReleaseRepo? + + var body: some View { + List { + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundStyle(.orange) + } + ForEach(repos) { repo in + NavigationLink { + MobileReleaseRepoDetailView(repo: repo) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(repo.repositoryFullName) + if let tag = repo.latestReleaseTag { + Text("Latest: \(tag)").font(.caption).foregroundStyle(.secondary) + } + } + } + .swipeActions { + Button("Remove", role: .destructive) { pendingDelete = repo } + } + } + if repos.isEmpty, !isLoading { + Text("No release repositories. Tap + to add one.").foregroundStyle(.secondary) + } + } + .navigationTitle("Releases") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showingAdd = true } label: { Image(systemName: "plus") } + } + } + .sheet(isPresented: $showingAdd) { + MobileAutopilotRepoPicker( + title: "Add Release Repository", + existingRepoFullNames: Set(repos.map { $0.repositoryFullName.lowercased() }) + ) { repo in + await add(repo) + } + .environmentObject(state) + .mobileSheetPresentation() + } + .alert( + "Remove repository?", + isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } }) + ) { + Button("Cancel", role: .cancel) {} + Button("Remove", role: .destructive) { + if let repo = pendingDelete { Task { await delete(repo) } } + pendingDelete = nil + } + } + .mobileAutopilotLoadingOverlay(isLoading && repos.isEmpty) + .refreshable { await reload() } + .task { await reload() } + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + repos = try await state.listReleaseRepos().items + } catch { + errorMessage = error.localizedDescription + } + } + + private func add(_ repo: SecretsManagedRepo) async { + do { + try await state.addReleaseRepo(AddReleaseRepoBody( + installationId: repo.installationId, + repositoryId: repo.id, + repositoryFullName: repo.fullName + )) + await reload() + } catch { + errorMessage = error.localizedDescription + } + } + + private func delete(_ repo: ReleaseRepo) async { + do { + try await state.deleteReleaseRepo(id: repo.id) + await reload() + } catch { + errorMessage = error.localizedDescription + } + } +} + +struct MobileReleaseRepoDetailView: View { + @EnvironmentObject private var state: MobileAppState + let repo: ReleaseRepo + + @State private var workflows: [MobileReleaseWorkflow] = [] + @State private var isLoading = true + @State private var errorMessage: String? + @State private var statusMessage: String? + @State private var tokenValue = "" + @State private var isInstallingToken = false + @State private var dispatchWorkflow: MobileReleaseWorkflow? + + var body: some View { + List { + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundStyle(.orange) + } + if let statusMessage { + Label(statusMessage, systemImage: "checkmark.circle").font(.footnote).foregroundStyle(.green) + } + + Section("Workflows") { + if workflows.isEmpty, !isLoading { + Text("No workflows scanned yet.").foregroundStyle(.secondary) + } + ForEach(workflows) { workflow in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(workflow.displayName) + Text(workflow.workflowPath).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + if workflow.isSelected { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + } + if workflow.hasWorkflowDispatch { + Button("Release") { dispatchWorkflow = workflow } + .buttonStyle(.borderless) + } + } + } + } + + Section { + SecureField("ghp_… or github_pat_…", text: $tokenValue) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button { + Task { await installToken() } + } label: { + if isInstallingToken { + HStack { ProgressView(); Text("Installing…") } + } else { + Label("Install RELEASE_TOKEN", systemImage: "key.horizontal") + } + } + .disabled(isInstallingToken || tokenValue.trimmingCharacters(in: .whitespaces).isEmpty) + } header: { + Text("RELEASE_TOKEN") + } footer: { + Text("Installed as the repo's GitHub Actions secret so the release workflow can publish.") + } + } + .navigationTitle(repo.repositoryFullName) + .navigationBarTitleDisplayMode(.inline) + .sheet(item: $dispatchWorkflow) { workflow in + MobileReleaseDispatchView(repo: repo, workflow: workflow) { message in + statusMessage = message + await reload() + } + .environmentObject(state) + .mobileSheetPresentation() + } + .mobileAutopilotLoadingOverlay(isLoading && workflows.isEmpty) + .refreshable { await reload() } + .task { await reload() } + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + workflows = try await state.listReleaseWorkflows(repoId: repo.id) + } catch { + errorMessage = error.localizedDescription + } + } + + private func installToken() async { + isInstallingToken = true + statusMessage = nil + errorMessage = nil + defer { isInstallingToken = false } + do { + let result = try await state.installReleaseToken(repoId: repo.id, value: tokenValue.trimmingCharacters(in: .whitespacesAndNewlines)) + statusMessage = "Installed \(result.secretName)." + tokenValue = "" + } catch { + errorMessage = error.localizedDescription + } + } +} + +/// Fills out a release workflow's `workflow_dispatch` inputs and triggers it. +struct MobileReleaseDispatchView: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + let repo: ReleaseRepo + let workflow: MobileReleaseWorkflow + let onDispatched: (String) async -> Void + + @State private var branch: String + @State private var inputValues: [String: String] = [:] + @State private var isDispatching = false + @State private var errorMessage: String? + + init(repo: ReleaseRepo, workflow: MobileReleaseWorkflow, onDispatched: @escaping (String) async -> Void) { + self.repo = repo + self.workflow = workflow + self.onDispatched = onDispatched + _branch = State(initialValue: repo.repositoryFullName.isEmpty ? "main" : "main") + } + + var body: some View { + NavigationStack { + Form { + Section("Branch") { + TextField("main", text: $branch) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + if let inputs = workflow.inputs, !inputs.isEmpty { + Section("Inputs") { + ForEach(inputs) { input in + inputRow(input) + } + } + } + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundStyle(.orange) + } + } + .navigationTitle("Create Release") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Dispatch") { Task { await dispatch() } } + .disabled(isDispatching || branch.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .onAppear(perform: seedDefaults) + } + } + + @ViewBuilder + private func inputRow(_ input: MobileReleaseWorkflowInput) -> some View { + let binding = Binding( + get: { inputValues[input.name] ?? input.defaultString ?? "" }, + set: { inputValues[input.name] = $0 } + ) + if input.type == "boolean" { + Toggle(input.name, isOn: Binding( + get: { (inputValues[input.name] ?? (input.defaultBool == true ? "true" : "false")) == "true" }, + set: { inputValues[input.name] = $0 ? "true" : "false" } + )) + } else if input.type == "choice", let options = input.options { + Picker(input.name, selection: binding) { + ForEach(options, id: \.self) { Text($0).tag($0) } + } + } else { + VStack(alignment: .leading, spacing: 2) { + Text(input.name).font(.caption).foregroundStyle(.secondary) + TextField(input.description ?? input.name, text: binding) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + } + + private func seedDefaults() { + for input in workflow.inputs ?? [] where inputValues[input.name] == nil { + if let value = input.defaultString { inputValues[input.name] = value } + } + } + + private func dispatch() async { + isDispatching = true + errorMessage = nil + defer { isDispatching = false } + do { + let result = try await state.dispatchRelease( + repoId: repo.id, + request: ReleaseDispatchRequest( + workflowId: workflow.id, + branch: branch.trimmingCharacters(in: .whitespacesAndNewlines), + inputs: inputValues + ) + ) + if result.success == false, let error = result.error { + errorMessage = error + return + } + await onDispatched("Release dispatched.") + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCodeMobile/Views/Autopilot/MobileRepoSetupView.swift b/RxCodeMobile/Views/Autopilot/MobileRepoSetupView.swift new file mode 100644 index 00000000..471138a7 --- /dev/null +++ b/RxCodeMobile/Views/Autopilot/MobileRepoSetupView.swift @@ -0,0 +1,294 @@ +import JSONSchema +import JSONSchemaForm +import RxCodeCore +import RxCodeSync +import SwiftUI + +/// Repo-setup templates: reusable GitHub merge settings + branch rulesets +/// applied to new repositories. Mirrors the desktop `RepoSetupManageSheet`. +struct MobileRepoSetupView: View { + @EnvironmentObject private var state: MobileAppState + + @State private var templates: [RepoSetupTemplate] = [] + @State private var isLoading = true + @State private var errorMessage: String? + @State private var editingTemplate: EditingTarget? + @State private var pendingDelete: RepoSetupTemplate? + + enum EditingTarget: Identifiable { + case new + case existing(RepoSetupTemplate) + var id: String { + switch self { + case .new: return "new" + case .existing(let template): return template.id + } + } + } + + var body: some View { + List { + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundStyle(.orange) + } + Section { + Text("Templates of GitHub merge settings and branch rulesets. The default template is applied to newly created repositories.") + .font(.caption).foregroundStyle(.secondary) + } + ForEach(templates) { template in + Button { + editingTemplate = .existing(template) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(template.name).foregroundStyle(.primary) + HStack(spacing: 6) { + if template.isDefault { + Text("Default").font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 1) + .background(Color.accentColor.opacity(0.15), in: Capsule()) + } + Text(template.enabled ? "Enabled" : "Disabled") + .font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary) + } + } + .swipeActions { + Button("Delete", role: .destructive) { pendingDelete = template } + } + } + if templates.isEmpty, !isLoading { + Text("No templates yet. Tap + to create one.").foregroundStyle(.secondary) + } + } + .navigationTitle("Repo Setup") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { editingTemplate = .new } label: { Image(systemName: "plus") } + } + } + .sheet(item: $editingTemplate) { target in + MobileRepoSetupEditor(target: target) { await reload() } + .environmentObject(state) + .mobileSheetPresentation() + } + .alert( + "Delete template?", + isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } }) + ) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + if let template = pendingDelete { Task { await delete(template) } } + pendingDelete = nil + } + } + .mobileAutopilotLoadingOverlay(isLoading && templates.isEmpty) + .refreshable { await reload() } + .task { await reload() } + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + templates = try await state.repoSetupTemplates() + } catch { + errorMessage = error.localizedDescription + } + } + + private func delete(_ template: RepoSetupTemplate) async { + do { + try await state.deleteRepoSetupTemplate(id: template.id) + await reload() + } catch { + errorMessage = error.localizedDescription + } + } +} + +struct MobileRepoSetupEditor: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + let target: MobileRepoSetupView.EditingTarget + let onSaved: () async -> Void + + @State private var name = "" + @State private var enabled = true + @State private var isDefault = false + + @State private var schema: JSONSchema? + @State private var uiSchema: [String: Any]? + @State private var formData: FormData = .object(properties: [:]) + @State private var controller = JSONSchemaFormController() + @State private var useFallback = false + @State private var mergeSettingsJSON = "{}" + + @State private var rulesetJSON = "" + + @State private var isLoading = true + @State private var loadError: String? + @State private var isSaving = false + @State private var errorMessage: String? + + private var existing: RepoSetupTemplate? { + if case .existing(let template) = target { return template } + return nil + } + + var body: some View { + NavigationStack { + Form { + if isLoading { + HStack(spacing: 8) { ProgressView(); Text("Loading…").foregroundStyle(.secondary) } + } else if let loadError { + VStack(spacing: 8) { + Label(loadError, systemImage: "exclamationmark.triangle").foregroundStyle(.orange) + Button("Retry") { Task { await load() } } + } + } else { + Section("Template") { + TextField("Name", text: $name) + Toggle("Enabled", isOn: $enabled) + Toggle("Default for new repositories", isOn: $isDefault) + } + + Section("Merge Settings") { + if useFallback { + Text("Form couldn't be rendered — edit raw JSON.") + .font(.caption).foregroundStyle(.secondary) + TextEditor(text: $mergeSettingsJSON) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 180) + } else if let schema { + JSONSchemaForm( + schema: schema, + uiSchema: uiSchema, + formData: $formData, + liveValidate: true, + showSubmitButton: false, + controller: controller + ) + } + } + + Section { + TextEditor(text: $rulesetJSON) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 140) + } header: { + Text("Branch Ruleset (optional)") + } footer: { + Text("Paste a GitHub ruleset JSON with name, target, enforcement, and rules.") + } + + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundStyle(.orange) + } + } + } + .navigationTitle(existing == nil ? "New Template" : "Edit Template") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { Task { await save() } } + .disabled(isSaving || isLoading || loadError != nil || name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .task { await load() } + } + } + + private func load() async { + isLoading = true + loadError = nil + defer { isLoading = false } + if let existing { + name = existing.name + enabled = existing.enabled + isDefault = existing.isDefault + mergeSettingsJSON = existing.mergeSettings.prettyJSONString + rulesetJSON = existing.rulesetConfig?.prettyJSONString ?? "" + } + do { + let env = try await state.repoSetupSchema() + uiSchema = env.uiSchema?.foundationObject as? [String: Any] + do { + let parsed = try JSONSchema(jsonString: env.schema.rawJSONString) + let seedJSON = existing?.mergeSettings.rawJSONString ?? "{}" + let seeded = try FormData.fromJSONString(seedJSON) + formData = seeded.applyingDefaults(schema: parsed) + schema = parsed + useFallback = false + } catch { + useFallback = true + } + } catch { + loadError = error.localizedDescription + } + } + + private func save() async { + isSaving = true + errorMessage = nil + defer { isSaving = false } + do { + // Merge settings + let mergeSettings: JSONValue + if useFallback { + guard let data = mergeSettingsJSON.data(using: .utf8), + let parsed = try? JSONValue(jsonData: data) else { + errorMessage = "Merge settings must be valid JSON." + return + } + mergeSettings = parsed + } else { + let ok = try await controller.submit() + guard ok else { + errorMessage = "Please fix the highlighted fields." + return + } + let data = try JSONEncoder().encode(formData) + mergeSettings = try JSONValue(jsonData: data) + } + + // Optional ruleset + var rulesetConfig: JSONValue? + let trimmedRuleset = rulesetJSON.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedRuleset.isEmpty { + switch GitHubRulesetSummary.validate(rawJSON: trimmedRuleset) { + case .success(let summary): + rulesetConfig = summary.value + case .failure(let validationError): + errorMessage = validationError.localizedDescription + return + } + } + + let input = RepoSetupTemplateInput( + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + enabled: enabled, + isDefault: isDefault, + mergeSettings: mergeSettings, + rulesetConfig: rulesetConfig + ) + if let existing { + _ = try await state.updateRepoSetupTemplate(id: existing.id, input) + } else { + _ = try await state.createRepoSetupTemplate(input) + } + await onSaved() + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCodeMobile/Views/Autopilot/MobileSecretsView.swift b/RxCodeMobile/Views/Autopilot/MobileSecretsView.swift new file mode 100644 index 00000000..2250bc3c --- /dev/null +++ b/RxCodeMobile/Views/Autopilot/MobileSecretsView.swift @@ -0,0 +1,423 @@ +import RxCodeCore +import RxCodeSync +import SwiftUI + +/// Secrets management: repositories → environments → encrypted files. The +/// desktop holds the passkey-derived key and performs all encryption and +/// decryption; plaintext only ever travels over the already-E2E sync channel. +/// Enrollment itself stays a Mac-side passkey action. +struct MobileSecretsView: View { + @EnvironmentObject private var state: MobileAppState + + @State private var enrolled: Bool? + @State private var repos: [SecretsManagedRepo] = [] + @State private var search = "" + @State private var isLoading = true + @State private var errorMessage: String? + @State private var searchTask: Task? + + var body: some View { + List { + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundStyle(.orange) + } + switch enrolled { + case .some(false): + Section { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Encryption not set up") + Text("Enroll with your passkey on your Mac to manage secrets.") + .font(.caption).foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "lock.slash").foregroundStyle(.orange) + } + } + case .some(true): + ForEach(repos) { repo in + NavigationLink { + MobileSecretsRepoView(repoFullName: repo.fullName) + } label: { + HStack(spacing: 10) { + Image(systemName: repo.isManaged ? "key.fill" : "key") + .foregroundStyle(repo.isManaged ? Color.accentColor : .secondary) + VStack(alignment: .leading, spacing: 2) { + Text(repo.fullName) + if repo.isManaged { + Text("\(repo.environmentsCount) environment(s), \(repo.filesCount) file(s)") + .font(.caption).foregroundStyle(.secondary) + } + } + } + } + } + if repos.isEmpty, !isLoading { + Text("No repositories available.").foregroundStyle(.secondary) + } + case .none: + if isLoading { + HStack(spacing: 8) { ProgressView(); Text("Loading…").foregroundStyle(.secondary) } + } + } + } + .navigationTitle("Secrets") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $search) + .onChange(of: search) { _, _ in + searchTask?.cancel() + searchTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + await reloadRepos() + } + } + .mobileAutopilotLoadingOverlay(enrolled == nil && isLoading) + .refreshable { await load() } + .task { await load() } + } + + private func load() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + enrolled = try await state.secretsEnrollmentStatus() + if enrolled == true { await reloadRepos() } + } catch { + errorMessage = error.localizedDescription + } + } + + private func reloadRepos() async { + do { + repos = try await state.listAutopilotRepos(search: search).items + } catch { + errorMessage = error.localizedDescription + } + } +} + +/// Environments for one secrets repo. +struct MobileSecretsRepoView: View { + @EnvironmentObject private var state: MobileAppState + let repoFullName: String + + @State private var environments: [SecretsEnvironment] = [] + @State private var isLoading = true + @State private var errorMessage: String? + @State private var showingAdd = false + @State private var newName = "" + @State private var pendingDelete: SecretsEnvironment? + + var body: some View { + List { + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundStyle(.orange) + } + ForEach(environments) { env in + NavigationLink { + MobileSecretsEnvironmentView(repoFullName: repoFullName, environment: env) + } label: { + HStack { + Label(env.name, systemImage: "leaf") + Spacer() + if let count = env.filesCount { + Text("\(count)").foregroundStyle(.secondary) + } + } + } + .swipeActions { + Button("Delete", role: .destructive) { pendingDelete = env } + } + } + if environments.isEmpty, !isLoading { + Text("No environments yet. Tap + to add one.").foregroundStyle(.secondary) + } + } + .navigationTitle(repoFullName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { newName = ""; showingAdd = true } label: { Image(systemName: "plus") } + } + } + .alert("New environment", isPresented: $showingAdd) { + TextField("Name (e.g. production)", text: $newName) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button("Cancel", role: .cancel) {} + Button("Create") { Task { await create() } } + } + .alert( + "Delete environment?", + isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } }) + ) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + if let env = pendingDelete { Task { await delete(env) } } + pendingDelete = nil + } + } message: { + Text("This permanently deletes the environment and its secret files.") + } + .mobileAutopilotLoadingOverlay(isLoading && environments.isEmpty) + .refreshable { await reload() } + .task { await reload() } + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + environments = try await state.listSecretEnvironments(repo: repoFullName) + } catch { + errorMessage = error.localizedDescription + } + } + + private func create() async { + let name = newName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + do { + try await state.createSecretEnvironment(repo: repoFullName, name: name) + await reload() + } catch { + errorMessage = error.localizedDescription + } + } + + private func delete(_ env: SecretsEnvironment) async { + do { + try await state.deleteSecretEnvironment(repo: repoFullName, envId: env.id) + await reload() + } catch { + errorMessage = error.localizedDescription + } + } +} + +/// Files in one environment, with on-demand decrypted values. +struct MobileSecretsEnvironmentView: View { + @EnvironmentObject private var state: MobileAppState + let repoFullName: String + let environment: SecretsEnvironment + + @State private var files: [SecretsFileMeta] = [] + @State private var plaintextByName: [String: String] = [:] + @State private var revealed = false + @State private var isLoading = true + @State private var errorMessage: String? + @State private var editingFile: EditingFile? + @State private var pendingDelete: SecretsFileMeta? + + struct EditingFile: Identifiable { + let id = UUID() + var filename: String + var content: String + let isNew: Bool + } + + var body: some View { + List { + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundStyle(.orange) + } + Section { + Button { + Task { await toggleReveal() } + } label: { + Label(revealed ? "Hide values" : "View values", + systemImage: revealed ? "eye.slash" : "eye") + } + } + Section("Files") { + ForEach(files) { file in + Button { + Task { await edit(file) } + } label: { + VStack(alignment: .leading, spacing: 3) { + Text(file.filename).foregroundStyle(.primary) + if revealed, let value = plaintextByName[file.filename] { + Text(value) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + } + .swipeActions { + Button("Delete", role: .destructive) { pendingDelete = file } + } + } + if files.isEmpty, !isLoading { + Text("No files yet. Tap + to add one.").foregroundStyle(.secondary) + } + } + } + .navigationTitle(environment.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + editingFile = EditingFile(filename: "", content: "", isNew: true) + } label: { Image(systemName: "plus") } + } + } + .sheet(item: $editingFile) { item in + MobileSecretFileEditor(repoFullName: repoFullName, envId: environment.id, file: item) { + await reload() + if revealed { await loadPlaintext() } + } + .environmentObject(state) + .mobileSheetPresentation() + } + .alert( + "Delete file?", + isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } }) + ) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + if let file = pendingDelete { Task { await delete(file) } } + pendingDelete = nil + } + } + .mobileAutopilotLoadingOverlay(isLoading && files.isEmpty) + .refreshable { await reload() } + .task { await reload() } + } + + private func reload() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + files = try await state.listSecretFiles(repo: repoFullName, envId: environment.id) + } catch { + errorMessage = error.localizedDescription + } + } + + private func toggleReveal() async { + if revealed { + revealed = false + return + } + await loadPlaintext() + revealed = true + } + + private func loadPlaintext() async { + do { + let files = try await state.secretEnvironmentPlaintext(repo: repoFullName, envId: environment.id) + plaintextByName = Dictionary(files.map { ($0.filename, $0.content) }, uniquingKeysWith: { _, last in last }) + } catch { + errorMessage = error.localizedDescription + } + } + + private func edit(_ file: SecretsFileMeta) async { + // Ensure we have the plaintext for this file before opening the editor. + if plaintextByName[file.filename] == nil { + await loadPlaintext() + } + editingFile = EditingFile( + filename: file.filename, + content: plaintextByName[file.filename] ?? "", + isNew: false + ) + } + + private func delete(_ file: SecretsFileMeta) async { + do { + try await state.deleteSecretFile(repo: repoFullName, envId: environment.id, fileId: file.id) + await reload() + } catch { + errorMessage = error.localizedDescription + } + } +} + +/// Create or edit a single secret file. The desktop re-encrypts on save. +struct MobileSecretFileEditor: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + let repoFullName: String + let envId: String + let file: MobileSecretsEnvironmentView.EditingFile + let onSaved: () async -> Void + + @State private var filename: String + @State private var content: String + @State private var isSaving = false + @State private var errorMessage: String? + + init( + repoFullName: String, + envId: String, + file: MobileSecretsEnvironmentView.EditingFile, + onSaved: @escaping () async -> Void + ) { + self.repoFullName = repoFullName + self.envId = envId + self.file = file + self.onSaved = onSaved + _filename = State(initialValue: file.filename) + _content = State(initialValue: file.content) + } + + var body: some View { + NavigationStack { + Form { + Section("Filename") { + TextField(".env", text: $filename) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .disabled(!file.isNew) + } + Section("Contents") { + TextEditor(text: $content) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 200) + } + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.footnote).foregroundStyle(.orange) + } + } + .navigationTitle(file.isNew ? "Add File" : filename) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { Task { await save() } } + .disabled(isSaving || filename.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + } + + private func save() async { + isSaving = true + errorMessage = nil + defer { isSaving = false } + do { + try await state.upsertSecretFile( + repo: repoFullName, + envId: envId, + filename: filename.trimmingCharacters(in: .whitespacesAndNewlines), + content: content + ) + await onSaved() + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/RxCodeMobile/Views/MobileSettingsView.swift b/RxCodeMobile/Views/MobileSettingsView.swift index 84b4c145..5352ca35 100644 --- a/RxCodeMobile/Views/MobileSettingsView.swift +++ b/RxCodeMobile/Views/MobileSettingsView.swift @@ -217,10 +217,15 @@ struct MobileSettingsView: View { } label: { Label("MCP Servers", systemImage: "server.rack") } + NavigationLink { + MobileAutopilotView() + } label: { + Label("Autopilot", systemImage: "airplane") + } } header: { Text("Desktop Configuration") } footer: { - Text("Install skills and agents, and configure MCP servers on the active Mac.") + Text("Install skills and agents, configure MCP servers, and manage Autopilot on the active Mac.") } } From 183918949d09ce483b460e4726984346a6fb1610 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:13:53 +0800 Subject: [PATCH 2/8] feat: mirror autopilot project/briefing context menu to iOS Add a one-to-one mobile mirror of the desktop autopilot context menu (secrets / docs / release) on the project sidebar card and briefing detail surfaces, plus a first-add loading overlay + secrets download form when a repo-backed project is created. Actions are desktop-mediated over the existing RxCodeSync relay via a new `project` AutopilotDomain (status + setup/search/create/download ops): the Mac reuses its own projectHasSecrets/Docs/ReleaseWorkflow checks, the same context-menu request properties, and decrypts + writes secrets into the project folder so plaintext never leaves it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Protocol/Payload+Autopilot.swift | 69 ++++ RxCode/App/AppState+MobileAutopilot.swift | 77 ++++ .../State/MobileAppState+Autopilot.swift | 46 +++ .../Autopilot/ProjectAutopilotMenu.swift | 344 ++++++++++++++++++ .../Views/MobileBriefingDetailView.swift | 45 ++- RxCodeMobile/Views/ProjectsSidebar.swift | 54 +++ .../Views/RemoteFolderPickerView.swift | 19 +- 7 files changed, 647 insertions(+), 7 deletions(-) create mode 100644 RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift index 9d4575ed..ca22e8b3 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift @@ -26,6 +26,7 @@ public enum AutopilotDomain: String, Codable, Sendable { case ciUpdates case docs case release + case project } /// Every autopilot operation, globally unique so the desktop can switch on it @@ -92,6 +93,19 @@ public enum AutopilotOp: String, Codable, Sendable { case releaseListWorkflows case releaseDispatch case releaseInstallToken + + // Project context-menu actions — desktop-mediated so the Mac performs the + // exact work its own project/briefing context menu does. `*Setup` ops set + // the same AppState request the desktop menu sets (the Mac surfaces the + // setup chat/sheet); `projectSecretsDownload` decrypts on the Mac and writes + // the files into the project folder (the phone never sees plaintext). + case projectAutopilotStatus + case projectSecretsSetup + case projectDocsSetup + case projectDocsSearch + case projectReleaseSetup + case projectReleaseCreate + case projectSecretsDownload } /// Mobile → desktop: a single autopilot operation. `body` is a JSON-encoded @@ -435,3 +449,58 @@ public struct CIPullRequestList: Codable, Sendable { public let items: [CIPullRequest] public init(items: [CIPullRequest]) { self.items = items } } + +// MARK: - Project context-menu actions + +/// Addresses a project by its local id; the desktop resolves it to the linked +/// GitHub repo and on-disk path. +public struct AutopilotProjectBody: Codable, Sendable { + public let projectId: UUID + public init(projectId: UUID) { self.projectId = projectId } +} + +/// Per-project autopilot state powering the mobile context menu. Mirrors the +/// desktop's `projectHasSecrets` / `projectHasDocs` / `projectHasReleaseWorkflow` +/// checks so the phone can pick the same menu items (Download vs Set Up, etc.). +public struct AutopilotProjectStatus: Codable, Sendable { + public let gitHubRepo: String? + public let hasSecrets: Bool + public let hasDocs: Bool + public let hasRelease: Bool + + public init(gitHubRepo: String?, hasSecrets: Bool, hasDocs: Bool, hasRelease: Bool) { + self.gitHubRepo = gitHubRepo + self.hasSecrets = hasSecrets + self.hasDocs = hasDocs + self.hasRelease = hasRelease + } +} + +/// Ask the desktop to download + decrypt a secret environment and write its +/// files into the project's folder on the Mac. The Mac holds the passkey-derived +/// KEK and does the decryption, mirroring `AutopilotSecretsHook`'s desktop +/// download. `overwrite` clobbers files that already exist in the folder. +public struct AutopilotProjectSecretsDownloadBody: Codable, Sendable { + public let projectId: UUID + public let envId: String + public let overwrite: Bool + + public init(projectId: UUID, envId: String, overwrite: Bool) { + self.projectId = projectId + self.envId = envId + self.overwrite = overwrite + } +} + +/// Result of `projectSecretsDownload`: filenames actually written, plus the +/// files skipped because they already existed (only when `overwrite` was false). +/// The phone re-issues with `overwrite: true` to clobber the conflicts. +public struct AutopilotProjectSecretsDownloadResult: Codable, Sendable { + public let written: [String] + public let conflicts: [String] + + public init(written: [String], conflicts: [String]) { + self.written = written + self.conflicts = conflicts + } +} diff --git a/RxCode/App/AppState+MobileAutopilot.swift b/RxCode/App/AppState+MobileAutopilot.swift index fd38c41b..f4e24271 100644 --- a/RxCode/App/AppState+MobileAutopilot.swift +++ b/RxCode/App/AppState+MobileAutopilot.swift @@ -253,6 +253,83 @@ extension AppState { case .releaseInstallToken: let body = try decodeAutopilotBody(request, as: AutopilotReleaseTokenBody.self) return try encoder.encode(try await release.installReleaseToken(repoId: body.repoId, value: body.value)) + + // MARK: Project context-menu actions (desktop-mediated 1:1 with the + // macOS project/briefing context menu — see AutopilotSecretsHook / + // AutopilotDocsHook / AutopilotReleaseHook). + case .projectAutopilotStatus: + let project = try autopilotProject(request) + let status = AutopilotProjectStatus( + gitHubRepo: project.gitHubRepo, + hasSecrets: projectHasSecrets(project), + hasDocs: projectHasDocs(project), + hasRelease: projectHasReleaseWorkflow(project) + ) + return try encoder.encode(status) + + case .projectSecretsSetup: + // Same request the desktop "Set Up Secrets" menu item sets; the Mac + // surfaces the secrets-setup form / chat for the project. + let project = try autopilotProject(request) + secretsSetupRequest = SecretsSetupRequest( + repoFullName: project.gitHubRepo, + projectPath: project.path, + filename: nil + ) + return nil + + case .projectDocsSetup: + let project = try autopilotProject(request) + docsSetupRequest = DocsSetupRequest(projectId: project.id, repoFullName: project.gitHubRepo) + return nil + + case .projectDocsSearch: + // Same as the desktop "Search Docs" item — opens the docs-capable + // search overlay on the Mac. + _ = try autopilotProject(request) + docsSearchRequest = UUID() + return nil + + case .projectReleaseSetup: + let project = try autopilotProject(request) + releaseSetupRequest = ReleaseSetupRequest(projectId: project.id, repoFullName: project.gitHubRepo) + return nil + + case .projectReleaseCreate: + // Same as the desktop "Create Release" item — presents the + // create-release sheet on the Mac, pinned to this project. + let project = try autopilotProject(request) + releaseCreateRequest = project + return nil + + case .projectSecretsDownload: + let body = try decodeAutopilotBody(request, as: AutopilotProjectSecretsDownloadBody.self) + guard let project = projects.first(where: { $0.id == body.projectId }) else { + throw MobileRemoteConfigError.invalidRequest("No project found for the requested id.") + } + guard let repo = project.gitHubRepo else { + throw MobileRemoteConfigError.invalidRequest("Project is not linked to a GitHub repo.") + } + let bundle = try await secrets.bundle(repo: repo, env: body.envId) + let files = try await decryptSecretBundle(bundle) + let directory = URL(fileURLWithPath: project.path, isDirectory: true) + // Files that would clobber something already in the folder. When + // overwrite is requested nothing is skipped, so there are no conflicts. + let conflicts = body.overwrite ? [] : files + .map(\.filename) + .filter { FileManager.default.fileExists(atPath: directory.appendingPathComponent($0).path) } + let written = try writeDecryptedSecrets(files, to: directory, overwrite: body.overwrite) + return try encoder.encode(AutopilotProjectSecretsDownloadResult(written: written, conflicts: conflicts)) + } + } + + /// Resolves an `AutopilotProjectBody` request to a known project, throwing a + /// descriptive error when the id is missing or unknown. + private func autopilotProject(_ request: AutopilotRequestPayload) throws -> Project { + let body = try decodeAutopilotBody(request, as: AutopilotProjectBody.self) + guard let project = projects.first(where: { $0.id == body.projectId }) else { + throw MobileRemoteConfigError.invalidRequest("No project found for the requested id.") } + return project } } diff --git a/RxCodeMobile/State/MobileAppState+Autopilot.swift b/RxCodeMobile/State/MobileAppState+Autopilot.swift index 8e72212c..99e920dd 100644 --- a/RxCodeMobile/State/MobileAppState+Autopilot.swift +++ b/RxCodeMobile/State/MobileAppState+Autopilot.swift @@ -364,4 +364,50 @@ extension MobileAppState { func installReleaseToken(repoId: String, value: String) async throws -> ReleaseGithubSecretResult { try await autopilotSend(.release, .releaseInstallToken, body: AutopilotReleaseTokenBody(repoId: repoId, value: value), as: ReleaseGithubSecretResult.self) } + + // MARK: - Project context-menu actions (desktop-mediated, 1:1 with macOS) + + /// Per-project autopilot state (has secrets / docs / release), mirroring the + /// desktop checks that decide which context-menu items to show. + func projectAutopilotStatus(projectId: UUID) async throws -> AutopilotProjectStatus { + try await autopilotSend(.project, .projectAutopilotStatus, + body: AutopilotProjectBody(projectId: projectId), + as: AutopilotProjectStatus.self) + } + + /// Asks the Mac to surface the secrets-setup flow for the project (same as + /// the desktop "Set Up Secrets" menu item). + func requestProjectSecretsSetup(projectId: UUID) async throws { + try await autopilotSendVoid(.project, .projectSecretsSetup, body: AutopilotProjectBody(projectId: projectId)) + } + + /// Asks the Mac to start the docs-setup chat for the project ("Set Up Docs"). + func requestProjectDocsSetup(projectId: UUID) async throws { + try await autopilotSendVoid(.project, .projectDocsSetup, body: AutopilotProjectBody(projectId: projectId)) + } + + /// Asks the Mac to open its docs search overlay ("Search Docs"). + func requestProjectDocsSearch(projectId: UUID) async throws { + try await autopilotSendVoid(.project, .projectDocsSearch, body: AutopilotProjectBody(projectId: projectId)) + } + + /// Asks the Mac to start the release-setup chat for the project. + func requestProjectReleaseSetup(projectId: UUID) async throws { + try await autopilotSendVoid(.project, .projectReleaseSetup, body: AutopilotProjectBody(projectId: projectId)) + } + + /// Asks the Mac to present its create-release sheet for the project. + func requestProjectReleaseCreate(projectId: UUID) async throws { + try await autopilotSendVoid(.project, .projectReleaseCreate, body: AutopilotProjectBody(projectId: projectId)) + } + + /// Asks the Mac to download + decrypt the chosen environment and write its + /// files into the project folder (the Mac holds the KEK; plaintext stays on + /// the Mac). Returns the files written and any skipped conflicts. + @discardableResult + func downloadProjectSecrets(projectId: UUID, envId: String, overwrite: Bool) async throws -> AutopilotProjectSecretsDownloadResult { + try await autopilotSend(.project, .projectSecretsDownload, + body: AutopilotProjectSecretsDownloadBody(projectId: projectId, envId: envId, overwrite: overwrite), + as: AutopilotProjectSecretsDownloadResult.self) + } } diff --git a/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift b/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift new file mode 100644 index 00000000..3ef7ee48 --- /dev/null +++ b/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift @@ -0,0 +1,344 @@ +import SwiftUI +import RxCodeCore +import RxCodeSync + +/// One-to-one mobile mirror of the desktop project/briefing autopilot context +/// menu (`AutopilotSecretsHook` / `AutopilotDocsHook` / `AutopilotReleaseHook`). +/// +/// The desktop shows, per project (gated on a linked GitHub repo): +/// • Download Secret / Set Up Secrets (key.fill) +/// • Search Docs / Set Up Docs Search (books.vertical.fill) +/// • Create Release / Set Up Release Workflow (tag.fill) +/// +/// Every item is desktop-mediated — the Mac performs the same work it does when +/// you click the menu there. The one exception that runs UI on the phone is +/// "Download Secret", which opens the on-device environment picker before asking +/// the Mac to decrypt + write the files into the project folder. +/// +/// Drop the buttons into any `Menu { }` or `.contextMenu { }`; the host owns the +/// `@State` for the loaded status, the download sheet, and the info alert and +/// passes them in. Use `projectAutopilotMenu(...)` on a host that already has a +/// stable identity (toolbar button, project card) so the sheet/alert can attach. + +/// A transient message surfaced after a desktop-mediated action fires (e.g. +/// "Secrets setup started on your Mac") or when one fails. +struct AutopilotMenuInfo: Identifiable { + let id = UUID() + let text: String + var isError = false +} + +struct ProjectAutopilotMenuItems: View { + @EnvironmentObject private var state: MobileAppState + let project: Project + /// Loaded per-project state; `nil` while the first fetch is in flight. + let status: AutopilotProjectStatus? + @Binding var showDownloadSheet: Bool + @Binding var info: AutopilotMenuInfo? + + var body: some View { + // Mirror the desktop guard: no repo → no autopilot items. + if project.gitHubRepo == nil { + EmptyView() + } else if let status { + secretsItem(status) + docsItem(status) + releaseItem(status) + } else { + Button {} label: { + Label("Loading Autopilot…", systemImage: "hourglass") + } + .disabled(true) + } + } + + @ViewBuilder + private func secretsItem(_ status: AutopilotProjectStatus) -> some View { + if status.hasSecrets { + Button { + showDownloadSheet = true + } label: { + Label("Download Secret", systemImage: "key.fill") + } + } else { + Button { + fire("Secrets setup started on your Mac.") { + try await state.requestProjectSecretsSetup(projectId: project.id) + } + } label: { + Label("Set Up Secrets", systemImage: "key.fill") + } + } + } + + @ViewBuilder + private func docsItem(_ status: AutopilotProjectStatus) -> some View { + if status.hasDocs { + Button { + fire("Opened docs search on your Mac.") { + try await state.requestProjectDocsSearch(projectId: project.id) + } + } label: { + Label("Search Docs", systemImage: "books.vertical.fill") + } + } else { + Button { + fire("Docs setup started on your Mac.") { + try await state.requestProjectDocsSetup(projectId: project.id) + } + } label: { + Label("Set Up Docs Search", systemImage: "books.vertical.fill") + } + } + } + + @ViewBuilder + private func releaseItem(_ status: AutopilotProjectStatus) -> some View { + if status.hasRelease { + Button { + fire("Opened create-release on your Mac.") { + try await state.requestProjectReleaseCreate(projectId: project.id) + } + } label: { + Label("Create Release", systemImage: "tag.fill") + } + } else { + Button { + fire("Release setup started on your Mac.") { + try await state.requestProjectReleaseSetup(projectId: project.id) + } + } label: { + Label("Set Up Release Workflow", systemImage: "tag.fill") + } + } + } + + /// Runs a desktop-mediated action and reports success/failure via the alert. + private func fire(_ success: String, _ action: @escaping () async throws -> Void) { + Task { + do { + try await action() + info = AutopilotMenuInfo(text: success) + } catch { + info = AutopilotMenuInfo(text: error.localizedDescription, isError: true) + } + } + } +} + +// MARK: - Host modifier + +extension View { + /// Attaches the per-project autopilot status loader, the secrets-download + /// sheet, and the action-result alert to a host view. Pair with + /// `ProjectAutopilotMenuItems` placed inside the host's `Menu`/`.contextMenu`. + func projectAutopilotMenuHost( + project: Project?, + status: Binding, + showDownloadSheet: Binding, + info: Binding, + state: MobileAppState + ) -> some View { + modifier(ProjectAutopilotMenuHostModifier( + project: project, + status: status, + showDownloadSheet: showDownloadSheet, + info: info, + state: state + )) + } +} + +private struct ProjectAutopilotMenuHostModifier: ViewModifier { + let project: Project? + @Binding var status: AutopilotProjectStatus? + @Binding var showDownloadSheet: Bool + @Binding var info: AutopilotMenuInfo? + let state: MobileAppState + + func body(content: Content) -> some View { + content + .task(id: project?.id) { + // Only repo-backed projects have autopilot state to load. + guard let project, project.gitHubRepo != nil else { return } + status = try? await state.projectAutopilotStatus(projectId: project.id) + } + .sheet(isPresented: $showDownloadSheet) { + if let project { + ProjectSecretsDownloadSheet(project: project) + .environmentObject(state) + .mobileSheetPresentation() + } + } + .alert(item: $info) { message in + Alert( + title: Text(message.isError ? "Couldn’t Complete" : "Sent to Your Mac"), + message: Text(message.text), + dismissButton: .default(Text("OK")) + ) + } + } +} + +// MARK: - Secrets download form + +/// Mobile mirror of the desktop secrets auto-download (`AutopilotSecretsHook`): +/// pick an environment, then ask the Mac to decrypt it and write the files into +/// the project folder. The phone never sees plaintext — the Mac holds the KEK. +struct ProjectSecretsDownloadSheet: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + let project: Project + /// When true (first-add flow), dismiss instead of showing the "No Secrets" + /// empty state — the form should only linger when there's something to grab. + var autoDismissWhenEmpty = false + + @State private var environments: [SecretsEnvironment] = [] + @State private var isLoading = true + @State private var loadError: String? + @State private var selectedEnvId: String? + @State private var overwrite = false + @State private var isDownloading = false + @State private var actionError: String? + /// Files skipped on the last attempt because they already exist — surfaced + /// as a prompt to enable overwrite (mirrors the desktop overwrite confirm). + @State private var conflicts: [String] = [] + + var body: some View { + NavigationStack { + Group { + if let loadError { + ContentUnavailableView( + "Couldn’t Load Environments", + systemImage: "exclamationmark.triangle", + description: Text(loadError) + ) + } else if !isLoading && environments.isEmpty { + ContentUnavailableView( + "No Secrets", + systemImage: "key", + description: Text("This repository has no secret environments to download.") + ) + } else { + form + } + } + .navigationTitle("Download Secret") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Button { + download() + } label: { + if isDownloading { ProgressView() } else { Text("Download") } + } + .disabled(selectedEnvId == nil || isDownloading) + } + } + .mobileAutopilotLoadingOverlay(isLoading && environments.isEmpty, title: "Loading environments…") + } + .task { await loadEnvironments() } + } + + private var form: some View { + List { + Section { + ForEach(environments) { env in + Button { + selectedEnvId = env.id + } label: { + HStack { + Text(env.name) + .foregroundStyle(.primary) + Spacer() + if selectedEnvId == env.id { + Image(systemName: "checkmark") + .foregroundStyle(Color.accentColor) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } header: { + Text("Environment") + } footer: { + Text("Files are decrypted on your Mac and written into the project folder.") + } + + Section { + Toggle("Overwrite existing files", isOn: $overwrite) + } footer: { + if !conflicts.isEmpty { + Text("These files already exist and were skipped: \(conflicts.joined(separator: ", ")). Turn on overwrite to replace them.") + .foregroundStyle(.orange) + } + } + + if let actionError { + Section { + Text(actionError) + .foregroundStyle(.red) + } + } + } + } + + private func loadEnvironments() async { + guard let repo = project.gitHubRepo else { + loadError = String(localized: "This project is not linked to a GitHub repo.") + isLoading = false + return + } + isLoading = true + defer { isLoading = false } + do { + let envs = try await state.listSecretEnvironments(repo: repo) + environments = envs + // First-add: nothing to download → close instead of an empty sheet. + if envs.isEmpty, autoDismissWhenEmpty { + dismiss() + return + } + // Preselect when there's only one, matching a single-choice download. + if envs.count == 1 { selectedEnvId = envs.first?.id } + } catch { + if autoDismissWhenEmpty { + // First-add is best-effort — don't block project creation on a + // secrets check that failed (offline, not enrolled, etc.). + dismiss() + return + } + loadError = error.localizedDescription + } + } + + private func download() { + guard let envId = selectedEnvId else { return } + isDownloading = true + actionError = nil + conflicts = [] + Task { + defer { isDownloading = false } + do { + let result = try await state.downloadProjectSecrets( + projectId: project.id, + envId: envId, + overwrite: overwrite + ) + if !result.conflicts.isEmpty { + // Some files were skipped — keep the sheet open and prompt to + // overwrite, exactly like the desktop confirm step. + conflicts = result.conflicts + } else { + dismiss() + } + } catch { + actionError = error.localizedDescription + } + } + } +} diff --git a/RxCodeMobile/Views/MobileBriefingDetailView.swift b/RxCodeMobile/Views/MobileBriefingDetailView.swift index c8fd03d1..66c70fcb 100644 --- a/RxCodeMobile/Views/MobileBriefingDetailView.swift +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -12,6 +12,11 @@ struct MobileBriefingDetailView: View { @State private var showingNewThread = false @State private var isInitializingGit = false + // Autopilot context menu (1:1 with the desktop briefing/project menu). + @State private var autopilotStatus: AutopilotProjectStatus? + @State private var showingSecretsDownload = false + @State private var autopilotInfo: AutopilotMenuInfo? + var body: some View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: 24) { @@ -40,14 +45,25 @@ struct MobileBriefingDetailView: View { .accessibilityIdentifier("briefing-detail-new-thread") } - if let gitHubURL { + if showsActionsMenu { ToolbarItem(placement: .topBarTrailing) { Menu { - Link(destination: gitHubURL) { - Label( - gitHubURLIsPullRequest ? "Open Pull Request" : "Open on GitHub", - systemImage: "arrow.up.forward.square" + if let project, project.gitHubRepo != nil { + ProjectAutopilotMenuItems( + project: project, + status: autopilotStatus, + showDownloadSheet: $showingSecretsDownload, + info: $autopilotInfo ) + if gitHubURL != nil { Divider() } + } + if let gitHubURL { + Link(destination: gitHubURL) { + Label( + gitHubURLIsPullRequest ? "Open Pull Request" : "Open on GitHub", + systemImage: "arrow.up.forward.square" + ) + } } } label: { Image(systemName: "ellipsis") @@ -58,6 +74,13 @@ struct MobileBriefingDetailView: View { } } } + .projectAutopilotMenuHost( + project: project, + status: $autopilotStatus, + showDownloadSheet: $showingSecretsDownload, + info: $autopilotInfo, + state: state + ) .sheet(isPresented: $showingNewThread) { NewThreadSheet( projectID: groupKey.projectId, @@ -91,7 +114,17 @@ struct MobileBriefingDetailView: View { } private var projectName: String { - state.projects.first(where: { $0.id == groupKey.projectId })?.name ?? "Unknown Project" + project?.name ?? "Unknown Project" + } + + private var project: Project? { + state.projects.first(where: { $0.id == groupKey.projectId }) + } + + /// Show the ellipsis menu when there's an autopilot-capable repo or a GitHub + /// link to surface. + private var showsActionsMenu: Bool { + project?.gitHubRepo != nil || gitHubURL != nil } /// GitHub destination for the "Open on GitHub" action. Prefers the pull diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift index 8e4a27a9..a94c968f 100644 --- a/RxCodeMobile/Views/ProjectsSidebar.swift +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -385,7 +385,25 @@ private struct GlassProjectCard: View { let namespace: Namespace.ID var onSelect: (() -> Void)? + @EnvironmentObject private var state: MobileAppState + // Autopilot context menu (1:1 with the desktop project/sidebar menu). + @State private var autopilotStatus: AutopilotProjectStatus? + @State private var showingSecretsDownload = false + @State private var autopilotInfo: AutopilotMenuInfo? + var body: some View { + cardButton + .modifier(ProjectCardAutopilotMenuModifier( + project: project, + status: $autopilotStatus, + showDownloadSheet: $showingSecretsDownload, + info: $autopilotInfo, + state: state + )) + } + + @ViewBuilder + private var cardButton: some View { // The UI-test identifier is applied directly on the button of each // branch — applying it to an enclosing container does not reach the // button element XCUITest queries. @@ -528,6 +546,42 @@ private struct GlassProjectCard: View { } } +// MARK: - Project Card Autopilot Menu + +/// Attaches the autopilot context menu (long-press) + its sheet/alert to a +/// project card, but only for repo-backed projects (matching the desktop, which +/// shows no autopilot items without a linked GitHub repo). +private struct ProjectCardAutopilotMenuModifier: ViewModifier { + let project: Project + @Binding var status: AutopilotProjectStatus? + @Binding var showDownloadSheet: Bool + @Binding var info: AutopilotMenuInfo? + let state: MobileAppState + + func body(content: Content) -> some View { + if project.gitHubRepo != nil { + content + .contextMenu { + ProjectAutopilotMenuItems( + project: project, + status: status, + showDownloadSheet: $showDownloadSheet, + info: $info + ) + } + .projectAutopilotMenuHost( + project: project, + status: $status, + showDownloadSheet: $showDownloadSheet, + info: $info, + state: state + ) + } else { + content + } + } +} + // MARK: - Search Thread Hit Card struct SearchThreadHitCard: View { diff --git a/RxCodeMobile/Views/RemoteFolderPickerView.swift b/RxCodeMobile/Views/RemoteFolderPickerView.swift index 15468bbd..064452df 100644 --- a/RxCodeMobile/Views/RemoteFolderPickerView.swift +++ b/RxCodeMobile/Views/RemoteFolderPickerView.swift @@ -1,4 +1,5 @@ import SwiftUI +import RxCodeCore import RxCodeSync /// Browses the paired Mac's folder tree. Reused for three jobs: adding a new @@ -33,6 +34,10 @@ struct RemoteFolderPickerView: View { @EnvironmentObject private var state: MobileAppState @Environment(\.dismiss) private var dismiss @State private var navigationPath: [FolderLocation] = [] + /// Set after a project is created (addProject mode) when it has a linked + /// GitHub repo — presents the first-add secrets download form. Dismissing + /// that form closes the picker. + @State private var firstAddProject: Project? var mode: Mode = .addProject @@ -113,10 +118,22 @@ struct RemoteFolderPickerView: View { Text(state.remoteProjectCreateError ?? String(localized: "Unknown error.")) } .onChange(of: state.lastCreatedProjectID) { _, newValue in - if case .addProject = mode, newValue != nil { + guard case .addProject = mode, let newValue else { return } + // Mirror the desktop's on-repository-add secrets flow: if the new + // project is repo-backed, offer to download its secrets (loading + // overlay → environment picker) before closing. Otherwise just close. + if let created = state.projects.first(where: { $0.id == newValue }), + created.gitHubRepo != nil { + firstAddProject = created + } else { dismiss() } } + .sheet(item: $firstAddProject, onDismiss: { dismiss() }) { project in + ProjectSecretsDownloadSheet(project: project, autoDismissWhenEmpty: true) + .environmentObject(state) + .mobileSheetPresentation() + } } private var folderList: some View { From dd8db9625caac98041b7cb752ce1b2ac3d76570b Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:27:33 +0800 Subject: [PATCH 3/8] fix: claude code todos --- .../Sources/RxCodeCore/Models/TodoItem.swift | 83 ++++++++++++- .../RxCodeCoreTests/TodoExtractorTests.swift | 113 ++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) diff --git a/Packages/Sources/RxCodeCore/Models/TodoItem.swift b/Packages/Sources/RxCodeCore/Models/TodoItem.swift index 0d23ca59..4b33be4d 100644 --- a/Packages/Sources/RxCodeCore/Models/TodoItem.swift +++ b/Packages/Sources/RxCodeCore/Models/TodoItem.swift @@ -39,7 +39,88 @@ public enum TodoExtractor { return parse(input: toolCall.input) } } - return nil + // Newer Claude Code drives its task list through the incremental + // `TaskCreate` / `TaskUpdate` tools instead of `TodoWrite`. Fall back to + // reconstructing the list from those when no `TodoWrite` is present. + return fromTaskTools(in: messages) + } + + /// Reconstruct a todo list from the newer Claude Code task tools + /// (`TaskCreate` / `TaskUpdate`). + /// + /// Unlike `TodoWrite` — which submits the whole list on every call — the + /// task tools are incremental: `TaskCreate` adds a single `pending` task + /// whose id is assigned by the tool and returned in the result text + /// ("Task #N created successfully: …"), and `TaskUpdate` mutates a task by + /// `taskId` (status, subject, …; `deleted` removes it). We replay every + /// task tool call in message order to rebuild the current state. + /// + /// Returns `nil` when the transcript contains no task tool calls, so callers + /// can distinguish "no task list" from "an empty task list". + public static func fromTaskTools(in messages: [ChatMessage]) -> [TodoItem]? { + struct Task { + var subject: String + var activeForm: String + var status: TodoItem.Status + } + var order: [Int] = [] + var tasks: [Int: Task] = [:] + var sawAny = false + + for message in messages { + for block in message.blocks { + guard let toolCall = block.toolCall else { continue } + switch toolCall.name.lowercased() { + case "taskcreate": + // The id is only known once the tool result lands; skip + // until then (the task surfaces on the next render). + guard let id = parseCreatedTaskId(fromResult: toolCall.result) else { continue } + sawAny = true + let subject = toolCall.input["subject"]?.stringValue ?? "" + let activeForm = toolCall.input["activeForm"]?.stringValue ?? subject + if tasks[id] == nil { order.append(id) } + tasks[id] = Task(subject: subject, activeForm: activeForm, status: .pending) + + case "taskupdate": + guard let idString = toolCall.input["taskId"]?.stringValue, + let id = Int(idString) else { continue } + let rawStatus = toolCall.input["status"]?.stringValue + if rawStatus == "deleted" { + if tasks.removeValue(forKey: id) != nil { + order.removeAll { $0 == id } + sawAny = true + } + continue + } + guard var task = tasks[id] else { continue } + sawAny = true + if let rawStatus, let status = TodoItem.Status(rawValue: rawStatus) { + task.status = status + } + if let subject = toolCall.input["subject"]?.stringValue { task.subject = subject } + if let activeForm = toolCall.input["activeForm"]?.stringValue { task.activeForm = activeForm } + tasks[id] = task + + default: + continue + } + } + } + + guard sawAny else { return nil } + return order.compactMap { id in + guard let task = tasks[id] else { return nil } + return TodoItem(id: id, content: task.subject, activeForm: task.activeForm, status: task.status) + } + } + + /// Pull the assigned task id out of a `TaskCreate` result string such as + /// "Task #1 created successfully: …". Returns `nil` if the result is absent + /// (still streaming) or doesn't carry a `#` id. + static func parseCreatedTaskId(fromResult result: String?) -> Int? { + guard let result, let hash = result.firstIndex(of: "#") else { return nil } + let digits = result[result.index(after: hash)...].prefix { $0.isNumber } + return Int(digits) } /// Parse a raw `TodoWrite` `input` dictionary into typed ``TodoItem`` values. diff --git a/Packages/Tests/RxCodeCoreTests/TodoExtractorTests.swift b/Packages/Tests/RxCodeCoreTests/TodoExtractorTests.swift index 236f6631..c5e8e32e 100644 --- a/Packages/Tests/RxCodeCoreTests/TodoExtractorTests.swift +++ b/Packages/Tests/RxCodeCoreTests/TodoExtractorTests.swift @@ -41,4 +41,117 @@ struct TodoExtractorTests { #expect(todos == nil) } + + // MARK: - Task tools (TaskCreate / TaskUpdate) + + private func taskCreate(id: Int, subject: String, activeForm: String? = nil) -> MessageBlock { + var input: [String: JSONValue] = ["subject": .string(subject), "description": .string(subject)] + if let activeForm { input["activeForm"] = .string(activeForm) } + return .toolCall(ToolCall( + id: "tc-\(id)", + name: "TaskCreate", + input: input, + result: "Task #\(id) created successfully: \(subject)" + )) + } + + private func taskUpdate(id: Int, status: String) -> MessageBlock { + .toolCall(ToolCall( + id: "tu-\(id)-\(status)", + name: "TaskUpdate", + input: ["taskId": .string("\(id)"), "status": .string(status)], + result: "Task #\(id) updated" + )) + } + + private func message(_ blocks: [MessageBlock]) -> ChatMessage { + ChatMessage(role: .assistant, blocks: blocks) + } + + @Test("TaskCreate calls reconstruct a pending todo list") + func taskCreateReconstructsList() { + let todos = TodoExtractor.latest(in: [ + message([ + taskCreate(id: 1, subject: "Autopilot protocol layer", activeForm: "Building protocol layer"), + taskCreate(id: 2, subject: "Build & verify Android app") + ]) + ]) + + #expect(todos == [ + TodoItem(id: 1, content: "Autopilot protocol layer", activeForm: "Building protocol layer", status: .pending), + TodoItem(id: 2, content: "Build & verify Android app", activeForm: "Build & verify Android app", status: .pending) + ]) + } + + @Test("TaskUpdate replays status transitions and preserves creation order") + func taskUpdateAppliesStatus() { + let todos = TodoExtractor.latest(in: [ + message([ + taskCreate(id: 1, subject: "First"), + taskCreate(id: 2, subject: "Second"), + taskCreate(id: 3, subject: "Third") + ]), + message([ + taskUpdate(id: 1, status: "completed"), + taskUpdate(id: 2, status: "in_progress") + ]) + ]) + + #expect(todos == [ + TodoItem(id: 1, content: "First", activeForm: "First", status: .completed), + TodoItem(id: 2, content: "Second", activeForm: "Second", status: .inProgress), + TodoItem(id: 3, content: "Third", activeForm: "Third", status: .pending) + ]) + } + + @Test("TaskUpdate deleted removes the task") + func taskDeleteRemovesTask() { + let todos = TodoExtractor.latest(in: [ + message([ + taskCreate(id: 1, subject: "Keep"), + taskCreate(id: 2, subject: "Drop") + ]), + message([taskUpdate(id: 2, status: "deleted")]) + ]) + + #expect(todos == [ + TodoItem(id: 1, content: "Keep", activeForm: "Keep", status: .pending) + ]) + } + + @Test("TaskCreate without a result yet is skipped until its id lands") + func taskCreateWithoutResultSkipped() { + let pending = MessageBlock.toolCall(ToolCall( + id: "tc-pending", + name: "TaskCreate", + input: ["subject": .string("Not ready"), "description": .string("Not ready")], + result: nil + )) + let todos = TodoExtractor.fromTaskTools(in: [message([pending])]) + + #expect(todos == nil) + } + + @Test("TodoWrite takes precedence over task tools when both present") + func todoWriteWins() { + let todos = TodoExtractor.latest(in: [ + message([taskCreate(id: 1, subject: "Task-tool item")]), + message([ + .toolCall(ToolCall( + id: "todo-1", + name: "TodoWrite", + input: ["todos": .array([ + .object([ + "content": .string("TodoWrite item"), + "status": .string("pending") + ]) + ])] + )) + ]) + ]) + + #expect(todos == [ + TodoItem(id: 0, content: "TodoWrite item", activeForm: "TodoWrite item", status: .pending) + ]) + } } From d78aed7243862e7aa0713f0ae4f954b1836ffbab Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:32:09 +0800 Subject: [PATCH 4/8] feat: mirror iOS autopilot settings to Android Port the full Autopilot settings feature from RxCodeMobile (iOS) to RxCodeAndroid with 1:1 parity over the existing E2E-encrypted relay. - Protocol: autopilot_request/autopilot_result payloads (base64 body matching Swift Data encoding), AutopilotDomain/AutopilotOp, and all request-body wrappers; wired into Payload serializer. - Models: wire-compatible @Serializable mirrors of the RxCodeCore DTOs (account, repo picker, automation, repo setup, secrets, CI, docs, release) with matching JSON keys and a tolerant FlexibleTimestamp. - AutopilotService: generic request/response round-trip correlated by clientRequestID with a 45s timeout, plus typed suspend wrappers. - JSON Schema Form: new Compose package rendering JSON Schema + uiSchema into Material 3 controls (no iOS-equivalent library exists on Android). - Screens: hub, Automation, Repo Setup, CI Auto-Update, Documentation, Releases, and Secrets (repo/env/file) with a shared paginated repo picker, reachable from a new Settings "Autopilot" entry. - Secrets crypto: byte-for-byte port of SecretsCrypto (HKDF KEK, WebCrypto-layout AES-GCM, ECDH-P256/HPKE) with a passkey-PRF-derived KEK via androidx.credentials, so plaintext never crosses the relay. Co-Authored-By: Claude Opus 4.8 (1M context) --- RxCodeAndroid/app/build.gradle.kts | 2 + .../app/rxlab/rxcode/crypto/SecretsCrypto.kt | 223 ++++++++ .../rxlab/rxcode/crypto/SecretsKeyVault.kt | 103 ++++ .../app/rxlab/rxcode/proto/AutopilotModels.kt | 477 ++++++++++++++++ .../rxlab/rxcode/proto/AutopilotPayloads.kt | 211 ++++++++ .../java/app/rxlab/rxcode/proto/Payload.kt | 10 + .../rxlab/rxcode/state/AutopilotService.kt | 415 ++++++++++++++ .../app/rxlab/rxcode/state/MobileAppState.kt | 21 + .../app/rxlab/rxcode/state/SecretsManager.kt | 90 +++ .../rxcode/ui/autopilot/AutomationScreen.kt | 216 ++++++++ .../rxcode/ui/autopilot/AutopilotCommon.kt | 158 ++++++ .../rxcode/ui/autopilot/AutopilotHubScreen.kt | 262 +++++++++ .../rxcode/ui/autopilot/AutopilotNavHost.kt | 82 +++ .../ui/autopilot/AutopilotRepoPicker.kt | 197 +++++++ .../rxcode/ui/autopilot/CiUpdatesScreen.kt | 369 +++++++++++++ .../rxlab/rxcode/ui/autopilot/DocsScreen.kt | 432 +++++++++++++++ .../rxcode/ui/autopilot/ReleaseScreen.kt | 419 ++++++++++++++ .../rxcode/ui/autopilot/RepoSetupScreen.kt | 421 +++++++++++++++ .../rxcode/ui/autopilot/SecretsScreen.kt | 511 ++++++++++++++++++ .../ui/jsonschemaform/JsonSchemaForm.kt | 286 ++++++++++ .../ui/jsonschemaform/JsonSchemaModel.kt | 150 +++++ .../rxcode/ui/settings/SettingsScreen.kt | 69 +++ RxCodeAndroid/gradle/libs.versions.toml | 3 + 23 files changed, 5127 insertions(+) create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/crypto/SecretsCrypto.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/crypto/SecretsKeyVault.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/SecretsManager.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutomationScreen.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotCommon.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotHubScreen.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotNavHost.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotRepoPicker.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/CiUpdatesScreen.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/DocsScreen.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ReleaseScreen.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/RepoSetupScreen.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/SecretsScreen.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/jsonschemaform/JsonSchemaForm.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/jsonschemaform/JsonSchemaModel.kt diff --git a/RxCodeAndroid/app/build.gradle.kts b/RxCodeAndroid/app/build.gradle.kts index cf0b42a2..8606d5f7 100644 --- a/RxCodeAndroid/app/build.gradle.kts +++ b/RxCodeAndroid/app/build.gradle.kts @@ -85,6 +85,8 @@ dependencies { implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.security.crypto) + implementation(libs.androidx.credentials) + implementation(libs.androidx.credentials.play.services.auth) implementation(libs.bouncycastle) implementation(libs.androidx.camera.core) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/crypto/SecretsCrypto.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/crypto/SecretsCrypto.kt new file mode 100644 index 00000000..1ab1a8b5 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/crypto/SecretsCrypto.kt @@ -0,0 +1,223 @@ +package app.rxlab.rxcode.crypto + +import app.rxlab.rxcode.proto.SecretsEnvironmentKey +import app.rxlab.rxcode.proto.SecretsUserKey +import org.bouncycastle.asn1.x9.ECNamedCurveTable +import org.bouncycastle.crypto.agreement.ECDHBasicAgreement +import org.bouncycastle.crypto.digests.SHA256Digest +import org.bouncycastle.crypto.generators.ECKeyPairGenerator +import org.bouncycastle.crypto.generators.HKDFBytesGenerator +import org.bouncycastle.crypto.params.ECDomainParameters +import org.bouncycastle.crypto.params.ECKeyGenerationParameters +import org.bouncycastle.crypto.params.ECPrivateKeyParameters +import org.bouncycastle.crypto.params.ECPublicKeyParameters +import org.bouncycastle.crypto.params.HKDFParameters +import org.json.JSONObject +import java.math.BigInteger +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Byte-for-byte port of `RxCodeCore/Secrets/SecretsCrypto.swift` (itself a port + * of github-pm's `lib/secrets/crypto.ts`). Keeping this interoperable with the + * macOS/iOS/web implementations is the whole point: a secret encrypted on any + * platform must decrypt here and vice-versa. + * + * Scheme: + * - 32-byte **PRF output** comes from the user's passkey (see [SecretsKeyVault]). + * - **KEK** = `HKDF-SHA256(prf, salt: ∅, info: ".../kek")`. + * - Each user has an **ECDH P-256 keypair**; the private key is a JWK + * AES-GCM-wrapped under the KEK. + * - Each environment has a random 32-byte **DEK**, wrapped under the owner's KEK + * (`wrapMode: "kek"`) or sealed to a public key (`wrapMode: "hpke"`). + * - Files are AES-GCM encrypted under the DEK. + * + * AES-GCM layout matches WebCrypto: `iv` (12 bytes) stored separately, and + * `ciphertext` is `rawCiphertext ‖ 16-byte tag` (JCA's GCM already appends the + * tag to its output, matching WebCrypto). + */ +object SecretsCrypto { + + // Constants — must match github-pm/lib/secrets/constants.ts. + val prfSalt: ByteArray = "github-pm-secrets-v1-prf-salt!!!".toByteArray(Charsets.UTF_8) + private val hkdfInfoKEK = "github-pm-secrets-v1/kek".toByteArray(Charsets.UTF_8) + private val hkdfInfoHPKE = "github-pm-secrets-v1/hpke".toByteArray(Charsets.UTF_8) + const val WEBAUTHN_RP_ID = "rxlab.app" + + class CryptoException(message: String) : Exception(message) + + private val x9 = ECNamedCurveTable.getByName("P-256") + private val domain = ECDomainParameters(x9.curve, x9.g, x9.n, x9.h) + private val random = SecureRandom() + + private fun b64(data: ByteArray): String = Base64.getEncoder().encodeToString(data) + private fun b64d(s: String): ByteArray = Base64.getDecoder().decode(s) + + // MARK: - KEK + + /** `HKDF-SHA256(prf, salt: ∅, info: ".../kek")` → 32-byte AES key. */ + fun deriveKEK(prfOutput: ByteArray): ByteArray = + hkdf(prfOutput, ByteArray(0), hkdfInfoKEK, 32) + + private fun hkdf(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray { + val gen = HKDFBytesGenerator(SHA256Digest()) + gen.init(HKDFParameters(ikm, salt, info)) + val out = ByteArray(length) + gen.generateBytes(out, 0, length) + return out + } + + // MARK: - AES-GCM (WebCrypto-compatible) + + /** Encrypts, returning base64 `ciphertext` (`raw ‖ tag`) + base64 `iv`. */ + fun aesGcmEncrypt(key: ByteArray, plaintext: ByteArray): Pair { + val iv = ByteArray(12).also { random.nextBytes(it) } + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv)) + val combined = cipher.doFinal(plaintext) // raw ‖ tag + return b64(combined) to b64(iv) + } + + fun aesGcmDecrypt(key: ByteArray, ciphertextB64: String, ivB64: String): ByteArray { + val ctTag = b64d(ciphertextB64) + val iv = b64d(ivB64) + if (ctTag.size < 16) throw CryptoException("Ciphertext is too short to contain an auth tag.") + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv)) + return cipher.doFinal(ctTag) + } + + // MARK: - DEK + + fun generateDEK(): ByteArray = ByteArray(32).also { random.nextBytes(it) } + + // MARK: - ECDH P-256 / HPKE + + private fun bigIntTo32(value: BigInteger): ByteArray { + val raw = value.toByteArray() + return when { + raw.size == 32 -> raw + raw.size == 33 && raw[0] == 0.toByte() -> raw.copyOfRange(1, 33) + raw.size < 32 -> ByteArray(32 - raw.size) + raw + else -> raw.copyOfRange(raw.size - 32, raw.size) + } + } + + private fun importPublicKey(b64: String): ECPublicKeyParameters { + val point = domain.curve.decodePoint(b64d(b64)) + return ECPublicKeyParameters(point, domain) + } + + private fun ecdhSharedSecret( + priv: ECPrivateKeyParameters, + pub: ECPublicKeyParameters, + ): ByteArray { + val agree = ECDHBasicAgreement() + agree.init(priv) + return bigIntTo32(agree.calculateAgreement(pub)) // X coordinate, like WebCrypto/CryptoKit + } + + private fun hpkeKey(shared: ByteArray): ByteArray = hkdf(shared, ByteArray(0), hkdfInfoHPKE, 32) + + fun sealToPublicKey(plaintext: ByteArray, recipientPublicKeyB64: String): Triple { + val recipient = importPublicKey(recipientPublicKeyB64) + val gen = ECKeyPairGenerator().apply { init(ECKeyGenerationParameters(domain, random)) } + val ephemeral = gen.generateKeyPair() + val ephPriv = ephemeral.private as ECPrivateKeyParameters + val ephPub = ephemeral.public as ECPublicKeyParameters + val shared = ecdhSharedSecret(ephPriv, recipient) + val enc = aesGcmEncrypt(hpkeKey(shared), plaintext) + val ephB64 = b64(ephPub.q.getEncoded(false)) + return Triple(enc.first, enc.second, ephB64) + } + + fun unsealWithPrivateKey( + ciphertextB64: String, + ivB64: String, + ephemeralPublicKeyB64: String, + recipientPrivateKey: ECPrivateKeyParameters, + ): ByteArray { + val ephPub = importPublicKey(ephemeralPublicKeyB64) + val shared = ecdhSharedSecret(recipientPrivateKey, ephPub) + return aesGcmDecrypt(hpkeKey(shared), ciphertextB64, ivB64) + } + + // MARK: - User private key (JWK) + + private fun importPrivateKeyJWK(jwk: JSONObject): ECPrivateKeyParameters { + val d = jwk.optString("d", "") + if (d.isBlank()) throw CryptoException("Stored private key is not a valid P-256 JWK.") + val dBytes = base64urlDecode(d) ?: throw CryptoException("Malformed JWK private scalar.") + if (dBytes.size != 32) throw CryptoException("Stored private key is not a valid P-256 JWK.") + return ECPrivateKeyParameters(BigInteger(1, dBytes), domain) + } + + fun unwrapPrivateKey(wrappedPrivateKey: String, wrapIv: String, kek: ByteArray): ECPrivateKeyParameters { + val json = aesGcmDecrypt(kek, wrappedPrivateKey, wrapIv) + return importPrivateKeyJWK(JSONObject(String(json, Charsets.UTF_8))) + } + + // MARK: - Flows (mirror SecretsFlows.swift) + + /** Fresh DEK wrapped under the owner KEK (`wrapMode: "kek"`). */ + fun buildOwnerEnvironmentKey(kek: ByteArray): Pair { + val dek = generateDEK() + val wrap = aesGcmEncrypt(kek, dek) + val body = app.rxlab.rxcode.proto.EnvironmentKeyBody( + wrapMode = "kek", + wrappedDek = wrap.first, + wrapIv = wrap.second, + ephemeralPublicKey = null, + ) + return body to dek + } + + /** Resolves an environment key row into the raw DEK bytes. */ + fun unwrapDEK( + envKey: SecretsEnvironmentKey, + kek: ByteArray, + userPrivateKey: ECPrivateKeyParameters?, + ): ByteArray = when (envKey.wrapMode) { + "kek" -> { + val iv = envKey.wrapIv ?: throw CryptoException("Wrapped key is missing its IV.") + aesGcmDecrypt(kek, envKey.wrappedDek, iv) + } + "hpke" -> { + val priv = userPrivateKey + ?: throw CryptoException("This environment was shared with you; your enrollment key is required to open it.") + val iv = envKey.wrapIv + val eph = envKey.ephemeralPublicKey + if (iv == null || eph == null) throw CryptoException("Shared key is missing its ephemeral public key or IV.") + unsealWithPrivateKey(envKey.wrappedDek, iv, eph, priv) + } + else -> throw CryptoException("Unsupported wrap mode: ${envKey.wrapMode}.") + } + + fun unwrapUserPrivateKey(userKey: SecretsUserKey, kek: ByteArray): ECPrivateKeyParameters? { + val wrapped = userKey.wrappedPrivateKey ?: return null + val iv = userKey.wrapIv ?: return null + return unwrapPrivateKey(wrapped, iv, kek) + } + + // MARK: - File helpers + + fun encryptFile(plaintext: String, dek: ByteArray): Triple { + val bytes = plaintext.toByteArray(Charsets.UTF_8) + val enc = aesGcmEncrypt(dek, bytes) + return Triple(enc.first, enc.second, bytes.size) + } + + fun decryptFile(ciphertextB64: String, ivB64: String, dek: ByteArray): String = + String(aesGcmDecrypt(dek, ciphertextB64, ivB64), Charsets.UTF_8) + + // MARK: - base64url + + private fun base64urlDecode(s: String): ByteArray? { + var str = s.replace('-', '+').replace('_', '/') + while (str.length % 4 != 0) str += "=" + return runCatching { Base64.getDecoder().decode(str) }.getOrNull() + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/crypto/SecretsKeyVault.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/crypto/SecretsKeyVault.kt new file mode 100644 index 00000000..d386edbc --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/crypto/SecretsKeyVault.kt @@ -0,0 +1,103 @@ +package app.rxlab.rxcode.crypto + +import android.content.Context +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialException +import org.json.JSONObject +import java.security.SecureRandom +import java.util.Base64 + +/** + * Owns the passkey-derived secrets KEK and caches it for a few minutes so a + * burst of encrypt/decrypt operations only prompts once. The KEK never + * persists. Android counterpart of iOS `MobileSecretsKeyVault`. + * + * The KEK is derived from the WebAuthn PRF output of the user's `rxlab.app` + * passkey — the same credential and PRF salt the Mac/web use, so the derived + * KEK is identical across platforms and on-device encryption/decryption + * interoperates. Requires a passkey synced to this device (Google Password + * Manager) whose provider supports the PRF extension. + */ +class SecretsKeyVault { + private var cachedKEK: ByteArray? = null + private var cachedAtMs: Long = 0 + private val ttlMs = 5 * 60 * 1000L + private val random = SecureRandom() + + class PasskeyException(message: String) : Exception(message) + + /** Returns the KEK, running a passkey PRF ceremony when the cache is cold. */ + suspend fun kek(context: Context, nowMs: Long): ByteArray { + cachedKEK?.let { if (nowMs - cachedAtMs < ttlMs) return it } + val prf = evaluatePRF(context) + val kek = SecretsCrypto.deriveKEK(prf) + cachedKEK = kek + cachedAtMs = nowMs + return kek + } + + fun clear() { + cachedKEK = null + cachedAtMs = 0 + } + + private suspend fun evaluatePRF(context: Context): ByteArray { + val challenge = ByteArray(32).also { random.nextBytes(it) } + val requestJson = buildRequestJson( + challengeB64Url = base64url(challenge), + saltB64Url = base64url(SecretsCrypto.prfSalt), + ) + val option = GetPublicKeyCredentialOption(requestJson) + val request = GetCredentialRequest(listOf(option)) + val manager = CredentialManager.create(context) + + val response = try { + manager.getCredential(context, request) + } catch (e: GetCredentialCancellationException) { + throw PasskeyException("Passkey authentication was cancelled.") + } catch (e: GetCredentialException) { + throw PasskeyException("Passkey authentication failed: ${e.message ?: e.type}") + } + + val credential = response.credential as? PublicKeyCredential + ?: throw PasskeyException("Unexpected passkey credential.") + val json = JSONObject(credential.authenticationResponseJson) + val first = json + .optJSONObject("clientExtensionResults") + ?.optJSONObject("prf") + ?.optJSONObject("results") + ?.optString("first", "") + ?.takeIf { it.isNotBlank() } + ?: throw PasskeyException( + "This passkey can't unlock secrets — it doesn't support the PRF extension. " + + "Use the passkey you enrolled with." + ) + return base64urlDecode(first) + ?: throw PasskeyException("Malformed PRF result from the passkey.") + } + + private fun buildRequestJson(challengeB64Url: String, saltB64Url: String): String = + JSONObject().apply { + put("challenge", challengeB64Url) + put("rpId", SecretsCrypto.WEBAUTHN_RP_ID) + put("userVerification", "required") + put("timeout", 60_000) + put( + "extensions", + JSONObject().put( + "prf", + JSONObject().put("eval", JSONObject().put("first", saltB64Url)), + ), + ) + }.toString() + + private fun base64url(data: ByteArray): String = + Base64.getUrlEncoder().withoutPadding().encodeToString(data) + + private fun base64urlDecode(s: String): ByteArray? = + runCatching { Base64.getUrlDecoder().decode(s) }.getOrNull() +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt new file mode 100644 index 00000000..ec7a8e7e --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt @@ -0,0 +1,477 @@ +package app.rxlab.rxcode.proto + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull +import java.time.Instant + +/** + * Domain models for the autopilot remote-management feature. Mirrors the + * `RxCodeCore` Swift types. JSON keys match the Swift `Codable` encoding; + * `@SerialName` is applied wherever the wire key differs from the Kotlin name. + * + * JSON-Schema-shaped values (schema/uiSchema/preferences/merge settings/ + * rulesets) are kept as raw [JsonElement] — the Swift side models them with a + * generic `JSONValue` enum and we want the same lossless passthrough. + */ + +// MARK: - Flexible timestamp + +/** + * Tolerant timestamp: the desktop encodes ISO-8601 strings, but some upstream + * GitHub payloads carry epoch-milliseconds numbers (and either can be null). + * Mirrors Swift `FlexibleTimestamp`. + */ +@Serializable(with = FlexibleTimestampSerializer::class) +data class FlexibleTimestamp(val instant: Instant?) + +object FlexibleTimestampSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("FlexibleTimestamp", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): FlexibleTimestamp { + if (decoder !is JsonDecoder) return FlexibleTimestamp(null) + val element = decoder.decodeJsonElement() + if (element is JsonNull) return FlexibleTimestamp(null) + val primitive = element as? JsonPrimitive ?: return FlexibleTimestamp(null) + // Numeric epoch-ms (or epoch-seconds for small values). + primitive.longOrNull?.let { millis -> + return FlexibleTimestamp(Instant.ofEpochMilli(if (millis < 1_000_000_000_000L) millis * 1000 else millis)) + } + primitive.doubleOrNull?.let { d -> + return FlexibleTimestamp(Instant.ofEpochMilli(d.toLong())) + } + val content = primitive.content + if (content.isBlank() || content == "null") return FlexibleTimestamp(null) + return FlexibleTimestamp(runCatching { Instant.parse(content) }.getOrNull()) + } + + override fun serialize(encoder: Encoder, value: FlexibleTimestamp) { + val i = value.instant + if (i == null) encoder.encodeString("") else encoder.encodeString(i.toString()) + } +} + +// MARK: - Account + +@Serializable +data class AutopilotAccountStatus( + val isSignedIn: Boolean, + val name: String? = null, + val email: String? = null, + val avatarURL: String? = null, +) + +// MARK: - Pagination + +@Serializable +data class Pagination(val nextCursor: String? = null, val hasMore: Boolean = false) + +// MARK: - Automation / config (schema-driven) + +@Serializable +data class SchemaEnvelope(val schema: JsonElement, val uiSchema: JsonElement? = null) + +@Serializable +data class PreferencesValues(val values: JsonElement) + +// MARK: - Repo setup + +@Serializable +data class RepoSetupTemplate( + val id: String, + val name: String, + val enabled: Boolean = true, + val isDefault: Boolean = false, + val mergeSettings: JsonElement, + val rulesetConfig: JsonElement? = null, +) + +@Serializable +data class RepoSetupTemplateList(val items: List = emptyList()) + +@Serializable +data class RepoSetupTemplateInput( + val name: String, + val enabled: Boolean, + val isDefault: Boolean, + val mergeSettings: JsonElement, + val rulesetConfig: JsonElement? = null, +) + +// MARK: - Shared repo picker + +@Serializable +data class SecretsManagedRepo( + val id: Long, + val fullName: String, + val name: String, + val owner: String, + @SerialName("private") val isPrivate: Boolean = false, + val defaultBranch: String? = null, + val secretsRepoId: String? = null, + val installationId: Long, + val environmentsCount: Int = 0, + val filesCount: Int = 0, + val isCurrent: Boolean = false, +) { + val isManaged: Boolean get() = secretsRepoId != null +} + +@Serializable +data class SecretsManagedRepoPage( + val items: List = emptyList(), + val pagination: Pagination = Pagination(), +) + +// MARK: - Secrets + +@Serializable +data class SecretsUserKey( + val enrolled: Boolean = false, + val publicKey: String? = null, + val wrappedPrivateKey: String? = null, + val wrapIv: String? = null, + val algorithm: String? = null, +) + +@Serializable +data class AutopilotSecretsEnrollment(val enrolled: Boolean) + +@Serializable +data class SecretsEnvironment( + val id: String, + val name: String, + val filesCount: Int? = null, +) + +@Serializable +data class SecretsEnvironmentList(val items: List = emptyList()) + +@Serializable +data class EnvironmentKeyBody( + val wrapMode: String, + val wrappedDek: String, + val wrapIv: String? = null, + val ephemeralPublicKey: String? = null, +) + +@Serializable +data class CreateEnvironmentBody(val name: String, val ownerKey: EnvironmentKeyBody) + +@Serializable +data class SecretsFileMeta( + val id: String, + val filename: String, + val size: Int? = null, + val encryptedByUserId: String? = null, +) + +@Serializable +data class SecretsFileList(val items: List = emptyList()) + +@Serializable +data class UpsertFileBody( + val filename: String, + val ciphertext: String, + val iv: String, + val size: Int? = null, +) + +@Serializable +data class SecretsEnvironmentKey( + val wrapMode: String, + val wrappedDek: String, + val wrapIv: String? = null, + val ephemeralPublicKey: String? = null, +) + +@Serializable +data class SecretsBundleFile( + val filename: String, + val ciphertext: String, + val iv: String, + val size: Int? = null, +) + +@Serializable +data class SecretsBundle( + val environmentKey: SecretsEnvironmentKey, + val files: List = emptyList(), +) + +// MARK: - CI auto-update + +@Serializable +enum class CIScanFrequency(val wire: String) { + @SerialName("daily") DAILY("daily"), + @SerialName("weekly") WEEKLY("weekly"), + @SerialName("monthly") MONTHLY("monthly"); + + val displayName: String + get() = when (this) { + DAILY -> "Daily" + WEEKLY -> "Weekly" + MONTHLY -> "Monthly" + } +} + +@Serializable +data class WatchedRepo( + val id: String, + val installationId: Long, + val repositoryId: Long, + val repositoryFullName: String, + val owner: String? = null, + val repo: String? = null, + val lastScannedAt: FlexibleTimestamp? = null, + val scheduleId: String? = null, + @SerialName("scanFrequency") val scanFrequencyRaw: CIScanFrequency? = null, + val createdAt: FlexibleTimestamp? = null, + val updatedAt: FlexibleTimestamp? = null, +) { + val fullName: String get() = repositoryFullName + val scanFrequency: CIScanFrequency get() = scanFrequencyRaw ?: CIScanFrequency.WEEKLY +} + +@Serializable +data class WatchedRepoPage( + val repositories: List = emptyList(), + val nextCursor: String? = null, + val hasMore: Boolean = false, +) + +@Serializable +data class AddWatchedRepoBody( + val installationId: Long, + val repositoryId: Long, + val repositoryFullName: String, + val scanFrequency: CIScanFrequency, +) + +@Serializable +data class CITriggerResponse( + val success: Boolean = false, + val prUrl: String? = null, + val error: String? = null, +) + +@Serializable +data class CIUpdateRunHistory( + val id: String, + val watchedRepositoryId: String? = null, + val status: String = "", + val workflowsScanned: Int? = null, + val actionsFound: Int? = null, + val outdatedActionsCount: Int? = null, + val prUrl: String? = null, + val prNumber: Int? = null, + val errorMessage: String? = null, + val startedAt: FlexibleTimestamp? = null, + val completedAt: FlexibleTimestamp? = null, +) { + val isSuccess: Boolean get() = status == "success" +} + +@Serializable +data class CIPullRequest( + val number: Int? = null, + val title: String? = null, + val state: String? = null, + @SerialName("html_url") val htmlURL: String? = null, + val merged: Boolean? = null, + @SerialName("created_at") val createdAt: FlexibleTimestamp? = null, +) { + val stableId: String get() = number?.toString() ?: (htmlURL ?: title ?: "pr") + val isOpen: Boolean get() = (state ?: "open").lowercase() == "open" +} + +// MARK: - Docs + +@Serializable +data class DocsRepo( + val id: String, + val installationId: Long? = null, + val repositoryId: Long? = null, + val repositoryFullName: String, + val owner: String? = null, + val repo: String? = null, + val lastIndexedAt: String? = null, + val documentsCount: Int? = null, + val createdAt: String? = null, + val updatedAt: String? = null, +) { + val fullName: String get() = repositoryFullName +} + +@Serializable +data class DocsRepoListResponse( + val items: List = emptyList(), + val pagination: Pagination? = null, +) + +@Serializable +data class AddDocsRepoBody( + val installationId: Long, + val repositoryId: Long, + val repositoryFullName: String, +) + +@Serializable +data class DocsDocument( + val docId: String, + val title: String? = null, + val embeddingStatus: String? = null, + val updatedAt: String? = null, +) + +@Serializable +data class DocsDocumentList( + val items: List = emptyList(), + val pagination: Pagination? = null, +) + +@Serializable +data class DocsDocumentVersion( + val id: String? = null, + val documentId: String? = null, + val version: Int = 0, + val content: String? = null, + val contentHash: String? = null, + val embeddingStatus: String? = null, + val embeddingError: String? = null, + val createdAt: String? = null, + val embeddedAt: String? = null, +) + +@Serializable +data class DocsDocumentDetail( + val document: Document, + val currentVersion: DocsDocumentVersion? = null, +) { + @Serializable + data class Document( + val id: String? = null, + val docsRepositoryId: String? = null, + val docId: String, + val originalLink: String? = null, + val currentVersionId: String? = null, + val currentVersion: Int? = null, + val createdAt: String? = null, + val updatedAt: String? = null, + ) +} + +@Serializable +data class DocsUploadResult( + val jobId: String? = null, + val accepted: Int? = null, + val skipped: Int? = null, + val total: Int? = null, +) + +@Serializable +data class DocsUploadToken( + val id: String? = null, + val plaintext: String? = null, + val tokenPrefix: String? = null, +) { + val token: String? get() = plaintext +} + +@Serializable +data class DocsGithubSecretResult( + val secretName: String, + val repositoryFullName: String, +) + +// MARK: - Release + +@Serializable +data class ReleaseRepo( + val id: String, + val installationId: Long? = null, + val repositoryId: Long? = null, + val repositoryFullName: String, + val owner: String? = null, + val repo: String? = null, + val lastScannedAt: String? = null, + val latestReleaseTag: String? = null, + val latestReleaseAt: String? = null, + val versionsListVisibility: String? = null, + val createdAt: String? = null, + val updatedAt: String? = null, +) { + val fullName: String get() = repositoryFullName +} + +@Serializable +data class ReleaseRepoListResponse( + val items: List = emptyList(), + val pagination: Pagination? = null, +) + +@Serializable +data class AddReleaseRepoBody( + val installationId: Long, + val repositoryId: Long, + val repositoryFullName: String, +) + +@Serializable +data class MobileReleaseWorkflowInput( + val name: String, + val type: String, + val description: String? = null, + val required: Boolean = false, + val defaultString: String? = null, + val defaultBool: Boolean? = null, + val options: List? = null, +) + +@Serializable +data class MobileReleaseWorkflow( + val id: String, + val workflowPath: String, + val workflowName: String? = null, + val isSelected: Boolean = false, + val isReleaseWorkflow: Boolean? = null, + val hasWorkflowDispatch: Boolean = false, + val inputs: List? = null, +) { + val displayName: String + get() = workflowName?.takeIf { it.isNotEmpty() } + ?: workflowPath.substringAfterLast('/') +} + +@Serializable +data class ReleaseDispatchRequest( + val workflowId: String, + val branch: String, + val inputs: Map = emptyMap(), +) + +@Serializable +data class ReleaseDispatchResult( + val success: Boolean? = null, + val dispatchId: String? = null, + val workflowRunUrl: String? = null, + val error: String? = null, +) + +@Serializable +data class ReleaseGithubSecretResult( + val secretName: String, + val repositoryFullName: String, +) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt new file mode 100644 index 00000000..7c1941bc --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt @@ -0,0 +1,211 @@ +package app.rxlab.rxcode.proto + +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * Autopilot remote-management wire types. Mirrors + * `RxCodeSync/Protocol/Payload+Autopilot.swift`. + * + * The desktop is the source of truth for every autopilot feature (it holds the + * rxlab bearer token and the passkey-derived secrets key, and is the only side + * that talks to `autopilot.rxlab.app`). Mobile mirrors the desktop's Autopilot + * settings tab by sending a single generic request/result pair over the + * E2E-encrypted relay channel; the desktop performs the action and replies. + * + * One generic pair (`autopilot_request` / `autopilot_result`) carries a + * `domain` + `operation` discriminator and a JSON `body`. On the wire the body + * is a base64 string because Swift's `JSONEncoder` encodes `Data` as base64 by + * default — see [AutopilotCodec] for the encode/decode helpers. + */ + +/** Autopilot sub-feature a request targets (used for logging/grouping). */ +enum class AutopilotDomain(val wire: String) { + ACCOUNT("account"), + AUTOMATION("automation"), + REPO_SETUP("repoSetup"), + SECRETS("secrets"), + CI_UPDATES("ciUpdates"), + DOCS("docs"), + RELEASE("release"), +} + +/** Every autopilot operation, globally unique so the desktop can switch on it. */ +enum class AutopilotOp(val wire: String) { + // Account + ACCOUNT_STATUS("accountStatus"), + LIST_MANAGED_REPOS("listManagedRepos"), + + // Automation settings (schema-driven form) + AUTOMATION_SCHEMA("automationSchema"), + AUTOMATION_VALUES("automationValues"), + AUTOMATION_SAVE("automationSave"), + + // Repo setup templates + REPO_SETUP_SCHEMA("repoSetupSchema"), + REPO_SETUP_LIST("repoSetupList"), + REPO_SETUP_CREATE("repoSetupCreate"), + REPO_SETUP_UPDATE("repoSetupUpdate"), + REPO_SETUP_DELETE("repoSetupDelete"), + + // Secrets — desktop relays opaque ciphertext only. + SECRETS_ENROLLMENT_STATUS("secretsEnrollmentStatus"), + SECRETS_GET_USER_KEY("secretsGetUserKey"), + SECRETS_LIST_ENVIRONMENTS("secretsListEnvironments"), + SECRETS_CREATE_ENVIRONMENT("secretsCreateEnvironment"), + SECRETS_DELETE_ENVIRONMENT("secretsDeleteEnvironment"), + SECRETS_LIST_FILES("secretsListFiles"), + SECRETS_GET_BUNDLE("secretsGetBundle"), + SECRETS_UPSERT_FILE("secretsUpsertFile"), + SECRETS_DELETE_FILE("secretsDeleteFile"), + + // CI auto-update + CI_LIST("ciList"), + CI_ADD("ciAdd"), + CI_UPDATE_FREQUENCY("ciUpdateFrequency"), + CI_DELETE("ciDelete"), + CI_HISTORY("ciHistory"), + CI_TRIGGER("ciTrigger"), + CI_LIST_PRS("ciListPRs"), + CI_CLOSE_PR("ciClosePR"), + + // Docs + DOCS_LIST("docsList"), + DOCS_ADD("docsAdd"), + DOCS_DELETE("docsDelete"), + DOCS_LIST_DOCUMENTS("docsListDocuments"), + DOCS_GET_DOCUMENT("docsGetDocument"), + DOCS_UPLOAD_DOCUMENT("docsUploadDocument"), + DOCS_DELETE_DOCUMENT("docsDeleteDocument"), + DOCS_CREATE_UPLOAD_TOKEN("docsCreateUploadToken"), + DOCS_INSTALL_GITHUB_SECRET("docsInstallGithubSecret"), + + // Release + RELEASE_LIST("releaseList"), + RELEASE_ADD("releaseAdd"), + RELEASE_DELETE("releaseDelete"), + RELEASE_LIST_WORKFLOWS("releaseListWorkflows"), + RELEASE_DISPATCH("releaseDispatch"), + RELEASE_INSTALL_TOKEN("releaseInstallToken"), +} + +/** + * Mobile → desktop: a single autopilot operation. `body` is a base64-encoded + * JSON request DTO (null for parameterless operations). + */ +@Serializable +data class AutopilotRequestPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID = UUID.randomUUID(), + val domain: String, + val operation: String, + val body: String? = null, +) + +/** + * Desktop → mobile: the result of an autopilot operation. `body` is a + * base64-encoded JSON response DTO (null for operations that return nothing, + * or on failure). + */ +@Serializable +data class AutopilotResultPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID, + val domain: String, + val operation: String, + val ok: Boolean, + val errorMessage: String? = null, + val body: String? = null, +) + +// MARK: - Shared request bodies + +/** Search + cursor for the shared accessible-repos picker. */ +@Serializable +data class AutopilotReposQuery(val search: String? = null, val cursor: String? = null) + +@Serializable +data class AutopilotCursorQuery(val cursor: String? = null) + +/** Generic single-id body (template id, watched-repo id, docs/release-repo id). */ +@Serializable +data class AutopilotIDBody(val id: String) + +/** Body addressing a docs/release repo by its internal id or `owner/repo`. */ +@Serializable +data class AutopilotRepoIDBody(val repoId: String) + +// MARK: - Repo setup bodies + +@Serializable +data class AutopilotRepoSetupUpdateBody(val id: String, val input: RepoSetupTemplateInput) + +// MARK: - Secrets bodies + +@Serializable +data class AutopilotSecretsRepoBody(val repo: String) + +@Serializable +data class AutopilotSecretsCreateEnvBody(val repo: String, val body: CreateEnvironmentBody) + +@Serializable +data class AutopilotSecretsEnvRefBody(val repo: String, val envId: String) + +@Serializable +data class AutopilotSecretsUpsertFileBody(val repo: String, val envId: String, val file: UpsertFileBody) + +@Serializable +data class AutopilotSecretsDeleteFileBody(val repo: String, val envId: String, val fileId: String) + +/** + * One decrypted secret file. Produced on the phone after local decryption — it + * is NOT a wire type and never crosses the relay. + */ +data class AutopilotSecretFilePlaintext(val filename: String, val content: String) + +// MARK: - CI auto-update bodies + +@Serializable +data class AutopilotCIFrequencyBody(val id: String, val frequency: CIScanFrequency) + +@Serializable +data class AutopilotCIHistoryBody(val id: String, val limit: Int? = null) + +@Serializable +data class AutopilotCIClosePRBody(val id: String, val prNumber: Int) + +// MARK: - Docs bodies + +@Serializable +data class AutopilotDocsDocBody(val repoId: String, val docId: String) + +@Serializable +data class AutopilotDocsListDocumentsBody(val repoId: String, val cursor: String? = null) + +@Serializable +data class AutopilotDocsUploadBody( + val repoId: String, + val docId: String, + val content: String, + val originalLink: String? = null, +) + +@Serializable +data class AutopilotDocsCreateTokenBody(val repoId: String, val name: String? = null) + +// MARK: - Release bodies + +@Serializable +data class AutopilotReleaseDispatchBody(val repoId: String, val request: ReleaseDispatchRequest) + +@Serializable +data class AutopilotReleaseTokenBody(val repoId: String, val value: String) + +// MARK: - Result list wrappers + +@Serializable +data class MobileReleaseWorkflowList(val items: List) + +@Serializable +data class CIUpdateRunHistoryList(val items: List) + +@Serializable +data class CIPullRequestList(val items: List) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Payload.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Payload.kt index 3d6f719d..c35bf951 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Payload.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Payload.kt @@ -118,6 +118,12 @@ sealed class Payload { data class ThreadChangesResult(val data: ThreadChangesResultPayload) : Payload() { override val type = "thread_changes_result" } + data class AutopilotRequest(val data: AutopilotRequestPayload) : Payload() { + override val type = "autopilot_request" + } + data class AutopilotResult(val data: AutopilotResultPayload) : Payload() { + override val type = "autopilot_result" + } data class Ping(val data: PingPayload) : Payload() { override val type = "ping" } @@ -497,6 +503,8 @@ object PayloadSerializer : KSerializer { "run_task_update" -> Payload.RunTaskUpdate(json.decodeFromJsonElement(RunTaskUpdatePayload.serializer(), data)) "thread_changes_request" -> Payload.ThreadChangesRequest(json.decodeFromJsonElement(ThreadChangesRequestPayload.serializer(), data)) "thread_changes_result" -> Payload.ThreadChangesResult(json.decodeFromJsonElement(ThreadChangesResultPayload.serializer(), data)) + "autopilot_request" -> Payload.AutopilotRequest(json.decodeFromJsonElement(AutopilotRequestPayload.serializer(), data)) + "autopilot_result" -> Payload.AutopilotResult(json.decodeFromJsonElement(AutopilotResultPayload.serializer(), data)) "ping" -> Payload.Ping(json.decodeFromJsonElement(PingPayload.serializer(), data)) "pong" -> Payload.Pong(json.decodeFromJsonElement(PongPayload.serializer(), data)) else -> Payload.Unknown(type, data) @@ -536,6 +544,8 @@ object PayloadSerializer : KSerializer { is Payload.RunTaskUpdate -> value.type to json.encodeToJsonElement(RunTaskUpdatePayload.serializer(), value.data) is Payload.ThreadChangesRequest -> value.type to json.encodeToJsonElement(ThreadChangesRequestPayload.serializer(), value.data) is Payload.ThreadChangesResult -> value.type to json.encodeToJsonElement(ThreadChangesResultPayload.serializer(), value.data) + is Payload.AutopilotRequest -> value.type to json.encodeToJsonElement(AutopilotRequestPayload.serializer(), value.data) + is Payload.AutopilotResult -> value.type to json.encodeToJsonElement(AutopilotResultPayload.serializer(), value.data) is Payload.Ping -> value.type to json.encodeToJsonElement(PingPayload.serializer(), value.data) is Payload.Pong -> value.type to json.encodeToJsonElement(PongPayload.serializer(), value.data) is Payload.Unknown -> value.type to (value.raw ?: JsonObject(emptyMap())) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt new file mode 100644 index 00000000..655537d0 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt @@ -0,0 +1,415 @@ +package app.rxlab.rxcode.state + +import app.rxlab.rxcode.proto.AddDocsRepoBody +import app.rxlab.rxcode.proto.AddReleaseRepoBody +import app.rxlab.rxcode.proto.AddWatchedRepoBody +import app.rxlab.rxcode.proto.AutopilotAccountStatus +import app.rxlab.rxcode.proto.AutopilotCIClosePRBody +import app.rxlab.rxcode.proto.AutopilotCIFrequencyBody +import app.rxlab.rxcode.proto.AutopilotCIHistoryBody +import app.rxlab.rxcode.proto.AutopilotCursorQuery +import app.rxlab.rxcode.proto.AutopilotDocsCreateTokenBody +import app.rxlab.rxcode.proto.AutopilotDocsDocBody +import app.rxlab.rxcode.proto.AutopilotDocsListDocumentsBody +import app.rxlab.rxcode.proto.AutopilotDocsUploadBody +import app.rxlab.rxcode.proto.AutopilotDomain +import app.rxlab.rxcode.proto.AutopilotIDBody +import app.rxlab.rxcode.proto.AutopilotOp +import app.rxlab.rxcode.proto.AutopilotReleaseDispatchBody +import app.rxlab.rxcode.proto.AutopilotReleaseTokenBody +import app.rxlab.rxcode.proto.AutopilotReposQuery +import app.rxlab.rxcode.proto.AutopilotRepoIDBody +import app.rxlab.rxcode.proto.AutopilotRepoSetupUpdateBody +import app.rxlab.rxcode.proto.AutopilotRequestPayload +import app.rxlab.rxcode.proto.AutopilotResultPayload +import app.rxlab.rxcode.proto.AutopilotSecretsCreateEnvBody +import app.rxlab.rxcode.proto.AutopilotSecretsDeleteFileBody +import app.rxlab.rxcode.proto.AutopilotSecretsEnrollment +import app.rxlab.rxcode.proto.AutopilotSecretsEnvRefBody +import app.rxlab.rxcode.proto.AutopilotSecretsRepoBody +import app.rxlab.rxcode.proto.AutopilotSecretsUpsertFileBody +import app.rxlab.rxcode.proto.CIPullRequest +import app.rxlab.rxcode.proto.CIPullRequestList +import app.rxlab.rxcode.proto.CIScanFrequency +import app.rxlab.rxcode.proto.CITriggerResponse +import app.rxlab.rxcode.proto.CIUpdateRunHistory +import app.rxlab.rxcode.proto.CIUpdateRunHistoryList +import app.rxlab.rxcode.proto.CreateEnvironmentBody +import app.rxlab.rxcode.proto.DocsDocumentDetail +import app.rxlab.rxcode.proto.DocsDocumentList +import app.rxlab.rxcode.proto.DocsGithubSecretResult +import app.rxlab.rxcode.proto.DocsRepoListResponse +import app.rxlab.rxcode.proto.DocsUploadResult +import app.rxlab.rxcode.proto.DocsUploadToken +import app.rxlab.rxcode.proto.MobileReleaseWorkflow +import app.rxlab.rxcode.proto.MobileReleaseWorkflowList +import app.rxlab.rxcode.proto.Payload +import app.rxlab.rxcode.proto.PreferencesValues +import app.rxlab.rxcode.proto.ReleaseDispatchRequest +import app.rxlab.rxcode.proto.ReleaseDispatchResult +import app.rxlab.rxcode.proto.ReleaseGithubSecretResult +import app.rxlab.rxcode.proto.ReleaseRepoListResponse +import app.rxlab.rxcode.proto.RepoSetupTemplate +import app.rxlab.rxcode.proto.RepoSetupTemplateInput +import app.rxlab.rxcode.proto.RepoSetupTemplateList +import app.rxlab.rxcode.proto.RxJson +import app.rxlab.rxcode.proto.SchemaEnvelope +import app.rxlab.rxcode.proto.SecretsBundle +import app.rxlab.rxcode.proto.SecretsEnvironment +import app.rxlab.rxcode.proto.SecretsEnvironmentList +import app.rxlab.rxcode.proto.SecretsFileList +import app.rxlab.rxcode.proto.SecretsFileMeta +import app.rxlab.rxcode.proto.SecretsManagedRepoPage +import app.rxlab.rxcode.proto.SecretsUserKey +import app.rxlab.rxcode.proto.UpsertFileBody +import app.rxlab.rxcode.proto.WatchedRepoPage +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonElement +import java.util.Base64 +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** Errors surfaced by the autopilot remote-call layer. Message is user-facing. */ +class AutopilotException(message: String) : Exception(message) + +/** + * Mirrors `MobileAppState+Autopilot.swift`: a single generic request/result + * round trip over the encrypted relay, correlated by `clientRequestID`, plus + * typed wrappers for every autopilot operation. + * + * The desktop is the source of truth and the only side that talks to + * `autopilot.rxlab.app`; for secrets the desktop relays opaque ciphertext only + * — encryption/decryption happens on-device (see [SecretsManager]). + */ +class AutopilotService( + private val send: suspend (Payload, String) -> Boolean, + private val activeDesktopHex: () -> String?, +) { + private val pending = ConcurrentHashMap>() + + /** Routed here by [MobileAppState] when an `autopilot_result` arrives. */ + fun handleResult(result: AutopilotResultPayload) { + pending.remove(result.clientRequestID)?.complete(result) + } + + /** Fail every in-flight request, e.g. when the active desktop changes. */ + fun cancelAll(reason: String) { + val snapshot = pending.values.toList() + pending.clear() + snapshot.forEach { it.completeExceptionally(AutopilotException(reason)) } + } + + // MARK: - Generic round trip + + private suspend fun rawCall( + domain: AutopilotDomain, + op: AutopilotOp, + bodyBase64: String?, + ): AutopilotResultPayload { + val hex = activeDesktopHex() + ?: throw AutopilotException("Connect an online Mac to use Autopilot.") + val payload = AutopilotRequestPayload(domain = domain.wire, operation = op.wire, body = bodyBase64) + val deferred = CompletableDeferred() + pending[payload.clientRequestID] = deferred + val sent = try { + send(Payload.AutopilotRequest(payload), hex) + } catch (t: Throwable) { + pending.remove(payload.clientRequestID) + throw AutopilotException(t.message ?: "Failed to reach your Mac.") + } + if (!sent) { + pending.remove(payload.clientRequestID) + throw AutopilotException("Couldn't send the request. Check your Mac and try again.") + } + val result = withTimeoutOrNull(AUTOPILOT_TIMEOUT_MS) { deferred.await() } + if (result == null) { + pending.remove(payload.clientRequestID) + throw AutopilotException("Request timed out. Check your Mac and try again.") + } + if (!result.ok) { + throw AutopilotException(result.errorMessage ?: "The Mac could not complete the request.") + } + return result + } + + private inline fun encodeBody(body: Req): String = + Base64.getEncoder().encodeToString( + RxJson.encodeToString(body).toByteArray(Charsets.UTF_8) + ) + + private inline fun decodeResult(result: AutopilotResultPayload): Res { + val body = result.body ?: throw AutopilotException("The Mac returned an empty response.") + val json = String(Base64.getDecoder().decode(body), Charsets.UTF_8) + return RxJson.decodeFromString(json) + } + + // MARK: - Account + + suspend fun accountStatus(): AutopilotAccountStatus = + decodeResult(rawCall(AutopilotDomain.ACCOUNT, AutopilotOp.ACCOUNT_STATUS, null)) + + suspend fun listRepos(search: String?, cursor: String? = null): SecretsManagedRepoPage = + decodeResult( + rawCall( + AutopilotDomain.ACCOUNT, AutopilotOp.LIST_MANAGED_REPOS, + encodeBody(AutopilotReposQuery(search = search, cursor = cursor)), + ) + ) + + // MARK: - Automation + + suspend fun automationSchema(): SchemaEnvelope = + decodeResult(rawCall(AutopilotDomain.AUTOMATION, AutopilotOp.AUTOMATION_SCHEMA, null)) + + suspend fun automationValues(): PreferencesValues = + decodeResult(rawCall(AutopilotDomain.AUTOMATION, AutopilotOp.AUTOMATION_VALUES, null)) + + suspend fun saveAutomationValues(values: JsonElement): PreferencesValues = + decodeResult( + rawCall( + AutopilotDomain.AUTOMATION, AutopilotOp.AUTOMATION_SAVE, + encodeBody(PreferencesValues(values)), + ) + ) + + // MARK: - Repo setup + + suspend fun repoSetupSchema(): SchemaEnvelope = + decodeResult(rawCall(AutopilotDomain.REPO_SETUP, AutopilotOp.REPO_SETUP_SCHEMA, null)) + + suspend fun repoSetupTemplates(): List = + decodeResult( + rawCall(AutopilotDomain.REPO_SETUP, AutopilotOp.REPO_SETUP_LIST, null) + ).items + + suspend fun createRepoSetupTemplate(input: RepoSetupTemplateInput): RepoSetupTemplate = + decodeResult( + rawCall(AutopilotDomain.REPO_SETUP, AutopilotOp.REPO_SETUP_CREATE, encodeBody(input)) + ) + + suspend fun updateRepoSetupTemplate(id: String, input: RepoSetupTemplateInput): RepoSetupTemplate = + decodeResult( + rawCall( + AutopilotDomain.REPO_SETUP, AutopilotOp.REPO_SETUP_UPDATE, + encodeBody(AutopilotRepoSetupUpdateBody(id, input)), + ) + ) + + suspend fun deleteRepoSetupTemplate(id: String) { + rawCall(AutopilotDomain.REPO_SETUP, AutopilotOp.REPO_SETUP_DELETE, encodeBody(AutopilotIDBody(id))) + } + + // MARK: - Secrets (relay only — crypto orchestration lives in SecretsManager) + + suspend fun secretsEnrollmentStatus(): Boolean = + decodeResult( + rawCall(AutopilotDomain.SECRETS, AutopilotOp.SECRETS_ENROLLMENT_STATUS, null) + ).enrolled + + suspend fun secretsUserKey(): SecretsUserKey = + decodeResult(rawCall(AutopilotDomain.SECRETS, AutopilotOp.SECRETS_GET_USER_KEY, null)) + + suspend fun listSecretEnvironments(repo: String): List = + decodeResult( + rawCall( + AutopilotDomain.SECRETS, AutopilotOp.SECRETS_LIST_ENVIRONMENTS, + encodeBody(AutopilotSecretsRepoBody(repo)), + ) + ).items + + suspend fun createSecretEnvironment(repo: String, body: CreateEnvironmentBody) { + rawCall( + AutopilotDomain.SECRETS, AutopilotOp.SECRETS_CREATE_ENVIRONMENT, + encodeBody(AutopilotSecretsCreateEnvBody(repo, body)), + ) + } + + suspend fun deleteSecretEnvironment(repo: String, envId: String) { + rawCall( + AutopilotDomain.SECRETS, AutopilotOp.SECRETS_DELETE_ENVIRONMENT, + encodeBody(AutopilotSecretsEnvRefBody(repo, envId)), + ) + } + + suspend fun listSecretFiles(repo: String, envId: String): List = + decodeResult( + rawCall( + AutopilotDomain.SECRETS, AutopilotOp.SECRETS_LIST_FILES, + encodeBody(AutopilotSecretsEnvRefBody(repo, envId)), + ) + ).items + + suspend fun secretsBundle(repo: String, envId: String): SecretsBundle = + decodeResult( + rawCall( + AutopilotDomain.SECRETS, AutopilotOp.SECRETS_GET_BUNDLE, + encodeBody(AutopilotSecretsEnvRefBody(repo, envId)), + ) + ) + + suspend fun upsertSecretFile(repo: String, envId: String, file: UpsertFileBody) { + rawCall( + AutopilotDomain.SECRETS, AutopilotOp.SECRETS_UPSERT_FILE, + encodeBody(AutopilotSecretsUpsertFileBody(repo, envId, file)), + ) + } + + suspend fun deleteSecretFile(repo: String, envId: String, fileId: String) { + rawCall( + AutopilotDomain.SECRETS, AutopilotOp.SECRETS_DELETE_FILE, + encodeBody(AutopilotSecretsDeleteFileBody(repo, envId, fileId)), + ) + } + + // MARK: - CI auto-update + + suspend fun listWatchedRepos(cursor: String? = null): WatchedRepoPage = + decodeResult( + rawCall(AutopilotDomain.CI_UPDATES, AutopilotOp.CI_LIST, encodeBody(AutopilotCursorQuery(cursor))) + ) + + suspend fun addWatchedRepo(body: AddWatchedRepoBody) { + rawCall(AutopilotDomain.CI_UPDATES, AutopilotOp.CI_ADD, encodeBody(body)) + } + + suspend fun updateWatchedRepoFrequency(id: String, frequency: CIScanFrequency) { + rawCall( + AutopilotDomain.CI_UPDATES, AutopilotOp.CI_UPDATE_FREQUENCY, + encodeBody(AutopilotCIFrequencyBody(id, frequency)), + ) + } + + suspend fun deleteWatchedRepo(id: String) { + rawCall(AutopilotDomain.CI_UPDATES, AutopilotOp.CI_DELETE, encodeBody(AutopilotIDBody(id))) + } + + suspend fun watchedRepoHistory(id: String, limit: Int? = null): List = + decodeResult( + rawCall(AutopilotDomain.CI_UPDATES, AutopilotOp.CI_HISTORY, encodeBody(AutopilotCIHistoryBody(id, limit))) + ).items + + suspend fun triggerWatchedRepoScan(id: String): CITriggerResponse = + decodeResult( + rawCall(AutopilotDomain.CI_UPDATES, AutopilotOp.CI_TRIGGER, encodeBody(AutopilotIDBody(id))) + ) + + suspend fun watchedRepoPullRequests(id: String): List = + decodeResult( + rawCall(AutopilotDomain.CI_UPDATES, AutopilotOp.CI_LIST_PRS, encodeBody(AutopilotIDBody(id))) + ).items + + suspend fun closeWatchedRepoPR(id: String, prNumber: Int) { + rawCall(AutopilotDomain.CI_UPDATES, AutopilotOp.CI_CLOSE_PR, encodeBody(AutopilotCIClosePRBody(id, prNumber))) + } + + // MARK: - Docs + + suspend fun listDocsRepos(search: String? = null, cursor: String? = null): DocsRepoListResponse = + decodeResult( + rawCall(AutopilotDomain.DOCS, AutopilotOp.DOCS_LIST, encodeBody(AutopilotReposQuery(search, cursor))) + ) + + suspend fun addDocsRepo(body: AddDocsRepoBody) { + rawCall(AutopilotDomain.DOCS, AutopilotOp.DOCS_ADD, encodeBody(body)) + } + + suspend fun deleteDocsRepo(id: String) { + rawCall(AutopilotDomain.DOCS, AutopilotOp.DOCS_DELETE, encodeBody(AutopilotIDBody(id))) + } + + suspend fun listDocuments(repoId: String, cursor: String? = null): DocsDocumentList = + decodeResult( + rawCall( + AutopilotDomain.DOCS, AutopilotOp.DOCS_LIST_DOCUMENTS, + encodeBody(AutopilotDocsListDocumentsBody(repoId, cursor)), + ) + ) + + suspend fun getDocument(repoId: String, docId: String): DocsDocumentDetail = + decodeResult( + rawCall( + AutopilotDomain.DOCS, AutopilotOp.DOCS_GET_DOCUMENT, + encodeBody(AutopilotDocsDocBody(repoId, docId)), + ) + ) + + suspend fun uploadDocument( + repoId: String, + docId: String, + content: String, + originalLink: String?, + ): DocsUploadResult = + decodeResult( + rawCall( + AutopilotDomain.DOCS, AutopilotOp.DOCS_UPLOAD_DOCUMENT, + encodeBody(AutopilotDocsUploadBody(repoId, docId, content, originalLink)), + ) + ) + + suspend fun deleteDocument(repoId: String, docId: String) { + rawCall(AutopilotDomain.DOCS, AutopilotOp.DOCS_DELETE_DOCUMENT, encodeBody(AutopilotDocsDocBody(repoId, docId))) + } + + suspend fun createDocsUploadToken(repoId: String, name: String?): DocsUploadToken = + decodeResult( + rawCall( + AutopilotDomain.DOCS, AutopilotOp.DOCS_CREATE_UPLOAD_TOKEN, + encodeBody(AutopilotDocsCreateTokenBody(repoId, name)), + ) + ) + + suspend fun installDocsGithubSecret(repoId: String): DocsGithubSecretResult = + decodeResult( + rawCall( + AutopilotDomain.DOCS, AutopilotOp.DOCS_INSTALL_GITHUB_SECRET, + encodeBody(AutopilotRepoIDBody(repoId)), + ) + ) + + // MARK: - Release + + suspend fun listReleaseRepos(cursor: String? = null): ReleaseRepoListResponse = + decodeResult( + rawCall(AutopilotDomain.RELEASE, AutopilotOp.RELEASE_LIST, encodeBody(AutopilotCursorQuery(cursor))) + ) + + suspend fun addReleaseRepo(body: AddReleaseRepoBody) { + rawCall(AutopilotDomain.RELEASE, AutopilotOp.RELEASE_ADD, encodeBody(body)) + } + + suspend fun deleteReleaseRepo(id: String) { + rawCall(AutopilotDomain.RELEASE, AutopilotOp.RELEASE_DELETE, encodeBody(AutopilotIDBody(id))) + } + + suspend fun listReleaseWorkflows(repoId: String): List = + decodeResult( + rawCall( + AutopilotDomain.RELEASE, AutopilotOp.RELEASE_LIST_WORKFLOWS, + encodeBody(AutopilotRepoIDBody(repoId)), + ) + ).items + + suspend fun dispatchRelease(repoId: String, request: ReleaseDispatchRequest): ReleaseDispatchResult = + decodeResult( + rawCall( + AutopilotDomain.RELEASE, AutopilotOp.RELEASE_DISPATCH, + encodeBody(AutopilotReleaseDispatchBody(repoId, request)), + ) + ) + + suspend fun installReleaseToken(repoId: String, value: String): ReleaseGithubSecretResult = + decodeResult( + rawCall( + AutopilotDomain.RELEASE, AutopilotOp.RELEASE_INSTALL_TOKEN, + encodeBody(AutopilotReleaseTokenBody(repoId, value)), + ) + ) + + companion object { + /** Longer ceiling than config calls: scans/secret minting take a while. */ + private const val AUTOPILOT_TIMEOUT_MS = 45_000L + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt index aa2be393..386bca8f 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt @@ -70,6 +70,19 @@ class MobileAppState @Inject constructor( private var pairingTimeout: Job? = null private var pendingThreadChangesId: UUID? = null + /** + * Autopilot remote-management round-trips (mirrors the desktop's Autopilot + * settings tab). Sends through the same encrypted [client]; replies are + * routed back in [handleInbound] via [AutopilotService.handleResult]. + */ + val autopilot: AutopilotService = AutopilotService( + send = { payload, hex -> client.send(payload, hex) }, + activeDesktopHex = { _state.value.activeDesktopPubkey.takeIf { it.isNotEmpty() } }, + ) + + /** On-device secrets crypto orchestration (passkey-derived KEK + relay). */ + val secrets: SecretsManager = SecretsManager(autopilot) + init { viewModelScope.launch { observeStore() } viewModelScope.launch { observeSyncEvents() } @@ -172,6 +185,10 @@ class MobileAppState @Inject constructor( is Payload.RunProfileResult -> handleRunProfileResult(fromHex, payload.data) is Payload.RunTaskUpdate -> handleRunTaskUpdate(fromHex, payload.data.task) is Payload.ThreadChangesResult -> handleThreadChangesResult(fromHex, payload.data) + is Payload.AutopilotResult -> { + if (!isActiveDesktop(fromHex)) return + autopilot.handleResult(payload.data) + } is Payload.Ping -> { // Reply with pong to satisfy the desktop's liveness check. client.send(Payload.Pong(PongPayload()), fromHex) @@ -795,6 +812,8 @@ class MobileAppState @Inject constructor( } fun switchActiveDesktop(desktop: PairedDesktop) { + autopilot.cancelAll("The paired Mac changed before the request finished.") + secrets.clearKek() viewModelScope.launch { store.setActive(desktop.id) desktop.relayUrl?.let { @@ -806,6 +825,8 @@ class MobileAppState @Inject constructor( } fun removeDesktop(desktop: PairedDesktop) { + autopilot.cancelAll("This device is no longer paired with that Mac.") + secrets.clearKek() viewModelScope.launch { store.remove(desktop.id) client.removePeer(desktop.pubkeyHex) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/SecretsManager.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/SecretsManager.kt new file mode 100644 index 00000000..2f9d8627 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/SecretsManager.kt @@ -0,0 +1,90 @@ +package app.rxlab.rxcode.state + +import android.content.Context +import app.rxlab.rxcode.crypto.SecretsCrypto +import app.rxlab.rxcode.crypto.SecretsKeyVault +import app.rxlab.rxcode.proto.AutopilotSecretFilePlaintext +import app.rxlab.rxcode.proto.CreateEnvironmentBody +import app.rxlab.rxcode.proto.SecretsBundle +import app.rxlab.rxcode.proto.SecretsEnvironment +import app.rxlab.rxcode.proto.SecretsFileMeta +import app.rxlab.rxcode.proto.UpsertFileBody +import org.bouncycastle.crypto.params.ECPrivateKeyParameters + +/** + * Orchestrates on-device secrets crypto with the relay-only [AutopilotService]. + * Mirrors the secrets section of `MobileAppState+Autopilot.swift`: the desktop + * relays opaque ciphertext, while the phone derives the KEK from its passkey + * ([SecretsKeyVault]) and performs all encryption/decryption locally — plaintext + * never crosses the relay. + */ +class SecretsManager( + private val service: AutopilotService, + private val vault: SecretsKeyVault = SecretsKeyVault(), +) { + suspend fun enrollmentStatus(): Boolean = service.secretsEnrollmentStatus() + + suspend fun listEnvironments(repo: String): List = + service.listSecretEnvironments(repo) + + /** Builds the owner environment key on-device, then asks the desktop to POST it. */ + suspend fun createEnvironment(context: Context, repo: String, name: String) { + val kek = vault.kek(context, System.currentTimeMillis()) + val (body, _) = SecretsCrypto.buildOwnerEnvironmentKey(kek) + service.createSecretEnvironment(repo, CreateEnvironmentBody(name, body)) + } + + suspend fun deleteEnvironment(repo: String, envId: String) = + service.deleteSecretEnvironment(repo, envId) + + suspend fun listFiles(repo: String, envId: String): List = + service.listSecretFiles(repo, envId) + + suspend fun environmentPlaintext( + context: Context, + repo: String, + envId: String, + ): List { + val (dek, bundle) = resolveDEK(context, repo, envId) + return bundle.files.map { + AutopilotSecretFilePlaintext(it.filename, SecretsCrypto.decryptFile(it.ciphertext, it.iv, dek)) + } + } + + suspend fun upsertFile( + context: Context, + repo: String, + envId: String, + filename: String, + content: String, + ) { + val (dek, _) = resolveDEK(context, repo, envId) + val (ciphertext, iv, size) = SecretsCrypto.encryptFile(content, dek) + service.upsertSecretFile(repo, envId, UpsertFileBody(filename, ciphertext, iv, size)) + } + + suspend fun deleteFile(repo: String, envId: String, fileId: String) = + service.deleteSecretFile(repo, envId, fileId) + + fun clearKek() = vault.clear() + + /** + * Resolves the environment DEK on-device: derive the KEK from the passkey + * and unwrap the DEK (unwrapping the user's private key first for + * HPKE-shared environments). + */ + private suspend fun resolveDEK( + context: Context, + repo: String, + envId: String, + ): Pair { + val kek = vault.kek(context, System.currentTimeMillis()) + val bundle = service.secretsBundle(repo, envId) + var userPrivateKey: ECPrivateKeyParameters? = null + if (bundle.environmentKey.wrapMode == "hpke") { + userPrivateKey = SecretsCrypto.unwrapUserPrivateKey(service.secretsUserKey(), kek) + } + val dek = SecretsCrypto.unwrapDEK(bundle.environmentKey, kek, userPrivateKey) + return dek to bundle + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutomationScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutomationScreen.kt new file mode 100644 index 00000000..2d9e9319 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutomationScreen.kt @@ -0,0 +1,216 @@ +package app.rxlab.rxcode.ui.autopilot + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.RxJson +import app.rxlab.rxcode.state.AutopilotException +import app.rxlab.rxcode.state.AutopilotService +import app.rxlab.rxcode.ui.jsonschemaform.JsonSchemaForm +import app.rxlab.rxcode.ui.jsonschemaform.rememberJsonSchemaFormState +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * Automation settings — mirrors iOS `MobileAutomationView`. Renders the + * desktop-supplied JSON Schema as a form (falling back to a raw JSON editor + * when the schema can't be rendered), then saves the values back. + */ +@Composable +fun AutomationScreen( + service: AutopilotService, + online: Boolean, + onBack: () -> Unit, +) { + var isLoading by remember { mutableStateOf(true) } + var loadError by remember { mutableStateOf(null) } + var schema by remember { mutableStateOf(null) } + var uiSchema by remember { mutableStateOf(null) } + var values by remember { mutableStateOf(null) } + var reloadKey by remember { mutableStateOf(0) } + + LaunchedEffect(online, reloadKey) { + if (!online) { + isLoading = false + loadError = "Connect an online Mac to use Autopilot." + return@LaunchedEffect + } + isLoading = true + loadError = null + try { + val envelope = service.automationSchema() + schema = envelope.schema as? JsonObject + uiSchema = envelope.uiSchema as? JsonObject + values = service.automationValues().values + } catch (e: AutopilotException) { + loadError = e.message + } finally { + isLoading = false + } + } + + when { + isLoading -> AutopilotScaffold("Automation", onBack) { m -> + AutopilotLoadingOverlay(true, m.fillMaxSize()) + } + loadError != null -> AutopilotScaffold("Automation", onBack) { m -> + Column(m.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + AutopilotErrorRow(loadError!!) + TextButton(onClick = { reloadKey++ }) { Text("Retry") } + } + } + schema != null -> AutomationLoaded( + schema = schema!!, + uiSchema = uiSchema, + existing = values, + service = service, + online = online, + onBack = onBack, + ) + else -> AutomationFallback( + initial = values, + service = service, + online = online, + onBack = onBack, + ) + } +} + +@Composable +private fun AutomationLoaded( + schema: JsonObject, + uiSchema: JsonObject?, + existing: JsonElement?, + service: AutopilotService, + online: Boolean, + onBack: () -> Unit, +) { + val scope = rememberCoroutineScope() + val formState = rememberJsonSchemaFormState(schema, existing) + var isSaving by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var savedMessage by remember { mutableStateOf(null) } + + AutopilotScaffold( + title = "Automation", + onBack = onBack, + actions = { + TextButton( + enabled = online && !isSaving, + onClick = { + val obj = formState.submit() ?: return@TextButton + isSaving = true + errorMessage = null + savedMessage = null + scope.launch { + try { + service.saveAutomationValues(obj) + savedMessage = "Saved." + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isSaving = false + } + } + }, + ) { Text("Save") } + }, + ) { m -> + Column( + m.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + JsonSchemaForm(schema, uiSchema, formState) + if (formState.showErrors) { + formState.errors.forEach { AutopilotErrorRow(it) } + } + errorMessage?.let { AutopilotErrorRow(it) } + savedMessage?.let { AutopilotSuccessRow(it) } + } + } +} + +@Composable +private fun AutomationFallback( + initial: JsonElement?, + service: AutopilotService, + online: Boolean, + onBack: () -> Unit, +) { + val scope = rememberCoroutineScope() + var text by remember { + mutableStateOf(initial?.let { RxJson.encodeToString(JsonElement.serializer(), it) } ?: "{}") + } + var isSaving by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var savedMessage by remember { mutableStateOf(null) } + + AutopilotScaffold( + title = "Automation", + onBack = onBack, + actions = { + TextButton( + enabled = online && !isSaving, + onClick = { + val parsed = runCatching { RxJson.parseToJsonElement(text) }.getOrNull() + if (parsed == null) { + errorMessage = "Invalid JSON." + return@TextButton + } + isSaving = true + errorMessage = null + savedMessage = null + scope.launch { + try { + service.saveAutomationValues(parsed) + savedMessage = "Saved." + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isSaving = false + } + } + }, + ) { Text("Save") } + }, + ) { m -> + Column( + m.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + "This settings form couldn't be rendered. Edit the raw JSON instead.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.fillMaxWidth(), + minLines = 12, + ) + if (isSaving) CircularProgressIndicator(Modifier.padding(top = 4.dp)) + errorMessage?.let { AutopilotErrorRow(it) } + savedMessage?.let { AutopilotSuccessRow(it) } + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotCommon.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotCommon.kt new file mode 100644 index 00000000..11d737e4 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotCommon.kt @@ -0,0 +1,158 @@ +package app.rxlab.rxcode.ui.autopilot + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.WarningAmber +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** Warm orange used for inline warnings/errors, matching iOS `.orange`. */ +val AutopilotWarning = Color(0xFFE8910C) + +/** Success green, matching iOS `.green`. */ +val AutopilotSuccess = Color(0xFF34C759) + +/** + * Standard chrome for an autopilot sub-screen: a top app bar with a back + * button plus optional trailing [actions]. Disables interaction when offline + * is handled by callers (they gate buttons), matching the iOS `.disabled`. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AutopilotScaffold( + title: String, + onBack: () -> Unit, + actions: @Composable () -> Unit = {}, + content: @Composable (Modifier) -> Unit, +) { + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + TopAppBar( + title = { Text(title, maxLines = 1) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") + } + }, + actions = { actions() }, + ) + }, + ) { padding -> + content(Modifier.padding(padding)) + } +} + +/** Centered loading indicator shown over a screen on its initial fetch only. */ +@Composable +fun AutopilotLoadingOverlay( + isLoading: Boolean, + modifier: Modifier = Modifier, + title: String = "Loading…", +) { + AnimatedVisibility(visible = isLoading) { + Box( + modifier + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + modifier = Modifier.fillMaxSize(), + ) { + Column( + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + Text( + title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 12.dp), + ) + } + } + } + } +} + +/** Inline orange warning row used for `errorMessage` surfaces. */ +@Composable +fun AutopilotErrorRow(message: String, modifier: Modifier = Modifier) { + Row( + modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.WarningAmber, + contentDescription = null, + tint = AutopilotWarning, + modifier = Modifier.size(18.dp), + ) + Text(message, style = MaterialTheme.typography.bodySmall, color = AutopilotWarning) + } +} + +/** Inline green success row used for `statusMessage` surfaces. */ +@Composable +fun AutopilotSuccessRow(message: String, modifier: Modifier = Modifier) { + Row( + modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.CheckCircle, + contentDescription = null, + tint = AutopilotSuccess, + modifier = Modifier.size(18.dp), + ) + Text(message, style = MaterialTheme.typography.bodySmall, color = AutopilotSuccess) + } +} + +/** Small section header label, matching the look of `SettingsScreen`. */ +@Composable +fun AutopilotSectionLabel(text: String, modifier: Modifier = Modifier) { + Text( + text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = modifier.padding(start = 4.dp, top = 4.dp, bottom = 4.dp), + ) +} + +@Composable +fun AutopilotEmptyState(text: String, modifier: Modifier = Modifier) { + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.padding(vertical = 8.dp), + ) +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotHubScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotHubScreen.kt new file mode 100644 index 00000000..9aea107e --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotHubScreen.kt @@ -0,0 +1,262 @@ +package app.rxlab.rxcode.ui.autopilot + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.LocalOffer +import androidx.compose.material.icons.outlined.Loop +import androidx.compose.material.icons.outlined.MenuBook +import androidx.compose.material.icons.outlined.PersonOutline +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.VpnKey +import androidx.compose.material.icons.outlined.WifiOff +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.AutopilotAccountStatus +import app.rxlab.rxcode.state.AutopilotException +import app.rxlab.rxcode.state.AutopilotService +import coil.compose.AsyncImage + +/** + * Autopilot hub — mirrors iOS `MobileAutopilotView`. Shows the rxlab account + * status, an offline notice when the Mac is unreachable, and navigation into + * the Configuration (Automation, Repo Setup) and Repositories (Secrets, CI, + * Docs, Releases) sub-features. + */ +@Composable +fun AutopilotHubScreen( + service: AutopilotService, + online: Boolean, + nav: AutopilotNav, +) { + var account by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var loadError by remember { mutableStateOf(null) } + + LaunchedEffect(online) { + if (!online) return@LaunchedEffect + isLoading = true + loadError = null + try { + account = service.accountStatus() + } catch (e: AutopilotException) { + loadError = e.message + } finally { + isLoading = false + } + } + + val signedIn = account?.isSignedIn == true + + AutopilotScaffold(title = "Autopilot", onBack = { nav.pop() }) { modifier -> + Box(modifier.fillMaxSize()) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (!online) { + item { OfflineNotice() } + } + + item { AccountCard(account, isLoading) } + + if (signedIn) { + item { AutopilotSectionLabel("Configuration") } + item { + HubRow("Automation", Icons.Outlined.AutoAwesome, online) { + nav.push(AutopilotRoute.Automation) + } + } + item { + HubRow("Repo Setup", Icons.Outlined.Tune, online) { + nav.push(AutopilotRoute.RepoSetup) + } + } + + item { AutopilotSectionLabel("Repositories") } + item { + HubRow("Secrets", Icons.Outlined.VpnKey, online) { + nav.push(AutopilotRoute.Secrets) + } + } + item { + HubRow("CI Auto-Update", Icons.Outlined.Loop, online) { + nav.push(AutopilotRoute.Ci) + } + } + item { + HubRow("Documentation", Icons.Outlined.MenuBook, online) { + nav.push(AutopilotRoute.Docs) + } + } + item { + HubRow("Releases", Icons.Outlined.LocalOffer, online) { + nav.push(AutopilotRoute.Release) + } + } + } else if (!isLoading && loadError == null && online) { + item { + Text( + "Sign in with rxlab on your Mac to enable Autopilot.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + loadError?.let { item { AutopilotErrorRow(it) } } + } + } + } +} + +@Composable +private fun OfflineNotice() { + ElevatedCard( + Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Row( + Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Outlined.WifiOff, contentDescription = null, tint = AutopilotWarning) + Column { + Text("Mac offline", style = MaterialTheme.typography.titleSmall) + Text( + "Connect to an online Mac to manage Autopilot.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun AccountCard(account: AutopilotAccountStatus?, isLoading: Boolean) { + ElevatedCard( + Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Row( + Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + when { + isLoading && account == null -> { + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) + Text( + "Checking account…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + account?.isSignedIn == true -> { + val avatar = account.avatarURL + Surface(shape = CircleShape, modifier = Modifier.size(36.dp)) { + if (avatar != null) { + AsyncImage(model = avatar, contentDescription = null) + } else { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.PersonOutline, contentDescription = null) + } + } + } + Column(Modifier.weight(1f)) { + Text( + account.name ?: "Signed in", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + account.email?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + else -> { + Icon(Icons.Outlined.PersonOutline, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + "Not signed in", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun HubRow( + title: String, + icon: ImageVector, + enabled: Boolean, + onClick: () -> Unit, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + onClick = { if (enabled) onClick() }, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Row( + Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Text( + title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Icon( + Icons.AutoMirrored.Outlined.ArrowForwardIos, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotNavHost.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotNavHost.kt new file mode 100644 index 00000000..dbdc42e8 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotNavHost.kt @@ -0,0 +1,82 @@ +package app.rxlab.rxcode.ui.autopilot + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import app.rxlab.rxcode.proto.DocsRepo +import app.rxlab.rxcode.proto.ReleaseRepo +import app.rxlab.rxcode.proto.SecretsEnvironment +import app.rxlab.rxcode.proto.WatchedRepo +import app.rxlab.rxcode.state.MobileAppState + +/** + * Destinations inside the Autopilot feature. Modeled as a tiny in-memory back + * stack (not the global NavHost) so detail screens can carry already-fetched + * model objects, mirroring iOS `NavigationStack` value-typed destinations. + */ +sealed interface AutopilotRoute { + data object Hub : AutopilotRoute + data object Automation : AutopilotRoute + data object RepoSetup : AutopilotRoute + data object Secrets : AutopilotRoute + data class SecretsRepo(val repo: String) : AutopilotRoute + data class SecretsEnv(val repo: String, val env: SecretsEnvironment) : AutopilotRoute + data object Ci : AutopilotRoute + data class CiRepo(val repo: WatchedRepo) : AutopilotRoute + data object Docs : AutopilotRoute + data class DocsRepoDetail(val repo: DocsRepo) : AutopilotRoute + data object Release : AutopilotRoute + data class ReleaseRepoDetail(val repo: ReleaseRepo) : AutopilotRoute +} + +class AutopilotNav( + private val stack: SnapshotStateList, + private val exit: () -> Unit, +) { + fun push(route: AutopilotRoute) { + stack.add(route) + } + + fun pop() { + if (stack.size > 1) stack.removeAt(stack.lastIndex) else exit() + } +} + +/** + * Host for the Autopilot settings stack. Rendered full-screen from + * `SettingsScreen` when the user taps the "Autopilot" row. [onExit] pops the + * whole feature (back from the hub). + */ +@Composable +fun AutopilotNavHost( + app: MobileAppState, + online: Boolean, + onExit: () -> Unit, +) { + val stack = remember { mutableStateListOf(AutopilotRoute.Hub) } + val nav = remember(stack) { AutopilotNav(stack, onExit) } + val service = app.autopilot + + BackHandler { nav.pop() } + + when (val route = stack.last()) { + AutopilotRoute.Hub -> AutopilotHubScreen(service, online, nav) + AutopilotRoute.Automation -> AutomationScreen(service, online, nav::pop) + AutopilotRoute.RepoSetup -> RepoSetupScreen(service, online, nav::pop) + + AutopilotRoute.Secrets -> SecretsScreen(app, online, nav) + is AutopilotRoute.SecretsRepo -> SecretsRepoScreen(app, route.repo, online, nav) + is AutopilotRoute.SecretsEnv -> SecretsEnvScreen(app, route.repo, route.env, online, nav::pop) + + AutopilotRoute.Ci -> CiUpdatesScreen(service, online, nav) + is AutopilotRoute.CiRepo -> CiRepoScreen(service, route.repo, online, nav::pop) + + AutopilotRoute.Docs -> DocsScreen(service, online, nav) + is AutopilotRoute.DocsRepoDetail -> DocsRepoScreen(service, route.repo, online, nav::pop) + + AutopilotRoute.Release -> ReleaseScreen(service, online, nav) + is AutopilotRoute.ReleaseRepoDetail -> ReleaseRepoScreen(service, route.repo, online, nav::pop) + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotRepoPicker.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotRepoPicker.kt new file mode 100644 index 00000000..bf6194c1 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/AutopilotRepoPicker.kt @@ -0,0 +1,197 @@ +package app.rxlab.rxcode.ui.autopilot + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddCircleOutline +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.MenuBook +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.SecretsManagedRepo +import app.rxlab.rxcode.state.AutopilotException +import app.rxlab.rxcode.state.AutopilotService +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Shared "add repository" picker — mirrors iOS `MobileAutopilotRepoPicker`. + * Lists accessible GitHub repos (paginated + searchable), excludes the ones + * already managed, and invokes [onSelect] with the chosen repo. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AutopilotRepoPicker( + service: AutopilotService, + title: String, + existingFullNames: Set, + onSelect: suspend (SecretsManagedRepo) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + var search by remember { mutableStateOf("") } + var repos by remember { mutableStateOf>(emptyList()) } + var cursor by remember { mutableStateOf(null) } + var hasMore by remember { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var addingFullName by remember { mutableStateOf(null) } + + val excluded = remember(existingFullNames) { existingFullNames.map { it.lowercase() }.toSet() } + val visible = repos.filter { it.fullName.lowercase() !in excluded } + + // Debounced reload on search change + initial load. + LaunchedEffect(search) { + if (search.isNotEmpty()) delay(300) + isLoading = true + errorMessage = null + try { + val page = service.listRepos(search.ifBlank { null }) + repos = page.items + cursor = page.pagination.nextCursor + hasMore = page.pagination.hasMore + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isLoading = false + } + } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(Modifier.padding(horizontal = 16.dp).padding(bottom = 24.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(bottom = 8.dp)) + OutlinedTextField( + value = search, + onValueChange = { search = it }, + label = { Text("Search repositories") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + errorMessage?.let { AutopilotErrorRow(it, Modifier.padding(top = 8.dp)) } + + Box(Modifier.fillMaxWidth().heightIn(min = 120.dp, max = 480.dp)) { + LazyColumn(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + items(visible, key = { it.id }) { repo -> + RepoPickerRow( + repo = repo, + adding = addingFullName == repo.fullName, + enabled = addingFullName == null, + onClick = { + addingFullName = repo.fullName + scope.launch { + try { + onSelect(repo) + onDismiss() + } catch (e: AutopilotException) { + errorMessage = e.message + addingFullName = null + } + } + }, + ) + } + if (hasMore) { + item { + TextButton( + enabled = !isLoading, + onClick = { + scope.launch { + isLoading = true + try { + val page = service.listRepos(search.ifBlank { null }, cursor) + repos = repos + page.items + cursor = page.pagination.nextCursor + hasMore = page.pagination.hasMore + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isLoading = false + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Load more") } + } + } + if (visible.isEmpty() && !isLoading && errorMessage == null) { + item { AutopilotEmptyState("No repositories available to add.") } + } + } + if (isLoading && repos.isEmpty()) { + CircularProgressIndicator(Modifier.align(Alignment.Center)) + } + } + } + } +} + +@Composable +private fun RepoPickerRow( + repo: SecretsManagedRepo, + adding: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + if (repo.isPrivate) Icons.Outlined.Lock else Icons.Outlined.MenuBook, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Column(Modifier.weight(1f)) { + Text(repo.fullName, style = MaterialTheme.typography.bodyMedium) + if (repo.isCurrent) { + Text( + "Current project", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (adding) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Icon( + Icons.Outlined.AddCircleOutline, + contentDescription = "Add", + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/CiUpdatesScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/CiUpdatesScreen.kt new file mode 100644 index 00000000..4a8b880a --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/CiUpdatesScreen.kt @@ -0,0 +1,369 @@ +package app.rxlab.rxcode.ui.autopilot + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Loop +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.AddWatchedRepoBody +import app.rxlab.rxcode.proto.CIPullRequest +import app.rxlab.rxcode.proto.CIScanFrequency +import app.rxlab.rxcode.proto.CIUpdateRunHistory +import app.rxlab.rxcode.proto.WatchedRepo +import app.rxlab.rxcode.state.AutopilotException +import app.rxlab.rxcode.state.AutopilotService +import kotlinx.coroutines.launch + +@Composable +fun CiUpdatesScreen( + service: AutopilotService, + online: Boolean, + nav: AutopilotNav, +) { + val scope = rememberCoroutineScope() + var repos by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + var reloadKey by remember { mutableStateOf(0) } + var showPicker by remember { mutableStateOf(false) } + var deleteTarget by remember { mutableStateOf(null) } + + LaunchedEffect(online, reloadKey) { + if (!online) { isLoading = false; return@LaunchedEffect } + isLoading = true + errorMessage = null + try { + repos = service.listWatchedRepos().repositories + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isLoading = false + } + } + + AutopilotScaffold( + title = "CI Auto-Update", + onBack = { nav.pop() }, + actions = { + IconButton(enabled = online, onClick = { showPicker = true }) { + Icon(Icons.Outlined.Add, contentDescription = "Watch repository") + } + }, + ) { m -> + Box(m.fillMaxSize()) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + errorMessage?.let { item { AutopilotErrorRow(it) } } + if (repos.isEmpty() && !isLoading) { + item { AutopilotEmptyState("No watched repositories. Tap + to add one.") } + } + items(repos, key = { it.id }) { repo -> + RepoListCard( + title = repo.fullName, + subtitle = repo.scanFrequency.displayName, + enabled = online, + onClick = { nav.push(AutopilotRoute.CiRepo(repo)) }, + onDelete = { deleteTarget = repo }, + ) + } + } + AutopilotLoadingOverlay(isLoading && repos.isEmpty()) + } + } + + if (showPicker) { + AutopilotRepoPicker( + service = service, + title = "Watch Repository", + existingFullNames = repos.map { it.fullName }.toSet(), + onSelect = { picked -> + service.addWatchedRepo( + AddWatchedRepoBody( + installationId = picked.installationId, + repositoryId = picked.id, + repositoryFullName = picked.fullName, + scanFrequency = CIScanFrequency.WEEKLY, + ) + ) + reloadKey++ + }, + onDismiss = { showPicker = false }, + ) + } + + deleteTarget?.let { target -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text("Stop watching?") }, + text = { Text("\"${target.fullName}\" will no longer be scanned for outdated actions.") }, + confirmButton = { + TextButton(onClick = { + deleteTarget = null + scope.launch { + try { service.deleteWatchedRepo(target.id); reloadKey++ } + catch (e: AutopilotException) { errorMessage = e.message } + } + }) { Text("Remove", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = { deleteTarget = null }) { Text("Cancel") } }, + ) + } +} + +@Composable +fun CiRepoScreen( + service: AutopilotService, + repo: WatchedRepo, + online: Boolean, + onBack: () -> Unit, +) { + val scope = rememberCoroutineScope() + var frequency by remember { mutableStateOf(repo.scanFrequency) } + var history by remember { mutableStateOf>(emptyList()) } + var pullRequests by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var isTriggering by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var statusMessage by remember { mutableStateOf(null) } + var reloadKey by remember { mutableStateOf(0) } + var closeTarget by remember { mutableStateOf(null) } + + LaunchedEffect(reloadKey) { + if (!online) { isLoading = false; return@LaunchedEffect } + isLoading = true + errorMessage = null + try { + history = service.watchedRepoHistory(repo.id, limit = 50) + pullRequests = service.watchedRepoPullRequests(repo.id) + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isLoading = false + } + } + + AutopilotScaffold(title = repo.fullName, onBack = onBack) { m -> + Box(m.fillMaxSize()) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + errorMessage?.let { item { AutopilotErrorRow(it) } } + statusMessage?.let { item { AutopilotSuccessRow(it) } } + + item { AutopilotSectionLabel("Scan") } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CIScanFrequency.entries.forEach { freq -> + FilterChip( + selected = frequency == freq, + enabled = online, + onClick = { + frequency = freq + scope.launch { + try { service.updateWatchedRepoFrequency(repo.id, freq) } + catch (e: AutopilotException) { errorMessage = e.message } + } + }, + label = { Text(freq.displayName) }, + ) + } + } + } + item { + OutlinedButton( + enabled = online && !isTriggering, + onClick = { + isTriggering = true + errorMessage = null + statusMessage = null + scope.launch { + try { + val r = service.triggerWatchedRepoScan(repo.id) + statusMessage = if (r.success) "Scan started." else (r.error ?: "Scan failed.") + reloadKey++ + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isTriggering = false + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + if (isTriggering) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + Text(" Scanning…") + } else { + Icon(Icons.Outlined.Loop, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(" Trigger scan now") + } + } + } + + item { AutopilotSectionLabel("Scan History") } + if (history.isEmpty() && !isLoading) { + item { AutopilotEmptyState("No scans yet.") } + } + items(history, key = { it.id }) { run -> HistoryRow(run) } + + item { AutopilotSectionLabel("Pull Requests") } + if (pullRequests.isEmpty() && !isLoading) { + item { AutopilotEmptyState("No open auto-update pull requests.") } + } + items(pullRequests, key = { it.stableId }) { pr -> + PullRequestRow(pr, online) { closeTarget = pr } + } + } + AutopilotLoadingOverlay(isLoading && history.isEmpty() && pullRequests.isEmpty()) + } + } + + closeTarget?.let { pr -> + AlertDialog( + onDismissRequest = { closeTarget = null }, + title = { Text("Close pull request?") }, + text = { Text(pr.title ?: "PR #${pr.number ?: "?"}") }, + confirmButton = { + TextButton(onClick = { + val number = pr.number + closeTarget = null + if (number != null) { + scope.launch { + try { service.closeWatchedRepoPR(repo.id, number); reloadKey++ } + catch (e: AutopilotException) { errorMessage = e.message } + } + } + }) { Text("Close PR", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = { closeTarget = null }) { Text("Cancel") } }, + ) + } +} + +@Composable +private fun HistoryRow(run: CIUpdateRunHistory) { + ElevatedCard( + Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + if (run.isSuccess) Icons.Outlined.CheckCircle else Icons.Outlined.Cancel, + contentDescription = null, + tint = if (run.isSuccess) AutopilotSuccess else MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp), + ) + Text( + if (run.isSuccess) "Success" else "Error", + style = MaterialTheme.typography.bodyMedium, + ) + } + run.outdatedActionsCount?.let { + Text( + "$it outdated action(s)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + run.errorMessage?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + run.prUrl?.let { + Text("PR: $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +@Composable +private fun PullRequestRow(pr: CIPullRequest, online: Boolean, onClose: () -> Unit) { + ElevatedCard( + Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Column(Modifier.weight(1f)) { + Text(pr.title ?: "PR #${pr.number ?: "?"}", style = MaterialTheme.typography.bodyMedium) + pr.number?.let { + Text("#$it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + IconButton(enabled = online, onClick = onClose) { + Icon(Icons.Outlined.Cancel, contentDescription = "Close PR", tint = MaterialTheme.colorScheme.error) + } + } + } +} + +/** Shared list-card used by CI / Docs / Release repo lists. */ +@Composable +fun RepoListCard( + title: String, + subtitle: String?, + enabled: Boolean, + onClick: () -> Unit, + onDelete: () -> Unit, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + onClick = { if (enabled) onClick() }, + colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Row( + Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + subtitle?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + IconButton(onClick = onDelete) { + Icon(Icons.Outlined.Delete, contentDescription = "Remove", tint = MaterialTheme.colorScheme.error) + } + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/DocsScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/DocsScreen.kt new file mode 100644 index 00000000..67c331c4 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/DocsScreen.kt @@ -0,0 +1,432 @@ +package app.rxlab.rxcode.ui.autopilot + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.KeyboardOptions +import app.rxlab.rxcode.proto.AddDocsRepoBody +import app.rxlab.rxcode.proto.DocsDocument +import app.rxlab.rxcode.proto.DocsRepo +import app.rxlab.rxcode.state.AutopilotException +import app.rxlab.rxcode.state.AutopilotService +import app.rxlab.rxcode.ui.util.RxMarkdownText +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun DocsScreen( + service: AutopilotService, + online: Boolean, + nav: AutopilotNav, +) { + val scope = rememberCoroutineScope() + var search by remember { mutableStateOf("") } + var repos by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + var reloadKey by remember { mutableStateOf(0) } + var showPicker by remember { mutableStateOf(false) } + var deleteTarget by remember { mutableStateOf(null) } + + LaunchedEffect(online, search, reloadKey) { + if (!online) { isLoading = false; return@LaunchedEffect } + if (search.isNotEmpty()) delay(300) + isLoading = true + errorMessage = null + try { + repos = service.listDocsRepos(search.ifBlank { null }).items + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isLoading = false + } + } + + AutopilotScaffold( + title = "Documentation", + onBack = { nav.pop() }, + actions = { + IconButton(enabled = online, onClick = { showPicker = true }) { + Icon(Icons.Outlined.Add, contentDescription = "Add docs repository") + } + }, + ) { m -> + Box(m.fillMaxSize()) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + OutlinedTextField( + value = search, + onValueChange = { search = it }, + label = { Text("Search repositories") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + errorMessage?.let { item { AutopilotErrorRow(it) } } + if (repos.isEmpty() && !isLoading) { + item { AutopilotEmptyState("No documentation repositories. Tap + to add one.") } + } + items(repos, key = { it.id }) { repo -> + RepoListCard( + title = repo.fullName, + subtitle = repo.documentsCount?.let { "$it document(s)" }, + enabled = online, + onClick = { nav.push(AutopilotRoute.DocsRepoDetail(repo)) }, + onDelete = { deleteTarget = repo }, + ) + } + } + AutopilotLoadingOverlay(isLoading && repos.isEmpty()) + } + } + + if (showPicker) { + AutopilotRepoPicker( + service = service, + title = "Add Docs Repository", + existingFullNames = repos.map { it.fullName }.toSet(), + onSelect = { picked -> + service.addDocsRepo( + AddDocsRepoBody(picked.installationId, picked.id, picked.fullName) + ) + reloadKey++ + }, + onDismiss = { showPicker = false }, + ) + } + + deleteTarget?.let { target -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text("Remove repository?") }, + text = { Text("This removes the repository and its indexed documents from docs search.") }, + confirmButton = { + TextButton(onClick = { + deleteTarget = null + scope.launch { + try { service.deleteDocsRepo(target.id); reloadKey++ } + catch (e: AutopilotException) { errorMessage = e.message } + } + }) { Text("Remove", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = { deleteTarget = null }) { Text("Cancel") } }, + ) + } +} + +@Composable +fun DocsRepoScreen( + service: AutopilotService, + repo: DocsRepo, + online: Boolean, + onBack: () -> Unit, +) { + val scope = rememberCoroutineScope() + var documents by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var isInstallingSecret by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var statusMessage by remember { mutableStateOf(null) } + var reloadKey by remember { mutableStateOf(0) } + var viewingDoc by remember { mutableStateOf(null) } + var deleteTarget by remember { mutableStateOf(null) } + var showUpload by remember { mutableStateOf(false) } + + LaunchedEffect(reloadKey) { + if (!online) { isLoading = false; return@LaunchedEffect } + isLoading = true + errorMessage = null + try { + documents = service.listDocuments(repo.id).items + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isLoading = false + } + } + + viewingDoc?.let { doc -> + DocumentViewer(service, repo.id, doc, onBack = { viewingDoc = null }) + return + } + if (showUpload) { + DocsUploadScreen( + service = service, + repoId = repo.id, + online = online, + onClose = { showUpload = false }, + onUploaded = { showUpload = false; statusMessage = "Document uploaded."; reloadKey++ }, + ) + return + } + + AutopilotScaffold( + title = repo.fullName, + onBack = onBack, + actions = { + IconButton(enabled = online, onClick = { showUpload = true }) { + Icon(Icons.Outlined.Add, contentDescription = "Add document") + } + }, + ) { m -> + Box(m.fillMaxSize()) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + errorMessage?.let { item { AutopilotErrorRow(it) } } + statusMessage?.let { item { AutopilotSuccessRow(it) } } + + item { AutopilotSectionLabel("CI") } + item { + Text( + "Mints a DOCS_UPLOAD_TOKEN and installs it as the repo's GitHub Actions " + + "secret so CI can publish docs automatically.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + item { + OutlinedButton( + enabled = online && !isInstallingSecret, + onClick = { + isInstallingSecret = true + errorMessage = null + statusMessage = null + scope.launch { + try { + val r = service.installDocsGithubSecret(repo.id) + statusMessage = "Installed ${r.secretName}." + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isInstallingSecret = false + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + if (isInstallingSecret) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + Text(" Installing…") + } else { + Icon(Icons.Outlined.Key, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(" Install CI upload token") + } + } + } + + item { AutopilotSectionLabel("Documents") } + if (documents.isEmpty() && !isLoading) { + item { AutopilotEmptyState("No documents indexed yet.") } + } + items(documents, key = { it.docId }) { doc -> + DocumentRow(doc, online, onOpen = { viewingDoc = doc }, onDelete = { deleteTarget = doc }) + } + } + AutopilotLoadingOverlay(isLoading && documents.isEmpty()) + } + } + + deleteTarget?.let { target -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text("Delete document?") }, + text = { Text(target.title ?: target.docId) }, + confirmButton = { + TextButton(onClick = { + deleteTarget = null + scope.launch { + try { service.deleteDocument(repo.id, target.docId); reloadKey++ } + catch (e: AutopilotException) { errorMessage = e.message } + } + }) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = { deleteTarget = null }) { Text("Cancel") } }, + ) + } +} + +@Composable +private fun DocumentRow( + doc: DocsDocument, + online: Boolean, + onOpen: () -> Unit, + onDelete: () -> Unit, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + onClick = { if (online) onOpen() }, + colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Column(Modifier.weight(1f)) { + Text(doc.title ?: doc.docId, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(doc.docId, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + doc.embeddingStatus?.let { + AssistChip(onClick = {}, enabled = false, label = { Text(it) }) + } + } + } + IconButton(onClick = onDelete) { + Icon(Icons.Outlined.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error) + } + } + } +} + +@Composable +private fun DocumentViewer( + service: AutopilotService, + repoId: String, + doc: DocsDocument, + onBack: () -> Unit, +) { + var content by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + + LaunchedEffect(doc.docId) { + isLoading = true + try { + val detail = service.getDocument(repoId, doc.docId) + content = detail.currentVersion?.content ?: "(no content)" + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isLoading = false + } + } + + AutopilotScaffold(title = doc.docId, onBack = onBack) { m -> + Box(m.fillMaxSize()) { + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + ) { + errorMessage?.let { AutopilotErrorRow(it) } + content?.let { RxMarkdownText(markdown = it) } + } + AutopilotLoadingOverlay(isLoading) + } + } +} + +@Composable +private fun DocsUploadScreen( + service: AutopilotService, + repoId: String, + online: Boolean, + onClose: () -> Unit, + onUploaded: () -> Unit, +) { + val scope = rememberCoroutineScope() + var docId by remember { mutableStateOf("") } + var originalLink by remember { mutableStateOf("") } + var content by remember { mutableStateOf("") } + var isSaving by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + AutopilotScaffold( + title = "Add Document", + onBack = onClose, + actions = { + TextButton( + enabled = online && !isSaving && docId.isNotBlank() && content.isNotBlank(), + onClick = { + isSaving = true + errorMessage = null + scope.launch { + try { + service.uploadDocument( + repoId = repoId, + docId = docId.trim(), + content = content, + originalLink = originalLink.trim().ifBlank { null }, + ) + onUploaded() + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isSaving = false + } + } + }, + ) { Text("Upload") } + }, + ) { m -> + Column( + m.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = docId, + onValueChange = { docId = it }, + label = { Text("Document ID (slug)") }, + placeholder = { Text("design/overview") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = originalLink, + onValueChange = { originalLink = it }, + label = { Text("Source link (optional)") }, + placeholder = { Text("https://…") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = content, + onValueChange = { content = it }, + label = { Text("Markdown") }, + minLines = 10, + modifier = Modifier.fillMaxWidth(), + ) + errorMessage?.let { AutopilotErrorRow(it) } + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ReleaseScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ReleaseScreen.kt new file mode 100644 index 00000000..9cd862ad --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ReleaseScreen.kt @@ -0,0 +1,419 @@ +package app.rxlab.rxcode.ui.autopilot + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.AddReleaseRepoBody +import app.rxlab.rxcode.proto.MobileReleaseWorkflow +import app.rxlab.rxcode.proto.ReleaseDispatchRequest +import app.rxlab.rxcode.proto.ReleaseRepo +import app.rxlab.rxcode.state.AutopilotException +import app.rxlab.rxcode.state.AutopilotService +import kotlinx.coroutines.launch + +@Composable +fun ReleaseScreen( + service: AutopilotService, + online: Boolean, + nav: AutopilotNav, +) { + val scope = rememberCoroutineScope() + var repos by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + var reloadKey by remember { mutableStateOf(0) } + var showPicker by remember { mutableStateOf(false) } + var deleteTarget by remember { mutableStateOf(null) } + + LaunchedEffect(online, reloadKey) { + if (!online) { isLoading = false; return@LaunchedEffect } + isLoading = true + errorMessage = null + try { + repos = service.listReleaseRepos().items + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isLoading = false + } + } + + AutopilotScaffold( + title = "Releases", + onBack = { nav.pop() }, + actions = { + IconButton(enabled = online, onClick = { showPicker = true }) { + Icon(Icons.Outlined.Add, contentDescription = "Add release repository") + } + }, + ) { m -> + Box(m.fillMaxSize()) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + errorMessage?.let { item { AutopilotErrorRow(it) } } + if (repos.isEmpty() && !isLoading) { + item { AutopilotEmptyState("No release repositories. Tap + to add one.") } + } + items(repos, key = { it.id }) { repo -> + RepoListCard( + title = repo.fullName, + subtitle = repo.latestReleaseTag?.let { "Latest: $it" }, + enabled = online, + onClick = { nav.push(AutopilotRoute.ReleaseRepoDetail(repo)) }, + onDelete = { deleteTarget = repo }, + ) + } + } + AutopilotLoadingOverlay(isLoading && repos.isEmpty()) + } + } + + if (showPicker) { + AutopilotRepoPicker( + service = service, + title = "Add Release Repository", + existingFullNames = repos.map { it.fullName }.toSet(), + onSelect = { picked -> + service.addReleaseRepo(AddReleaseRepoBody(picked.installationId, picked.id, picked.fullName)) + reloadKey++ + }, + onDismiss = { showPicker = false }, + ) + } + + deleteTarget?.let { target -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text("Remove repository?") }, + text = { Text("\"${target.fullName}\" will be removed from release management.") }, + confirmButton = { + TextButton(onClick = { + deleteTarget = null + scope.launch { + try { service.deleteReleaseRepo(target.id); reloadKey++ } + catch (e: AutopilotException) { errorMessage = e.message } + } + }) { Text("Remove", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = { deleteTarget = null }) { Text("Cancel") } }, + ) + } +} + +@Composable +fun ReleaseRepoScreen( + service: AutopilotService, + repo: ReleaseRepo, + online: Boolean, + onBack: () -> Unit, +) { + val scope = rememberCoroutineScope() + var workflows by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + var statusMessage by remember { mutableStateOf(null) } + var tokenValue by remember { mutableStateOf("") } + var isInstallingToken by remember { mutableStateOf(false) } + var reloadKey by remember { mutableStateOf(0) } + var dispatchWorkflow by remember { mutableStateOf(null) } + + LaunchedEffect(reloadKey) { + if (!online) { isLoading = false; return@LaunchedEffect } + isLoading = true + errorMessage = null + try { + workflows = service.listReleaseWorkflows(repo.id) + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isLoading = false + } + } + + dispatchWorkflow?.let { workflow -> + ReleaseDispatchScreen( + service = service, + repoId = repo.id, + workflow = workflow, + online = online, + onClose = { dispatchWorkflow = null }, + onDispatched = { message -> dispatchWorkflow = null; statusMessage = message; reloadKey++ }, + ) + return + } + + AutopilotScaffold(title = repo.fullName, onBack = onBack) { m -> + Box(m.fillMaxSize()) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + errorMessage?.let { item { AutopilotErrorRow(it) } } + statusMessage?.let { item { AutopilotSuccessRow(it) } } + + item { AutopilotSectionLabel("Workflows") } + if (workflows.isEmpty() && !isLoading) { + item { AutopilotEmptyState("No workflows scanned yet.") } + } + items(workflows, key = { it.id }) { workflow -> + WorkflowRow(workflow, online) { dispatchWorkflow = workflow } + } + + item { AutopilotSectionLabel("RELEASE_TOKEN") } + item { + Text( + "Installed as the repo's GitHub Actions secret so the release workflow can publish.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + item { + OutlinedTextField( + value = tokenValue, + onValueChange = { tokenValue = it }, + label = { Text("ghp_… or github_pat_…") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + } + item { + OutlinedButton( + enabled = online && !isInstallingToken && tokenValue.isNotBlank(), + onClick = { + isInstallingToken = true + errorMessage = null + statusMessage = null + scope.launch { + try { + val r = service.installReleaseToken(repo.id, tokenValue) + statusMessage = "Installed ${r.secretName}." + tokenValue = "" + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isInstallingToken = false + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + if (isInstallingToken) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + Text(" Installing…") + } else { + Icon(Icons.Outlined.Key, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(" Install RELEASE_TOKEN") + } + } + } + } + AutopilotLoadingOverlay(isLoading && workflows.isEmpty()) + } + } +} + +@Composable +private fun WorkflowRow( + workflow: MobileReleaseWorkflow, + online: Boolean, + onRelease: () -> Unit, +) { + ElevatedCard( + Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Column(Modifier.weight(1f)) { + Text(workflow.displayName, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(workflow.workflowPath, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (workflow.isSelected) { + Icon(Icons.Outlined.CheckCircle, contentDescription = "Selected", tint = AutopilotSuccess, modifier = Modifier.size(20.dp)) + } + if (workflow.hasWorkflowDispatch) { + TextButton(enabled = online, onClick = onRelease) { Text("Release") } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ReleaseDispatchScreen( + service: AutopilotService, + repoId: String, + workflow: MobileReleaseWorkflow, + online: Boolean, + onClose: () -> Unit, + onDispatched: (String) -> Unit, +) { + val scope = rememberCoroutineScope() + var branch by remember { mutableStateOf("main") } + val inputValues = remember { mutableStateMapOf() } + var isDispatching by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + // Seed defaults once. + LaunchedEffect(workflow.id) { + workflow.inputs?.forEach { input -> + val default = input.defaultString ?: input.defaultBool?.toString() + if (default != null) inputValues[input.name] = default + } + } + + AutopilotScaffold( + title = "Create Release", + onBack = onClose, + actions = { + TextButton( + enabled = online && !isDispatching && branch.isNotBlank(), + onClick = { + isDispatching = true + errorMessage = null + scope.launch { + try { + val result = service.dispatchRelease( + repoId, + ReleaseDispatchRequest( + workflowId = workflow.id, + branch = branch.trim(), + inputs = inputValues.toMap(), + ), + ) + if (result.success == false && result.error != null) { + errorMessage = result.error + } else { + onDispatched("Release dispatched.") + } + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isDispatching = false + } + } + }, + ) { Text("Dispatch") } + }, + ) { m -> + Column( + m.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + AutopilotSectionLabel("Branch") + OutlinedTextField( + value = branch, + onValueChange = { branch = it }, + placeholder = { Text("main") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + val inputs = workflow.inputs.orEmpty() + if (inputs.isNotEmpty()) { + AutopilotSectionLabel("Inputs") + inputs.forEach { input -> + val value = inputValues[input.name] ?: "" + when { + input.type == "boolean" -> { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(input.name, style = MaterialTheme.typography.bodyLarge) + Switch( + checked = value == "true", + onCheckedChange = { inputValues[input.name] = it.toString() }, + ) + } + } + input.type == "choice" && !input.options.isNullOrEmpty() -> { + var expanded by remember(input.name) { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(input.name) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + input.options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { inputValues[input.name] = option; expanded = false }, + ) + } + } + } + } + else -> { + OutlinedTextField( + value = value, + onValueChange = { inputValues[input.name] = it }, + label = { Text(input.name) }, + placeholder = { input.description?.let { Text(it) } }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + + errorMessage?.let { AutopilotErrorRow(it) } + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/RepoSetupScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/RepoSetupScreen.kt new file mode 100644 index 00000000..74e60c1c --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/RepoSetupScreen.kt @@ -0,0 +1,421 @@ +package app.rxlab.rxcode.ui.autopilot + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.RepoSetupTemplate +import app.rxlab.rxcode.proto.RepoSetupTemplateInput +import app.rxlab.rxcode.proto.RxJson +import app.rxlab.rxcode.state.AutopilotException +import app.rxlab.rxcode.state.AutopilotService +import app.rxlab.rxcode.ui.jsonschemaform.JsonSchemaForm +import app.rxlab.rxcode.ui.jsonschemaform.rememberJsonSchemaFormState +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +/** Distinguishes the editor's create vs. edit modes. */ +private sealed interface RepoSetupEditTarget { + data object New : RepoSetupEditTarget + data class Existing(val template: RepoSetupTemplate) : RepoSetupEditTarget +} + +@Composable +fun RepoSetupScreen( + service: AutopilotService, + online: Boolean, + onBack: () -> Unit, +) { + val scope = rememberCoroutineScope() + var templates by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + var reloadKey by remember { mutableStateOf(0) } + var editing by remember { mutableStateOf(null) } + var deleteTarget by remember { mutableStateOf(null) } + + LaunchedEffect(online, reloadKey) { + if (!online) { isLoading = false; return@LaunchedEffect } + isLoading = true + errorMessage = null + try { + templates = service.repoSetupTemplates() + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isLoading = false + } + } + + editing?.let { target -> + RepoSetupEditor( + target = target, + service = service, + online = online, + onClose = { editing = null }, + onSaved = { editing = null; reloadKey++ }, + ) + return + } + + AutopilotScaffold( + title = "Repo Setup", + onBack = onBack, + actions = { + IconButton(enabled = online, onClick = { editing = RepoSetupEditTarget.New }) { + Icon(Icons.Outlined.Add, contentDescription = "Add template") + } + }, + ) { m -> + Box(m.fillMaxSize()) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Text( + "Templates of GitHub merge settings and branch rulesets. The default " + + "template is applied to newly created repositories.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + errorMessage?.let { item { AutopilotErrorRow(it) } } + + if (templates.isEmpty() && !isLoading) { + item { AutopilotEmptyState("No templates yet. Tap + to create one.") } + } + + items(templates, key = { it.id }) { template -> + TemplateRow( + template = template, + enabled = online, + onEdit = { editing = RepoSetupEditTarget.Existing(template) }, + onDelete = { deleteTarget = template }, + ) + } + } + AutopilotLoadingOverlay(isLoading && templates.isEmpty()) + } + } + + deleteTarget?.let { target -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text("Delete template?") }, + text = { Text("\"${target.name}\" will be removed.") }, + confirmButton = { + TextButton(onClick = { + deleteTarget = null + scope.launch { + try { + service.deleteRepoSetupTemplate(target.id) + reloadKey++ + } catch (e: AutopilotException) { + errorMessage = e.message + } + } + }) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = { deleteTarget = null }) { Text("Cancel") } }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TemplateRow( + template: RepoSetupTemplate, + enabled: Boolean, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + onClick = { if (enabled) onEdit() }, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Row( + Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(Modifier.weight(1f)) { + Text(template.name, style = MaterialTheme.typography.bodyLarge) + Text( + if (template.enabled) "Enabled" else "Disabled", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (template.isDefault) { + AssistChip(onClick = {}, enabled = false, label = { Text("Default") }) + } + IconButton(onClick = onDelete) { + Icon(Icons.Outlined.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error) + } + } + } +} + +@Composable +private fun RepoSetupEditor( + target: RepoSetupEditTarget, + service: AutopilotService, + online: Boolean, + onClose: () -> Unit, + onSaved: () -> Unit, +) { + val existing = (target as? RepoSetupEditTarget.Existing)?.template + var isLoading by remember { mutableStateOf(true) } + var loadError by remember { mutableStateOf(null) } + var schema by remember { mutableStateOf(null) } + var uiSchema by remember { mutableStateOf(null) } + + LaunchedEffect(online) { + if (!online) { isLoading = false; loadError = "Connect an online Mac to use Autopilot."; return@LaunchedEffect } + isLoading = true + loadError = null + try { + val envelope = service.repoSetupSchema() + schema = envelope.schema as? JsonObject + uiSchema = envelope.uiSchema as? JsonObject + } catch (e: AutopilotException) { + loadError = e.message + } finally { + isLoading = false + } + } + + val title = if (existing == null) "New Template" else "Edit Template" + + if (isLoading) { + AutopilotScaffold(title, onClose) { m -> AutopilotLoadingOverlay(true, m.fillMaxSize()) } + return + } + + RepoSetupEditorLoaded( + title = title, + schema = schema, + uiSchema = uiSchema, + loadError = loadError, + existing = existing, + service = service, + online = online, + onClose = onClose, + onSaved = onSaved, + ) +} + +@Composable +private fun RepoSetupEditorLoaded( + title: String, + schema: JsonObject?, + uiSchema: JsonObject?, + loadError: String?, + existing: RepoSetupTemplate?, + service: AutopilotService, + online: Boolean, + onClose: () -> Unit, + onSaved: () -> Unit, +) { + val scope = rememberCoroutineScope() + var name by remember { mutableStateOf(existing?.name ?: "") } + var enabled by remember { mutableStateOf(existing?.enabled ?: true) } + var isDefault by remember { mutableStateOf(existing?.isDefault ?: false) } + var rulesetText by remember { + mutableStateOf( + existing?.rulesetConfig?.let { RxJson.encodeToString(JsonElement.serializer(), it) } ?: "" + ) + } + var mergeFallbackText by remember { + mutableStateOf( + existing?.mergeSettings?.let { RxJson.encodeToString(JsonElement.serializer(), it) } ?: "{}" + ) + } + var isSaving by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + val formState = if (schema != null) { + rememberJsonSchemaFormState(schema, existing?.mergeSettings) + } else { + null + } + + fun save() { + if (name.isBlank()) { errorMessage = "Name is required."; return } + val mergeSettings: JsonElement = if (formState != null) { + formState.submit() ?: return + } else { + runCatching { RxJson.parseToJsonElement(mergeFallbackText) }.getOrElse { + errorMessage = "Merge settings JSON is invalid." + return + } + } + val ruleset: JsonElement? = if (rulesetText.isBlank()) { + null + } else { + when (val r = validateRuleset(rulesetText)) { + is RulesetResult.Ok -> r.value + is RulesetResult.Error -> { errorMessage = r.message; return } + } + } + val input = RepoSetupTemplateInput( + name = name.trim(), + enabled = enabled, + isDefault = isDefault, + mergeSettings = mergeSettings, + rulesetConfig = ruleset, + ) + isSaving = true + errorMessage = null + scope.launch { + try { + if (existing == null) service.createRepoSetupTemplate(input) + else service.updateRepoSetupTemplate(existing.id, input) + onSaved() + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isSaving = false + } + } + } + + AutopilotScaffold( + title = title, + onBack = onClose, + actions = { + TextButton(enabled = online && !isSaving && name.isNotBlank(), onClick = { save() }) { + Text("Save") + } + }, + ) { m -> + Column( + m.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + loadError?.let { AutopilotErrorRow(it) } + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + ToggleRow("Enabled", enabled) { enabled = it } + ToggleRow("Default for new repositories", isDefault) { isDefault = it } + + AutopilotSectionLabel("Merge Settings") + if (formState != null && schema != null) { + JsonSchemaForm(schema, uiSchema, formState) + if (formState.showErrors) formState.errors.forEach { AutopilotErrorRow(it) } + } else { + Text( + "Form couldn't be rendered — edit raw JSON.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = mergeFallbackText, + onValueChange = { mergeFallbackText = it }, + modifier = Modifier.fillMaxWidth(), + minLines = 6, + ) + } + + AutopilotSectionLabel("Branch Ruleset (optional)") + Text( + "Paste a GitHub ruleset JSON with name, target, enforcement, and rules.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = rulesetText, + onValueChange = { rulesetText = it }, + modifier = Modifier.fillMaxWidth(), + minLines = 5, + ) + + errorMessage?.let { AutopilotErrorRow(it) } + } + } +} + +@Composable +private fun ToggleRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MaterialTheme.typography.bodyLarge) + Switch(checked = checked, onCheckedChange = onChange) + } +} + +// MARK: - Ruleset validation (mirrors Swift GitHubRulesetSummary.validate) + +private sealed interface RulesetResult { + data class Ok(val value: JsonElement) : RulesetResult + data class Error(val message: String) : RulesetResult +} + +private fun validateRuleset(rawJSON: String): RulesetResult { + if (rawJSON.isBlank()) return RulesetResult.Error("Ruleset JSON is empty.") + val element = runCatching { RxJson.parseToJsonElement(rawJSON) }.getOrNull() + ?: return RulesetResult.Error("Ruleset JSON is invalid.") + val obj = element as? JsonObject ?: return RulesetResult.Error("Ruleset must be a JSON object.") + val missing = buildList { + if ((obj["name"] as? JsonPrimitive)?.contentOrNull.isNullOrBlank()) add("name") + if ((obj["target"] as? JsonPrimitive)?.contentOrNull.isNullOrBlank()) add("target") + if ((obj["enforcement"] as? JsonPrimitive)?.contentOrNull.isNullOrBlank()) add("enforcement") + if (obj["rules"] !is JsonArray) add("rules") + } + return if (missing.isEmpty()) { + RulesetResult.Ok(obj) + } else { + RulesetResult.Error("Ruleset is missing: ${missing.joinToString(", ")}") + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/SecretsScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/SecretsScreen.kt new file mode 100644 index 00000000..a84a6dd0 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/SecretsScreen.kt @@ -0,0 +1,511 @@ +package app.rxlab.rxcode.ui.autopilot + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Eco +import androidx.compose.material.icons.outlined.LockOpen +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material.icons.outlined.VpnKey +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.AutopilotSecretFilePlaintext +import app.rxlab.rxcode.proto.SecretsEnvironment +import app.rxlab.rxcode.proto.SecretsFileMeta +import app.rxlab.rxcode.proto.SecretsManagedRepo +import app.rxlab.rxcode.state.AutopilotException +import app.rxlab.rxcode.state.MobileAppState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun SecretsScreen( + app: MobileAppState, + online: Boolean, + nav: AutopilotNav, +) { + var search by remember { mutableStateOf("") } + var enrolled by remember { mutableStateOf(null) } + var repos by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + + LaunchedEffect(online, search) { + if (!online) { isLoading = false; return@LaunchedEffect } + if (search.isNotEmpty()) delay(300) + isLoading = true + errorMessage = null + try { + if (enrolled == null) enrolled = app.secrets.enrollmentStatus() + if (enrolled == true) { + repos = app.autopilot.listRepos(search.ifBlank { null }).items + } + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isLoading = false + } + } + + AutopilotScaffold(title = "Secrets", onBack = { nav.pop() }) { m -> + Box(m.fillMaxSize()) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + errorMessage?.let { item { AutopilotErrorRow(it) } } + + when (enrolled) { + false -> item { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(Icons.Outlined.LockOpen, contentDescription = null, tint = AutopilotWarning) + Column { + Text("Encryption not set up", style = MaterialTheme.typography.titleSmall) + Text( + "Enroll with your passkey on your Mac to manage secrets.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + true -> { + item { + OutlinedTextField( + value = search, + onValueChange = { search = it }, + label = { Text("Search repositories") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + if (repos.isEmpty() && !isLoading) { + item { AutopilotEmptyState("No repositories available.") } + } + items(repos, key = { it.id }) { repo -> + SecretsRepoRow(repo, online) { nav.push(AutopilotRoute.SecretsRepo(repo.fullName)) } + } + } + null -> Unit + } + } + AutopilotLoadingOverlay(enrolled == null && isLoading) + } + } +} + +@Composable +private fun SecretsRepoRow(repo: SecretsManagedRepo, online: Boolean, onClick: () -> Unit) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + onClick = { if (online) onClick() }, + colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Icon( + Icons.Outlined.VpnKey, + contentDescription = null, + tint = if (repo.isManaged) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column(Modifier.weight(1f)) { + Text(repo.fullName, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (repo.isManaged) { + Text( + "${repo.environmentsCount} environment(s), ${repo.filesCount} file(s)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +fun SecretsRepoScreen( + app: MobileAppState, + repo: String, + online: Boolean, + nav: AutopilotNav, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + var environments by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + var reloadKey by remember { mutableStateOf(0) } + var showCreate by remember { mutableStateOf(false) } + var newName by remember { mutableStateOf("") } + var deleteTarget by remember { mutableStateOf(null) } + + LaunchedEffect(reloadKey) { + if (!online) { isLoading = false; return@LaunchedEffect } + isLoading = true + errorMessage = null + try { + environments = app.secrets.listEnvironments(repo) + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isLoading = false + } + } + + AutopilotScaffold( + title = repo, + onBack = { nav.pop() }, + actions = { + IconButton(enabled = online, onClick = { newName = ""; showCreate = true }) { + Icon(Icons.Outlined.Add, contentDescription = "Add environment") + } + }, + ) { m -> + Box(m.fillMaxSize()) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + errorMessage?.let { item { AutopilotErrorRow(it) } } + if (environments.isEmpty() && !isLoading) { + item { AutopilotEmptyState("No environments yet. Tap + to add one.") } + } + items(environments, key = { it.id }) { env -> + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + onClick = { if (online) nav.push(AutopilotRoute.SecretsEnv(repo, env)) }, + colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Icon(Icons.Outlined.Eco, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Column(Modifier.weight(1f)) { + Text(env.name, style = MaterialTheme.typography.bodyLarge) + env.filesCount?.let { + Text("$it file(s)", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + IconButton(onClick = { deleteTarget = env }) { + Icon(Icons.Outlined.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error) + } + } + } + } + } + AutopilotLoadingOverlay(isLoading && environments.isEmpty()) + } + } + + if (showCreate) { + AlertDialog( + onDismissRequest = { showCreate = false }, + title = { Text("New environment") }, + text = { + OutlinedTextField( + value = newName, + onValueChange = { newName = it }, + label = { Text("Name") }, + placeholder = { Text("production") }, + singleLine = true, + ) + }, + confirmButton = { + TextButton( + enabled = newName.isNotBlank(), + onClick = { + val name = newName.trim() + showCreate = false + scope.launch { + try { + app.secrets.createEnvironment(context, repo, name) + reloadKey++ + } catch (e: Exception) { + errorMessage = e.message + } + } + }, + ) { Text("Create") } + }, + dismissButton = { TextButton(onClick = { showCreate = false }) { Text("Cancel") } }, + ) + } + + deleteTarget?.let { env -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text("Delete environment?") }, + text = { Text("This permanently deletes the environment and its secret files.") }, + confirmButton = { + TextButton(onClick = { + deleteTarget = null + scope.launch { + try { app.secrets.deleteEnvironment(repo, env.id); reloadKey++ } + catch (e: AutopilotException) { errorMessage = e.message } + } + }) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = { deleteTarget = null }) { Text("Cancel") } }, + ) + } +} + +@Composable +fun SecretsEnvScreen( + app: MobileAppState, + repo: String, + env: SecretsEnvironment, + online: Boolean, + onBack: () -> Unit, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + var files by remember { mutableStateOf>(emptyList()) } + var plaintextByName by remember { mutableStateOf>(emptyMap()) } + var revealed by remember { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + var reloadKey by remember { mutableStateOf(0) } + var editing by remember { mutableStateOf(null) } + var showAddFile by remember { mutableStateOf(false) } + var deleteTarget by remember { mutableStateOf(null) } + + LaunchedEffect(reloadKey) { + if (!online) { isLoading = false; return@LaunchedEffect } + isLoading = true + errorMessage = null + try { + files = app.secrets.listFiles(repo, env.id) + if (revealed) { + plaintextByName = app.secrets.environmentPlaintext(context, repo, env.id).associate { it.filename to it.content } + } + } catch (e: AutopilotException) { + errorMessage = e.message + } finally { + isLoading = false + } + } + + if (showAddFile || editing != null) { + SecretFileEditor( + app = app, + repo = repo, + envId = env.id, + existing = editing, + existingContent = editing?.let { plaintextByName[it.filename] } ?: "", + online = online, + onClose = { showAddFile = false; editing = null }, + onSaved = { + showAddFile = false + editing = null + reloadKey++ + }, + ) + return + } + + AutopilotScaffold( + title = env.name, + onBack = onBack, + actions = { + IconButton(enabled = online, onClick = { showAddFile = true }) { + Icon(Icons.Outlined.Add, contentDescription = "Add file") + } + }, + ) { m -> + Box(m.fillMaxSize()) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + errorMessage?.let { item { AutopilotErrorRow(it) } } + + item { + OutlinedButton( + enabled = online, + onClick = { + scope.launch { + if (revealed) { + revealed = false + plaintextByName = emptyMap() + } else { + try { + plaintextByName = app.secrets + .environmentPlaintext(context, repo, env.id) + .associate { it.filename to it.content } + revealed = true + } catch (e: Exception) { + errorMessage = e.message + } + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + if (revealed) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Text(if (revealed) " Hide values" else " View values") + } + } + + item { AutopilotSectionLabel("Files") } + if (files.isEmpty() && !isLoading) { + item { AutopilotEmptyState("No files yet. Tap + to add one.") } + } + items(files, key = { it.id }) { file -> + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + onClick = { if (online) editing = file }, + colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Column(Modifier.weight(1f)) { + Text(file.filename, style = MaterialTheme.typography.bodyLarge) + if (revealed) { + plaintextByName[file.filename]?.let { preview -> + Text( + preview, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + IconButton(onClick = { deleteTarget = file }) { + Icon(Icons.Outlined.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error) + } + } + } + } + } + AutopilotLoadingOverlay(isLoading && files.isEmpty()) + } + } + + deleteTarget?.let { file -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text("Delete file?") }, + text = { Text(file.filename) }, + confirmButton = { + TextButton(onClick = { + deleteTarget = null + scope.launch { + try { app.secrets.deleteFile(repo, env.id, file.id); reloadKey++ } + catch (e: AutopilotException) { errorMessage = e.message } + } + }) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = { deleteTarget = null }) { Text("Cancel") } }, + ) + } +} + +@Composable +private fun SecretFileEditor( + app: MobileAppState, + repo: String, + envId: String, + existing: SecretsFileMeta?, + existingContent: String, + online: Boolean, + onClose: () -> Unit, + onSaved: () -> Unit, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + var filename by remember { mutableStateOf(existing?.filename ?: "") } + var content by remember { mutableStateOf(existingContent) } + var isSaving by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + AutopilotScaffold( + title = existing?.filename ?: "Add File", + onBack = onClose, + actions = { + TextButton( + enabled = online && !isSaving && filename.isNotBlank(), + onClick = { + isSaving = true + errorMessage = null + scope.launch { + try { + app.secrets.upsertFile(context, repo, envId, filename.trim(), content) + onSaved() + } catch (e: Exception) { + errorMessage = e.message + } finally { + isSaving = false + } + } + }, + ) { Text("Save") } + }, + ) { m -> + Column( + m.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + AutopilotSectionLabel("Filename") + OutlinedTextField( + value = filename, + onValueChange = { filename = it }, + placeholder = { Text(".env") }, + singleLine = true, + enabled = existing == null, + modifier = Modifier.fillMaxWidth(), + ) + AutopilotSectionLabel("Contents") + OutlinedTextField( + value = content, + onValueChange = { content = it }, + minLines = 8, + modifier = Modifier.fillMaxWidth(), + ) + errorMessage?.let { AutopilotErrorRow(it) } + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/jsonschemaform/JsonSchemaForm.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/jsonschemaform/JsonSchemaForm.kt new file mode 100644 index 00000000..1a9ea0a9 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/jsonschemaform/JsonSchemaForm.kt @@ -0,0 +1,286 @@ +package app.rxlab.rxcode.ui.jsonschemaform + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.KeyboardOptions +import app.rxlab.rxcode.proto.RxJson +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull + +/** + * Holds the live form data + validation state for a [JsonSchemaForm]. Mirrors + * iOS `FormData` + `JSONSchemaFormController`: call [submit] on save to get the + * validated object, or null when validation fails (which flips on the inline + * error display). + */ +class JsonSchemaFormState internal constructor( + initial: JsonObject, + private val schema: JsonObject, +) { + var data by mutableStateOf(initial) + private set + + var showErrors by mutableStateOf(false) + internal set + + val errors: List get() = validate(schema, data) + + fun setValue(path: List, value: JsonElement?) { + data = setIn(data, path, value) + } + + fun getValue(path: List): JsonElement? = getIn(data, path) + + /** Returns the validated object, or null (and shows errors) when invalid. */ + fun submit(): JsonObject? { + return if (errors.isEmpty()) { + data + } else { + showErrors = true + null + } + } + + companion object { + internal fun saver(schema: JsonObject): Saver = Saver( + save = { RxJson.encodeToString(JsonObject.serializer(), it.data) }, + restore = { stored -> + val obj = runCatching { + RxJson.decodeFromString(JsonObject.serializer(), stored) + }.getOrDefault(JsonObject(emptyMap())) + JsonSchemaFormState(obj, schema) + }, + ) + } +} + +/** + * Builds (and remembers across config changes) a form state seeded from + * [schema] defaults merged over [existing] saved values. + */ +@Composable +fun rememberJsonSchemaFormState(schema: JsonObject, existing: JsonElement?): JsonSchemaFormState { + return rememberSaveable(saver = JsonSchemaFormState.saver(schema)) { + JsonSchemaFormState(applyDefaults(schema, existing), schema) + } +} + +/** + * Renders [schema] (a JSON Schema object) into Material 3 controls bound to + * [state]. [uiSchema] is the optional RJSF-style UI schema (`ui:widget`, + * `ui:order`, …). + */ +@Composable +fun JsonSchemaForm( + schema: JsonObject, + uiSchema: JsonObject?, + state: JsonSchemaFormState, + modifier: Modifier = Modifier, +) { + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + SchemaObjectFields( + schema = schema, + uiSchema = uiSchema, + path = emptyList(), + required = schema.requiredKeys(), + state = state, + ) + } +} + +@Composable +private fun SchemaObjectFields( + schema: JsonObject, + uiSchema: JsonObject?, + path: List, + required: Set, + state: JsonSchemaFormState, +) { + for (key in orderedKeys(schema, uiSchema)) { + val prop = schema.properties()[key] ?: continue + SchemaField( + key = key, + prop = prop, + uiSchema = childUiSchema(uiSchema, key), + widget = uiWidget(uiSchema, key), + path = path + key, + isRequired = key in required, + state = state, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SchemaField( + key: String, + prop: JsonObject, + uiSchema: JsonObject?, + widget: String?, + path: List, + isRequired: Boolean, + state: JsonSchemaFormState, +) { + val label = prop.titleOrKey(key) + if (isRequired) " *" else "" + val description = prop.description() + val current = state.getValue(path) + val enumValues = prop.enumValues() + val type = prop.schemaType() + + when { + // Nested object → labeled section, recurse. + type == "object" -> { + HorizontalDivider() + Text( + prop.titleOrKey(key), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(top = 4.dp), + ) + description?.let { FieldHelp(it) } + Column( + Modifier.padding(start = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SchemaObjectFields(prop, uiSchema, path, prop.requiredKeys(), state) + } + } + + // Boolean → switch row. + type == "boolean" -> { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.bodyLarge) + description?.let { FieldHelp(it) } + } + Switch( + checked = (current as? JsonPrimitive)?.booleanOrNull ?: false, + onCheckedChange = { state.setValue(path, JsonPrimitive(it)) }, + ) + } + } + + // Enum → dropdown. + enumValues != null -> { + var expanded by remember { mutableStateOf(false) } + val selected = (current as? JsonPrimitive)?.contentOrNull ?: "" + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = selected, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + supportingText = description?.let { { Text(it) } }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + enumValues.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + state.setValue(path, JsonPrimitive(option)) + expanded = false + }, + ) + } + } + } + } + + // Number / integer → numeric text field. + type == "integer" || type == "number" -> { + val text = (current as? JsonPrimitive)?.contentOrNull ?: "" + OutlinedTextField( + value = text, + onValueChange = { raw -> + if (raw.isBlank()) { + state.setValue(path, null) + } else { + val num = if (type == "integer") raw.toLongOrNull() else raw.toDoubleOrNull() + // Store as a numeric primitive when parseable, else keep raw + // text so the user can keep typing; validation flags it. + state.setValue(path, num?.let { JsonPrimitive(it) } ?: JsonPrimitive(raw)) + } + }, + label = { Text(label) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = if (type == "integer") KeyboardType.Number else KeyboardType.Decimal, + ), + supportingText = description?.let { { Text(it) } }, + isError = state.showErrors && isRequired && text.isBlank(), + modifier = Modifier.fillMaxWidth(), + ) + } + + // Fallback → string text field (single or multiline / password). + else -> { + val text = when (current) { + is JsonPrimitive -> current.contentOrNull ?: "" + null, is JsonNull -> "" + else -> current.toString() + } + OutlinedTextField( + value = text, + onValueChange = { raw -> state.setValue(path, if (raw.isEmpty()) null else JsonPrimitive(raw)) }, + label = { Text(label) }, + singleLine = widget != "textarea", + minLines = if (widget == "textarea") 4 else 1, + visualTransformation = if (widget == "password") { + PasswordVisualTransformation() + } else { + androidx.compose.ui.text.input.VisualTransformation.None + }, + supportingText = description?.let { { Text(it) } }, + isError = state.showErrors && isRequired && text.isBlank(), + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun FieldHelp(text: String) { + Text( + text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/jsonschemaform/JsonSchemaModel.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/jsonschemaform/JsonSchemaModel.kt new file mode 100644 index 00000000..0c072df6 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/jsonschemaform/JsonSchemaModel.kt @@ -0,0 +1,150 @@ +package app.rxlab.rxcode.ui.jsonschemaform + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Lightweight JSON-Schema helpers backing [JsonSchemaForm]. There is no + * JSONSchemaForm equivalent on Android, so this package renders a subset of + * JSON Schema Draft-07 (object/string/number/integer/boolean/enum/array, plus + * `title`/`description`/`default`/`required`) into Material 3 controls — the + * same shape the iOS app feeds the `JSONSchemaForm` Swift library. + */ + +/** Returns the schema's declared type, tolerating `["string","null"]` unions. */ +fun JsonObject.schemaType(): String? = when (val t = this["type"]) { + is JsonPrimitive -> t.contentOrNull + is JsonArray -> t.firstOrNull { (it as? JsonPrimitive)?.contentOrNull != "null" } + ?.let { (it as? JsonPrimitive)?.contentOrNull } + else -> null +} + +fun JsonObject.titleOrKey(key: String): String = + (this["title"] as? JsonPrimitive)?.contentOrNull ?: humanize(key) + +fun JsonObject.description(): String? = (this["description"] as? JsonPrimitive)?.contentOrNull + +fun JsonObject.enumValues(): List? = + (this["enum"] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + +fun JsonObject.requiredKeys(): Set = + (this["required"] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }?.toSet() + ?: emptySet() + +fun JsonObject.properties(): Map = + (this["properties"] as? JsonObject)?.mapNotNull { (k, v) -> + (v as? JsonObject)?.let { k to it } + }?.toMap() ?: emptyMap() + +/** Property render order honoring an optional uiSchema `ui:order`. */ +fun orderedKeys(schema: JsonObject, uiSchema: JsonObject?): List { + val keys = schema.properties().keys.toList() + val order = (uiSchema?.get("ui:order") as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + ?: return keys + val wildcard = order.indexOf("*") + val explicit = order.filter { it != "*" && it in keys } + val rest = keys.filter { it !in explicit } + return if (wildcard >= 0) { + val before = order.subList(0, wildcard).filter { it in keys } + val after = order.subList(wildcard + 1, order.size).filter { it in keys } + before + rest.filter { it !in after } + after + } else { + explicit + rest + } +} + +/** The `ui:widget` hint for a field (`textarea`, `password`, etc.), if any. */ +fun uiWidget(uiSchema: JsonObject?, key: String): String? = + ((uiSchema?.get(key) as? JsonObject)?.get("ui:widget") as? JsonPrimitive)?.contentOrNull + +fun childUiSchema(uiSchema: JsonObject?, key: String): JsonObject? = + uiSchema?.get(key) as? JsonObject + +/** + * Merge saved [existing] values over the schema defaults so the form opens + * pre-filled. Mirrors `FormData.applyingDefaults(schema:)` on iOS. + */ +fun applyDefaults(schema: JsonObject, existing: JsonElement?): JsonObject { + val out = linkedMapOf() + val existingObj = existing as? JsonObject + for ((key, prop) in schema.properties()) { + val saved = existingObj?.get(key) + when (prop.schemaType()) { + "object" -> out[key] = applyDefaults(prop, saved) + else -> { + val value = saved ?: prop["default"] + if (value != null && value !is JsonNull) out[key] = value + } + } + } + // Preserve any saved keys the schema doesn't describe. + existingObj?.forEach { (k, v) -> if (k !in out) out[k] = v } + return JsonObject(out) +} + +/** Immutable set of a value at a key path, creating intermediate objects. */ +fun setIn(obj: JsonObject, path: List, value: JsonElement?): JsonObject { + if (path.isEmpty()) return obj + val key = path.first() + val map = obj.toMutableMap() + if (path.size == 1) { + if (value == null) map.remove(key) else map[key] = value + } else { + val child = map[key] as? JsonObject ?: JsonObject(emptyMap()) + map[key] = setIn(child, path.drop(1), value) + } + return JsonObject(map) +} + +fun getIn(obj: JsonObject, path: List): JsonElement? { + var cursor: JsonElement = obj + for (key in path) { + cursor = (cursor as? JsonObject)?.get(key) ?: return null + } + return cursor +} + +/** Validate required + numeric fields. Returns human-readable error messages. */ +fun validate(schema: JsonObject, data: JsonObject, prefix: String = ""): List { + val errors = mutableListOf() + val required = schema.requiredKeys() + for ((key, prop) in schema.properties()) { + val label = prop.titleOrKey(key) + val value = data[key] + val present = value != null && value !is JsonNull && + !(value is JsonPrimitive && value.isString && value.content.isBlank()) + if (key in required && !present) { + errors += "${if (prefix.isEmpty()) "" else "$prefix · "}$label is required." + continue + } + if (!present) continue + when (prop.schemaType()) { + "integer", "number" -> { + val ok = (value as? JsonPrimitive)?.let { + it.doubleOrNull != null || it.contentOrNull?.toDoubleOrNull() != null + } ?: false + if (!ok) errors += "$label must be a number." + } + "object" -> { + if (value is JsonObject) errors += validate(prop, value, label) + } + else -> Unit + } + } + return errors +} + +private fun humanize(key: String): String = + key.replace('_', ' ') + .replace(Regex("([a-z])([A-Z])"), "$1 $2") + .replaceFirstChar { it.uppercaseChar() } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/settings/SettingsScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/settings/SettingsScreen.kt index 94f49935..8945bdac 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/settings/SettingsScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/settings/SettingsScreen.kt @@ -20,7 +20,9 @@ import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos import androidx.compose.material.icons.outlined.DesktopMac +import androidx.compose.material.icons.outlined.Flight import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Router import androidx.compose.material3.AlertDialog @@ -42,6 +44,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -79,6 +82,17 @@ fun SettingsScreen( ) { val haptics = rememberHaptics() var unpairTarget by remember { mutableStateOf(null) } + var showAutopilot by rememberSaveable { mutableStateOf(false) } + + if (showAutopilot) { + app.rxlab.rxcode.ui.autopilot.AutopilotNavHost( + app = viewModel, + online = state.isPaired && + state.connectionState == RelayClient.ConnectionState.CONNECTED, + onExit = { showAutopilot = false }, + ) + return + } Scaffold( containerColor = MaterialTheme.colorScheme.background, @@ -146,6 +160,61 @@ fun SettingsScreen( ) } + // MARK: - Desktop Configuration (Autopilot) + if (state.isPaired) { + item { + SectionLabel("Desktop Configuration") + } + item { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + onClick = { + haptics.play(HapticEvent.LightTap) + showAutopilot = true + }, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Row( + Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(44.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Flight, contentDescription = null) + } + } + Column(Modifier.weight(1f)) { + Text( + "Autopilot", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Automation, secrets, CI, docs, and releases", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + Icon( + Icons.AutoMirrored.Outlined.ArrowForwardIos, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + } + } + } + } + // MARK: - About item { SectionLabel("About") diff --git a/RxCodeAndroid/gradle/libs.versions.toml b/RxCodeAndroid/gradle/libs.versions.toml index 4a3d666a..697ccb98 100644 --- a/RxCodeAndroid/gradle/libs.versions.toml +++ b/RxCodeAndroid/gradle/libs.versions.toml @@ -15,6 +15,7 @@ hiltNavigationCompose = "1.2.0" okhttp = "4.12.0" datastorePreferences = "1.1.1" securityCrypto = "1.1.0-alpha06" +credentials = "1.3.0" bouncyCastle = "1.78.1" cameraX = "1.3.4" mlkitBarcode = "17.3.0" @@ -63,6 +64,8 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } +androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "credentials" } +androidx-credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credentials" } bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncyCastle" } From 11d1e16cf78a888c220209e95ae54a707bfa06fc Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:43:25 +0800 Subject: [PATCH 5/8] feat: drop docs-search context item; show create-release form on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the "Search Docs" project/briefing context-menu action on both iOS and macOS; the context menu now only offers the "Set Up Docs Search" (create docs) item. Docs search stays in the docs UI. - "Create Release" on iOS now presents the release dispatch form on the phone (ProjectReleaseCreateSheet) — resolving the project's release workflows and inputs and dispatching via owner/repo — instead of opening the create-release sheet on the paired Mac. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Hooks/hooks/AutopilotDocsHook.swift | 14 +- RxCodeMobile/Resources/Localizable.xcstrings | 56 ++++- .../Autopilot/ProjectAutopilotMenu.swift | 227 ++++++++++++++++-- .../Views/MobileBriefingDetailView.swift | 3 + RxCodeMobile/Views/ProjectsSidebar.swift | 5 + 5 files changed, 274 insertions(+), 31 deletions(-) diff --git a/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift b/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift index 1306c18d..991d69bb 100644 --- a/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift +++ b/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift @@ -31,18 +31,8 @@ final class AutopilotDocsHook: Hook { private func menuItems(for project: Project, controller: any HookController) -> [HookMenuItem] { guard project.gitHubRepo != nil else { return [] } - if controller.projectHasDocs(project) { - return [ - HookMenuItem( - id: "\(hookID).search.\(project.id.uuidString)", - title: "Search Docs", - systemImage: "books.vertical.fill" - ) { - controller.requestDocsSearch(project: project) - } - ] - } - + // Docs search lives in the docs UI now — the context menu only offers + // setting up / creating docs for the repo. return [ HookMenuItem( id: "\(hookID).setup.\(project.id.uuidString)", diff --git a/RxCodeMobile/Resources/Localizable.xcstrings b/RxCodeMobile/Resources/Localizable.xcstrings index 92ad3c15..93c33a30 100644 --- a/RxCodeMobile/Resources/Localizable.xcstrings +++ b/RxCodeMobile/Resources/Localizable.xcstrings @@ -1441,6 +1441,9 @@ } } } + }, + "Couldn’t Complete" : { + }, "Couldn't connect to the relay from the QR code. Check the relay address and try again." : { "localizations" : { @@ -1473,6 +1476,9 @@ } } } + }, + "Couldn’t Load Environments" : { + }, "Couldn't Load File" : { "localizations" : { @@ -2136,6 +2142,12 @@ } } } + }, + "Download" : { + + }, + "Download Secret" : { + }, "Duplicate" : { "localizations" : { @@ -2304,6 +2316,9 @@ } } } + }, + "Environment" : { + }, "Error" : { "localizations" : { @@ -2668,6 +2683,9 @@ } } } + }, + "Files are decrypted on your Mac and written into the project folder." : { + }, "Filter Skills" : { "localizations" : { @@ -3099,6 +3117,9 @@ } } } + }, + "Loading Autopilot…" : { + }, "Loading changes…" : { "localizations" : { @@ -3115,6 +3136,9 @@ } } } + }, + "Loading environments…" : { + }, "Loading folders" : { "localizations" : { @@ -3923,6 +3947,9 @@ } } } + }, + "No Secrets" : { + }, "No Selection" : { "localizations" : { @@ -4299,6 +4326,9 @@ } } } + }, + "Overwrite existing files" : { + }, "Package" : { @@ -5224,6 +5254,9 @@ } } } + }, + "Search Docs" : { + }, "Search or enter website name" : { "localizations" : { @@ -5386,6 +5419,9 @@ } } } + }, + "Sent to Your Mac" : { + }, "Serious" : { "localizations" : { @@ -5418,6 +5454,15 @@ } } } + }, + "Set Up Docs Search" : { + + }, + "Set Up Release Workflow" : { + + }, + "Set Up Secrets" : { + }, "Settings" : { "localizations" : { @@ -5854,6 +5899,9 @@ } } } + }, + "These files already exist and were skipped: %@. Turn on overwrite to replace them." : { + }, "Thinking" : { "localizations" : { @@ -5948,6 +5996,9 @@ } } } + }, + "This project is not linked to a GitHub repo." : { + }, "This QR has expired. Generate a new one on your Mac." : { "localizations" : { @@ -6022,6 +6073,9 @@ } } } + }, + "This repository has no secret environments to download." : { + }, "This settings form couldn't be rendered. Edit the raw JSON instead." : { "localizations" : { @@ -6618,4 +6672,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift b/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift index 3ef7ee48..ff9d8afe 100644 --- a/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift +++ b/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift @@ -34,6 +34,7 @@ struct ProjectAutopilotMenuItems: View { /// Loaded per-project state; `nil` while the first fetch is in flight. let status: AutopilotProjectStatus? @Binding var showDownloadSheet: Bool + @Binding var showReleaseCreate: Bool @Binding var info: AutopilotMenuInfo? var body: some View { @@ -73,22 +74,14 @@ struct ProjectAutopilotMenuItems: View { @ViewBuilder private func docsItem(_ status: AutopilotProjectStatus) -> some View { - if status.hasDocs { - Button { - fire("Opened docs search on your Mac.") { - try await state.requestProjectDocsSearch(projectId: project.id) - } - } label: { - Label("Search Docs", systemImage: "books.vertical.fill") - } - } else { - Button { - fire("Docs setup started on your Mac.") { - try await state.requestProjectDocsSetup(projectId: project.id) - } - } label: { - Label("Set Up Docs Search", systemImage: "books.vertical.fill") + // Docs search lives in the docs UI now — the context menu only offers + // setting up / creating docs for the repo. + Button { + fire("Docs setup started on your Mac.") { + try await state.requestProjectDocsSetup(projectId: project.id) } + } label: { + Label("Set Up Docs Search", systemImage: "books.vertical.fill") } } @@ -96,9 +89,9 @@ struct ProjectAutopilotMenuItems: View { private func releaseItem(_ status: AutopilotProjectStatus) -> some View { if status.hasRelease { Button { - fire("Opened create-release on your Mac.") { - try await state.requestProjectReleaseCreate(projectId: project.id) - } + // Show the create-release form on the phone (1:1 with the + // on-device release dispatch form) instead of opening it on Mac. + showReleaseCreate = true } label: { Label("Create Release", systemImage: "tag.fill") } @@ -136,6 +129,7 @@ extension View { project: Project?, status: Binding, showDownloadSheet: Binding, + showReleaseCreate: Binding, info: Binding, state: MobileAppState ) -> some View { @@ -143,6 +137,7 @@ extension View { project: project, status: status, showDownloadSheet: showDownloadSheet, + showReleaseCreate: showReleaseCreate, info: info, state: state )) @@ -153,6 +148,7 @@ private struct ProjectAutopilotMenuHostModifier: ViewModifier { let project: Project? @Binding var status: AutopilotProjectStatus? @Binding var showDownloadSheet: Bool + @Binding var showReleaseCreate: Bool @Binding var info: AutopilotMenuInfo? let state: MobileAppState @@ -170,6 +166,13 @@ private struct ProjectAutopilotMenuHostModifier: ViewModifier { .mobileSheetPresentation() } } + .sheet(isPresented: $showReleaseCreate) { + if let project { + ProjectReleaseCreateSheet(project: project) + .environmentObject(state) + .mobileSheetPresentation() + } + } .alert(item: $info) { message in Alert( title: Text(message.isError ? "Couldn’t Complete" : "Sent to Your Mac"), @@ -342,3 +345,191 @@ struct ProjectSecretsDownloadSheet: View { } } } + +// MARK: - Create-release form (on-device) + +/// On-device "Create Release" form, mirroring the desktop `ReleaseCreateSheet`: +/// resolves the project's release workflows, lets the user pick a dispatchable +/// one, fills its `workflow_dispatch` inputs, and triggers it — all on the phone +/// (the desktop only relays the request). `repoId` is the `owner/repo` slug, +/// which the desktop resolves server-side just like the macOS sheet does. +struct ProjectReleaseCreateSheet: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + let project: Project + + @State private var workflows: [MobileReleaseWorkflow] = [] + @State private var selectedWorkflowId: String? + @State private var branch = "main" + @State private var inputValues: [String: String] = [:] + @State private var isLoading = true + @State private var isDispatching = false + @State private var loadError: String? + @State private var dispatchError: String? + @State private var dispatchedURLString: String? + + private var repoFullName: String { project.gitHubRepo ?? "" } + private var dispatchableWorkflows: [MobileReleaseWorkflow] { + workflows.filter { $0.hasWorkflowDispatch } + } + private var selectedWorkflow: MobileReleaseWorkflow? { + workflows.first { $0.id == selectedWorkflowId } + } + private var resolvedBranch: String { + branch.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + NavigationStack { + Form { + if isLoading { + HStack { Spacer(); ProgressView(); Spacer() } + } else if let loadError { + Text(loadError).foregroundStyle(.red) + } else if dispatchableWorkflows.isEmpty { + Text("No dispatchable release workflow found for \(repoFullName). Add a workflow_dispatch release workflow and rescan, then try again.") + .foregroundStyle(.secondary) + } else { + detailsSection + if let inputs = selectedWorkflow?.inputs, !inputs.isEmpty { + Section("Workflow inputs") { + ForEach(inputs) { inputField($0) } + } + } + if let dispatchError { + Section { Text(dispatchError).foregroundStyle(.red).font(.callout) } + } + if let dispatchedURLString, let url = URL(string: dispatchedURLString) { + Section { + Label("Release workflow triggered", systemImage: "checkmark.seal.fill") + .foregroundStyle(.green) + Link("View workflow run on GitHub", destination: url) + } + } + } + } + .navigationTitle("Create Release") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(dispatchedURLString == nil ? "Cancel" : "Close") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await dispatch() } + } label: { + if isDispatching { ProgressView() } else { Text("Create Release") } + } + .disabled(isDispatching || selectedWorkflow == nil || resolvedBranch.isEmpty || dispatchedURLString != nil) + } + } + .mobileAutopilotLoadingOverlay(isLoading && workflows.isEmpty, title: "Loading workflows…") + } + .task { await load() } + } + + private var detailsSection: some View { + Section("Release") { + LabeledContent("Repository", value: repoFullName) + if dispatchableWorkflows.count > 1 { + Picker("Workflow", selection: $selectedWorkflowId) { + ForEach(dispatchableWorkflows) { Text($0.displayName).tag(Optional($0.id)) } + } + } else if let wf = dispatchableWorkflows.first { + LabeledContent("Workflow", value: wf.displayName) + } + TextField("Branch", text: $branch) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Text("The next version is computed by semantic-release from the commit history.") + .font(.caption).foregroundStyle(.secondary) + } + } + + @ViewBuilder + private func inputField(_ input: MobileReleaseWorkflowInput) -> some View { + let label = input.name + (input.required ? " *" : "") + if input.type == "boolean" { + Toggle(label, isOn: Binding( + get: { (inputValues[input.name] ?? (input.defaultBool == true ? "true" : "false")) == "true" }, + set: { inputValues[input.name] = $0 ? "true" : "false" } + )) + } else if input.type == "choice", let options = input.options { + Picker(label, selection: Binding( + get: { inputValues[input.name] ?? input.defaultString ?? options.first ?? "" }, + set: { inputValues[input.name] = $0 } + )) { + ForEach(options, id: \.self) { Text($0).tag($0) } + } + } else { + VStack(alignment: .leading, spacing: 2) { + TextField(label, text: Binding( + get: { inputValues[input.name] ?? input.defaultString ?? "" }, + set: { inputValues[input.name] = $0 } + )) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + if let desc = input.description, !desc.isEmpty { + Text(desc).font(.caption2).foregroundStyle(.secondary) + } + } + } + } + + private func load() async { + guard !repoFullName.isEmpty else { + loadError = String(localized: "This project is not linked to a GitHub repo.") + isLoading = false + return + } + isLoading = true + loadError = nil + defer { isLoading = false } + do { + workflows = try await state.listReleaseWorkflows(repoId: repoFullName) + let preferred = workflows.first { $0.isSelected && $0.hasWorkflowDispatch } + ?? dispatchableWorkflows.first + selectedWorkflowId = preferred?.id + seedDefaults(for: preferred) + } catch { + loadError = error.localizedDescription + } + } + + private func seedDefaults(for workflow: MobileReleaseWorkflow?) { + guard let inputs = workflow?.inputs else { return } + for input in inputs where inputValues[input.name] == nil { + if input.type == "boolean" { + inputValues[input.name] = (input.defaultBool ?? false) ? "true" : "false" + } else { + inputValues[input.name] = input.defaultString ?? (input.options?.first ?? "") + } + } + } + + private func dispatch() async { + guard let workflow = selectedWorkflow else { return } + isDispatching = true + dispatchError = nil + defer { isDispatching = false } + do { + let result = try await state.dispatchRelease( + repoId: repoFullName, + request: ReleaseDispatchRequest( + workflowId: workflow.id, + branch: resolvedBranch, + inputs: inputValues + ) + ) + if let urlString = result.workflowRunUrl { + dispatchedURLString = urlString + } else if result.success == false { + dispatchError = result.error ?? String(localized: "Failed to trigger the release workflow.") + } else { + dispatchedURLString = "https://github.com/\(repoFullName)/actions" + } + } catch { + dispatchError = error.localizedDescription + } + } +} diff --git a/RxCodeMobile/Views/MobileBriefingDetailView.swift b/RxCodeMobile/Views/MobileBriefingDetailView.swift index 66c70fcb..a39fdd50 100644 --- a/RxCodeMobile/Views/MobileBriefingDetailView.swift +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -15,6 +15,7 @@ struct MobileBriefingDetailView: View { // Autopilot context menu (1:1 with the desktop briefing/project menu). @State private var autopilotStatus: AutopilotProjectStatus? @State private var showingSecretsDownload = false + @State private var showingReleaseCreate = false @State private var autopilotInfo: AutopilotMenuInfo? var body: some View { @@ -53,6 +54,7 @@ struct MobileBriefingDetailView: View { project: project, status: autopilotStatus, showDownloadSheet: $showingSecretsDownload, + showReleaseCreate: $showingReleaseCreate, info: $autopilotInfo ) if gitHubURL != nil { Divider() } @@ -78,6 +80,7 @@ struct MobileBriefingDetailView: View { project: project, status: $autopilotStatus, showDownloadSheet: $showingSecretsDownload, + showReleaseCreate: $showingReleaseCreate, info: $autopilotInfo, state: state ) diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift index a94c968f..3d78c07e 100644 --- a/RxCodeMobile/Views/ProjectsSidebar.swift +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -389,6 +389,7 @@ private struct GlassProjectCard: View { // Autopilot context menu (1:1 with the desktop project/sidebar menu). @State private var autopilotStatus: AutopilotProjectStatus? @State private var showingSecretsDownload = false + @State private var showingReleaseCreate = false @State private var autopilotInfo: AutopilotMenuInfo? var body: some View { @@ -397,6 +398,7 @@ private struct GlassProjectCard: View { project: project, status: $autopilotStatus, showDownloadSheet: $showingSecretsDownload, + showReleaseCreate: $showingReleaseCreate, info: $autopilotInfo, state: state )) @@ -555,6 +557,7 @@ private struct ProjectCardAutopilotMenuModifier: ViewModifier { let project: Project @Binding var status: AutopilotProjectStatus? @Binding var showDownloadSheet: Bool + @Binding var showReleaseCreate: Bool @Binding var info: AutopilotMenuInfo? let state: MobileAppState @@ -566,6 +569,7 @@ private struct ProjectCardAutopilotMenuModifier: ViewModifier { project: project, status: status, showDownloadSheet: $showDownloadSheet, + showReleaseCreate: $showReleaseCreate, info: $info ) } @@ -573,6 +577,7 @@ private struct ProjectCardAutopilotMenuModifier: ViewModifier { project: project, status: $status, showDownloadSheet: $showDownloadSheet, + showReleaseCreate: $showReleaseCreate, info: $info, state: state ) From e9bdb8b004b289c7ca1fe2032b30613ad4636ef2 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:51:03 +0800 Subject: [PATCH 6/8] fix: project-list context menu now appears on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autopilot context menu was routed through a conditional ViewModifier wrapping the project card, and the long-press menu never registered. Attach .contextMenu (and the autopilot host) directly to the card — mirroring the working GlassThreadCard pattern in SessionsList — so long-pressing a project in the list shows the menu. Items stay gated on a linked GitHub repo. Co-Authored-By: Claude Opus 4.8 (1M context) --- RxCodeMobile/Views/ProjectsSidebar.swift | 59 ++++++++---------------- 1 file changed, 18 insertions(+), 41 deletions(-) diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift index 3d78c07e..c4c7b39c 100644 --- a/RxCodeMobile/Views/ProjectsSidebar.swift +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -393,15 +393,31 @@ private struct GlassProjectCard: View { @State private var autopilotInfo: AutopilotMenuInfo? var body: some View { + // Attach the autopilot context menu directly to the card (mirrors the + // working `GlassThreadCard` pattern in `SessionsList`). Routing it + // through a conditional `ViewModifier` previously meant the long-press + // menu never registered on the project list. The menu items themselves + // stay gated on a linked GitHub repo, matching the desktop. cardButton - .modifier(ProjectCardAutopilotMenuModifier( + .contextMenu { + if project.gitHubRepo != nil { + ProjectAutopilotMenuItems( + project: project, + status: autopilotStatus, + showDownloadSheet: $showingSecretsDownload, + showReleaseCreate: $showingReleaseCreate, + info: $autopilotInfo + ) + } + } + .projectAutopilotMenuHost( project: project, status: $autopilotStatus, showDownloadSheet: $showingSecretsDownload, showReleaseCreate: $showingReleaseCreate, info: $autopilotInfo, state: state - )) + ) } @ViewBuilder @@ -548,45 +564,6 @@ private struct GlassProjectCard: View { } } -// MARK: - Project Card Autopilot Menu - -/// Attaches the autopilot context menu (long-press) + its sheet/alert to a -/// project card, but only for repo-backed projects (matching the desktop, which -/// shows no autopilot items without a linked GitHub repo). -private struct ProjectCardAutopilotMenuModifier: ViewModifier { - let project: Project - @Binding var status: AutopilotProjectStatus? - @Binding var showDownloadSheet: Bool - @Binding var showReleaseCreate: Bool - @Binding var info: AutopilotMenuInfo? - let state: MobileAppState - - func body(content: Content) -> some View { - if project.gitHubRepo != nil { - content - .contextMenu { - ProjectAutopilotMenuItems( - project: project, - status: status, - showDownloadSheet: $showDownloadSheet, - showReleaseCreate: $showReleaseCreate, - info: $info - ) - } - .projectAutopilotMenuHost( - project: project, - status: $status, - showDownloadSheet: $showDownloadSheet, - showReleaseCreate: $showReleaseCreate, - info: $info, - state: state - ) - } else { - content - } - } -} - // MARK: - Search Thread Hit Card struct SearchThreadHitCard: View { From 4f364e29d4ef950965f8e0668781c469dba52747 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:56:11 +0800 Subject: [PATCH 7/8] feat: add Create Pull Request action to iOS briefing detail Adds a desktop-mediated projectCreatePullRequest autopilot op: the Mac pushes the branch, drafts the PR title/body from the branch briefing, and opens the pull request (it holds the GitHub token + checkout), returning the PR URL. The briefing detail actions menu now shows "Create Pull Request" for a repo-backed, real branch with no open PR yet (once a PR exists the existing "Open Pull Request" link covers it). On success the new PR opens in the browser; failures surface in the info alert. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Protocol/Payload+Autopilot.swift | 19 +++++++++++ RxCode/App/AppState+MobileAutopilot.swift | 10 ++++++ RxCodeMobile/Resources/Localizable.xcstrings | 30 ++++++++++++++-- .../State/MobileAppState+Autopilot.swift | 14 ++++++++ .../Views/MobileBriefingDetailView.swift | 34 +++++++++++++++++++ 5 files changed, 104 insertions(+), 3 deletions(-) diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift index ca22e8b3..9a247e69 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift @@ -106,6 +106,7 @@ public enum AutopilotOp: String, Codable, Sendable { case projectReleaseSetup case projectReleaseCreate case projectSecretsDownload + case projectCreatePullRequest } /// Mobile → desktop: a single autopilot operation. `body` is a JSON-encoded @@ -459,6 +460,24 @@ public struct AutopilotProjectBody: Codable, Sendable { public init(projectId: UUID) { self.projectId = projectId } } +/// Addresses a project + branch. Used by `projectCreatePullRequest`, where the +/// desktop pushes the branch, generates the PR title/body from the branch +/// briefing, and opens the pull request (it holds the GitHub token + checkout). +public struct AutopilotProjectBranchBody: Codable, Sendable { + public let projectId: UUID + public let branch: String + public init(projectId: UUID, branch: String) { + self.projectId = projectId + self.branch = branch + } +} + +/// Result of `projectCreatePullRequest`: the URL of the opened pull request. +public struct AutopilotPullRequestResult: Codable, Sendable { + public let url: String + public init(url: String) { self.url = url } +} + /// Per-project autopilot state powering the mobile context menu. Mirrors the /// desktop's `projectHasSecrets` / `projectHasDocs` / `projectHasReleaseWorkflow` /// checks so the phone can pick the same menu items (Download vs Set Up, etc.). diff --git a/RxCode/App/AppState+MobileAutopilot.swift b/RxCode/App/AppState+MobileAutopilot.swift index f4e24271..e392b4e6 100644 --- a/RxCode/App/AppState+MobileAutopilot.swift +++ b/RxCode/App/AppState+MobileAutopilot.swift @@ -302,6 +302,16 @@ extension AppState { releaseCreateRequest = project return nil + case .projectCreatePullRequest: + // Same as the desktop briefing "Create PR" button: push the branch, + // generate the title/body from the briefing, and open the PR. + let body = try decodeAutopilotBody(request, as: AutopilotProjectBranchBody.self) + guard let project = projects.first(where: { $0.id == body.projectId }) else { + throw MobileRemoteConfigError.invalidRequest("No project found for the requested id.") + } + let url = try await createPullRequestForBranch(project: project, branch: body.branch) + return try encoder.encode(AutopilotPullRequestResult(url: url.absoluteString)) + case .projectSecretsDownload: let body = try decodeAutopilotBody(request, as: AutopilotProjectSecretsDownloadBody.self) guard let project = projects.first(where: { $0.id == body.projectId }) else { diff --git a/RxCodeMobile/Resources/Localizable.xcstrings b/RxCodeMobile/Resources/Localizable.xcstrings index 93c33a30..6d8f07f8 100644 --- a/RxCodeMobile/Resources/Localizable.xcstrings +++ b/RxCodeMobile/Resources/Localizable.xcstrings @@ -2595,6 +2595,9 @@ } } } + }, + "Failed to trigger the release workflow." : { + }, "Failing" : { "localizations" : { @@ -3191,6 +3194,9 @@ } } } + }, + "Loading workflows…" : { + }, "Loading…" : { "localizations" : { @@ -3751,6 +3757,9 @@ } } } + }, + "No dispatchable release workflow found for %@. Add a workflow_dispatch release workflow and rescan, then try again." : { + }, "No documentation repositories. Tap + to add one." : { "localizations" : { @@ -4708,6 +4717,9 @@ } } } + }, + "Release workflow triggered" : { + }, "RELEASE_TOKEN" : { @@ -4871,6 +4883,9 @@ } } } + }, + "Repository" : { + }, "Request timed out. Check your Mac and try again." : { "localizations" : { @@ -5254,9 +5269,6 @@ } } } - }, - "Search Docs" : { - }, "Search or enter website name" : { "localizations" : { @@ -5873,6 +5885,9 @@ } } } + }, + "The next version is computed by semantic-release from the commit history." : { + }, "The paired Mac changed before the request finished." : { "localizations" : { @@ -6547,6 +6562,9 @@ } } } + }, + "View workflow run on GitHub" : { + }, "What should we build in %@?" : { "localizations" : { @@ -6563,6 +6581,12 @@ } } } + }, + "Workflow" : { + + }, + "Workflow inputs" : { + }, "Workflows" : { "localizations" : { diff --git a/RxCodeMobile/State/MobileAppState+Autopilot.swift b/RxCodeMobile/State/MobileAppState+Autopilot.swift index 99e920dd..a7f6650d 100644 --- a/RxCodeMobile/State/MobileAppState+Autopilot.swift +++ b/RxCodeMobile/State/MobileAppState+Autopilot.swift @@ -401,6 +401,20 @@ extension MobileAppState { try await autopilotSendVoid(.project, .projectReleaseCreate, body: AutopilotProjectBody(projectId: projectId)) } + /// Asks the Mac to open a pull request for the project's branch: it pushes + /// the branch, generates the PR title/body from the branch briefing, and + /// opens the PR (the Mac holds the GitHub token + checkout). Returns the PR + /// URL so the phone can open it. + func requestProjectCreatePullRequest(projectId: UUID, branch: String) async throws -> URL { + let result = try await autopilotSend(.project, .projectCreatePullRequest, + body: AutopilotProjectBranchBody(projectId: projectId, branch: branch), + as: AutopilotPullRequestResult.self) + guard let url = URL(string: result.url) else { + throw AutopilotRemoteError.server(String(localized: "The Mac returned an invalid pull-request URL.")) + } + return url + } + /// Asks the Mac to download + decrypt the chosen environment and write its /// files into the project folder (the Mac holds the KEK; plaintext stays on /// the Mac). Returns the files written and any skipped conflicts. diff --git a/RxCodeMobile/Views/MobileBriefingDetailView.swift b/RxCodeMobile/Views/MobileBriefingDetailView.swift index a39fdd50..d8127e28 100644 --- a/RxCodeMobile/Views/MobileBriefingDetailView.swift +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -9,8 +9,10 @@ struct MobileBriefingDetailView: View { let groupKey: BriefingGroupKey var onOpenSession: (String) -> Void = { _ in } + @Environment(\.openURL) private var openURL @State private var showingNewThread = false @State private var isInitializingGit = false + @State private var isCreatingPR = false // Autopilot context menu (1:1 with the desktop briefing/project menu). @State private var autopilotStatus: AutopilotProjectStatus? @@ -57,6 +59,17 @@ struct MobileBriefingDetailView: View { showReleaseCreate: $showingReleaseCreate, info: $autopilotInfo ) + // Offer "Create PR" for a real branch with no open PR + // yet (mirrors the desktop briefing PR button); once a + // PR exists the "Open Pull Request" link below covers it. + if !isUnknownBranch && !gitHubURLIsPullRequest { + Button { + createPullRequest(project: project) + } label: { + Label("Create Pull Request", systemImage: "arrow.triangle.pull.request") + } + .disabled(isCreatingPR) + } if gitHubURL != nil { Divider() } } if let gitHubURL { @@ -167,6 +180,27 @@ struct MobileBriefingDetailView: View { } } + /// Ask the Mac to open a PR for this branch, then open it in the browser. + /// The Mac pushes the branch, drafts the title/body from the briefing, and + /// creates the PR; on failure we surface the reason in the info alert. + private func createPullRequest(project: Project) { + guard !isCreatingPR else { return } + isCreatingPR = true + Task { + defer { isCreatingPR = false } + do { + let url = try await state.requestProjectCreatePullRequest( + projectId: project.id, + branch: groupKey.branch + ) + await state.refreshSnapshot() + openURL(url) + } catch { + autopilotInfo = AutopilotMenuInfo(text: error.localizedDescription, isError: true) + } + } + } + // MARK: - Header Card private var headerCard: some View { From bc2c8ccf70204238f60335ee6e7f5099270edefe Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:10:15 +0800 Subject: [PATCH 8/8] fix: on-device autopilot menu actions, visible menu button, branch picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Context-menu setup actions (Secrets/Docs/Release) no longer run "on your Mac" — they open a new on-device chat (NewThreadSheet, prefilled with a setup prompt) and navigate to it via the deep-link router. Removed the "Sent to Your Mac" messaging. - Docs item only appears when the repo has no docs yet (iOS + macOS hook); "Search Docs" stays removed. Set Up Docs on iOS opens the setup chat. - Project cards now show a visible ellipsis menu button (the long-press context menu didn't register reliably on the glass cards); long-press kept as well. - Create-release branch field is a menu picker sourced from the project's synced branches (both the project "Create Release" sheet and the release repo dispatch sheet), falling back to free text when no branches are known. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../RxCodeSync/Protocol/FolderPayloads.swift | 29 +++++ .../Protocol/Payload+Autopilot.swift | 44 ++++++- .../Sources/RxCodeSync/Protocol/Payload.swift | 10 ++ .../Tests/RxCodeSyncTests/PayloadTests.swift | 36 ++++++ RxCode/App/AppState+MobileAutopilot.swift | 17 +++ RxCode/App/AppState+MobileSync.swift | 60 ++++++++++ RxCode/Resources/Localizable.xcstrings | 5 + .../Hooks/hooks/AutopilotDocsHook.swift | 7 +- .../MobileSyncService+EventDispatch.swift | 7 ++ RxCode/Services/MobileSyncService.swift | 1 + .../Services/OpenAISummarizationService.swift | 3 +- RxCode/Services/RxAuthService.swift | 67 +++++++++-- RxCodeMobile/Resources/Localizable.xcstrings | 28 ++++- .../State/MobileAppState+Autopilot.swift | 21 ++-- .../State/MobileAppState+Inbound.swift | 15 +++ .../State/MobileAppState+Intents.swift | 24 ++++ RxCodeMobile/State/MobileAppState.swift | 2 + .../Views/Autopilot/MobileReleaseView.swift | 32 ++++- .../Autopilot/ProjectAutopilotMenu.swift | 109 ++++++++++++------ .../Views/MobileBriefingDetailView.swift | 4 + RxCodeMobile/Views/NewThreadSheet.swift | 4 + RxCodeMobile/Views/ProjectsSidebar.swift | 37 +----- RxCodeMobile/Views/RootView.swift | 4 +- RxCodeMobile/Views/SessionsList.swift | 66 +++++++++++ 24 files changed, 523 insertions(+), 109 deletions(-) diff --git a/Packages/Sources/RxCodeSync/Protocol/FolderPayloads.swift b/Packages/Sources/RxCodeSync/Protocol/FolderPayloads.swift index 12c3bf3e..46f1c26e 100644 --- a/Packages/Sources/RxCodeSync/Protocol/FolderPayloads.swift +++ b/Packages/Sources/RxCodeSync/Protocol/FolderPayloads.swift @@ -131,3 +131,32 @@ public struct CreateProjectResultPayload: Codable, Sendable { self.errorMessage = errorMessage } } + +public struct DeleteProjectRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let projectID: UUID + + public init(clientRequestID: UUID = UUID(), projectID: UUID) { + self.clientRequestID = clientRequestID + self.projectID = projectID + } +} + +public struct DeleteProjectResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let projectID: UUID + public let ok: Bool + public let errorMessage: String? + + public init( + clientRequestID: UUID, + projectID: UUID, + ok: Bool, + errorMessage: String? = nil + ) { + self.clientRequestID = clientRequestID + self.projectID = projectID + self.ok = ok + self.errorMessage = errorMessage + } +} diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift index 9a247e69..ae7e6e2b 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift @@ -97,8 +97,11 @@ public enum AutopilotOp: String, Codable, Sendable { // Project context-menu actions — desktop-mediated so the Mac performs the // exact work its own project/briefing context menu does. `*Setup` ops set // the same AppState request the desktop menu sets (the Mac surfaces the - // setup chat/sheet); `projectSecretsDownload` decrypts on the Mac and writes - // the files into the project folder (the phone never sees plaintext). + // setup chat/sheet). For secrets download the phone decrypts on-device with + // its own passkey-derived KEK and sends the plaintext over the E2E relay via + // `projectSecretsWrite`; the Mac only writes the files into the project + // folder (no passkey prompt on the Mac). `projectSecretsDownload` is the + // legacy Mac-side-decrypt path, kept for wire compatibility. case projectAutopilotStatus case projectSecretsSetup case projectDocsSetup @@ -106,6 +109,7 @@ public enum AutopilotOp: String, Codable, Sendable { case projectReleaseSetup case projectReleaseCreate case projectSecretsDownload + case projectSecretsWrite case projectCreatePullRequest } @@ -511,9 +515,39 @@ public struct AutopilotProjectSecretsDownloadBody: Codable, Sendable { } } -/// Result of `projectSecretsDownload`: filenames actually written, plus the -/// files skipped because they already existed (only when `overwrite` was false). -/// The phone re-issues with `overwrite: true` to clobber the conflicts. +/// Mobile → desktop: secret files the phone already decrypted on-device (using +/// its passkey-derived KEK — the same iCloud-synced credential the Mac uses) to +/// be written into the project's folder on the Mac. The plaintext travels only +/// inside the E2E-encrypted relay channel to the paired Mac, which writes the +/// files without ever prompting for a passkey. Replaces the Mac-side-decrypt +/// `projectSecretsDownload` for mobile-initiated downloads. +public struct AutopilotProjectSecretsWriteBody: Codable, Sendable { + public let projectId: UUID + public let files: [Plaintext] + public let overwrite: Bool + + /// One already-decrypted file. A wire type (unlike `AutopilotSecretFilePlaintext`, + /// which is on-device only) because it crosses the encrypted relay. + public struct Plaintext: Codable, Sendable { + public let filename: String + public let content: String + public init(filename: String, content: String) { + self.filename = filename + self.content = content + } + } + + public init(projectId: UUID, files: [Plaintext], overwrite: Bool) { + self.projectId = projectId + self.files = files + self.overwrite = overwrite + } +} + +/// Result of `projectSecretsDownload` / `projectSecretsWrite`: filenames actually +/// written, plus the files skipped because they already existed (only when +/// `overwrite` was false). The phone re-issues with `overwrite: true` to clobber +/// the conflicts. public struct AutopilotProjectSecretsDownloadResult: Codable, Sendable { public let written: [String] public let conflicts: [String] diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index 3515ceed..97163e7c 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -43,6 +43,8 @@ public enum Payload: Sendable { case folderTreeResult(FolderTreeResultPayload) case createProjectRequest(CreateProjectRequestPayload) case createProjectResult(CreateProjectResultPayload) + case deleteProjectRequest(DeleteProjectRequestPayload) + case deleteProjectResult(DeleteProjectResultPayload) case runProfileMutationRequest(RunProfileMutationRequestPayload) case runProfileResult(RunProfileResultPayload) case runProfileRunRequest(RunProfileRunRequestPayload) @@ -110,6 +112,8 @@ public extension Payload { case .folderTreeResult: return "folder_tree_result" case .createProjectRequest: return "create_project_request" case .createProjectResult: return "create_project_result" + case .deleteProjectRequest: return "delete_project_request" + case .deleteProjectResult: return "delete_project_result" case .runProfileMutationRequest: return "run_profile_mutation_request" case .runProfileResult: return "run_profile_result" case .runProfileRunRequest: return "run_profile_run_request" @@ -731,6 +735,8 @@ extension Payload: Codable { case folderTreeResult = "folder_tree_result" case createProjectRequest = "create_project_request" case createProjectResult = "create_project_result" + case deleteProjectRequest = "delete_project_request" + case deleteProjectResult = "delete_project_result" case runProfileMutationRequest = "run_profile_mutation_request" case runProfileResult = "run_profile_result" case runProfileRunRequest = "run_profile_run_request" @@ -802,6 +808,8 @@ extension Payload: Codable { case .folderTreeResult: self = .folderTreeResult(try container.decode(FolderTreeResultPayload.self, forKey: .data)) case .createProjectRequest: self = .createProjectRequest(try container.decode(CreateProjectRequestPayload.self, forKey: .data)) case .createProjectResult: self = .createProjectResult(try container.decode(CreateProjectResultPayload.self, forKey: .data)) + case .deleteProjectRequest: self = .deleteProjectRequest(try container.decode(DeleteProjectRequestPayload.self, forKey: .data)) + case .deleteProjectResult: self = .deleteProjectResult(try container.decode(DeleteProjectResultPayload.self, forKey: .data)) case .runProfileMutationRequest: self = .runProfileMutationRequest(try container.decode(RunProfileMutationRequestPayload.self, forKey: .data)) case .runProfileResult: self = .runProfileResult(try container.decode(RunProfileResultPayload.self, forKey: .data)) case .runProfileRunRequest: self = .runProfileRunRequest(try container.decode(RunProfileRunRequestPayload.self, forKey: .data)) @@ -869,6 +877,8 @@ extension Payload: Codable { case .folderTreeResult(let p): try container.encode(TypeKey.folderTreeResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .createProjectRequest(let p): try container.encode(TypeKey.createProjectRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .createProjectResult(let p): try container.encode(TypeKey.createProjectResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .deleteProjectRequest(let p): try container.encode(TypeKey.deleteProjectRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .deleteProjectResult(let p): try container.encode(TypeKey.deleteProjectResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .runProfileMutationRequest(let p): try container.encode(TypeKey.runProfileMutationRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .runProfileResult(let p): try container.encode(TypeKey.runProfileResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .runProfileRunRequest(let p): try container.encode(TypeKey.runProfileRunRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) diff --git a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift index c064343b..5ff4760c 100644 --- a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift +++ b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift @@ -416,6 +416,42 @@ struct PayloadTests { #expect(createResult.project?.name == "RxCode") } + @Test("mobile delete project payloads round trip") + func mobileDeleteProjectPayloadsRoundTrip() throws { + let requestID = UUID(uuidString: "DDDDDDDD-EEEE-FFFF-0000-111111111111")! + let projectID = UUID(uuidString: "EEEEEEEE-FFFF-0000-1111-222222222222")! + let request = Payload.deleteProjectRequest( + DeleteProjectRequestPayload(clientRequestID: requestID, projectID: projectID) + ) + + let requestData = try JSONEncoder().encode(request) + let decodedRequest = try JSONDecoder().decode(Payload.self, from: requestData) + guard case .deleteProjectRequest(let deleteRequest) = decodedRequest else { + Issue.record("Expected delete project request") + return + } + #expect(deleteRequest.clientRequestID == requestID) + #expect(deleteRequest.projectID == projectID) + + let result = Payload.deleteProjectResult( + DeleteProjectResultPayload( + clientRequestID: requestID, + projectID: projectID, + ok: true + ) + ) + + let resultData = try JSONEncoder().encode(result) + let decodedResult = try JSONDecoder().decode(Payload.self, from: resultData) + guard case .deleteProjectResult(let deleteResult) = decodedResult else { + Issue.record("Expected delete project result") + return + } + #expect(deleteResult.ok) + #expect(deleteResult.projectID == projectID) + #expect(deleteResult.errorMessage == nil) + } + @Test("autopilot request payload round trips with an encoded body") func autopilotRequestRoundTrips() throws { let requestID = UUID(uuidString: "AAAAAAAA-1111-2222-3333-444444444444")! diff --git a/RxCode/App/AppState+MobileAutopilot.swift b/RxCode/App/AppState+MobileAutopilot.swift index e392b4e6..9abc8ef4 100644 --- a/RxCode/App/AppState+MobileAutopilot.swift +++ b/RxCode/App/AppState+MobileAutopilot.swift @@ -330,6 +330,23 @@ extension AppState { .filter { FileManager.default.fileExists(atPath: directory.appendingPathComponent($0).path) } let written = try writeDecryptedSecrets(files, to: directory, overwrite: body.overwrite) return try encoder.encode(AutopilotProjectSecretsDownloadResult(written: written, conflicts: conflicts)) + + case .projectSecretsWrite: + // The phone decrypted the environment on-device with its own + // passkey-derived KEK and sent the plaintext over the E2E relay; the + // Mac only writes the files into the project folder (no passkey + // prompt here). This is the mobile-initiated download path. + let body = try decodeAutopilotBody(request, as: AutopilotProjectSecretsWriteBody.self) + guard let project = projects.first(where: { $0.id == body.projectId }) else { + throw MobileRemoteConfigError.invalidRequest("No project found for the requested id.") + } + let files = body.files.map { (filename: $0.filename, content: $0.content) } + let directory = URL(fileURLWithPath: project.path, isDirectory: true) + let conflicts = body.overwrite ? [] : files + .map(\.filename) + .filter { FileManager.default.fileExists(atPath: directory.appendingPathComponent($0).path) } + let written = try writeDecryptedSecrets(files, to: directory, overwrite: body.overwrite) + return try encoder.encode(AutopilotProjectSecretsDownloadResult(written: written, conflicts: conflicts)) } } diff --git a/RxCode/App/AppState+MobileSync.swift b/RxCode/App/AppState+MobileSync.swift index 229b0e86..5e5f304e 100644 --- a/RxCode/App/AppState+MobileSync.swift +++ b/RxCode/App/AppState+MobileSync.swift @@ -204,6 +204,20 @@ extension AppState { } mobileSyncObservers.append(createProjectObserver) + let deleteProjectObserver = center.addObserver( + forName: .mobileSyncDeleteProjectRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? DeleteProjectRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileDeleteProjectRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(deleteProjectObserver) + let runProfileMutationObserver = center.addObserver( forName: .mobileSyncRunProfileMutationRequested, object: nil, @@ -706,4 +720,50 @@ extension AppState { await MobileSyncService.shared.send(.createProjectResult(result), toHex: hex) } + func handleMobileDeleteProjectRequest(_ request: DeleteProjectRequestPayload, fromHex: String) async { + guard let project = projects.first(where: { $0.id == request.projectID }) else { + // Already gone — treat as success so the phone settles to the same state. + await replyDeleteProjectResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: true, + errorMessage: nil, + toHex: fromHex + ) + await sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) + return + } + + // Mobile actions aren't tied to a desktop window; a fresh WindowState + // keeps the data-layer mutators happy without disturbing open windows. + let window = WindowState() + await deleteProject(project, in: window) + + await replyDeleteProjectResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: true, + errorMessage: nil, + toHex: fromHex + ) + await sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) + scheduleMobileSnapshotBroadcast() + } + + func replyDeleteProjectResult( + requestID: UUID, + projectID: UUID, + ok: Bool, + errorMessage: String?, + toHex hex: String + ) async { + let result = DeleteProjectResultPayload( + clientRequestID: requestID, + projectID: projectID, + ok: ok, + errorMessage: errorMessage + ) + await MobileSyncService.shared.send(.deleteProjectResult(result), toHex: hex) + } + } diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index b2953d0e..47066cf8 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -12359,6 +12359,7 @@ } }, "Search Docs" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -13011,8 +13012,12 @@ } } } + }, + "Set Up Docs" : { + }, "Set Up Docs Search" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { diff --git a/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift b/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift index 991d69bb..670c4dcc 100644 --- a/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift +++ b/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift @@ -31,12 +31,13 @@ final class AutopilotDocsHook: Hook { private func menuItems(for project: Project, controller: any HookController) -> [HookMenuItem] { guard project.gitHubRepo != nil else { return [] } - // Docs search lives in the docs UI now — the context menu only offers - // setting up / creating docs for the repo. + // Docs search was removed from the menu. Once a repo's docs are indexed + // there's nothing to do here; otherwise offer to set them up. + guard !controller.projectHasDocs(project) else { return [] } return [ HookMenuItem( id: "\(hookID).setup.\(project.id.uuidString)", - title: "Set Up Docs Search", + title: "Set Up Docs", systemImage: "books.vertical.fill" ) { controller.requestDocsSetup(project: project) diff --git a/RxCode/Services/MobileSyncService+EventDispatch.swift b/RxCode/Services/MobileSyncService+EventDispatch.swift index 74be21f2..71b7e476 100644 --- a/RxCode/Services/MobileSyncService+EventDispatch.swift +++ b/RxCode/Services/MobileSyncService+EventDispatch.swift @@ -335,6 +335,13 @@ extension MobileSyncService { object: nil, userInfo: ["from": inbound.fromHex, "payload": req] ) + case .deleteProjectRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "delete_project_request") else { return } + NotificationCenter.default.post( + name: .mobileSyncDeleteProjectRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) case .runProfileMutationRequest(let req): guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "run_profile_mutation_request") else { return } logger.info("[MobileSync] run profile mutation requested operation=\(req.operation.rawValue, privacy: .public) project=\(req.projectID.uuidString, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index 4205e65c..2ccc0e75 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -891,6 +891,7 @@ extension Notification.Name { static let mobileSyncBranchOpRequested = Notification.Name("mobileSync.branchOpRequested") static let mobileSyncFolderTreeRequested = Notification.Name("mobileSync.folderTreeRequested") static let mobileSyncCreateProjectRequested = Notification.Name("mobileSync.createProjectRequested") + static let mobileSyncDeleteProjectRequested = Notification.Name("mobileSync.deleteProjectRequested") static let mobileSyncRunProfileMutationRequested = Notification.Name("mobileSync.runProfileMutationRequested") static let mobileSyncRunProfileRunRequested = Notification.Name("mobileSync.runProfileRunRequested") static let mobileSyncRunProfileStopRequested = Notification.Name("mobileSync.runProfileStopRequested") diff --git a/RxCode/Services/OpenAISummarizationService.swift b/RxCode/Services/OpenAISummarizationService.swift index 1915bf4a..f788d876 100644 --- a/RxCode/Services/OpenAISummarizationService.swift +++ b/RxCode/Services/OpenAISummarizationService.swift @@ -272,7 +272,8 @@ actor OpenAISummarizationService { Format rules (MUST follow exactly): - The FIRST line is the PR title in Conventional Commits format: `(): ` — under 72 characters, lowercase imperative mood, no trailing period. - - `` MUST be one of: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert. + - `` MUST be EXACTLY one of these tokens, spelled verbatim: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert. + - Use the short token only. Do NOT expand or substitute it (e.g. write `feat`, never `feature`; `perf`, never `performance`). A title whose type is not on the list above is invalid. - Then exactly ONE blank line. - Then the PR description in GitHub-flavored markdown: a short summary paragraph, then a `## Changes` section with concise bullet points covering the main work. Keep it focused. - Do NOT wrap the output in code fences. Do NOT put the title in quotes. Do NOT prefix the title with anything (no "Title:"). diff --git a/RxCode/Services/RxAuthService.swift b/RxCode/Services/RxAuthService.swift index 3780fea1..7750ee84 100644 --- a/RxCode/Services/RxAuthService.swift +++ b/RxCode/Services/RxAuthService.swift @@ -36,6 +36,18 @@ final class RxAuthService { /// "infinite loading" while reading repos. One shared task fixes that. private var refreshTask: Task? + /// In-memory copy of the last access token read from the keychain, with its + /// expiry. The keychain read is what triggers macOS's "wants to use + /// confidential information" prompt when the running binary's code signature + /// no longer matches the item's ACL — and a burst of concurrent + /// `accessToken()` callers (repo list, installation list, CI poller, mobile + /// sync) each hit that read, turning one stale-signature prompt into a + /// storm. Caching here serves every caller from memory, so at most one + /// keychain read happens per launch until the token nears expiry. A refresh + /// rewrites the keychain items under the *current* signature, so the seed + /// read after a refresh no longer prompts either. + private var cachedToken: (value: String, expiresAt: Date)? + init() { let configuration = RxAuthConfiguration( issuer: Self.issuer, @@ -78,12 +90,24 @@ final class RxAuthService { /// 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(forceRefresh: Bool = false) async -> String? { - // Fast path — a cached, not-yet-expiring token needs no network hop. - // Skipped on a forced refresh, where the cached token was just rejected. + // Fastest path — an in-memory, not-yet-expiring token needs no keychain + // read at all, so concurrent callers never re-trigger the macOS keychain + // permission prompt. Skipped on a forced refresh, where the token was + // just rejected by the server. + if !forceRefresh, let cached = cachedToken, !Self.isExpiring(cached.expiresAt) { + return cached.value + } + + // Next path — a cached-in-keychain, not-yet-expiring token needs no + // network hop. This is the one read that may prompt (once) when the + // stored item was written by a build with a different signature; we seed + // the in-memory cache from it so the next caller skips the keychain. if !forceRefresh, - let cached = KeychainBackedTokenReader.readAccessToken(service: Self.keychainService), - !Self.accessTokenIsExpiring(service: Self.keychainService) { - return cached + let token = KeychainBackedTokenReader.readAccessToken(service: Self.keychainService), + let expiresAt = Self.readExpiry(service: Self.keychainService), + !Self.isExpiring(expiresAt) { + cachedToken = (token, expiresAt) + return token } do { @@ -92,7 +116,20 @@ final class RxAuthService { logger.warning("RxAuth refresh failed: \(error.localizedDescription, privacy: .public)") return nil } - return KeychainBackedTokenReader.readAccessToken(service: Self.keychainService) + return seedCacheFromKeychain() + } + + /// Read the freshly-refreshed token + expiry from the keychain once and + /// store them in memory. Called after a successful refresh, which rewrites + /// the keychain items under the current binary's signature — so this read + /// matches the ACL and does not prompt. + private func seedCacheFromKeychain() -> String? { + guard let token = KeychainBackedTokenReader.readAccessToken(service: Self.keychainService) else { + cachedToken = nil + return nil + } + cachedToken = (token, Self.readExpiry(service: Self.keychainService) ?? .distantPast) + return token } /// Run at most one `refreshTokenIfNeeded()` at a time; concurrent callers @@ -118,15 +155,20 @@ final class RxAuthService { logger.debug("accessToken: refreshTokenIfNeeded returned in \(ms, privacy: .public)ms") } - /// 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 { + /// Read the stored access-token expiry from the keychain, or `nil` when + /// none is recorded. + private static func readExpiry(service: String) -> Date? { guard let timestamp = KeychainHelper.readString(service: service, account: "expires_at"), let seconds = Double(timestamp) - else { return true } - return Date(timeIntervalSince1970: seconds).timeIntervalSinceNow < 600 + else { return nil } + return Date(timeIntervalSince1970: seconds) + } + + /// Mirror RxAuthSwift's `KeychainTokenStorage.isTokenExpired()`: treat the + /// token as expiring within 10 minutes of its stored expiry. + private static func isExpiring(_ expiresAt: Date) -> Bool { + expiresAt.timeIntervalSinceNow < 600 } func signIn() async throws { @@ -134,6 +176,7 @@ final class RxAuthService { } func signOut() async { + cachedToken = nil await manager.logout() } diff --git a/RxCodeMobile/Resources/Localizable.xcstrings b/RxCodeMobile/Resources/Localizable.xcstrings index 6d8f07f8..76fc6c7c 100644 --- a/RxCodeMobile/Resources/Localizable.xcstrings +++ b/RxCodeMobile/Resources/Localizable.xcstrings @@ -1617,6 +1617,9 @@ } } } + }, + "Create Pull Request" : { + }, "Create Release" : { "localizations" : { @@ -1659,6 +1662,9 @@ } } } + }, + "Creating Pull Request…" : { + }, "Critical" : { "localizations" : { @@ -1857,6 +1863,12 @@ } } } + }, + "Delete Project" : { + + }, + "Delete Project?" : { + }, "Delete template?" : { "localizations" : { @@ -2361,6 +2373,9 @@ } } } + }, + "Failed to delete project." : { + }, "Failed to detect runnables." : { "localizations" : { @@ -2687,7 +2702,7 @@ } } }, - "Files are decrypted on your Mac and written into the project folder." : { + "Files are decrypted on this device with your passkey, then written into the project folder on your Mac." : { }, "Filter Skills" : { @@ -5431,9 +5446,6 @@ } } } - }, - "Sent to Your Mac" : { - }, "Serious" : { "localizations" : { @@ -5467,7 +5479,7 @@ } } }, - "Set Up Docs Search" : { + "Set Up Docs" : { }, "Set Up Release Workflow" : { @@ -5885,6 +5897,9 @@ } } } + }, + "The Mac returned an invalid pull-request URL." : { + }, "The next version is computed by semantic-release from the commit history." : { @@ -6030,6 +6045,9 @@ } } } + }, + "This removes “%@” and all its threads from RxCode. Your files on the Mac are not deleted." : { + }, "This removes %@ and its downloaded binary from your Mac." : { "localizations" : { diff --git a/RxCodeMobile/State/MobileAppState+Autopilot.swift b/RxCodeMobile/State/MobileAppState+Autopilot.swift index a7f6650d..10bec770 100644 --- a/RxCodeMobile/State/MobileAppState+Autopilot.swift +++ b/RxCodeMobile/State/MobileAppState+Autopilot.swift @@ -415,13 +415,20 @@ extension MobileAppState { return url } - /// Asks the Mac to download + decrypt the chosen environment and write its - /// files into the project folder (the Mac holds the KEK; plaintext stays on - /// the Mac). Returns the files written and any skipped conflicts. + /// Downloads the chosen environment into the project folder. The phone + /// decrypts on-device with its passkey-derived KEK (the same iCloud-synced + /// credential the Mac uses) — running the phone's own passkey ceremony — then + /// sends the plaintext over the E2E-encrypted relay; the Mac only writes the + /// files into the project folder, with no passkey prompt on the Mac. Returns + /// the files written and any skipped conflicts. @discardableResult - func downloadProjectSecrets(projectId: UUID, envId: String, overwrite: Bool) async throws -> AutopilotProjectSecretsDownloadResult { - try await autopilotSend(.project, .projectSecretsDownload, - body: AutopilotProjectSecretsDownloadBody(projectId: projectId, envId: envId, overwrite: overwrite), - as: AutopilotProjectSecretsDownloadResult.self) + func downloadProjectSecrets(projectId: UUID, repo: String, envId: String, overwrite: Bool) async throws -> AutopilotProjectSecretsDownloadResult { + let plaintext = try await secretEnvironmentPlaintext(repo: repo, envId: envId) + let files = plaintext.map { + AutopilotProjectSecretsWriteBody.Plaintext(filename: $0.filename, content: $0.content) + } + return try await autopilotSend(.project, .projectSecretsWrite, + body: AutopilotProjectSecretsWriteBody(projectId: projectId, files: files, overwrite: overwrite), + as: AutopilotProjectSecretsDownloadResult.self) } } diff --git a/RxCodeMobile/State/MobileAppState+Inbound.swift b/RxCodeMobile/State/MobileAppState+Inbound.swift index 5e14c387..a4f6a289 100644 --- a/RxCodeMobile/State/MobileAppState+Inbound.swift +++ b/RxCodeMobile/State/MobileAppState+Inbound.swift @@ -213,6 +213,21 @@ extension MobileAppState { } else { remoteProjectCreateError = result.errorMessage ?? String(localized: "Failed to add project.") } + case .deleteProjectResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "delete_project_result") else { return } + guard pendingDeleteProjectRequestID == result.clientRequestID else { return } + pendingDeleteProjectRequestID = nil + if result.ok { + // Already removed optimistically; ensure it's gone and reconcile. + projects.removeAll { $0.id == result.projectID } + sessions.removeAll { $0.projectId == result.projectID } + remoteProjectDeleteError = nil + Task { await self.requestSnapshot() } + } else { + remoteProjectDeleteError = result.errorMessage ?? String(localized: "Failed to delete project.") + // Restore the project the desktop refused to delete. + Task { await self.requestSnapshot() } + } case .runProfileResult(let result): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "run_profile_result") else { return } logger.info("[RunProfiles] received result id=\(result.clientRequestID.uuidString, privacy: .public) ok=\(result.ok, privacy: .public) project=\(result.projectID.uuidString, privacy: .public) profiles=\(result.profiles?.count ?? 0, privacy: .public) task=\(result.task?.taskId.uuidString ?? "", privacy: .public) error=\(result.errorMessage ?? "", privacy: .public)") diff --git a/RxCodeMobile/State/MobileAppState+Intents.swift b/RxCodeMobile/State/MobileAppState+Intents.swift index 3652a0e9..f4698b93 100644 --- a/RxCodeMobile/State/MobileAppState+Intents.swift +++ b/RxCodeMobile/State/MobileAppState+Intents.swift @@ -142,6 +142,30 @@ extension MobileAppState { } } + /// Delete a project on the desktop. Optimistically drops it from local state + /// so the UI updates immediately; the desktop performs the cascading delete + /// (sessions, search index, memory) and confirms via `deleteProjectResult`. + /// On failure the next snapshot restores the project. + func deleteProject(projectID: UUID) async { + guard isPaired else { return } + let request = DeleteProjectRequestPayload(projectID: projectID) + pendingDeleteProjectRequestID = request.clientRequestID + remoteProjectDeleteError = nil + + // Optimistic local removal — drop the project and its sessions. + projects.removeAll { $0.id == projectID } + sessions.removeAll { $0.projectId == projectID } + + do { + try await client.send(.deleteProjectRequest(request), toHex: pairedDesktopPubkey) + } catch { + pendingDeleteProjectRequestID = nil + remoteProjectDeleteError = error.localizedDescription + // Re-sync to restore the optimistically removed project. + await requestSnapshot() + } + } + // MARK: - Plan mode /// Plan cards for a session, derived live from the synced messages using the diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index 14137c6c..e685ba74 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -225,8 +225,10 @@ final class MobileAppState: ObservableObject { @Published var remoteProjectCreateInFlight = false @Published var remoteProjectCreateError: String? @Published var lastCreatedProjectID: UUID? + @Published var remoteProjectDeleteError: String? var pendingFolderTreeRequestID: UUID? var pendingCreateProjectRequestID: UUID? + var pendingDeleteProjectRequestID: UUID? var identity: DeviceIdentity var client: SyncClient diff --git a/RxCodeMobile/Views/Autopilot/MobileReleaseView.swift b/RxCodeMobile/Views/Autopilot/MobileReleaseView.swift index 26e737bb..6aa03da0 100644 --- a/RxCodeMobile/Views/Autopilot/MobileReleaseView.swift +++ b/RxCodeMobile/Views/Autopilot/MobileReleaseView.swift @@ -226,16 +226,31 @@ struct MobileReleaseDispatchView: View { self.repo = repo self.workflow = workflow self.onDispatched = onDispatched - _branch = State(initialValue: repo.repositoryFullName.isEmpty ? "main" : "main") + _branch = State(initialValue: "main") + } + + /// Branches synced from the desktop for the local project that matches this + /// release repo (if any), offered as a picker instead of free text. + private var branchOptions: [String] { + guard let project = state.projects.first(where: { + $0.gitHubRepo?.lowercased() == repo.repositoryFullName.lowercased() + }) else { return [] } + return state.availableBranchesByProject[project.id] ?? [] } var body: some View { NavigationStack { Form { Section("Branch") { - TextField("main", text: $branch) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + if branchOptions.isEmpty { + TextField("main", text: $branch) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } else { + Picker("Branch", selection: $branch) { + ForEach(branchOptions, id: \.self) { Text($0).tag($0) } + } + } } if let inputs = workflow.inputs, !inputs.isEmpty { Section("Inputs") { @@ -291,6 +306,15 @@ struct MobileReleaseDispatchView: View { for input in workflow.inputs ?? [] where inputValues[input.name] == nil { if let value = input.defaultString { inputValues[input.name] = value } } + // Open the branch picker on a valid selection. + if !branchOptions.isEmpty { + let project = state.projects.first { + $0.gitHubRepo?.lowercased() == repo.repositoryFullName.lowercased() + } + let current = project.flatMap { state.projectBranches[$0.id] } + branch = current.flatMap { branchOptions.contains($0) ? $0 : nil } + ?? (branchOptions.contains(branch) ? branch : branchOptions.first ?? branch) + } } private func dispatch() async { diff --git a/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift b/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift index ff9d8afe..881a2298 100644 --- a/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift +++ b/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift @@ -11,23 +11,30 @@ import RxCodeSync /// • Create Release / Set Up Release Workflow (tag.fill) /// /// Every item is desktop-mediated — the Mac performs the same work it does when -/// you click the menu there. The one exception that runs UI on the phone is -/// "Download Secret", which opens the on-device environment picker before asking -/// the Mac to decrypt + write the files into the project folder. +/// you click the menu there. The one exception that runs on the phone is +/// "Download Secret", which opens the on-device environment picker, decrypts the +/// environment locally with the phone's passkey, and sends the plaintext over the +/// E2E relay for the Mac to write into the project folder. /// /// Drop the buttons into any `Menu { }` or `.contextMenu { }`; the host owns the /// `@State` for the loaded status, the download sheet, and the info alert and /// passes them in. Use `projectAutopilotMenu(...)` on a host that already has a /// stable identity (toolbar button, project card) so the sheet/alert can attach. -/// A transient message surfaced after a desktop-mediated action fires (e.g. -/// "Secrets setup started on your Mac") or when one fails. +/// A transient message surfaced when an autopilot action fails. struct AutopilotMenuInfo: Identifiable { let id = UUID() let text: String var isError = false } +/// Request to open a new on-device setup chat, prefilled with `prompt`. Setting +/// this on the host presents `NewThreadSheet`; nothing runs "on the Mac". +struct AutopilotSetupChat: Identifiable { + let id = UUID() + let prompt: String +} + struct ProjectAutopilotMenuItems: View { @EnvironmentObject private var state: MobileAppState let project: Project @@ -35,6 +42,7 @@ struct ProjectAutopilotMenuItems: View { let status: AutopilotProjectStatus? @Binding var showDownloadSheet: Bool @Binding var showReleaseCreate: Bool + @Binding var setupChat: AutopilotSetupChat? @Binding var info: AutopilotMenuInfo? var body: some View { @@ -63,9 +71,10 @@ struct ProjectAutopilotMenuItems: View { } } else { Button { - fire("Secrets setup started on your Mac.") { - try await state.requestProjectSecretsSetup(projectId: project.id) - } + // Set up secrets through an on-device chat — never on the Mac. + setupChat = AutopilotSetupChat( + prompt: "Set up encrypted secrets for this repository so I can sync environment files securely." + ) } label: { Label("Set Up Secrets", systemImage: "key.fill") } @@ -74,14 +83,16 @@ struct ProjectAutopilotMenuItems: View { @ViewBuilder private func docsItem(_ status: AutopilotProjectStatus) -> some View { - // Docs search lives in the docs UI now — the context menu only offers - // setting up / creating docs for the repo. - Button { - fire("Docs setup started on your Mac.") { - try await state.requestProjectDocsSetup(projectId: project.id) + // Already-indexed repos show nothing here (docs search lives in the docs + // UI). Otherwise offer to set docs up via an on-device chat. + if !status.hasDocs { + Button { + setupChat = AutopilotSetupChat( + prompt: "Set up documentation publishing for this repository so its docs are indexed into RxCode docs search." + ) + } label: { + Label("Set Up Docs", systemImage: "books.vertical.fill") } - } label: { - Label("Set Up Docs Search", systemImage: "books.vertical.fill") } } @@ -97,26 +108,15 @@ struct ProjectAutopilotMenuItems: View { } } else { Button { - fire("Release setup started on your Mac.") { - try await state.requestProjectReleaseSetup(projectId: project.id) - } + // Set up the release workflow through an on-device chat. + setupChat = AutopilotSetupChat( + prompt: "Set up a release workflow for this repository (semantic-release + a GitHub Actions release workflow)." + ) } label: { Label("Set Up Release Workflow", systemImage: "tag.fill") } } } - - /// Runs a desktop-mediated action and reports success/failure via the alert. - private func fire(_ success: String, _ action: @escaping () async throws -> Void) { - Task { - do { - try await action() - info = AutopilotMenuInfo(text: success) - } catch { - info = AutopilotMenuInfo(text: error.localizedDescription, isError: true) - } - } - } } // MARK: - Host modifier @@ -130,6 +130,7 @@ extension View { status: Binding, showDownloadSheet: Binding, showReleaseCreate: Binding, + setupChat: Binding, info: Binding, state: MobileAppState ) -> some View { @@ -138,6 +139,7 @@ extension View { status: status, showDownloadSheet: showDownloadSheet, showReleaseCreate: showReleaseCreate, + setupChat: setupChat, info: info, state: state )) @@ -149,6 +151,7 @@ private struct ProjectAutopilotMenuHostModifier: ViewModifier { @Binding var status: AutopilotProjectStatus? @Binding var showDownloadSheet: Bool @Binding var showReleaseCreate: Bool + @Binding var setupChat: AutopilotSetupChat? @Binding var info: AutopilotMenuInfo? let state: MobileAppState @@ -173,9 +176,20 @@ private struct ProjectAutopilotMenuHostModifier: ViewModifier { .mobileSheetPresentation() } } + .sheet(item: $setupChat) { request in + if let project { + // Set up via an on-device chat. NewThreadSheet creates the + // thread; we route to it through the deep-link navigation so + // the user lands in the chat (everything stays on iOS). + NewThreadSheet(projectID: project.id, initialText: request.prompt) { newSessionID in + state.pendingDeepLink = MobileDeepLink(sessionID: newSessionID, projectID: project.id) + } + .environmentObject(state) + } + } .alert(item: $info) { message in Alert( - title: Text(message.isError ? "Couldn’t Complete" : "Sent to Your Mac"), + title: Text(message.isError ? "Couldn’t Complete" : "Done"), message: Text(message.text), dismissButton: .default(Text("OK")) ) @@ -186,8 +200,10 @@ private struct ProjectAutopilotMenuHostModifier: ViewModifier { // MARK: - Secrets download form /// Mobile mirror of the desktop secrets auto-download (`AutopilotSecretsHook`): -/// pick an environment, then ask the Mac to decrypt it and write the files into -/// the project folder. The phone never sees plaintext — the Mac holds the KEK. +/// pick an environment, decrypt it on-device with the phone's passkey-derived +/// KEK, then send the plaintext over the E2E relay for the Mac to write into the +/// project folder. The passkey ceremony runs on the phone, so nothing waits on a +/// Mac-side prompt. struct ProjectSecretsDownloadSheet: View { @EnvironmentObject private var state: MobileAppState @Environment(\.dismiss) private var dismiss @@ -269,7 +285,7 @@ struct ProjectSecretsDownloadSheet: View { } header: { Text("Environment") } footer: { - Text("Files are decrypted on your Mac and written into the project folder.") + Text("Files are decrypted on this device with your passkey, then written into the project folder on your Mac.") } Section { @@ -320,7 +336,7 @@ struct ProjectSecretsDownloadSheet: View { } private func download() { - guard let envId = selectedEnvId else { return } + guard let envId = selectedEnvId, let repo = project.gitHubRepo else { return } isDownloading = true actionError = nil conflicts = [] @@ -329,6 +345,7 @@ struct ProjectSecretsDownloadSheet: View { do { let result = try await state.downloadProjectSecrets( projectId: project.id, + repo: repo, envId: envId, overwrite: overwrite ) @@ -369,6 +386,9 @@ struct ProjectReleaseCreateSheet: View { @State private var dispatchedURLString: String? private var repoFullName: String { project.gitHubRepo ?? "" } + /// Branch names synced from the desktop for this project, offered as a + /// picker so the user doesn't have to type (mirrors the macOS sheet). + private var branchOptions: [String] { state.availableBranchesByProject[project.id] ?? [] } private var dispatchableWorkflows: [MobileReleaseWorkflow] { workflows.filter { $0.hasWorkflowDispatch } } @@ -438,9 +458,15 @@ struct ProjectReleaseCreateSheet: View { } else if let wf = dispatchableWorkflows.first { LabeledContent("Workflow", value: wf.displayName) } - TextField("Branch", text: $branch) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + if branchOptions.isEmpty { + TextField("Branch", text: $branch) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } else { + Picker("Branch", selection: $branch) { + ForEach(branchOptions, id: \.self) { Text($0).tag($0) } + } + } Text("The next version is computed by semantic-release from the commit history.") .font(.caption).foregroundStyle(.secondary) } @@ -484,6 +510,13 @@ struct ProjectReleaseCreateSheet: View { } isLoading = true loadError = nil + // Seed the branch from the project's current branch so the picker opens + // on a valid selection; fall back to the first available branch. + if !branchOptions.isEmpty { + let current = state.projectBranches[project.id] + branch = current.flatMap { branchOptions.contains($0) ? $0 : nil } + ?? (branchOptions.contains(branch) ? branch : branchOptions.first ?? branch) + } defer { isLoading = false } do { workflows = try await state.listReleaseWorkflows(repoId: repoFullName) diff --git a/RxCodeMobile/Views/MobileBriefingDetailView.swift b/RxCodeMobile/Views/MobileBriefingDetailView.swift index d8127e28..b2bc3cc2 100644 --- a/RxCodeMobile/Views/MobileBriefingDetailView.swift +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -18,6 +18,7 @@ struct MobileBriefingDetailView: View { @State private var autopilotStatus: AutopilotProjectStatus? @State private var showingSecretsDownload = false @State private var showingReleaseCreate = false + @State private var autopilotSetupChat: AutopilotSetupChat? @State private var autopilotInfo: AutopilotMenuInfo? var body: some View { @@ -57,6 +58,7 @@ struct MobileBriefingDetailView: View { status: autopilotStatus, showDownloadSheet: $showingSecretsDownload, showReleaseCreate: $showingReleaseCreate, + setupChat: $autopilotSetupChat, info: $autopilotInfo ) // Offer "Create PR" for a real branch with no open PR @@ -94,6 +96,7 @@ struct MobileBriefingDetailView: View { status: $autopilotStatus, showDownloadSheet: $showingSecretsDownload, showReleaseCreate: $showingReleaseCreate, + setupChat: $autopilotSetupChat, info: $autopilotInfo, state: state ) @@ -116,6 +119,7 @@ struct MobileBriefingDetailView: View { "branch": groupKey.branch, ]) } + .mobileAutopilotLoadingOverlay(isCreatingPR, title: "Creating Pull Request…") } private var group: GroupedBriefing? { diff --git a/RxCodeMobile/Views/NewThreadSheet.swift b/RxCodeMobile/Views/NewThreadSheet.swift index 4ef94c11..350ea0e6 100644 --- a/RxCodeMobile/Views/NewThreadSheet.swift +++ b/RxCodeMobile/Views/NewThreadSheet.swift @@ -14,6 +14,9 @@ struct NewThreadSheet: View { let projectID: UUID var preferredBranch: String? = nil + /// Optional prompt to prefill the composer with (e.g. an autopilot setup + /// request). The user can review/edit before sending. + var initialText: String? = nil let onSessionCreated: (String) -> Void @State private var text: String = "" @@ -76,6 +79,7 @@ struct NewThreadSheet: View { .mobileSheetPresentation() .onAppear { seedConfigIfNeeded() + if let initialText, text.isEmpty { text = initialText } DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { isFocused = true } diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift index c4c7b39c..55d54b97 100644 --- a/RxCodeMobile/Views/ProjectsSidebar.swift +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -57,8 +57,8 @@ struct ProjectsSidebar: View { } .onChange(of: state.lastCreatedProjectID) { _, newValue in guard let newValue else { return } - selected = newValue - showingBriefing = false + // Don't auto-select/navigate to a freshly added project — just + // surface it in the list. The user opens it when they choose to. AnalyticsService.shared.log(.newProjectStarted, parameters: [ "project_id": newValue.uuidString, ]) @@ -385,39 +385,10 @@ private struct GlassProjectCard: View { let namespace: Namespace.ID var onSelect: (() -> Void)? - @EnvironmentObject private var state: MobileAppState - // Autopilot context menu (1:1 with the desktop project/sidebar menu). - @State private var autopilotStatus: AutopilotProjectStatus? - @State private var showingSecretsDownload = false - @State private var showingReleaseCreate = false - @State private var autopilotInfo: AutopilotMenuInfo? - var body: some View { - // Attach the autopilot context menu directly to the card (mirrors the - // working `GlassThreadCard` pattern in `SessionsList`). Routing it - // through a conditional `ViewModifier` previously meant the long-press - // menu never registered on the project list. The menu items themselves - // stay gated on a linked GitHub repo, matching the desktop. + // The autopilot actions now live in the project detail (thread list) + // toolbar — the project card is a plain navigation row. cardButton - .contextMenu { - if project.gitHubRepo != nil { - ProjectAutopilotMenuItems( - project: project, - status: autopilotStatus, - showDownloadSheet: $showingSecretsDownload, - showReleaseCreate: $showingReleaseCreate, - info: $autopilotInfo - ) - } - } - .projectAutopilotMenuHost( - project: project, - status: $autopilotStatus, - showDownloadSheet: $showingSecretsDownload, - showReleaseCreate: $showingReleaseCreate, - info: $autopilotInfo, - state: state - ) } @ViewBuilder diff --git a/RxCodeMobile/Views/RootView.swift b/RxCodeMobile/Views/RootView.swift index 651457c9..e38c4efe 100644 --- a/RxCodeMobile/Views/RootView.swift +++ b/RxCodeMobile/Views/RootView.swift @@ -1,4 +1,5 @@ import SwiftUI +import RxCodeCore import RxCodeSync import os.log @@ -298,7 +299,8 @@ struct RootView: View { NavigationSplitView { projectSidebar } content: { - if let projectID = selectedProject { + if let projectID = selectedProject, + state.projects.contains(where: { $0.id == projectID }) { SessionsList(projectID: projectID, selected: $selectedSession) } else { Text("Select a project") diff --git a/RxCodeMobile/Views/SessionsList.swift b/RxCodeMobile/Views/SessionsList.swift index 5243cb14..64e5845c 100644 --- a/RxCodeMobile/Views/SessionsList.swift +++ b/RxCodeMobile/Views/SessionsList.swift @@ -22,13 +22,27 @@ enum MobileDraftSessionID { struct SessionsList: View { @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss let projectID: UUID @Binding var selected: String? var usesSelection = true @State private var searchText = "" @State private var showingNewThread = false + @State private var showingDeleteProjectConfirm = false @Namespace private var glassNamespace + // Autopilot actions (1:1 with the desktop project menu), moved here from the + // project list card so they live in the project detail toolbar. + @State private var autopilotStatus: AutopilotProjectStatus? + @State private var showingSecretsDownload = false + @State private var showingReleaseCreate = false + @State private var autopilotSetupChat: AutopilotSetupChat? + @State private var autopilotInfo: AutopilotMenuInfo? + + private var project: Project? { + state.projects.first { $0.id == projectID } + } + /// Number of rows currently materialized. The list grows in `pageSize` /// increments as the user scrolls so we never render every thread at once. @State private var displayLimit = SessionsList.pageSize @@ -70,12 +84,64 @@ struct SessionsList: View { emptyStateView } } + .projectAutopilotMenuHost( + project: project, + status: $autopilotStatus, + showDownloadSheet: $showingSecretsDownload, + showReleaseCreate: $showingReleaseCreate, + setupChat: $autopilotSetupChat, + info: $autopilotInfo, + state: state + ) + .confirmationDialog( + "Delete Project?", + isPresented: $showingDeleteProjectConfirm, + titleVisibility: .visible + ) { + Button("Delete Project", role: .destructive) { + Task { + await state.deleteProject(projectID: projectID) + dismiss() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes “\(project?.name ?? "this project")” and all its threads from RxCode. Your files on the Mac are not deleted.") + } } // MARK: - Toolbar @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { + if let project { + ToolbarItem(placement: .topBarTrailing) { + Menu { + // Autopilot actions only apply to repo-backed projects. + if project.gitHubRepo != nil { + ProjectAutopilotMenuItems( + project: project, + status: autopilotStatus, + showDownloadSheet: $showingSecretsDownload, + showReleaseCreate: $showingReleaseCreate, + setupChat: $autopilotSetupChat, + info: $autopilotInfo + ) + Divider() + } + Button(role: .destructive) { + showingDeleteProjectConfirm = true + } label: { + Label("Delete Project", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis.circle") + .font(.system(size: 16, weight: .medium)) + } + .accessibilityLabel("Project actions") + .accessibilityIdentifier("project-actions-\(projectID.uuidString)") + } + } ToolbarItem(placement: .topBarTrailing) { Button { showingNewThread = true