From 43146374523d5c18412353d4d380b2d73a51129f Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Fri, 15 May 2026 18:19:34 +0800 Subject: [PATCH] feat: update website about acp clients --- .../Sources/RxCodeCore/Models/Project.swift | 8 +- README.md | 12 +- RxCode/App/AppState.swift | 99 +++++-- RxCode/Views/MainView.swift | 8 +- .../Views/Settings/ACPClientSettingsTab.swift | 48 ++- website/app/globals.css | 170 +++++++++++ website/app/lib/og-image.tsx | 2 +- website/app/lib/release.ts | 246 ++++++++++++++++ website/app/page.tsx | 134 +++++---- website/app/release/page.tsx | 275 ++++++++++++++++++ website/public/screenshot/acp1.png | Bin 0 -> 223064 bytes website/public/screenshot/acp2.png | Bin 0 -> 919618 bytes 12 files changed, 911 insertions(+), 91 deletions(-) create mode 100644 website/app/release/page.tsx create mode 100644 website/public/screenshot/acp1.png create mode 100644 website/public/screenshot/acp2.png diff --git a/Packages/Sources/RxCodeCore/Models/Project.swift b/Packages/Sources/RxCodeCore/Models/Project.swift index 98355d02..33ecc76a 100644 --- a/Packages/Sources/RxCodeCore/Models/Project.swift +++ b/Packages/Sources/RxCodeCore/Models/Project.swift @@ -6,18 +6,24 @@ public struct Project: Identifiable, Codable, Sendable, Hashable { public var path: String public var gitHubRepo: String? public var lastSessionId: String? + public var lastAgentProvider: AgentProvider? + public var lastModel: String? public init( id: UUID = UUID(), name: String, path: String, gitHubRepo: String? = nil, - lastSessionId: String? = nil + lastSessionId: String? = nil, + lastAgentProvider: AgentProvider? = nil, + lastModel: String? = nil ) { self.id = id self.name = name self.path = path self.gitHubRepo = gitHubRepo self.lastSessionId = lastSessionId + self.lastAgentProvider = lastAgentProvider + self.lastModel = lastModel } } diff --git a/README.md b/README.md index c0f7d1f4..dad81f91 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **The terminal was for the few. RxCode is for everyone.** -RxCode is a lightweight native macOS desktop client for Claude Code and Codex. It brings the CLI agent workflow into a project-centric GUI with streaming chat, repository switching, file browsing, Git status, permissions, terminal access, and per-project notes. +RxCode is a lightweight native macOS desktop client for Claude Code, Codex, and Agent Client Protocol (ACP) clients such as Gemini CLI and OpenCode. It brings the CLI agent workflow into a project-centric GUI with streaming chat, repository switching, file browsing, Git status, permissions, terminal access, and per-project notes. ![Platform](https://img.shields.io/badge/platform-macOS%2015.0%2B-blue) ![Swift](https://img.shields.io/badge/Swift-6.x-orange) @@ -21,7 +21,7 @@ RxCode is a lightweight native macOS desktop client for Claude Code and Codex. I The terminal is a wall. For most people who aren't developers, it's a closed door — install a CLI, generate SSH keys, approve every tool call without a real preview of what it's about to do. None of that is hard for engineers; all of it is hard for everyone else. The terminal was for the few, and it still is. -RxCode was built so my non-developer coworkers could use Claude Code and Codex without learning a shell first. It doesn't reinvent the agent. It talks to the real runtimes underneath: `claude` for Claude Code, and the Codex app-server through the `codex` CLI for Codex sessions. Your existing CLI setup stays the source of truth. What sits on top is a native Mac app: +RxCode was built so my non-developer coworkers could use Claude Code, Codex, and ACP-compatible clients without learning a shell first. It doesn't reinvent the agent. It talks to the real runtimes underneath: `claude` for Claude Code, the Codex app-server through the `codex` CLI for Codex sessions, and ACP clients installed from the public registry. Your existing CLI setup stays the source of truth when you bring your own runtime. What sits on top is a native Mac app: - Approval modals that surface the actual diff before any tool runs, with risk-aware Allow / Allow Session / Deny options. - Per-project windows you can run in parallel — switch tabs, double-click to spin off a window, keep streams alive in the background. @@ -38,7 +38,8 @@ Same agents, no terminal required. | RxCode feature | Why it matters | |---------------|----------------| | **Native macOS app** | Built with SwiftUI, not Electron. The current v1.2.0 release is about 5.6 MB to download and about 13 MB unpacked, without bundling a browser runtime. | -| **Claude Code and Codex support** | Choose the agent runtime per session. RxCode detects installed `claude` and `codex` binaries, surfaces available models, and keeps the same project-centric workflow for both. | +| **Claude Code, Codex, and ACP support** | Choose the agent runtime per session. RxCode detects installed `claude` and `codex` binaries, surfaces available models, and also runs ACP-compatible clients such as Gemini CLI and OpenCode. | +| **ACP registry installer** | Browse the public ACP registry from Settings, install compatible clients directly, and add registry agents to the chat model picker without manual setup. | | **Project-centric workspace** | Register multiple local repositories, switch between them from project tabs, or open a project in its own window for parallel sessions. In-progress streams keep running in the background while you switch. | | **Custom slash commands** | Add, edit, disable, import, and export custom slash commands. Built-in commands can be edited locally, while JSON import/export stays custom-only. | | **Shortcut buttons** | Create quick buttons for prompts or terminal commands you run repeatedly. Terminal-command shortcuts can launch directly into RxCode's interactive terminal popup. | @@ -52,14 +53,14 @@ Same agents, no terminal required. | Feature | Description | |---------|-------------| -| **Streaming Chat** | Real-time Claude Code and Codex conversations with Markdown rendering, tool call visualization, diff views, and error bubbles for failed empty responses. | +| **Streaming Chat** | Real-time Claude Code, Codex, and ACP client conversations with Markdown rendering, tool call visualization, diff views, and error bubbles for failed empty responses. | | **Multi-Project Workspace** | Register local folders or GitHub repositories, switch freely, and keep per-project session history. | | **Dedicated Project Windows** | Double-click a project tab to open it in an independent window and work across multiple repositories at once. | | **Per-Session Controls** | Choose model, permission mode, and effort level per session from the chat toolbar. Defaults are configurable in Settings. | | **Permission Modes** | Ask, Accept Edits, Plan, Auto, and Bypass modes mirror Claude Code's permission model and can be changed from the toolbar. | | **Permission Management** | Risk-based approve/deny UI with Allow, Allow Session, Deny, and 5-minute auto-deny handling. | | **Effort Levels** | Auto, Low, Medium, High, XHigh, and Max reasoning controls for each session. | -| **Model Selection** | Claude Code aliases and Codex models with localized descriptions, including Opus, Sonnet, Haiku, 1M context, plan variants, and GPT coding models. | +| **Model Selection** | Claude Code aliases, Codex models, and ACP-advertised models with localized descriptions, including Opus, Sonnet, Haiku, 1M context, plan variants, GPT coding models, and client-provided model lists. | | **File Attachments** | Drag-and-drop files and images. Smart paste detects images, file paths, URLs, and long text. | | **Attachment Auto-Preview Settings** | Toggle automatic preview chips separately for URLs, file paths, images, and long text. | | **Slash Commands** | Built-in and custom command system with built-in command edits/toggles and custom-command JSON import/export. | @@ -87,6 +88,7 @@ Same agents, no terminal required. - At least one supported agent runtime installed and authenticated: - **[Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)** - **Codex CLI** + - **ACP-compatible clients**, installed from Settings -> ACP Clients -> Registry or configured externally - **Xcode with Swift 6.2+ toolchain** for building the current source tree --- diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index a71aa418..a6f30343 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -661,6 +661,61 @@ final class AppState { (openProjectWindowCounts[projectId] ?? 0) > 0 } + private func isModelAvailable(_ model: String, provider: AgentProvider) -> Bool { + availableAgentModelSections() + .flatMap(\.models) + .contains { $0.provider == provider && $0.id == model } + } + + private func projectDefaultModelSelection(for project: Project?) -> (provider: AgentProvider, model: String)? { + guard let project, + let provider = project.lastAgentProvider, + let model = project.lastModel, + isModelAvailable(model, provider: provider) + else { + return nil + } + return (provider, model) + } + + func defaultModelSelection(for project: Project?) -> (provider: AgentProvider, model: String) { + projectDefaultModelSelection(for: project) ?? (selectedAgentProvider, selectedModel) + } + + func effectiveModelSelection(in window: WindowState) -> (provider: AgentProvider, model: String) { + if let provider = window.sessionAgentProvider, let model = window.sessionModel { + return (provider, model) + } + if let model = window.sessionModel { + return (window.sessionAgentProvider ?? selectedAgentProvider, model) + } + return defaultModelSelection(for: window.selectedProject) + } + + private func rememberProjectModelSelection(_ model: String, provider: AgentProvider, in window: WindowState) { + guard let project = window.selectedProject, + let index = projects.firstIndex(where: { $0.id == project.id }) + else { + return + } + + guard projects[index].lastAgentProvider != provider || projects[index].lastModel != model else { + return + } + + projects[index].lastAgentProvider = provider + projects[index].lastModel = model + window.selectedProject = projects[index] + + Task { + do { + try await persistence.saveProjects(projects) + } catch { + logger.error("Failed to save project model selection: \(error.localizedDescription)") + } + } + } + /// Routes a notification tap to the right window without spawning a new one. /// Hands off to an existing project window if one is open for that project; /// otherwise navigates the supplied main window in place. @@ -687,6 +742,7 @@ final class AppState { if let acpParts { selectedACPClientId = acpParts.clientId } + rememberProjectModelSelection(model, provider: resolvedProvider, in: window) updateState(key) { state in state.agentProvider = resolvedProvider state.model = model @@ -1659,8 +1715,9 @@ final class AppState { bridge.streamingStartDate = state.streamingStartDate bridge.liveOutputTokens = state.currentTurnOutputTokens bridge.lastTurnContextUsedPercentage = state.lastTurnContextUsedPercentage - let provider = window.sessionAgentProvider ?? selectedAgentProvider - let currentModel = window.sessionModel ?? selectedModel + let selection = effectiveModelSelection(in: window) + let provider = selection.provider + let currentModel = selection.model bridge.agentProvider = provider bridge.modelDisplayName = modelDisplayName(for: currentModel, provider: provider, in: window) bridge.sessionStats = ChatSessionStats( @@ -1724,7 +1781,7 @@ final class AppState { // S2: warn (in logs) if another process touched the same jsonl very // recently — likely a `claude` running in the terminal on the same // session. We don't block, but the operator can spot it after the fact. - if (window.sessionAgentProvider ?? selectedAgentProvider) == .claudeCode, + if effectiveModelSelection(in: window).provider == .claudeCode, let sid = window.currentSessionId, let cwd = window.selectedProject?.path, cliStore.detectExternalActivity(sid: sid, cwd: cwd, withinSeconds: 5) @@ -1912,8 +1969,11 @@ final class AppState { let tempId = "pending-\(streamId.uuidString)" window.currentSessionId = tempId window.insertPendingPlaceholder(tempId) - let snapProvider = window.sessionAgentProvider ?? selectedAgentProvider - let snapModel = window.sessionModel + let snapSelection = effectiveModelSelection(in: window) + let snapProvider = snapSelection.provider + let snapModel = snapSelection.model + window.sessionAgentProvider = snapProvider + window.sessionModel = snapModel let snapEffort = window.sessionEffort let snapPermission = window.sessionPermissionMode updateState(tempId) { state in @@ -1948,14 +2008,15 @@ final class AppState { // otherwise race the insertion at line ~1168 and bail with "no summary". if isNewSession { let initialTitle = ChatSession.placeholderTitle(from: displayText ?? prompt) - let provider = window.sessionAgentProvider ?? selectedAgentProvider + let selection = effectiveModelSelection(in: window) + let provider = selection.provider let placeholder = ChatSession( id: sessionKey, projectId: project.id, title: initialTitle, messages: [], agentProvider: provider, - model: window.sessionModel ?? selectedModel, + model: selection.model, origin: provider.defaultSessionOrigin ) allSessionSummaries.insert(placeholder.summary, at: 0) @@ -2020,10 +2081,9 @@ final class AppState { let effectiveCwd = sessionStates[sessionKey]?.worktreePath ?? allSessionSummaries.first(where: { $0.id == sessionKey })?.worktreePath ?? project.path - let effectiveProvider = sessionStates[sessionKey]?.agentProvider - ?? window.sessionAgentProvider - ?? selectedAgentProvider - let effectiveModel = window.sessionModel ?? selectedModel + let selection = effectiveModelSelection(in: window) + let effectiveProvider = sessionStates[sessionKey]?.agentProvider ?? selection.provider + let effectiveModel = sessionStates[sessionKey]?.model ?? selection.model let task = Task { [weak self, window] in guard let self else { return } @@ -2895,7 +2955,7 @@ final class AppState { sessionStates[key]?.activeStreamId = nil if let streamToCancel { - let provider = sessionStates[key]?.agentProvider ?? window.sessionAgentProvider ?? selectedAgentProvider + let provider = sessionStates[key]?.agentProvider ?? effectiveModelSelection(in: window).provider switch provider { case .claudeCode: await claude.cancel(streamId: streamToCancel) @@ -3717,10 +3777,11 @@ final class AppState { } // Persist via sidecar meta let snap = allSessionSummaries.first(where: { $0.id == sessionId }) + let fallbackProvider = defaultModelSelection(for: project).provider let updated = (snap ?? ChatSession.Summary( id: sessionId, projectId: project.id, title: ChatSession.defaultTitle, createdAt: Date(), updatedAt: Date(), isPinned: false, - agentProvider: sessionStates[sessionId]?.agentProvider ?? selectedAgentProvider, + agentProvider: sessionStates[sessionId]?.agentProvider ?? fallbackProvider, worktreePath: info.path.path, worktreeBranch: info.branch )).makeSession() await updateSessionMetadata(updated) { s in @@ -4050,12 +4111,13 @@ final class AppState { /// indexed yet (e.g. brand-new session whose `.result` arrived before the /// summary list refresh). private func summaryFor(sessionId: String, projectId: UUID) -> ChatSession.Summary { - allSessionSummaries.first(where: { $0.id == sessionId }) + let fallbackProvider = defaultModelSelection(for: projects.first { $0.id == projectId }).provider + return allSessionSummaries.first(where: { $0.id == sessionId }) ?? ChatSession.Summary( id: sessionId, projectId: projectId, title: "", createdAt: Date(), updatedAt: Date(), isPinned: false, - agentProvider: sessionStates[sessionId]?.agentProvider ?? selectedAgentProvider, - origin: (sessionStates[sessionId]?.agentProvider ?? selectedAgentProvider).defaultSessionOrigin + agentProvider: sessionStates[sessionId]?.agentProvider ?? fallbackProvider, + origin: (sessionStates[sessionId]?.agentProvider ?? fallbackProvider).defaultSessionOrigin ) } @@ -4389,7 +4451,8 @@ final class AppState { await permission.refreshRunToken() let currentPermissionMode = sessionStates[sessionKey]?.permissionMode ?? permissionMode - let agentProvider = sessionStates[sessionKey]?.agentProvider ?? selectedAgentProvider + let projectSelection = defaultModelSelection(for: projects.first { $0.id == projectId }) + let agentProvider = sessionStates[sessionKey]?.agentProvider ?? projectSelection.provider var hookSettingsPath: String? if agentProvider == .claudeCode, !currentPermissionMode.skipsHookPipeline { do { hookSettingsPath = try await permission.writeHookSettingsFile() } @@ -4398,7 +4461,7 @@ final class AppState { await permission.registerSession(sid: sessionKey, projectKey: cwd, mode: currentPermissionMode) - let model = sessionStates[sessionKey]?.model ?? selectedModel + let model = sessionStates[sessionKey]?.model ?? projectSelection.model let effort = sessionStates[sessionKey]?.effort ?? (selectedEffort == "auto" ? nil : selectedEffort) let task = Task { [weak self, window] in guard let self else { return } diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index 23595565..1d494388 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -449,8 +449,8 @@ struct ChatToolbarControls: View { } private var effectiveMode: PermissionMode { windowState.sessionPermissionMode ?? appState.permissionMode } - private var effectiveModel: String { windowState.sessionModel ?? appState.selectedModel } - private var effectiveProvider: AgentProvider { windowState.sessionAgentProvider ?? appState.selectedAgentProvider } + private var effectiveModel: String { appState.effectiveModelSelection(in: windowState).model } + private var effectiveProvider: AgentProvider { appState.effectiveModelSelection(in: windowState).provider } var body: some View { HStack(spacing: placement == .composer ? 8 : 4) { @@ -788,8 +788,8 @@ struct ModelPickerSheet: View { @State private var selectedIndex: Int = 0 @FocusState private var isFocused: Bool - private var effectiveModel: String { windowState.sessionModel ?? appState.selectedModel } - private var effectiveProvider: AgentProvider { windowState.sessionAgentProvider ?? appState.selectedAgentProvider } + private var effectiveModel: String { appState.effectiveModelSelection(in: windowState).model } + private var effectiveProvider: AgentProvider { appState.effectiveModelSelection(in: windowState).provider } private var flatModels: [AgentModel] { appState.availableAgentModelSections().flatMap(\.models) } var body: some View { diff --git a/RxCode/Views/Settings/ACPClientSettingsTab.swift b/RxCode/Views/Settings/ACPClientSettingsTab.swift index ed2c609c..8c1187e2 100644 --- a/RxCode/Views/Settings/ACPClientSettingsTab.swift +++ b/RxCode/Views/Settings/ACPClientSettingsTab.swift @@ -1,9 +1,24 @@ import SwiftUI import RxCodeCore +private enum ACPClientSettingsPage: String, CaseIterable, Identifiable { + case installed + case registry + + var id: Self { self } + + var title: String { + switch self { + case .installed: return "Installed" + case .registry: return "Registry" + } + } +} + struct ACPClientSettingsTab: View { @Environment(AppState.self) private var appState + @State private var selectedPage: ACPClientSettingsPage = .installed @State private var pendingRemoval: ACPClientSpec? @State private var editingClient: ACPClientSpec? @State private var actionError: String? @@ -14,19 +29,30 @@ struct ACPClientSettingsTab: View { ScrollView { VStack(alignment: .leading, spacing: 20) { header + pagePicker Divider() - installedSection - Divider() - registrySection + switch selectedPage { + case .installed: + installedSection + case .registry: + registrySection + } } .padding(24) .frame(maxWidth: .infinity, alignment: .leading) } .task { - if appState.acpRegistry == nil && !appState.acpRegistryLoading { + if selectedPage == .registry && appState.acpRegistry == nil && !appState.acpRegistryLoading { await appState.refreshACPRegistry() } } + .onChange(of: selectedPage) { _, page in + guard page == .registry, + appState.acpRegistry == nil, + !appState.acpRegistryLoading + else { return } + Task { await appState.refreshACPRegistry() } + } .sheet(item: $editingClient) { client in ACPClientEditorSheet( client: client, @@ -69,6 +95,18 @@ struct ACPClientSettingsTab: View { } } + private var pagePicker: some View { + Picker("ACP page", selection: $selectedPage) { + ForEach(ACPClientSettingsPage.allCases) { page in + Text(page.title).tag(page) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 240) + .frame(maxWidth: .infinity, alignment: .center) + } + // MARK: - Installed private var installedSection: some View { @@ -77,7 +115,7 @@ struct ACPClientSettingsTab: View { .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) if appState.acpClients.isEmpty { - Text("No clients installed. Add one from the registry below.") + Text("No clients installed. Add one from the Registry tab.") .font(.system(size: ClaudeTheme.size(11))) .foregroundStyle(.secondary) } else { diff --git a/website/app/globals.css b/website/app/globals.css index ac547435..5c44070f 100644 --- a/website/app/globals.css +++ b/website/app/globals.css @@ -70,3 +70,173 @@ body { .scrollbar-hide { scrollbar-width: none; } + +.release-notes { + color: var(--color-on-surface); + line-height: 1.65; + font-size: 0.975rem; +} + +.release-notes h1, +.release-notes h2, +.release-notes h3, +.release-notes h4, +.release-notes h5, +.release-notes h6 { + font-family: var(--font-display); + font-weight: 600; + letter-spacing: -0.01em; + color: var(--color-on-surface); + margin-top: 1.75em; + margin-bottom: 0.6em; + line-height: 1.25; +} + +.release-notes > :first-child { + margin-top: 0; +} + +.release-notes h1 { + font-size: 1.6rem; +} + +.release-notes h2 { + font-size: 1.35rem; + padding-bottom: 0.35em; + border-bottom: 1px solid var(--color-surface-variant); +} + +.release-notes h3 { + font-size: 1.05rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-primary); + font-family: var(--font-mono); + font-weight: 500; +} + +.release-notes h4 { + font-size: 1rem; +} + +.release-notes p { + margin: 0.9em 0; + color: var(--color-on-surface-variant); +} + +.release-notes a { + color: var(--color-primary); + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--color-primary) 40%, transparent); + text-underline-offset: 3px; + transition: text-decoration-color 0.15s ease; +} + +.release-notes a:hover { + text-decoration-color: var(--color-primary); +} + +.release-notes ul, +.release-notes ol { + margin: 0.75em 0; + padding-left: 1.4em; + color: var(--color-on-surface-variant); +} + +.release-notes ul { + list-style: none; +} + +.release-notes ul > li { + position: relative; + padding-left: 1em; +} + +.release-notes ul > li::before { + content: "›"; + position: absolute; + left: 0; + top: 0; + color: var(--color-primary); + font-family: var(--font-mono); +} + +.release-notes ol { + list-style: decimal; +} + +.release-notes li { + margin: 0.35em 0; +} + +.release-notes li > ul, +.release-notes li > ol { + margin: 0.25em 0; +} + +.release-notes code { + font-family: var(--font-mono); + font-size: 0.85em; + background: var(--color-surface-container); + border: 1px solid var(--color-surface-variant); + padding: 0.1em 0.4em; + border-radius: 4px; + color: var(--color-on-surface); +} + +.release-notes pre { + background: var(--color-surface-container-lowest); + border: 1px solid var(--color-surface-variant); + padding: 1em 1.1em; + border-radius: 6px; + overflow-x: auto; + margin: 1em 0; +} + +.release-notes pre code { + background: transparent; + border: none; + padding: 0; + font-size: 0.85em; +} + +.release-notes blockquote { + border-left: 3px solid var(--color-primary); + background: var(--color-surface-container-low); + margin: 1em 0; + padding: 0.6em 1em; + color: var(--color-on-surface-variant); +} + +.release-notes hr { + border: none; + border-top: 1px solid var(--color-surface-variant); + margin: 1.5em 0; +} + +.release-notes img { + max-width: 100%; + height: auto; + border: 1px solid var(--color-surface-variant); + border-radius: 6px; + margin: 1em 0; +} + +.release-notes table { + width: 100%; + border-collapse: collapse; + margin: 1em 0; + font-size: 0.9em; +} + +.release-notes th, +.release-notes td { + border: 1px solid var(--color-surface-variant); + padding: 0.5em 0.75em; + text-align: left; +} + +.release-notes th { + background: var(--color-surface-container); + font-weight: 600; +} diff --git a/website/app/lib/og-image.tsx b/website/app/lib/og-image.tsx index 27135a5e..52a5b85a 100644 --- a/website/app/lib/og-image.tsx +++ b/website/app/lib/og-image.tsx @@ -4,7 +4,7 @@ export const OG_SIZE = { width: 1200, height: 630 }; export function renderOgImage( title = "The Visual Command Center for AI Coding Agents", - description = "Native macOS · Claude Code & Codex · Free & Open Source" + description = "Native macOS · Claude Code, Codex & ACP clients · Free & Open Source" ): ImageResponse { return new ImageResponse( ( diff --git a/website/app/lib/release.ts b/website/app/lib/release.ts index 1b9553f7..8800c60f 100644 --- a/website/app/lib/release.ts +++ b/website/app/lib/release.ts @@ -2,12 +2,16 @@ import "server-only"; const RELEASE_API = "https://api.github.com/repos/rxtech-lab/rxcode/releases/latest"; +const RELEASES_API = + "https://api.github.com/repos/rxtech-lab/rxcode/releases?per_page=10"; const FALLBACK_TAG = "v1.0.1"; const FALLBACK_DMG = "https://github.com/rxtech-lab/rxcode/releases/download/v1.0.1/RxCode.dmg"; const FALLBACK_PAGE = "https://github.com/rxtech-lab/rxcode/releases/tag/v1.0.1"; +export const SPARKLE_APPCAST_URL = "https://update.code.rxlab.app/appcast.xml"; + export type ReleaseInfo = { tag: string; version: string; @@ -28,6 +32,8 @@ type GitHubRelease = { name: string; html_url: string; published_at: string; + body: string | null; + prerelease: boolean; assets: GitHubAsset[]; }; @@ -74,3 +80,243 @@ export function formatSize(bytes: number | null): string | null { const mb = bytes / (1024 * 1024); return `${mb.toFixed(1)} MB`; } + +export type SparkleItem = { + version: string; + shortVersionString: string; + pubDate: string | null; + releaseNotesUrl: string | null; + link: string | null; + enclosureUrl: string | null; + enclosureSize: number | null; + minimumSystemVersion: string | null; +}; + +export type SparkleRelease = SparkleItem & { + releaseNotesHtml: string | null; +}; + +export type AppReleaseNote = SparkleRelease & { + tag: string; + isPrerelease: boolean; +}; + +function pickTag(xml: string, tag: T): string | null { + const match = xml.match( + new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i") + ); + return match ? match[1].trim() : null; +} + +function pickAttr(xml: string, tag: string, attr: string): string | null { + const match = xml.match( + new RegExp(`<${tag}\\b[^>]*\\b${attr}="([^"]*)"`, "i") + ); + return match ? match[1] : null; +} + +function parseSparkleItems(xml: string): SparkleItem[] { + const items: SparkleItem[] = []; + const itemRegex = /]*>([\s\S]*?)<\/item>/gi; + let m: RegExpExecArray | null; + while ((m = itemRegex.exec(xml)) !== null) { + const body = m[1]; + items.push({ + version: pickTag(body, "sparkle:version") ?? "", + shortVersionString: + pickTag(body, "sparkle:shortVersionString") ?? + pickTag(body, "title") ?? + "", + pubDate: pickTag(body, "pubDate"), + releaseNotesUrl: pickTag(body, "sparkle:releaseNotesLink"), + link: pickTag(body, "link"), + enclosureUrl: pickAttr(body, "enclosure", "url"), + enclosureSize: (() => { + const raw = pickAttr(body, "enclosure", "length"); + const n = raw ? Number(raw) : NaN; + return Number.isFinite(n) ? n : null; + })(), + minimumSystemVersion: pickTag(body, "sparkle:minimumSystemVersion"), + }); + } + return items; +} + +function extractHtmlBody(html: string): string { + const bodyMatch = html.match(/]*>([\s\S]*?)<\/body>/i); + const inner = bodyMatch ? bodyMatch[1] : html; + return inner + .replace(//gi, "") + .replace(//gi, "") + .trim(); +} + +function resolveUrl(base: string, relative: string): string { + try { + return new URL(relative, base).toString(); + } catch { + return relative; + } +} + +async function fetchReleaseNotes(url: string): Promise { + try { + const res = await fetch(url, { next: { revalidate: 600 } }); + if (!res.ok) return null; + const html = await res.text(); + return extractHtmlBody(html); + } catch { + return null; + } +} + +export async function getSparkleReleases(): Promise { + try { + const res = await fetch(SPARKLE_APPCAST_URL, { + next: { revalidate: 600 }, + }); + if (!res.ok) return []; + const xml = await res.text(); + const items = parseSparkleItems(xml); + + const enriched = await Promise.all( + items.map(async (item) => { + const url = item.releaseNotesUrl + ? resolveUrl(SPARKLE_APPCAST_URL, item.releaseNotesUrl) + : null; + const releaseNotesHtml = url ? await fetchReleaseNotes(url) : null; + return { ...item, releaseNotesUrl: url, releaseNotesHtml }; + }) + ); + + return enriched.sort((a, b) => { + const ad = a.pubDate ? Date.parse(a.pubDate) : 0; + const bd = b.pubDate ? Date.parse(b.pubDate) : 0; + return bd - ad; + }); + } catch { + return []; + } +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function inlineMarkdownToHtml(value: string): string { + return escapeHtml(value) + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/`([^`]+)`/g, "$1"); +} + +function releaseMarkdownToHtml(markdown: string): string { + const lines = markdown.split(/\r?\n/); + const html: string[] = []; + let inList = false; + + const closeList = () => { + if (inList) { + html.push(""); + inList = false; + } + }; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + closeList(); + continue; + } + + if (trimmed.startsWith("### ")) { + closeList(); + html.push(`

${inlineMarkdownToHtml(trimmed.slice(4))}

`); + continue; + } + + if (trimmed.startsWith("## ")) { + closeList(); + html.push(`

${inlineMarkdownToHtml(trimmed.slice(3))}

`); + continue; + } + + if (trimmed.startsWith("# ")) { + closeList(); + html.push(`

${inlineMarkdownToHtml(trimmed.slice(2))}

`); + continue; + } + + if (trimmed.startsWith("- ")) { + if (!inList) { + html.push("