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/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 new file mode 100644 index 00000000..ae7e6e2b --- /dev/null +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift @@ -0,0 +1,559 @@ +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 + case project +} + +/// 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 + + // 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). 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 + case projectDocsSearch + case projectReleaseSetup + case projectReleaseCreate + case projectSecretsDownload + case projectSecretsWrite + case projectCreatePullRequest +} + +/// 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 } +} + +// 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 } +} + +/// 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.). +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 + } +} + +/// 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] + + public init(written: [String], conflicts: [String]) { + self.written = written + self.conflicts = conflicts + } +} diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index a1172780..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) @@ -64,6 +66,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) @@ -108,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" @@ -129,6 +135,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 @@ -727,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" @@ -748,6 +758,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 } @@ -796,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)) @@ -817,6 +831,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)) } @@ -861,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) @@ -882,6 +900,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/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) + ]) + } } diff --git a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift index d67c3158..5ff4760c 100644 --- a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift +++ b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift @@ -415,4 +415,118 @@ struct PayloadTests { #expect(createResult.project?.id == projectID) #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")! + 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..9abc8ef4 --- /dev/null +++ b/RxCode/App/AppState+MobileAutopilot.swift @@ -0,0 +1,362 @@ +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)) + + // 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 .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 { + 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)) + + 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)) + } + } + + /// 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/RxCode/App/AppState+MobileSync.swift b/RxCode/App/AppState+MobileSync.swift index 26d4be3d..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, @@ -384,6 +398,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() } @@ -692,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/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/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 1306c18d..670c4dcc 100644 --- a/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift +++ b/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift @@ -31,22 +31,13 @@ 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 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 c2659051..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)") @@ -423,6 +430,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..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") @@ -902,4 +903,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/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/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" } diff --git a/RxCodeMobile/Resources/Localizable.xcstrings b/RxCodeMobile/Resources/Localizable.xcstrings index 3fdd9fb8..76fc6c7c 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" : { @@ -1217,6 +1431,19 @@ } } } + }, + "Control what autopilot does automatically and the templates applied to new repositories." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制 Autopilot 自动执行的操作,以及应用于新仓库的模板。" + } + } + } + }, + "Couldn’t Complete" : { + }, "Couldn't connect to the relay from the QR code. Check the relay address and try again." : { "localizations" : { @@ -1250,8 +1477,18 @@ } } }, - "Couldn't Load File" : { + "Couldn’t Load Environments" : { + }, + "Couldn't Load File" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法加载文件" + } + } + } }, "Couldn't load the selected image." : { "localizations" : { @@ -1381,6 +1618,19 @@ } } }, + "Create Pull Request" : { + + }, + "Create Release" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建发布" + } + } + } + }, "Created from %@" : { "localizations" : { "en" : { @@ -1412,6 +1662,9 @@ } } } + }, + "Creating Pull Request…" : { + }, "Critical" : { "localizations" : { @@ -1445,6 +1698,16 @@ } } }, + "Current project" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前项目" + } + } + } + }, "Custom Scheme" : { "localizations" : { "en" : { @@ -1462,7 +1725,14 @@ } }, "Custom Script" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义脚本" + } + } + } }, "Custom Sources" : { "localizations" : { @@ -1528,34 +1798,100 @@ } } }, - "Delete" : { + "Default" : { "localizations" : { - "en" : { + "zh-Hans" : { "stringUnit" : { - "state" : "new", - "value" : "Delete" + "state" : "translated", + "value" : "默认" } - }, + } + } + }, + "Default for new repositories" : { + "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "删除" + "value" : "新仓库的默认设置" } } } }, - "Delete this thread?" : { + "Delete" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Delete this thread?" + "value" : "Delete" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "删除此对话?" + "value" : "删除" + } + } + } + }, + "Delete document?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除文档?" + } + } + } + }, + "Delete environment?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除环境?" + } + } + } + }, + "Delete file?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除文件?" + } + } + } + }, + "Delete Project" : { + + }, + "Delete Project?" : { + + }, + "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" : "删除此对话?" } } } @@ -1607,6 +1943,9 @@ } } } + }, + "design/overview" : { + }, "Desktop Behavior" : { "localizations" : { @@ -1673,13 +2012,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 +2073,16 @@ } } }, + "Disabled" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已禁用" + } + } + } + }, "Disconnected" : { "localizations" : { "en" : { @@ -1729,6 +2099,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" : { @@ -1744,6 +2154,12 @@ } } } + }, + "Download" : { + + }, + "Download Secret" : { + }, "Duplicate" : { "localizations" : { @@ -1793,6 +2209,16 @@ } } }, + "Edit Template" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑模板" + } + } + } + }, "Empty Folder" : { "localizations" : { "en" : { @@ -1825,6 +2251,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 +2287,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 +2329,19 @@ } } }, + "Environment" : { + + }, + "Error" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "错误" + } + } + } + }, "Extra High" : { "localizations" : { "en" : { @@ -1904,6 +2373,9 @@ } } } + }, + "Failed to delete project." : { + }, "Failed to detect runnables." : { "localizations" : { @@ -2018,7 +2490,14 @@ } }, "Failed to request file: %@" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请求文件失败:%@" + } + } + } }, "Failed to request MCP servers: %@" : { "localizations" : { @@ -2131,6 +2610,9 @@ } } } + }, + "Failed to trigger the release workflow." : { + }, "Failing" : { "localizations" : { @@ -2165,7 +2647,14 @@ } }, "Favorites" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收藏" + } + } + } }, "File paths" : { "localizations" : { @@ -2184,6 +2673,36 @@ } }, "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" : "文件" + } + } + } + }, + "Files are decrypted on this device with your passkey, then written into the project folder on your Mac." : { }, "Filter Skills" : { @@ -2249,6 +2768,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 +2841,14 @@ } }, "Go" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "前往" + } + } + } }, "Go to **Settings → Mobile** and tap *Pair new device*." : { "localizations" : { @@ -2317,6 +2866,16 @@ } } }, + "Hide values" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏值" + } + } + } + }, "High" : { "localizations" : { "en" : { @@ -2335,6 +2894,9 @@ }, "HTTP" : { + }, + "https://…" : { + }, "https://github.com/owner/repo" : { @@ -2356,19 +2918,43 @@ } }, "Initialize Git" : { - - }, - "Initializing…" : { - - }, - "Input" : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Input" - } - }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "初始化 Git" + } + } + } + }, + "Initializing…" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在初始化…" + } + } + } + }, + "Input" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Input" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输入" + } + } + } + }, + "Inputs" : { + "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2393,7 +2979,28 @@ } } }, + "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" : { @@ -2409,6 +3016,16 @@ } } }, + "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" : { @@ -2425,6 +3042,16 @@ } } }, + "Installed as the repo's GitHub Actions secret so the release workflow can publish." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已作为仓库的 GitHub Actions 密钥安装,以便发布工作流可以发布。" + } + } + } + }, "Installing…" : { "localizations" : { "en" : { @@ -2473,6 +3100,16 @@ } } }, + "Latest: %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最新:%@" + } + } + } + }, "Live" : { "localizations" : { "en" : { @@ -2488,6 +3125,19 @@ } } } + }, + "Load more" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "加载更多" + } + } + } + }, + "Loading Autopilot…" : { + }, "Loading changes…" : { "localizations" : { @@ -2504,6 +3154,9 @@ } } } + }, + "Loading environments…" : { + }, "Loading folders" : { "localizations" : { @@ -2522,7 +3175,14 @@ } }, "Loading messages" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在加载消息" + } + } + } }, "Loading skills…" : { "localizations" : { @@ -2541,6 +3201,16 @@ } }, "Loading thread" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在加载会话" + } + } + } + }, + "Loading workflows…" : { }, "Loading…" : { @@ -2623,6 +3293,16 @@ } } }, + "Mac offline" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mac 离线" + } + } + } + }, "main" : { }, @@ -2705,6 +3385,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 +3495,16 @@ } } }, + "Merge Settings" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "合并设置" + } + } + } + }, "Message…" : { "localizations" : { "en" : { @@ -2819,7 +3522,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 +3573,16 @@ } } }, + "Name (e.g. production)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "名称(例如 production)" + } + } + } + }, "New" : { "localizations" : { "en" : { @@ -2869,6 +3599,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 +3636,14 @@ } }, "Next change" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一处更改" + } + } + } }, "No agents found." : { "localizations" : { @@ -3016,6 +3773,49 @@ } } }, + "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" : { + "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 +3864,16 @@ } } }, + "No open auto-update pull requests." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有打开的自动更新拉取请求。" + } + } + } + }, "No Projects" : { "localizations" : { "en" : { @@ -3097,7 +3907,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 +3962,28 @@ } } }, - "No Selection" : { + "No scans yet." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未进行扫描。" + } + } + } + }, + "No Secrets" : { + }, + "No Selection" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未选择" + } + } + } }, "No skills found." : { "localizations" : { @@ -3183,7 +4050,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" : { @@ -3218,7 +4102,34 @@ } }, "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 workflows scanned yet." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未扫描到工作流。" + } + } + } }, "Normal" : { "localizations" : { @@ -3231,13 +4142,30 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正常" + "value" : "正常" + } + } + } + }, + "Not connected to your Mac." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未连接到你的 Mac。" + } + } + } + }, + "Not signed in" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未登录" } } } - }, - "Not connected to your Mac." : { - }, "Nothing to Show" : { "localizations" : { @@ -3319,6 +4247,16 @@ } } }, + "Open" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开" + } + } + } + }, "Open **RxCode** on your Mac." : { "localizations" : { "en" : { @@ -3368,10 +4306,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 +4342,40 @@ } }, "Optional xcodebuild destination" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "可选的 xcodebuild 目标" + } + } + } + }, + "Overwrite existing files" : { }, "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 +4473,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 +4552,24 @@ } }, "Previous change" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上一处更改" + } + } + } }, "Privacy Report" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐私报告" + } + } + } }, "Profiles" : { "localizations" : { @@ -3584,10 +4604,24 @@ } }, "Project actions" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "项目操作" + } + } + } }, "Project root" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "项目根目录" + } + } + } }, "Projects" : { "localizations" : { @@ -3621,6 +4655,16 @@ } } }, + "Pull Requests" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拉取请求" + } + } + } + }, "Queue a message..." : { "localizations" : { "en" : { @@ -3638,7 +4682,14 @@ } }, "Queued Messages" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "排队的消息" + } + } + } }, "Reconnecting in %llds" : { "localizations" : { @@ -3672,8 +4723,41 @@ } } }, - "Reload" : { + "Release" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发布" + } + } + } + }, + "Release workflow triggered" : { + + }, + "RELEASE_TOKEN" : { + }, + "Releases" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发布" + } + } + } + }, + "Reload" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新加载" + } + } + } }, "Remove" : { "localizations" : { @@ -3708,10 +4792,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 +4827,16 @@ } } }, + "Remove repository?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除仓库?" + } + } + } + }, "Rename" : { "localizations" : { "en" : { @@ -3762,6 +4870,36 @@ } }, "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" : "仓库" + } + } + } + }, + "Repository" : { }, "Request timed out. Check your Mac and try again." : { @@ -3951,7 +5089,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 +5117,26 @@ } } }, + "Scan" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "扫描" + } + } + } + }, + "Scan History" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "扫描历史" + } + } + } + }, "Scan QR" : { "localizations" : { "en" : { @@ -4068,6 +5233,16 @@ } } }, + "Scanning…" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在扫描…" + } + } + } + }, "Scheme" : { "localizations" : { "en" : { @@ -4085,7 +5260,14 @@ } }, "Script" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "脚本" + } + } + } }, "Scroll to bottom" : { "localizations" : { @@ -4104,7 +5286,14 @@ } }, "Search or enter website name" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索或输入网站名称" + } + } + } }, "Search projects and threads" : { "localizations" : { @@ -4154,6 +5343,16 @@ } } }, + "Secrets" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "密钥" + } + } + } + }, "Securely link this device to RxCode in seconds." : { "localizations" : { "en" : { @@ -4171,13 +5370,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" : { @@ -4258,6 +5478,15 @@ } } } + }, + "Set Up Docs" : { + + }, + "Set Up Release Workflow" : { + + }, + "Set Up Secrets" : { + }, "Settings" : { "localizations" : { @@ -4291,6 +5520,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 +5594,16 @@ } } }, + "Source link (optional)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "来源链接(可选)" + } + } + } + }, "SSE" : { }, @@ -4441,6 +5690,26 @@ } } }, + "Stop watching?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "停止监视?" + } + } + } + }, + "Success" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "成功" + } + } + } + }, "Summarization" : { "localizations" : { "en" : { @@ -4506,10 +5775,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 +5842,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 +5878,42 @@ } } }, + "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 Mac returned an invalid pull-request URL." : { + + }, + "The next version is computed by semantic-release from the commit history." : { + + }, + "The paired Mac changed before the request finished." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请求完成前已配对的 Mac 发生了变化。" + } + } + } + }, "Thermal" : { "localizations" : { "en" : { @@ -4591,8 +5930,18 @@ } } }, - "Thinking" : { + "These files already exist and were skipped: %@. Turn on overwrite to replace them." : { + }, + "Thinking" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "思考中" + } + } + } }, "This location has no visible folders." : { "localizations" : { @@ -4626,6 +5975,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" : { @@ -4657,6 +6026,9 @@ } } } + }, + "This project is not linked to a GitHub repo." : { + }, "This QR has expired. Generate a new one on your Mac." : { "localizations" : { @@ -4673,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" : { @@ -4722,6 +6097,29 @@ } } }, + "This removes the repository and its indexed documents from docs search." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这会从文档搜索中移除该仓库及其已索引的文档。" + } + } + } + }, + "This repository has no secret environments to download." : { + + }, + "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 +6153,24 @@ } }, "Thread name" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "会话名称" + } + } + } }, "Thread Summary" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "会话摘要" + } + } + } }, "Threads" : { "localizations" : { @@ -4777,10 +6189,24 @@ } }, "Todos" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "待办事项" + } + } + } }, "Todos & Summary" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "待办事项与摘要" + } + } + } }, "Tool" : { "localizations" : { @@ -4814,6 +6240,16 @@ } } }, + "Trigger scan now" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "立即触发扫描" + } + } + } + }, "Try Again" : { "localizations" : { "en" : { @@ -5006,6 +6442,16 @@ } } }, + "Upload" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上传" + } + } + } + }, "URL" : { }, @@ -5074,7 +6520,14 @@ } }, "Use Workspace" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用 Workspace" + } + } + } }, "Value" : { "localizations" : { @@ -5107,6 +6560,29 @@ } } } + }, + "View pull request" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "查看拉取请求" + } + } + } + }, + "View values" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "查看值" + } + } + } + }, + "View workflow run on GitHub" : { + }, "What should we build in %@?" : { "localizations" : { @@ -5124,6 +6600,22 @@ } } }, + "Workflow" : { + + }, + "Workflow inputs" : { + + }, + "Workflows" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "工作流" + } + } + } + }, "Working Directory" : { "localizations" : { "en" : { 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..10bec770 --- /dev/null +++ b/RxCodeMobile/State/MobileAppState+Autopilot.swift @@ -0,0 +1,434 @@ +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) + } + + // 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 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 + } + + /// 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, 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 b33eb349..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)") @@ -244,6 +259,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+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+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..e685ba74 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 = [] @@ -212,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/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..6aa03da0 --- /dev/null +++ b/RxCodeMobile/Views/Autopilot/MobileReleaseView.swift @@ -0,0 +1,343 @@ +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: "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") { + 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") { + 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 } + } + // 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 { + 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/Autopilot/ProjectAutopilotMenu.swift b/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift new file mode 100644 index 00000000..881a2298 --- /dev/null +++ b/RxCodeMobile/Views/Autopilot/ProjectAutopilotMenu.swift @@ -0,0 +1,568 @@ +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 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 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 + /// 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 setupChat: AutopilotSetupChat? + @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 { + // 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") + } + } + } + + @ViewBuilder + private func docsItem(_ status: AutopilotProjectStatus) -> some View { + // 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") + } + } + } + + @ViewBuilder + private func releaseItem(_ status: AutopilotProjectStatus) -> some View { + if status.hasRelease { + Button { + // 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") + } + } else { + Button { + // 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") + } + } + } +} + +// 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, + showReleaseCreate: Binding, + setupChat: Binding, + info: Binding, + state: MobileAppState + ) -> some View { + modifier(ProjectAutopilotMenuHostModifier( + project: project, + status: status, + showDownloadSheet: showDownloadSheet, + showReleaseCreate: showReleaseCreate, + setupChat: setupChat, + info: info, + state: state + )) + } +} + +private struct ProjectAutopilotMenuHostModifier: ViewModifier { + let project: Project? + @Binding var status: AutopilotProjectStatus? + @Binding var showDownloadSheet: Bool + @Binding var showReleaseCreate: Bool + @Binding var setupChat: AutopilotSetupChat? + @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() + } + } + .sheet(isPresented: $showReleaseCreate) { + if let project { + ProjectReleaseCreateSheet(project: project) + .environmentObject(state) + .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" : "Done"), + message: Text(message.text), + dismissButton: .default(Text("OK")) + ) + } + } +} + +// MARK: - Secrets download form + +/// Mobile mirror of the desktop secrets auto-download (`AutopilotSecretsHook`): +/// 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 + 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 this device with your passkey, then written into the project folder on your Mac.") + } + + 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, let repo = project.gitHubRepo else { return } + isDownloading = true + actionError = nil + conflicts = [] + Task { + defer { isDownloading = false } + do { + let result = try await state.downloadProjectSecrets( + projectId: project.id, + repo: repo, + 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 + } + } + } +} + +// 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 ?? "" } + /// 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 } + } + 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) + } + 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) + } + } + + @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 + // 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) + 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 c8fd03d1..b2bc3cc2 100644 --- a/RxCodeMobile/Views/MobileBriefingDetailView.swift +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -9,8 +9,17 @@ 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? + @State private var showingSecretsDownload = false + @State private var showingReleaseCreate = false + @State private var autopilotSetupChat: AutopilotSetupChat? + @State private var autopilotInfo: AutopilotMenuInfo? var body: some View { ScrollView(.vertical) { @@ -40,14 +49,38 @@ 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, + showReleaseCreate: $showingReleaseCreate, + setupChat: $autopilotSetupChat, + 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 { + Link(destination: gitHubURL) { + Label( + gitHubURLIsPullRequest ? "Open Pull Request" : "Open on GitHub", + systemImage: "arrow.up.forward.square" + ) + } } } label: { Image(systemName: "ellipsis") @@ -58,6 +91,15 @@ struct MobileBriefingDetailView: View { } } } + .projectAutopilotMenuHost( + project: project, + status: $autopilotStatus, + showDownloadSheet: $showingSecretsDownload, + showReleaseCreate: $showingReleaseCreate, + setupChat: $autopilotSetupChat, + info: $autopilotInfo, + state: state + ) .sheet(isPresented: $showingNewThread) { NewThreadSheet( projectID: groupKey.projectId, @@ -77,6 +119,7 @@ struct MobileBriefingDetailView: View { "branch": groupKey.branch, ]) } + .mobileAutopilotLoadingOverlay(isCreatingPR, title: "Creating Pull Request…") } private var group: GroupedBriefing? { @@ -91,7 +134,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 @@ -131,6 +184,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 { 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.") } } 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 8e4a27a9..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, ]) @@ -386,6 +386,13 @@ private struct GlassProjectCard: View { var onSelect: (() -> Void)? var body: some View { + // The autopilot actions now live in the project detail (thread list) + // toolbar — the project card is a plain navigation row. + cardButton + } + + @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. 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 { 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