From 7d1b2369ceaacb959c8442987d6155cc12dbc580 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Mon, 23 Mar 2026 17:34:54 -0700 Subject: [PATCH 01/51] Restructure app navigation with Dashboard and App tabs Replace the Build sidebar group with a top-level Dashboard tab (app grid with summary stats) and an App tab (sub-tab navbar routing between Overview, Simulator, Database, Tests, and Icon). The sidebar App row dynamically shows the selected project's icon and name. Release group no longer includes Overview (moved into App sub-tab). MCP tool navigation updated with backwards-compatible legacy tab name mapping. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AppCommands.swift | 14 +- src/AppState.swift | 63 +++++--- src/services/ASCManager.swift | 4 +- src/services/MCPToolExecutor.swift | 68 ++++++-- src/services/MCPToolRegistry.swift | 12 +- src/views/AppTabView.swift | 74 +++++++++ src/views/ContentView.swift | 57 +++++-- src/views/DashboardView.swift | 198 +++++++++++++++++++++++ src/views/build/ConnectAIPopover.swift | 12 +- src/views/build/DeviceSelectorView.swift | 2 +- src/views/build/SimulatorView.swift | 2 +- src/views/release/ASCOverview.swift | 6 +- src/views/sidebar/SidebarView.swift | 77 ++++++--- 13 files changed, 483 insertions(+), 106 deletions(-) create mode 100644 src/views/AppTabView.swift create mode 100644 src/views/DashboardView.swift diff --git a/src/AppCommands.swift b/src/AppCommands.swift index dc38b72..ad281b6 100644 --- a/src/AppCommands.swift +++ b/src/AppCommands.swift @@ -68,18 +68,20 @@ struct AppCommands: Commands { CommandGroup(after: .toolbar) { Divider() - Button("Simulator") { - appState.activeTab = .simulator + Button("Dashboard") { + appState.activeTab = .dashboard } .keyboardShortcut("1", modifiers: .command) - Button("Database") { - appState.activeTab = .database + Button("Simulator") { + appState.activeTab = .app + appState.activeAppSubTab = .simulator } .keyboardShortcut("2", modifiers: .command) - Button("Tests") { - appState.activeTab = .tests + Button("Database") { + appState.activeTab = .app + appState.activeAppSubTab = .database } .keyboardShortcut("3", modifiers: .command) } diff --git a/src/AppState.swift b/src/AppState.swift index 56eeeb7..8e513ad 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -3,14 +3,11 @@ import SwiftUI /// All navigation tabs in the app enum AppTab: String, CaseIterable, Identifiable { - // Build group - case simulator - case database - case tests - case assets + // Top-level standalone tabs + case dashboard + case app // Release group (ASC) - case ascOverview case storeListing case screenshots case appDetails @@ -34,7 +31,7 @@ enum AppTab: String, CaseIterable, Identifiable { var isASCTab: Bool { switch self { - case .ascOverview, .storeListing, .screenshots, .appDetails, .monetization, .review, + case .storeListing, .screenshots, .appDetails, .monetization, .review, .analytics, .reviews, .builds, .groups, .betaInfo, .feedback: return true default: @@ -44,11 +41,8 @@ enum AppTab: String, CaseIterable, Identifiable { var label: String { switch self { - case .simulator: "Simulator" - case .database: "Database" - case .tests: "Tests" - case .assets: "Assets" - case .ascOverview: "Overview" + case .dashboard: "Dashboard" + case .app: "App" case .storeListing: "Store Listing" case .screenshots: "Screenshots" case .appDetails: "App Details" @@ -66,11 +60,8 @@ enum AppTab: String, CaseIterable, Identifiable { var icon: String { switch self { - case .simulator: "iphone" - case .database: "cylinder" - case .tests: "checkmark.circle" - case .assets: "photo.badge.plus" - case .ascOverview: "chart.bar" + case .dashboard: "square.grid.2x2" + case .app: "app" case .storeListing: "text.page" case .screenshots: "photo.on.rectangle" case .appDetails: "info.circle" @@ -87,15 +78,13 @@ enum AppTab: String, CaseIterable, Identifiable { } enum Group: String, CaseIterable { - case build = "Build" case release = "Release" case insights = "Insights" case testFlight = "TestFlight" var tabs: [AppTab] { switch self { - case .build: [.simulator, .database, .tests, .assets] - case .release: [.ascOverview, .storeListing, .screenshots, .appDetails, .monetization, .review] + case .release: [.storeListing, .screenshots, .appDetails, .monetization, .review] case .insights: [.analytics, .reviews] case .testFlight: [.builds, .groups, .betaInfo, .feedback] } @@ -103,13 +92,45 @@ enum AppTab: String, CaseIterable, Identifiable { } } +/// Sub-tabs within the App tab (top navbar) +enum AppSubTab: String, CaseIterable, Identifiable { + case overview + case simulator + case database + case tests + case icon + + var id: String { rawValue } + + var label: String { + switch self { + case .overview: "Overview" + case .simulator: "Simulator" + case .database: "Database" + case .tests: "Tests" + case .icon: "Icon" + } + } + + var systemImage: String { + switch self { + case .overview: "chart.bar" + case .simulator: "iphone" + case .database: "cylinder" + case .tests: "checkmark.circle" + case .icon: "photo.badge.plus" + } + } +} + /// Root observable state for the entire app @MainActor @Observable final class AppState { // Navigation var activeProjectId: String? - var activeTab: AppTab = .simulator + var activeTab: AppTab = .dashboard + var activeAppSubTab: AppSubTab = .overview // Child observable managers var projectManager = ProjectManager() diff --git a/src/services/ASCManager.swift b/src/services/ASCManager.swift index ec83792..45aad70 100644 --- a/src/services/ASCManager.swift +++ b/src/services/ASCManager.swift @@ -1029,7 +1029,7 @@ final class ASCManager { } switch tab { - case .ascOverview: + case .app: refreshAppIconStatusIfNeeded(for: loadedProjectId) let versions = try await service.fetchAppStoreVersions(appId: appId) appStoreVersions = versions @@ -2044,7 +2044,7 @@ final class ASCManager { } try await service.submitForReview(appId: appId, versionId: versionId) isSubmitting = false - await refreshTabData(.ascOverview) + await refreshTabData(.app) } catch { isSubmitting = false submissionError = error.localizedDescription diff --git a/src/services/MCPToolExecutor.swift b/src/services/MCPToolExecutor.swift index e5f1585..9c5a9ac 100644 --- a/src/services/MCPToolExecutor.swift +++ b/src/services/MCPToolExecutor.swift @@ -80,7 +80,7 @@ actor MCPToolExecutor { default: targetTab = nil } } else if name == "asc_open_submit_preview" { - targetTab = .ascOverview + targetTab = .app } else if name == "screenshots_add_asset" || name == "screenshots_set_track" || name == "screenshots_save" { targetTab = .screenshots @@ -305,6 +305,7 @@ actor MCPToolExecutor { let state = await MainActor.run { () -> [String: Any] in var result: [String: Any] = [ "activeTab": appState.activeTab.rawValue, + "activeAppSubTab": appState.activeAppSubTab.rawValue, "isStreaming": appState.simulatorStream.isCapturing ] if let project = appState.activeProject { @@ -334,30 +335,58 @@ actor MCPToolExecutor { // MARK: - Navigation Tools private func executeNavSwitchTab(_ args: [String: Any]) async throws -> [String: Any] { - guard let tabStr = args["tab"] as? String, - let tab = AppTab(rawValue: tabStr) else { + guard let tabStr = args["tab"] as? String else { throw MCPServerService.MCPError.invalidToolArgs } - await MainActor.run { appState.activeTab = tab } - // Auto-connect database when switching to database tab - if tab == .database { - let status = await MainActor.run { appState.databaseManager.connectionStatus } - if status != .connected, let project = await MainActor.run(body: { appState.activeProject }) { - await appState.databaseManager.startAndConnect(projectId: project.id, projectPath: project.path) + // Map legacy tab names to new App sub-tabs + let legacySubTabMap: [String: AppSubTab] = [ + "simulator": .simulator, + "database": .database, + "tests": .tests, + "assets": .icon, + "icon": .icon, + "ascOverview": .overview, + "overview": .overview, + ] + + if let subTab = legacySubTabMap[tabStr] { + await MainActor.run { + appState.activeTab = .app + appState.activeAppSubTab = subTab + } + + // Auto-connect database when switching to database sub-tab + if subTab == .database { + let status = await MainActor.run { appState.databaseManager.connectionStatus } + if status != .connected, let project = await MainActor.run(body: { appState.activeProject }) { + await appState.databaseManager.startAndConnect(projectId: project.id, projectPath: project.path) + } } + + return mcpText("Switched to App > \(subTab.label)") } + guard let tab = AppTab(rawValue: tabStr) else { + throw MCPServerService.MCPError.invalidToolArgs + } + await MainActor.run { appState.activeTab = tab } + return mcpText("Switched to tab: \(tab.label)") } private func executeNavListTabs() async -> [String: Any] { - var groups: [[String: Any]] = [] + // Top-level standalone tabs + let topLevel: [[String: Any]] = [ + ["name": "dashboard", "label": "Dashboard", "icon": "square.grid.2x2"], + ["name": "app", "label": "App", "icon": "app", + "subTabs": AppSubTab.allCases.map { ["name": $0.rawValue, "label": $0.label, "icon": $0.systemImage] as [String: Any] }], + ] + var groups: [[String: Any]] = [["group": "Top", "tabs": topLevel]] for group in AppTab.Group.allCases { let tabs = group.tabs.map { ["name": $0.rawValue, "label": $0.label, "icon": $0.icon] as [String: Any] } groups.append(["group": group.rawValue, "tabs": tabs]) } - // Include settings separately groups.append(["group": "Other", "tabs": [["name": "settings", "label": "Settings", "icon": "gear"]]]) return mcpJSON(["groups": groups]) } @@ -903,7 +932,7 @@ actor MCPToolExecutor { do { try await service.attachBuild(versionId: versionId, buildId: latestBuild.id) // Refresh data so readiness reflects the attached build - await appState.ascManager.refreshTabData(.ascOverview) + await appState.ascManager.refreshTabData(.app) readiness = await MainActor.run { appState.ascManager.submissionReadiness } } catch { // Non-fatal: report in missing fields @@ -1223,8 +1252,15 @@ actor MCPToolExecutor { private func executeGetTabState(_ args: [String: Any]) async throws -> [String: Any] { let tabStr = args["tab"] as? String let tab: AppTab - if let tabStr, let parsed = AppTab(rawValue: tabStr) { - tab = parsed + if let tabStr { + // Map legacy "ascOverview" to ".app" + if tabStr == "ascOverview" || tabStr == "overview" { + tab = .app + } else if let parsed = AppTab(rawValue: tabStr) { + tab = parsed + } else { + tab = await MainActor.run { appState.activeTab } + } } else { tab = await MainActor.run { appState.activeTab } } @@ -1245,7 +1281,7 @@ actor MCPToolExecutor { } // Refresh IAP/subscription data for overview so readiness reflects latest state - if tab == .ascOverview { + if tab == .app { await appState.ascManager.refreshSubmissionReadinessData() } @@ -1265,7 +1301,7 @@ actor MCPToolExecutor { @MainActor private func tabStateData(for tab: AppTab, asc: ASCManager, projectId: String?) -> [String: Any] { switch tab { - case .ascOverview: + case .app: if let pid = projectId { asc.checkAppIcon(projectId: pid) } diff --git a/src/services/MCPToolRegistry.swift b/src/services/MCPToolRegistry.swift index c0eb3a6..da43026 100644 --- a/src/services/MCPToolRegistry.swift +++ b/src/services/MCPToolRegistry.swift @@ -20,9 +20,10 @@ enum MCPToolRegistry { name: "nav_switch_tab", description: "Switch the active sidebar tab", properties: [ - "tab": ["type": "string", "description": "Tab name", "enum": [ - "simulator", "database", "tests", "assets", - "ascOverview", "storeListing", "screenshots", "appDetails", "monetization", "review", + "tab": ["type": "string", "description": "Tab name. Use 'dashboard' or 'app' for top-level tabs. Legacy names (simulator, database, tests, assets, ascOverview) map to App sub-tabs.", "enum": [ + "dashboard", "app", + "simulator", "database", "tests", "assets", "overview", "ascOverview", + "storeListing", "screenshots", "appDetails", "monetization", "review", "analytics", "reviews", "builds", "groups", "betaInfo", "feedback", "settings" @@ -156,8 +157,9 @@ enum MCPToolRegistry { name: "get_tab_state", description: "Get the structured data state of any Blitz tab. Returns form field values, submission readiness, versions, builds, localizations, etc. Use this instead of screenshots to read UI state.", properties: [ - "tab": ["type": "string", "description": "Tab to read state from (defaults to currently active tab)", "enum": [ - "ascOverview", "storeListing", "screenshots", "appDetails", "monetization", "review", + "tab": ["type": "string", "description": "Tab to read state from (defaults to currently active tab). 'app' or 'ascOverview' returns overview/submission readiness.", "enum": [ + "app", "ascOverview", "overview", + "storeListing", "screenshots", "appDetails", "monetization", "review", "analytics", "reviews", "builds", "groups", "betaInfo", "feedback" ]] ], diff --git a/src/views/AppTabView.swift b/src/views/AppTabView.swift new file mode 100644 index 0000000..12dd070 --- /dev/null +++ b/src/views/AppTabView.swift @@ -0,0 +1,74 @@ +import SwiftUI + +struct AppTabView: View { + @Bindable var appState: AppState + + var body: some View { + VStack(spacing: 0) { + // Top navbar + topNavbar + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.bar) + + Divider() + + // Sub-tab content + subTabContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: - Top Navbar + + private var topNavbar: some View { + HStack(spacing: 2) { + ForEach(AppSubTab.allCases) { tab in + Button { + appState.activeAppSubTab = tab + } label: { + HStack(spacing: 4) { + Image(systemName: tab.systemImage) + .font(.system(size: 11)) + Text(tab.label) + .font(.system(size: 12, weight: .medium)) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + appState.activeAppSubTab == tab + ? Color.accentColor.opacity(0.12) + : Color.clear + ) + .foregroundStyle( + appState.activeAppSubTab == tab + ? Color.accentColor + : Color.secondary + ) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + Spacer() + } + } + + // MARK: - Sub-tab Content + + @ViewBuilder + private var subTabContent: some View { + switch appState.activeAppSubTab { + case .overview: + ASCOverview(appState: appState) + case .simulator: + SimulatorView(appState: appState) + case .database: + DatabaseView(appState: appState) + case .tests: + TestsView(appState: appState) + case .icon: + AssetsView(appState: appState) + } + } +} diff --git a/src/views/ContentView.swift b/src/views/ContentView.swift index 6b9dee5..40e2848 100644 --- a/src/views/ContentView.swift +++ b/src/views/ContentView.swift @@ -92,8 +92,8 @@ struct ContentView: View { // Auto-boot simulator when project opens await appState.simulatorManager.bootIfNeeded() - // Auto-start stream if landing on simulator tab - if appState.activeTab == .simulator { + // Auto-start stream if landing on simulator sub-tab + if appState.activeTab == .app && appState.activeAppSubTab == .simulator { await appState.simulatorStream.startStreaming( bootedDeviceId: appState.simulatorManager.bootedDeviceId ) @@ -107,6 +107,8 @@ struct ContentView: View { ) if appState.activeTab.isASCTab { await appState.ascManager.fetchTabData(appState.activeTab) + } else if appState.activeTab == .app && appState.activeAppSubTab == .overview { + await appState.ascManager.fetchTabData(.app) } } } @@ -137,6 +139,8 @@ struct ContentView: View { ) if appState.activeTab.isASCTab { await appState.ascManager.fetchTabData(appState.activeTab) + } else if appState.activeTab == .app && appState.activeAppSubTab == .overview { + await appState.ascManager.fetchTabData(.app) } } } @@ -145,12 +149,15 @@ struct ContentView: View { .onChange(of: appState.activeTab) { oldTab, newTab in tabSwitchTask?.cancel() tabSwitchTask = Task { - // Pause stream when leaving simulator tab - if oldTab == .simulator && newTab != .simulator { + let isLeavingSimulator = oldTab == .app && appState.activeAppSubTab == .simulator + let isEnteringSimulator = newTab == .app && appState.activeAppSubTab == .simulator + + // Pause stream when leaving simulator + if isLeavingSimulator && newTab != .app { await appState.simulatorStream.pauseStream() } - // Resume/start stream when entering simulator tab - if newTab == .simulator { + // Resume/start stream when entering simulator + if isEnteringSimulator { if appState.simulatorStream.isPaused { await appState.simulatorStream.resumeStream() } else if !appState.simulatorStream.isCapturing { @@ -165,6 +172,30 @@ struct ContentView: View { } } } + .onChange(of: appState.activeAppSubTab) { oldSub, newSub in + guard appState.activeTab == .app else { return } + tabSwitchTask?.cancel() + tabSwitchTask = Task { + // Pause stream when leaving simulator sub-tab + if oldSub == .simulator && newSub != .simulator { + await appState.simulatorStream.pauseStream() + } + // Resume/start stream when entering simulator sub-tab + if newSub == .simulator { + if appState.simulatorStream.isPaused { + await appState.simulatorStream.resumeStream() + } else if !appState.simulatorStream.isCapturing { + await appState.simulatorStream.startStreaming( + bootedDeviceId: appState.simulatorManager.bootedDeviceId + ) + } + } + // Fetch ASC overview data when entering overview sub-tab + if newSub == .overview { + await appState.ascManager.fetchTabData(.app) + } + } + } .sheet(isPresented: $appState.showNewProjectSheet) { NewProjectSheet(appState: appState, isPresented: $appState.showNewProjectSheet) } @@ -224,16 +255,10 @@ struct DetailView: View { @ViewBuilder private var activeTabView: some View { switch appState.activeTab { - case .simulator: - SimulatorView(appState: appState) - case .database: - DatabaseView(appState: appState) - case .tests: - TestsView(appState: appState) - case .assets: - AssetsView(appState: appState) - case .ascOverview: - ASCOverview(appState: appState) + case .dashboard: + DashboardView(appState: appState) + case .app: + AppTabView(appState: appState) case .storeListing: StoreListingView(appState: appState) case .screenshots: diff --git a/src/views/DashboardView.swift b/src/views/DashboardView.swift new file mode 100644 index 0000000..4489e8f --- /dev/null +++ b/src/views/DashboardView.swift @@ -0,0 +1,198 @@ +import SwiftUI + +struct DashboardView: View { + @Bindable var appState: AppState + + private var projects: [Project] { appState.projectManager.projects } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Stat cards + LazyVGrid( + columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], + spacing: 12 + ) { + statCard(title: "Live on Store", value: "\(liveCount)", color: .green, icon: "checkmark.seal.fill") + statCard(title: "Pending Review", value: "\(pendingCount)", color: .orange, icon: "clock.fill") + statCard(title: "Rejected Apps", value: "\(rejectedCount)", color: .red, icon: "xmark.seal.fill") + } + + // App grid header + HStack { + Text("My Apps") + .font(.title3.weight(.semibold)) + Spacer() + } + + // App grid + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)], + spacing: 16 + ) { + ForEach(projects) { project in + appCard(project: project) + .onTapGesture { + selectProject(project) + } + } + } + + Spacer(minLength: 0) + } + .padding(20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .bottomTrailing) { + Button { + appState.showNewProjectSheet = true + } label: { + Label("Create App", systemImage: "plus") + .font(.body.weight(.medium)) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(20) + } + .task { + if projects.isEmpty { + await appState.projectManager.loadProjects() + } + } + } + + // MARK: - Stat Card + + private func statCard(title: String, value: String, color: Color, icon: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.callout) + .foregroundStyle(color) + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } + Text(value) + .font(.system(size: 28, weight: .bold, design: .rounded)) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.background.secondary) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + // MARK: - App Card + + private func appCard(project: Project) -> some View { + let isSelected = project.id == appState.activeProjectId + + return VStack(spacing: 8) { + appIconView(project: project) + .frame(width: 56, height: 56) + + Text(project.name) + .font(.callout.weight(.medium)) + .lineLimit(1) + + Text(project.metadata.bundleIdentifier ?? project.type.rawValue) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(14) + .frame(maxWidth: .infinity) + .background(isSelected ? Color.accentColor.opacity(0.1) : Color(.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) + ) + .contentShape(Rectangle()) + } + + private func appIconView(project: Project) -> some View { + Group { + if let icon = Self.loadAppIcon(projectId: project.id) { + Image(nsImage: icon) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } else { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(projectColor(project).opacity(0.15)) + Image(systemName: projectIcon(project)) + .font(.system(size: 24)) + .foregroundStyle(projectColor(project)) + } + } + } + } + + // MARK: - Actions + + private func selectProject(_ project: Project) { + let storage = ProjectStorage() + storage.updateLastOpened(projectId: project.id) + appState.activeProjectId = project.id + } + + // MARK: - Stats (placeholder counts from project metadata) + + private var liveCount: Int { + // Placeholder — real implementation would query ASC per project + 0 + } + + private var pendingCount: Int { + 0 + } + + private var rejectedCount: Int { + 0 + } + + // MARK: - Helpers + + private func projectIcon(_ project: Project) -> String { + if project.platform == .macOS { return "desktopcomputer" } + switch project.type { + case .reactNative: return "atom" + case .swift: return "swift" + case .flutter: return "bird" + } + } + + private func projectColor(_ project: Project) -> Color { + switch project.type { + case .reactNative: return .cyan + case .swift: return .orange + case .flutter: return .blue + } + } + + static func loadAppIcon(projectId: String) -> NSImage? { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let blitzPath = "\(home)/.blitz/projects/\(projectId)/assets/AppIcon/icon_1024.png" + if let image = NSImage(contentsOfFile: blitzPath) { return image } + + let projectDir = "\(home)/.blitz/projects/\(projectId)" + let fm = FileManager.default + guard let enumerator = fm.enumerator(atPath: projectDir) else { return nil } + while let file = enumerator.nextObject() as? String { + guard file.hasSuffix("AppIcon.appiconset/Contents.json") else { continue } + let contentsPath = "\(projectDir)/\(file)" + guard let data = fm.contents(atPath: contentsPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let images = json["images"] as? [[String: Any]] else { continue } + for entry in images { + if let filename = entry["filename"] as? String { + let iconDir = (contentsPath as NSString).deletingLastPathComponent + if let image = NSImage(contentsOfFile: "\(iconDir)/\(filename)") { return image } + } + } + } + return nil + } +} diff --git a/src/views/build/ConnectAIPopover.swift b/src/views/build/ConnectAIPopover.swift index d0abd33..d752abe 100644 --- a/src/views/build/ConnectAIPopover.swift +++ b/src/views/build/ConnectAIPopover.swift @@ -86,15 +86,9 @@ struct ConnectAIPopover: View { /// Tab-specific default prompt, shared with TerminalLauncher. static func prompt(for tab: AppTab) -> String? { switch tab { - case .simulator: - return "Build and launch my app on the simulator, then describe what's on screen." - case .database: - return "Help me set up a database schema and authentication for my app." - case .tests: - return "Help me write and run tests for my app." - case .assets: - return "Help me generate and configure app icons and assets." - case .ascOverview: + case .dashboard: + return nil + case .app: return "Help me complete all the steps needed to submit my app to the App Store." case .storeListing: return "Help me write a compelling App Store listing — name, subtitle, description, and keywords." diff --git a/src/views/build/DeviceSelectorView.swift b/src/views/build/DeviceSelectorView.swift index 29cbcd5..e224554 100644 --- a/src/views/build/DeviceSelectorView.swift +++ b/src/views/build/DeviceSelectorView.swift @@ -95,7 +95,7 @@ struct DeviceSelectorView: View { await manager.loadSimulators() // 5. Start streaming the new device - if appState.activeTab == .simulator { + if appState.activeTab == .app && appState.activeAppSubTab == .simulator { await appState.simulatorStream.startStreaming( bootedDeviceId: sim.udid ) diff --git a/src/views/build/SimulatorView.swift b/src/views/build/SimulatorView.swift index 8752e1b..3a6fc27 100644 --- a/src/views/build/SimulatorView.swift +++ b/src/views/build/SimulatorView.swift @@ -234,7 +234,7 @@ struct SimulatorView: View { keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [self] event in // Only capture when simulator is streaming and this tab is active guard stream.isCapturing, - appState.activeTab == .simulator, + appState.activeTab == .app && appState.activeAppSubTab == .simulator, !appState.ascManager.showAppleIDLogin, let udid = appState.simulatorManager.bootedDeviceId else { return event diff --git a/src/views/release/ASCOverview.swift b/src/views/release/ASCOverview.swift index da5cadf..68c2704 100644 --- a/src/views/release/ASCOverview.swift +++ b/src/views/release/ASCOverview.swift @@ -13,7 +13,7 @@ struct ASCOverview: View { projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .ascOverview, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(asc: asc, tab: .app, platform: appState.activeProject?.platform ?? .iOS) { overviewContent } } @@ -22,7 +22,7 @@ struct ASCOverview: View { asc.checkAppIcon(projectId: pid) appIcon = Self.loadAppIcon(projectId: pid) } - await asc.fetchTabData(.ascOverview) + await asc.fetchTabData(.app) } .sheet(isPresented: $showPreview) { SubmitPreviewSheet(appState: appState) @@ -123,7 +123,7 @@ struct ASCOverview: View { .font(.headline) Button { - Task { await asc.refreshTabData(.ascOverview) } + Task { await asc.refreshTabData(.app) } } label: { Image(systemName: "arrow.clockwise") } diff --git a/src/views/sidebar/SidebarView.swift b/src/views/sidebar/SidebarView.swift index e4df04b..205e12d 100644 --- a/src/views/sidebar/SidebarView.swift +++ b/src/views/sidebar/SidebarView.swift @@ -2,40 +2,40 @@ import SwiftUI struct SidebarView: View { @Bindable var appState: AppState - - private func projectIcon(_ project: Project) -> String { - if project.platform == .macOS { return "desktopcomputer" } - switch project.type { - case .reactNative: return "atom" - case .swift: return "swift" - case .flutter: return "bird" - } - } + @State private var appIcon: NSImage? var body: some View { List(selection: $appState.activeTab) { - // Active project header - if let project = appState.activeProject { - Section { - HStack(spacing: 8) { + // Top-level standalone tabs + Section { + Label("Dashboard", systemImage: "square.grid.2x2") + .tag(AppTab.dashboard) + + // App tab — shows dynamic project icon + name + HStack(spacing: 8) { + if let icon = appIcon { + Image(nsImage: icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18, height: 18) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else if let project = appState.activeProject { Image(systemName: projectIcon(project)) - .foregroundStyle(.blue) - .font(.system(size: 14)) - Text(project.name) - .font(.system(size: 13, weight: .semibold)) - .lineLimit(1) + .foregroundStyle(projectColor(project)) + .frame(width: 18, height: 18) + } else { + Image(systemName: "app") + .frame(width: 18, height: 18) } - .padding(.vertical, 2) + Text(appState.activeProject?.name ?? "App") + .lineLimit(1) } + .tag(AppTab.app) } - - // Build group - Section("Build") { - ForEach(AppTab.Group.build.tabs) { tab in - Label(tab.label, systemImage: tab.icon) - .tag(tab) - } + .onChange(of: appState.activeProjectId) { _, _ in + reloadAppIcon() } + .onAppear { reloadAppIcon() } // Release group Section("Release") { @@ -70,4 +70,29 @@ struct SidebarView: View { .listStyle(.sidebar) .scrollDisabled(true) } + + private func projectIcon(_ project: Project) -> String { + if project.platform == .macOS { return "desktopcomputer" } + switch project.type { + case .reactNative: return "atom" + case .swift: return "swift" + case .flutter: return "bird" + } + } + + private func projectColor(_ project: Project) -> Color { + switch project.type { + case .reactNative: return .cyan + case .swift: return .orange + case .flutter: return .blue + } + } + + private func reloadAppIcon() { + guard let projectId = appState.activeProjectId else { + appIcon = nil + return + } + appIcon = DashboardView.loadAppIcon(projectId: projectId) + } } From 82842988f57de4fd78fa2f0d40788e20c6493532 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Mon, 23 Mar 2026 17:35:17 -0700 Subject: [PATCH 02/51] Add integrated terminal with SwiftTerm, multi-tab sessions, and split positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embed an interactive terminal panel using SwiftTerm (PTY-backed shell). TerminalManager on AppState manages session lifecycle independently from panel visibility — toggling (Cmd+`) only shows/hides the panel. Multiple terminal tabs supported with per-session shell processes. Panel can be positioned at bottom (VSplitView) or right (HSplitView) via a toggle button, persisted in settings.json. Terminal views use a container-based NSViewRepresentable so sessions survive SwiftUI view recycling. Co-Authored-By: Claude Opus 4.6 (1M context) --- Package.resolved | 24 ++++ Package.swift | 4 + src/AppCommands.swift | 11 ++ src/AppState.swift | 133 +++++++++++++++++++ src/services/SettingsService.swift | 3 + src/views/ContentView.swift | 34 ++++- src/views/build/IntegratedTerminalView.swift | 42 ++++++ src/views/build/TerminalPanelView.swift | 129 ++++++++++++++++++ 8 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 Package.resolved create mode 100644 src/views/build/IntegratedTerminalView.swift create mode 100644 src/views/build/TerminalPanelView.swift diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..209cd05 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "f47d0cb895ca8245ce9a43115105f5d3639a1454cf83268f88e98f5fa3c0fc57", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version" : "1.7.1" + } + }, + { + "identity" : "swiftterm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/migueldeicaza/SwiftTerm.git", + "state" : { + "revision" : "3c45fdcfcf4395c72d2a4ee23c0bce79017b5391", + "version" : "1.12.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 55a7cae..983a4dd 100644 --- a/Package.swift +++ b/Package.swift @@ -10,9 +10,13 @@ let package = Package( products: [ .executable(name: "Blitz", targets: ["Blitz"]), ], + dependencies: [ + .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0"), + ], targets: [ .executableTarget( name: "Blitz", + dependencies: ["SwiftTerm"], path: "src", exclude: ["metal"], resources: [.process("resources"), .copy("templates")], diff --git a/src/AppCommands.swift b/src/AppCommands.swift index ad281b6..5491090 100644 --- a/src/AppCommands.swift +++ b/src/AppCommands.swift @@ -86,6 +86,17 @@ struct AppCommands: Commands { .keyboardShortcut("3", modifiers: .command) } + // View > Terminal toggle + CommandGroup(after: .sidebar) { + Button(appState.showTerminal ? "Hide Terminal" : "Show Terminal") { + appState.showTerminal.toggle() + if appState.showTerminal && appState.terminalManager.sessions.isEmpty { + appState.terminalManager.createSession(projectPath: appState.activeProject?.path) + } + } + .keyboardShortcut("`", modifiers: .command) + } + // Build menu CommandMenu("Build") { Button("Run") { diff --git a/src/AppState.swift b/src/AppState.swift index 8e513ad..69a8289 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -141,6 +141,10 @@ var settingsStore = SettingsService.shared var projectSetup = ProjectSetupManager() var ascManager = ASCManager() var autoUpdate = AutoUpdateManager() + var terminalManager = TerminalManager() + + // Terminal panel visibility (toggle only — does not affect session lifecycle) + var showTerminal = false // Sheet control (toggled by menu bar, observed by ContentView) var showNewProjectSheet = false @@ -569,5 +573,134 @@ final class DatabaseManager { } } +// MARK: - Terminal + +import SwiftTerm + +/// A single terminal session backed by a pseudo-terminal process. +/// The `terminalView` is created once and reused across show/hide cycles. +@MainActor +final class TerminalSession: Identifiable { + let id = UUID() + var title: String + let terminalView: LocalProcessTerminalView + private(set) var isTerminated = false + + private var delegateProxy: TerminalSessionDelegateProxy? + + init(title: String, projectPath: String?, onTerminated: @escaping (UUID) -> Void, onTitleChanged: @escaping (UUID, String) -> Void) { + self.title = title + + let termView = LocalProcessTerminalView(frame: NSRect(x: 0, y: 0, width: 800, height: 400)) + termView.nativeBackgroundColor = NSColor(red: 0.1, green: 0.1, blue: 0.1, alpha: 1) + termView.nativeForegroundColor = NSColor.white + termView.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) + self.terminalView = termView + + let proxy = TerminalSessionDelegateProxy() + let sessionId = id + proxy.onTerminated = { onTerminated(sessionId) } + proxy.onTitleChanged = { newTitle in onTitleChanged(sessionId, newTitle) } + self.delegateProxy = proxy + termView.processDelegate = proxy + + let cwd: String + if let path = projectPath, FileManager.default.fileExists(atPath: path) { + cwd = path + } else { + cwd = FileManager.default.homeDirectoryForCurrentUser.path + } + + let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" + var env = ProcessInfo.processInfo.environment + env["TERM"] = "xterm-256color" + let envPairs = env.map { "\($0.key)=\($0.value)" } + + termView.startProcess( + executable: shell, + args: ["-l"], + environment: envPairs, + execName: "-\((shell as NSString).lastPathComponent)", + currentDirectory: cwd + ) + } + + func terminate() { + guard !isTerminated else { return } + isTerminated = true + terminalView.terminate() + } + + func markTerminated() { + isTerminated = true + } +} + +/// Bridges SwiftTerm delegate callbacks to closures for TerminalSession. +private class TerminalSessionDelegateProxy: NSObject, LocalProcessTerminalViewDelegate { + var onTerminated: (() -> Void)? + var onTitleChanged: ((String) -> Void)? + + func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {} + + func setTerminalTitle(source: LocalProcessTerminalView, title: String) { + DispatchQueue.main.async { self.onTitleChanged?(title) } + } + + func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} + + func processTerminated(source: TerminalView, exitCode: Int32?) { + DispatchQueue.main.async { self.onTerminated?() } + } +} + +/// Manages terminal session lifecycle. Lives on AppState to persist across all views. +@MainActor +@Observable +final class TerminalManager { + var sessions: [TerminalSession] = [] + var activeSessionId: UUID? + + private var sessionCounter = 0 + + var activeSession: TerminalSession? { + guard let id = activeSessionId else { return nil } + return sessions.first { $0.id == id } + } + + @discardableResult + func createSession(projectPath: String?) -> TerminalSession { + sessionCounter += 1 + let session = TerminalSession( + title: "Terminal \(sessionCounter)", + projectPath: projectPath, + onTerminated: { [weak self] id in + self?.sessions.first { $0.id == id }?.markTerminated() + }, + onTitleChanged: { [weak self] id, newTitle in + self?.sessions.first { $0.id == id }?.title = newTitle + } + ) + sessions.append(session) + activeSessionId = session.id + return session + } + + func closeSession(_ id: UUID) { + sessions.first { $0.id == id }?.terminate() + sessions.removeAll { $0.id == id } + if activeSessionId == id { + activeSessionId = sessions.last?.id + } + } + + func closeAllSessions() { + sessions.forEach { $0.terminate() } + sessions.removeAll() + activeSessionId = nil + sessionCounter = 0 + } +} + // SettingsStore is SettingsService (defined in Services/SettingsService.swift) typealias SettingsStore = SettingsService diff --git a/src/services/SettingsService.swift b/src/services/SettingsService.swift index 0562b0b..c5493b4 100644 --- a/src/services/SettingsService.swift +++ b/src/services/SettingsService.swift @@ -32,6 +32,7 @@ final class SettingsService { var defaultAgentCLI: String = AIAgent.claudeCode.rawValue var sendDefaultPrompt: Bool = true var skipAgentPermissions: Bool = false + var terminalPosition: String = "bottom" // "bottom" or "right" init() { self.settingsURL = BlitzPaths.settings @@ -52,6 +53,7 @@ final class SettingsService { if let agent = json["defaultAgentCLI"] as? String { defaultAgentCLI = agent } if let sendPrompt = json["sendDefaultPrompt"] as? Bool { sendDefaultPrompt = sendPrompt } if let skipPerms = json["skipAgentPermissions"] as? Bool { skipAgentPermissions = skipPerms } + if let termPos = json["terminalPosition"] as? String { terminalPosition = termPos } } func save() { @@ -64,6 +66,7 @@ final class SettingsService { "defaultAgentCLI": defaultAgentCLI, "sendDefaultPrompt": sendDefaultPrompt, "skipAgentPermissions": skipAgentPermissions, + "terminalPosition": terminalPosition, ] if let udid = defaultSimulatorUDID { json["defaultSimulatorUDID"] = udid diff --git a/src/views/ContentView.swift b/src/views/ContentView.swift index 40e2848..3196c67 100644 --- a/src/views/ContentView.swift +++ b/src/views/ContentView.swift @@ -52,7 +52,27 @@ struct ContentView: View { SidebarView(appState: appState) .navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 250) } detail: { - DetailView(appState: appState) + if appState.settingsStore.terminalPosition == "right" { + HSplitView { + DetailView(appState: appState) + .frame(minWidth: 300) + + if appState.showTerminal { + TerminalPanelView(appState: appState) + .frame(minWidth: 250, idealWidth: 400) + } + } + } else { + VSplitView { + DetailView(appState: appState) + .frame(minHeight: 200) + + if appState.showTerminal { + TerminalPanelView(appState: appState) + .frame(minHeight: 120, idealHeight: 250) + } + } + } } .navigationSplitViewStyle(.balanced) .toolbar { @@ -80,6 +100,18 @@ struct ContentView: View { } } } + ToolbarItem(placement: .navigation) { + Button { + appState.showTerminal.toggle() + // Auto-create first session when opening the panel + if appState.showTerminal && appState.terminalManager.sessions.isEmpty { + appState.terminalManager.createSession(projectPath: appState.activeProject?.path) + } + } label: { + Label("Terminal", systemImage: "terminal") + } + .help(appState.showTerminal ? "Hide terminal" : "Show terminal") + } } .background(HostingWindowFinder { window in mainWindow = window diff --git a/src/views/build/IntegratedTerminalView.swift b/src/views/build/IntegratedTerminalView.swift new file mode 100644 index 0000000..dd88d01 --- /dev/null +++ b/src/views/build/IntegratedTerminalView.swift @@ -0,0 +1,42 @@ +import SwiftUI +import SwiftTerm + +/// Hosts a `TerminalSession`'s `LocalProcessTerminalView` inside a container NSView. +/// The terminal view is owned by `TerminalSession` (not by SwiftUI), so it persists +/// across show/hide cycles and tab switches. +struct TerminalSessionView: NSViewRepresentable { + let session: TerminalSession + + func makeNSView(context: Context) -> NSView { + let container = NSView(frame: .zero) + container.wantsLayer = true + embed(session.terminalView, in: container) + return container + } + + func updateNSView(_ nsView: NSView, context: Context) { + let termView = session.terminalView + // Re-embed only if the session's view isn't already in this container + if termView.superview !== nsView { + nsView.subviews.forEach { $0.removeFromSuperview() } + embed(termView, in: nsView) + } + } + + static func dismantleNSView(_ nsView: NSView, coordinator: ()) { + // Detach terminal view from container — the view itself lives on in TerminalSession + nsView.subviews.forEach { $0.removeFromSuperview() } + } + + private func embed(_ termView: LocalProcessTerminalView, in container: NSView) { + termView.removeFromSuperview() + termView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(termView) + NSLayoutConstraint.activate([ + termView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + termView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + termView.topAnchor.constraint(equalTo: container.topAnchor), + termView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + } +} diff --git a/src/views/build/TerminalPanelView.swift b/src/views/build/TerminalPanelView.swift new file mode 100644 index 0000000..855a94c --- /dev/null +++ b/src/views/build/TerminalPanelView.swift @@ -0,0 +1,129 @@ +import SwiftUI + +struct TerminalPanelView: View { + @Bindable var appState: AppState + + private var manager: TerminalManager { appState.terminalManager } + private var isRight: Bool { appState.settingsStore.terminalPosition == "right" } + + var body: some View { + VStack(spacing: 0) { + tabBar + Divider() + terminalContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: - Tab Bar + + private var tabBar: some View { + HStack(spacing: 0) { + // Session tabs + ForEach(manager.sessions) { session in + sessionTab(session) + } + + // New tab button + Button { + manager.createSession(projectPath: appState.activeProject?.path) + } label: { + Image(systemName: "plus") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .help("New terminal") + + Spacer() + + // Position toggle + Button { + let settings = appState.settingsStore + settings.terminalPosition = isRight ? "bottom" : "right" + settings.save() + } label: { + Image(systemName: isRight ? "rectangle.bottomhalf.filled" : "rectangle.righthalf.filled") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .help(isRight ? "Move to bottom" : "Move to right") + + // Hide panel button + Button { + appState.showTerminal = false + } label: { + Image(systemName: isRight ? "chevron.right" : "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .help("Hide terminal panel") + .padding(.trailing, 8) + } + .padding(.leading, 8) + .padding(.vertical, 2) + .background(.bar) + } + + private func sessionTab(_ session: TerminalSession) -> some View { + let isActive = session.id == manager.activeSessionId + + return HStack(spacing: 4) { + Image(systemName: session.isTerminated ? "terminal" : "terminal.fill") + .font(.system(size: 10)) + + Text(session.title) + .font(.system(size: 11, weight: isActive ? .medium : .regular)) + .lineLimit(1) + + // Close button + Button { + manager.closeSession(session.id) + if manager.sessions.isEmpty { + appState.showTerminal = false + } + } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.tertiary) + .frame(width: 14, height: 14) + } + .buttonStyle(.plain) + .opacity(isActive ? 1 : 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(isActive ? Color.primary.opacity(0.08) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .contentShape(Rectangle()) + .onTapGesture { + manager.activeSessionId = session.id + } + } + + // MARK: - Content + + @ViewBuilder + private var terminalContent: some View { + if let session = manager.activeSession { + TerminalSessionView(session: session) + } else { + VStack(spacing: 8) { + Text("No terminal sessions") + .font(.callout) + .foregroundStyle(.secondary) + Button("New Terminal") { + manager.createSession(projectPath: appState.activeProject?.path) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} From d61b16df4e2ece7bd2c923457ad3ac0d083e22dd Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Mon, 23 Mar 2026 17:53:00 -0700 Subject: [PATCH 03/51] Fix terminal blank on split position change with stable view hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace conditional VSplitView/HSplitView branching with a single TerminalSplitView that uses GeometryReader + ZStack positioning. Switching orientation now only changes frame sizes and offsets — the terminal NSView is never removed from the hierarchy, preserving its Metal rendering context. Panel size persisted on AppState. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AppState.swift | 1 + src/views/ContentView.swift | 30 +++---- src/views/build/IntegratedTerminalView.swift | 9 +- src/views/build/TerminalSplitView.swift | 91 ++++++++++++++++++++ 4 files changed, 109 insertions(+), 22 deletions(-) create mode 100644 src/views/build/TerminalSplitView.swift diff --git a/src/AppState.swift b/src/AppState.swift index 69a8289..ff71c55 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -145,6 +145,7 @@ var settingsStore = SettingsService.shared // Terminal panel visibility (toggle only — does not affect session lifecycle) var showTerminal = false + var terminalPanelSize: CGFloat = 250 // Sheet control (toggled by menu bar, observed by ContentView) var showNewProjectSheet = false diff --git a/src/views/ContentView.swift b/src/views/ContentView.swift index 3196c67..7b83c24 100644 --- a/src/views/ContentView.swift +++ b/src/views/ContentView.swift @@ -52,26 +52,16 @@ struct ContentView: View { SidebarView(appState: appState) .navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 250) } detail: { - if appState.settingsStore.terminalPosition == "right" { - HSplitView { - DetailView(appState: appState) - .frame(minWidth: 300) - - if appState.showTerminal { - TerminalPanelView(appState: appState) - .frame(minWidth: 250, idealWidth: 400) - } - } - } else { - VSplitView { - DetailView(appState: appState) - .frame(minHeight: 200) - - if appState.showTerminal { - TerminalPanelView(appState: appState) - .frame(minHeight: 120, idealHeight: 250) - } - } + TerminalSplitView( + isHorizontal: appState.settingsStore.terminalPosition == "right", + showPanel: appState.showTerminal, + panelSize: $appState.terminalPanelSize, + minPanelSize: 120, + minContentSize: 200 + ) { + DetailView(appState: appState) + } panel: { + TerminalPanelView(appState: appState) } } .navigationSplitViewStyle(.balanced) diff --git a/src/views/build/IntegratedTerminalView.swift b/src/views/build/IntegratedTerminalView.swift index dd88d01..e1d7e0b 100644 --- a/src/views/build/IntegratedTerminalView.swift +++ b/src/views/build/IntegratedTerminalView.swift @@ -24,8 +24,10 @@ struct TerminalSessionView: NSViewRepresentable { } static func dismantleNSView(_ nsView: NSView, coordinator: ()) { - // Detach terminal view from container — the view itself lives on in TerminalSession - nsView.subviews.forEach { $0.removeFromSuperview() } + // Do NOT remove the terminal view here. It is managed by TerminalSession + // and will be re-embedded by makeNSView when the view hierarchy is rebuilt + // (e.g. switching split position). Removing it here causes the terminal's + // rendering context to be lost, resulting in a blank view. } private func embed(_ termView: LocalProcessTerminalView, in container: NSView) { @@ -38,5 +40,8 @@ struct TerminalSessionView: NSViewRepresentable { termView.topAnchor.constraint(equalTo: container.topAnchor), termView.bottomAnchor.constraint(equalTo: container.bottomAnchor), ]) + // Force a full redraw after re-embedding to restore rendering state + termView.needsLayout = true + termView.needsDisplay = true } } diff --git a/src/views/build/TerminalSplitView.swift b/src/views/build/TerminalSplitView.swift new file mode 100644 index 0000000..5196f3c --- /dev/null +++ b/src/views/build/TerminalSplitView.swift @@ -0,0 +1,91 @@ +import SwiftUI + +/// A split view that keeps a **stable view hierarchy** regardless of orientation. +/// Switching between bottom/right only changes frame sizes — child views are never +/// destroyed or recreated, which preserves terminal NSView rendering state. +struct TerminalSplitView: View { + let isHorizontal: Bool + let showPanel: Bool + @Binding var panelSize: CGFloat + let minPanelSize: CGFloat + let minContentSize: CGFloat + @ViewBuilder let content: () -> Content + @ViewBuilder let panel: () -> Panel + + @GestureState private var dragOffset: CGFloat = 0 + + var body: some View { + GeometryReader { geo in + let total = isHorizontal ? geo.size.width : geo.size.height + let raw = showPanel ? panelSize + dragOffset : 0 + let clamped = max(minPanelSize, min(raw, total - minContentSize)) + let divider: CGFloat = showPanel ? 1 : 0 + let contentSize = showPanel ? total - clamped - divider : total + + // Always the same ZStack → stable view identity for both children + ZStack(alignment: .topLeading) { + // Content — pinned to top-left + content() + .frame( + width: isHorizontal ? contentSize : geo.size.width, + height: isHorizontal ? geo.size.height : contentSize + ) + + if showPanel { + // Divider + Panel group + ZStack(alignment: .topLeading) { + // Divider line + Rectangle() + .fill(Color(nsColor: .separatorColor)) + .frame( + width: isHorizontal ? 1 : geo.size.width, + height: isHorizontal ? geo.size.height : 1 + ) + + // Drag hit area (invisible, wider than the 1px line) + Color.clear + .frame( + width: isHorizontal ? 9 : geo.size.width, + height: isHorizontal ? geo.size.height : 9 + ) + .offset(x: isHorizontal ? -4 : 0, y: isHorizontal ? 0 : -4) + .contentShape(Rectangle()) + .cursor(isHorizontal ? .resizeLeftRight : .resizeUpDown) + .gesture( + DragGesture(minimumDistance: 1) + .updating($dragOffset) { value, state, _ in + state = isHorizontal ? -value.translation.width : -value.translation.height + } + .onEnded { value in + let delta = isHorizontal ? -value.translation.width : -value.translation.height + panelSize = max(minPanelSize, min(panelSize + delta, total - minContentSize)) + } + ) + + // Panel + panel() + .frame( + width: isHorizontal ? clamped : geo.size.width, + height: isHorizontal ? geo.size.height : clamped + ) + .offset(x: isHorizontal ? divider : 0, y: isHorizontal ? 0 : divider) + } + .offset( + x: isHorizontal ? contentSize : 0, + y: isHorizontal ? 0 : contentSize + ) + } + } + } + } +} + +// MARK: - Cursor + +private extension View { + func cursor(_ cursor: NSCursor) -> some View { + onHover { inside in + if inside { cursor.push() } else { NSCursor.pop() } + } + } +} From 782f1f4fbc9bc6c3289a250c985bf615deaf7edb Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Mon, 23 Mar 2026 18:22:27 -0700 Subject: [PATCH 04/51] Improve terminal split resizing --- src/views/AppTabView.swift | 20 +++ src/views/ContentView.swift | 77 ++++++---- src/views/build/TerminalSplitView.swift | 194 +++++++++++++++++------- 3 files changed, 207 insertions(+), 84 deletions(-) diff --git a/src/views/AppTabView.swift b/src/views/AppTabView.swift index 12dd070..9405b5a 100644 --- a/src/views/AppTabView.swift +++ b/src/views/AppTabView.swift @@ -3,6 +3,24 @@ import SwiftUI struct AppTabView: View { @Bindable var appState: AppState + /// Minimum width needed to keep every App sub-tab button on a single line. + static let minimumSingleLineWidth: CGFloat = { + let textFont = NSFont.systemFont(ofSize: 12, weight: .medium) + let symbolAllowance: CGFloat = 14 + let buttonInnerSpacing: CGFloat = 4 + let buttonHorizontalPadding: CGFloat = 20 + let interButtonSpacing: CGFloat = 2 * CGFloat(max(AppSubTab.allCases.count - 1, 0)) + let navbarHorizontalPadding: CGFloat = 32 + let safetyMargin: CGFloat = 24 + + let totalButtonWidth = AppSubTab.allCases.reduce(CGFloat.zero) { partial, tab in + let textWidth = ceil((tab.label as NSString).size(withAttributes: [.font: textFont]).width) + return partial + textWidth + symbolAllowance + buttonInnerSpacing + buttonHorizontalPadding + } + + return totalButtonWidth + interButtonSpacing + navbarHorizontalPadding + safetyMargin + }() + var body: some View { VStack(spacing: 0) { // Top navbar @@ -32,7 +50,9 @@ struct AppTabView: View { .font(.system(size: 11)) Text(tab.label) .font(.system(size: 12, weight: .medium)) + .lineLimit(1) } + .fixedSize(horizontal: true, vertical: false) .padding(.horizontal, 10) .padding(.vertical, 5) .background( diff --git a/src/views/ContentView.swift b/src/views/ContentView.swift index 7b83c24..116acad 100644 --- a/src/views/ContentView.swift +++ b/src/views/ContentView.swift @@ -25,6 +25,14 @@ struct ContentView: View { @State private var tabSwitchTask: Task? @State private var showConnectAI = false + private var terminalSplitMinContentSize: CGFloat { + let baseMinContentSize: CGFloat = 200 + guard appState.settingsStore.terminalPosition == "right" else { + return baseMinContentSize + } + return max(baseMinContentSize, AppTabView.minimumSingleLineWidth) + } + private var appleIDLoginBinding: Binding { Binding( get: { appState.ascManager.showAppleIDLogin }, @@ -33,6 +41,41 @@ struct ContentView: View { } /// Consume pendingSetupProjectId and run project scaffolding if needed. + private func launchTerminal() { + let settings = appState.settingsStore + let terminal = settings.resolveDefaultTerminal().terminal + + if terminal.isBuiltIn { + // Show built-in terminal panel + appState.showTerminal = true + + // Create a new session with the AI agent command + let session = appState.terminalManager.createSession(projectPath: appState.activeProject?.path) + + // Build and send the agent CLI command + let agent = AIAgent(rawValue: settings.defaultAgentCLI) ?? .claudeCode + var command = agent.cliCommand + if settings.skipAgentPermissions, let flag = agent.skipPermissionsFlag { + command += " \(flag)" + } + if settings.sendDefaultPrompt, let prompt = ConnectAIPopover.prompt(for: appState.activeTab) { + let escaped = prompt.replacingOccurrences(of: "'", with: "'\\''") + command += " '\(escaped)'" + } + + // Small delay so the shell is ready to receive input + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + session.sendCommand(command) + } + } else { + // Launch external terminal + TerminalLauncher.launchFromSettings( + projectPath: appState.activeProject?.path, + activeTab: appState.activeTab + ) + } + } + private func startPendingSetupIfNeeded() async { guard let pendingId = appState.projectSetup.pendingSetupProjectId, pendingId == appState.activeProjectId, @@ -57,7 +100,7 @@ struct ContentView: View { showPanel: appState.showTerminal, panelSize: $appState.terminalPanelSize, minPanelSize: 120, - minContentSize: 200 + minContentSize: terminalSplitMinContentSize ) { DetailView(appState: appState) } panel: { @@ -66,41 +109,13 @@ struct ContentView: View { } .navigationSplitViewStyle(.balanced) .toolbar { - ToolbarItem(placement: .navigation) { - Button(action: { - // Try to auto-launch terminal with agent CLI - let launched = TerminalLauncher.launchFromSettings( - projectPath: appState.activeProject?.path, - activeTab: appState.activeTab - ) - if !launched { - // Fallback: show the popover - showConnectAI = true - } - }) { - Label("Connect AI", systemImage: "sparkles") - } - .help("Connect AI agent") - .popover(isPresented: $showConnectAI, arrowEdge: .bottom) { - ConnectAIPopover(projectPath: appState.activeProject?.path, activeTab: appState.activeTab) - } - .contextMenu { - Button("Show Connect AI Panel") { - showConnectAI = true - } - } - } ToolbarItem(placement: .navigation) { Button { - appState.showTerminal.toggle() - // Auto-create first session when opening the panel - if appState.showTerminal && appState.terminalManager.sessions.isEmpty { - appState.terminalManager.createSession(projectPath: appState.activeProject?.path) - } + launchTerminal() } label: { Label("Terminal", systemImage: "terminal") } - .help(appState.showTerminal ? "Hide terminal" : "Show terminal") + .help("Launch terminal with AI agent") } } .background(HostingWindowFinder { window in diff --git a/src/views/build/TerminalSplitView.swift b/src/views/build/TerminalSplitView.swift index 5196f3c..ab8eeff 100644 --- a/src/views/build/TerminalSplitView.swift +++ b/src/views/build/TerminalSplitView.swift @@ -12,15 +12,21 @@ struct TerminalSplitView: View { @ViewBuilder let content: () -> Content @ViewBuilder let panel: () -> Panel - @GestureState private var dragOffset: CGFloat = 0 + @State private var dragStartPanelSize: CGFloat? + @State private var isHoveringDivider = false + @State private var isDraggingDivider = false + + private let dividerThickness: CGFloat = 1 + private let grabAreaThickness: CGFloat = 20 var body: some View { GeometryReader { geo in - let total = isHorizontal ? geo.size.width : geo.size.height - let raw = showPanel ? panelSize + dragOffset : 0 - let clamped = max(minPanelSize, min(raw, total - minContentSize)) - let divider: CGFloat = showPanel ? 1 : 0 - let contentSize = showPanel ? total - clamped - divider : total + let total = axisLength(in: geo.size) + let visibleDividerThickness = showPanel ? dividerThickness : 0 + let clampedPanelSize = showPanel ? clampedPanelSize(panelSize, total: total) : 0 + let contentSize = max(total - clampedPanelSize - visibleDividerThickness, 0) + let dividerOffset = contentSize + let panelOffset = contentSize + visibleDividerThickness // Always the same ZStack → stable view identity for both children ZStack(alignment: .topLeading) { @@ -28,64 +34,146 @@ struct TerminalSplitView: View { content() .frame( width: isHorizontal ? contentSize : geo.size.width, - height: isHorizontal ? geo.size.height : contentSize + height: isHorizontal ? geo.size.height : contentSize, + alignment: .topLeading ) - if showPanel { - // Divider + Panel group - ZStack(alignment: .topLeading) { - // Divider line - Rectangle() - .fill(Color(nsColor: .separatorColor)) - .frame( - width: isHorizontal ? 1 : geo.size.width, - height: isHorizontal ? geo.size.height : 1 - ) - - // Drag hit area (invisible, wider than the 1px line) - Color.clear - .frame( - width: isHorizontal ? 9 : geo.size.width, - height: isHorizontal ? geo.size.height : 9 - ) - .offset(x: isHorizontal ? -4 : 0, y: isHorizontal ? 0 : -4) - .contentShape(Rectangle()) - .cursor(isHorizontal ? .resizeLeftRight : .resizeUpDown) - .gesture( - DragGesture(minimumDistance: 1) - .updating($dragOffset) { value, state, _ in - state = isHorizontal ? -value.translation.width : -value.translation.height - } - .onEnded { value in - let delta = isHorizontal ? -value.translation.width : -value.translation.height - panelSize = max(minPanelSize, min(panelSize + delta, total - minContentSize)) - } - ) - - // Panel - panel() - .frame( - width: isHorizontal ? clamped : geo.size.width, - height: isHorizontal ? geo.size.height : clamped - ) - .offset(x: isHorizontal ? divider : 0, y: isHorizontal ? 0 : divider) - } + // Panel — stays in the hierarchy even when hidden so orientation flips do not + // recreate the underlying NSView subtree. + panel() + .frame( + width: isHorizontal ? clampedPanelSize : geo.size.width, + height: isHorizontal ? geo.size.height : clampedPanelSize, + alignment: .topLeading + ) .offset( - x: isHorizontal ? contentSize : 0, - y: isHorizontal ? 0 : contentSize + x: isHorizontal ? panelOffset : 0, + y: isHorizontal ? 0 : panelOffset ) - } + .opacity(showPanel ? 1 : 0) + .allowsHitTesting(showPanel) + + // Visible divider line + Rectangle() + .fill(dividerColor) + .frame( + width: isHorizontal ? visibleDividerThickness : geo.size.width, + height: isHorizontal ? geo.size.height : visibleDividerThickness + ) + .offset( + x: isHorizontal ? dividerOffset : 0, + y: isHorizontal ? 0 : dividerOffset + ) + .opacity(showPanel ? 1 : 0) + .allowsHitTesting(false) + .animation(.easeInOut(duration: 0.15), value: isHoveringDivider) + .animation(.easeInOut(duration: 0.15), value: isDraggingDivider) + + dividerHandle(in: geo.size, dividerOffset: dividerOffset, total: total) + .opacity(showPanel ? 1 : 0) + .allowsHitTesting(showPanel) + .zIndex(1) + } + .onDisappear { + dragStartPanelSize = nil + isDraggingDivider = false + isHoveringDivider = false } } } + + private var dividerColor: Color { + if isHoveringDivider || isDraggingDivider { + return Color.accentColor.opacity(0.6) + } + return Color(nsColor: .separatorColor) + } + + private func dividerHandle(in size: CGSize, dividerOffset: CGFloat, total: CGFloat) -> some View { + // Use a nearly transparent fill instead of Color.clear so AppKit always has a reliable + // hit-testable surface above the embedded terminal NSView. + Rectangle() + .fill(Color.black.opacity(0.001)) + .contentShape(Rectangle()) + .frame( + width: isHorizontal ? grabAreaThickness : size.width, + height: isHorizontal ? size.height : grabAreaThickness + ) + .offset( + x: isHorizontal ? dividerOffset - ((grabAreaThickness - dividerThickness) / 2) : 0, + y: isHorizontal ? 0 : dividerOffset - ((grabAreaThickness - dividerThickness) / 2) + ) + .resizeCursor(isHorizontal ? .resizeLeftRight : .resizeUpDown, isHovering: $isHoveringDivider) + .highPriorityGesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let startSize = dragStartPanelSize ?? clampedPanelSize(panelSize, total: total) + dragStartPanelSize = startSize + isDraggingDivider = true + panelSize = clampedPanelSize(startSize + dragDelta(for: value), total: total) + } + .onEnded { value in + let startSize = dragStartPanelSize ?? clampedPanelSize(panelSize, total: total) + panelSize = clampedPanelSize(startSize + dragDelta(for: value), total: total) + dragStartPanelSize = nil + isDraggingDivider = false + } + ) + } + + private func axisLength(in size: CGSize) -> CGFloat { + isHorizontal ? size.width : size.height + } + + private func dragDelta(for value: DragGesture.Value) -> CGFloat { + isHorizontal ? -value.translation.width : -value.translation.height + } + + private func clampedPanelSize(_ proposed: CGFloat, total: CGFloat) -> CGFloat { + let maxPanelSize = max(total - minContentSize, 0) + let minAllowedPanelSize = min(minPanelSize, maxPanelSize) + return min(max(proposed, minAllowedPanelSize), maxPanelSize) + } } // MARK: - Cursor private extension View { - func cursor(_ cursor: NSCursor) -> some View { - onHover { inside in - if inside { cursor.push() } else { NSCursor.pop() } - } + func resizeCursor(_ cursor: NSCursor, isHovering: Binding) -> some View { + modifier(ResizeCursorModifier(cursor: cursor, isHovering: isHovering)) + } +} + +private struct ResizeCursorModifier: ViewModifier { + let cursor: NSCursor + @Binding var isHovering: Bool + + @State private var hasPushedCursor = false + + func body(content: Content) -> some View { + content + .onContinuousHover { phase in + switch phase { + case .active: + isHovering = true + if !hasPushedCursor { + cursor.push() + hasPushedCursor = true + } + case .ended: + isHovering = false + if hasPushedCursor { + NSCursor.pop() + hasPushedCursor = false + } + } + } + .onDisappear { + isHovering = false + if hasPushedCursor { + NSCursor.pop() + hasPushedCursor = false + } + } } } From dcc179fd2b7759c2f8bc34548947906dcaf2e44c Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Mon, 23 Mar 2026 18:23:36 -0700 Subject: [PATCH 05/51] Unify terminal and AI launch into single toolbar button Replace separate sparkles (Connect AI) and terminal toolbar buttons with a single terminal button. Add "Terminal (built-in)" as default terminal option; rename external terminals to include "(external)" suffix. When built-in is selected, clicking the button opens the integrated terminal panel, creates a new session, and auto-sends the AI agent CLI command with the tab-specific prompt. When an external terminal is selected, uses existing TerminalLauncher behavior. TerminalSession.sendCommand() sends text to the PTY stdin. TerminalApp enum gains .builtIn case with isBuiltIn helper. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AppState.swift | 7 +++++ src/services/SettingsService.swift | 2 +- src/services/TerminalLauncher.swift | 4 ++- src/views/ContentView.swift | 1 - src/views/OnboardingView.swift | 37 +++++++++++++++++++-------- src/views/settings/SettingsView.swift | 9 ++++++- 6 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/AppState.swift b/src/AppState.swift index ff71c55..8b8a318 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -635,6 +635,13 @@ final class TerminalSession: Identifiable { func markTerminated() { isTerminated = true } + + /// Send a command string to the shell (types it and presses Enter). + func sendCommand(_ command: String) { + guard !isTerminated else { return } + let data = Array((command + "\n").utf8) + terminalView.send(source: terminalView, data: data[...]) + } } /// Bridges SwiftTerm delegate callbacks to closures for TerminalSession. diff --git a/src/services/SettingsService.swift b/src/services/SettingsService.swift index c5493b4..a4d4c07 100644 --- a/src/services/SettingsService.swift +++ b/src/services/SettingsService.swift @@ -28,7 +28,7 @@ final class SettingsService { // Onboarding var hasCompletedOnboarding: Bool = false - var defaultTerminal: String = "terminal" // "terminal", "ghostty", "iterm", or custom path + var defaultTerminal: String = "builtIn" // "builtIn", "terminal", "ghostty", "iterm", or custom path var defaultAgentCLI: String = AIAgent.claudeCode.rawValue var sendDefaultPrompt: Bool = true var skipAgentPermissions: Bool = false diff --git a/src/services/TerminalLauncher.swift b/src/services/TerminalLauncher.swift index 956970e..28035d9 100644 --- a/src/services/TerminalLauncher.swift +++ b/src/services/TerminalLauncher.swift @@ -29,6 +29,8 @@ enum TerminalLauncher { } switch terminal.resolvedFallback { + case .builtIn: + return false // Handled by ContentView directly case .terminal: return launchTerminalApp(command: shellCommand) case .ghostty: @@ -176,7 +178,7 @@ enum TerminalLauncher { /// Ghostty uses direct process execution and doesn't need it. static func needsAutomationPermission(_ terminal: TerminalApp) -> Bool { switch terminal { - case .ghostty: return false + case .builtIn, .ghostty: return false default: return true } } diff --git a/src/views/ContentView.swift b/src/views/ContentView.swift index 116acad..6b8eb4d 100644 --- a/src/views/ContentView.swift +++ b/src/views/ContentView.swift @@ -23,7 +23,6 @@ struct ContentView: View { @Environment(\.openWindow) private var openWindow @State private var mainWindow: NSWindow? @State private var tabSwitchTask: Task? - @State private var showConnectAI = false private var terminalSplitMinContentSize: CGFloat { let baseMinContentSize: CGFloat = 200 diff --git a/src/views/OnboardingView.swift b/src/views/OnboardingView.swift index 18d8ba1..2a420d7 100644 --- a/src/views/OnboardingView.swift +++ b/src/views/OnboardingView.swift @@ -5,6 +5,7 @@ import UniformTypeIdentifiers /// Terminal app options for onboarding configuration enum TerminalApp: Hashable { + case builtIn case terminal case ghostty case iterm @@ -12,6 +13,7 @@ enum TerminalApp: Hashable { var id: String { switch self { + case .builtIn: return "builtIn" case .terminal: return "terminal" case .ghostty: return "ghostty" case .iterm: return "iterm" @@ -21,16 +23,19 @@ enum TerminalApp: Hashable { var displayName: String { switch self { - case .terminal: return "Terminal" - case .ghostty: return "Ghostty" - case .iterm: return "iTerm" + case .builtIn: return "Terminal (built-in)" + case .terminal: return "Terminal (external)" + case .ghostty: return "Ghostty (external)" + case .iterm: return "iTerm (external)" case .custom(let path): - return URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent + let name = URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent + return "\(name) (external)" } } var iconName: String { switch self { + case .builtIn: return "terminal" case .terminal: return "terminal" case .ghostty: return "terminal" case .iterm: return "terminal" @@ -40,6 +45,7 @@ enum TerminalApp: Hashable { var bundleIdentifier: String { switch self { + case .builtIn: return "" case .terminal: return "com.apple.Terminal" case .ghostty: return "com.mitchellh.ghostty" case .iterm: return "com.googlecode.iterm2" @@ -47,8 +53,14 @@ enum TerminalApp: Hashable { } } + var isBuiltIn: Bool { + if case .builtIn = self { return true } + return false + } + var isAvailable: Bool { switch self { + case .builtIn: return true case .custom(let path): return FileManager.default.fileExists(atPath: path) default: @@ -56,9 +68,9 @@ enum TerminalApp: Hashable { } } - /// Missing saved terminals fall back to Terminal so launches still work. + /// Missing saved terminals fall back to built-in so launches still work. var resolvedFallback: TerminalApp { - isAvailable ? self : .terminal + isAvailable ? self : .builtIn } /// Persist to settings as a string @@ -67,6 +79,7 @@ enum TerminalApp: Hashable { /// Restore from settings string static func from(_ value: String) -> TerminalApp { switch value { + case "builtIn": return .builtIn case "terminal": return .terminal case "ghostty": return .ghostty case "iterm": return .iterm @@ -80,7 +93,7 @@ struct OnboardingView: View { var onComplete: () -> Void @State private var currentStep = 0 - @State private var selectedTerminal: TerminalApp = .terminal + @State private var selectedTerminal: TerminalApp = .builtIn @State private var selectedAgent: AIAgent = .claudeCode @State private var detectedTerminals: [TerminalApp] = [] @State private var showCustomPicker = false @@ -332,7 +345,11 @@ struct OnboardingView: View { @ViewBuilder private func terminalIcon(for terminal: TerminalApp) -> some View { - if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: terminal.bundleIdentifier) { + if terminal.isBuiltIn { + Image(systemName: "terminal.fill") + .resizable() + .aspectRatio(contentMode: .fit) + } else if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: terminal.bundleIdentifier) { let icon = NSWorkspace.shared.icon(forFile: appURL.path) Image(nsImage: icon) .resizable() @@ -799,10 +816,10 @@ struct OnboardingView: View { // MARK: - Logic private func detectTerminals() -> [TerminalApp] { - var found: [TerminalApp] = [] + var found: [TerminalApp] = [.builtIn] let ws = NSWorkspace.shared - // Always include macOS Terminal + // macOS Terminal if ws.urlForApplication(withBundleIdentifier: "com.apple.Terminal") != nil { found.append(.terminal) } diff --git a/src/views/settings/SettingsView.swift b/src/views/settings/SettingsView.swift index d2983b5..58a3009 100644 --- a/src/views/settings/SettingsView.swift +++ b/src/views/settings/SettingsView.swift @@ -163,6 +163,10 @@ struct SettingsView: View { Text("Terminal") Spacer() Menu { + terminalMenuItem(.builtIn) + + Divider() + terminalMenuItem(.terminal) if NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.mitchellh.ghostty") != nil { @@ -307,7 +311,10 @@ struct SettingsView: View { @ViewBuilder private func terminalAppIcon(_ terminal: TerminalApp, size: CGFloat) -> some View { - if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: terminal.bundleIdentifier) { + if terminal.isBuiltIn { + Image(systemName: "terminal.fill") + .frame(width: size, height: size) + } else if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: terminal.bundleIdentifier) { let icon = Self.resizedIcon(NSWorkspace.shared.icon(forFile: appURL.path), size: size) Image(nsImage: icon) } else if case .custom(let path) = terminal { From 9d98790aeb5286c9abe57ce32f53e0e8b493048c Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Mon, 23 Mar 2026 18:46:22 -0700 Subject: [PATCH 06/51] Wire built-in terminal to onboarding and submission readiness fix buttons Onboarding: swap agent/terminal order, show "Built-in Terminal (recommended)" as default, collapse external terminals under a clickable "Use external terminal" toggle. Remove icons from section headers. ASCOverview: fix buttons now open the built-in terminal with the AI agent command when built-in is the configured terminal, matching the toolbar button behavior. Includes temporary always-show-onboarding flag for testing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/views/OnboardingView.swift | 112 +++++++++++++++++++--------- src/views/WelcomeWindow.swift | 5 +- src/views/release/ASCOverview.swift | 17 ++++- 3 files changed, 93 insertions(+), 41 deletions(-) diff --git a/src/views/OnboardingView.swift b/src/views/OnboardingView.swift index 2a420d7..9b27f80 100644 --- a/src/views/OnboardingView.swift +++ b/src/views/OnboardingView.swift @@ -33,6 +33,18 @@ enum TerminalApp: Hashable { } } + /// Name without the "(external)" suffix — used in onboarding disclosure. + var shortDisplayName: String { + switch self { + case .builtIn: return "Built-in Terminal" + case .terminal: return "Terminal" + case .ghostty: return "Ghostty" + case .iterm: return "iTerm" + case .custom(let path): + return URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent + } + } + var iconName: String { switch self { case .builtIn: return "terminal" @@ -97,6 +109,7 @@ struct OnboardingView: View { @State private var selectedAgent: AIAgent = .claudeCode @State private var detectedTerminals: [TerminalApp] = [] @State private var showCustomPicker = false + @State private var showExternalTerminals = false @State private var skipAgentPermissions: Bool init(appState: AppState, onComplete: @escaping () -> Void) { @@ -239,40 +252,9 @@ struct OnboardingView: View { // Right: configuration options ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 12) { - // Terminal selection - VStack(alignment: .leading, spacing: 6) { - Label("Default Terminal", systemImage: "terminal") - .font(.headline) - - VStack(spacing: 2) { - ForEach(detectedTerminals, id: \.self) { terminal in - terminalRow(terminal) - } - - // Custom picker button - Button { - showCustomPicker = true - } label: { - HStack(spacing: 10) { - Image(systemName: "folder") - .frame(width: 20) - Text("Choose Custom...") - Spacer() - } - .padding(.vertical, 4) - .padding(.horizontal, 10) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - } - } - - Divider() - - // Agent CLI selection + // Agent CLI selection (first — most users care about this) VStack(alignment: .leading, spacing: 6) { - Label("Default AI Agent", systemImage: "cpu") + Text("Default AI Agent") .font(.headline) VStack(spacing: 2) { @@ -284,8 +266,6 @@ struct OnboardingView: View { // Skip permissions toggle (only if agent supports it) if selectedAgent.skipPermissionsFlag != nil { - Divider() - Toggle(isOn: $skipAgentPermissions) { VStack(alignment: .leading, spacing: 1) { Text("Skip agent permissions") @@ -299,6 +279,60 @@ struct OnboardingView: View { .controlSize(.small) } + Divider() + + // Terminal selection + VStack(alignment: .leading, spacing: 6) { + Text("Terminal") + .font(.headline) + + // Built-in (recommended) — always shown + terminalRow(.builtIn, label: "Built-in Terminal (recommended)") + + // External terminals — collapsed under clickable header + VStack(alignment: .leading, spacing: 2) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + showExternalTerminals.toggle() + } + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .rotationEffect(.degrees(showExternalTerminals ? 90 : 0)) + Text("Use external terminal") + .font(.callout) + } + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.leading, 10) + .padding(.vertical, 4) + + if showExternalTerminals { + ForEach(externalTerminals, id: \.self) { terminal in + terminalRow(terminal, label: terminal.shortDisplayName) + } + + Button { + showCustomPicker = true + } label: { + HStack(spacing: 10) { + Image(systemName: "folder") + .frame(width: 20) + Text("Choose Custom...") + Spacer() + } + .padding(.vertical, 4) + .padding(.horizontal, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + } + } } .padding(.horizontal, 24) .padding(.vertical, 10) @@ -319,7 +353,11 @@ struct OnboardingView: View { } } - private func terminalRow(_ terminal: TerminalApp) -> some View { + private var externalTerminals: [TerminalApp] { + detectedTerminals.filter { !$0.isBuiltIn } + } + + private func terminalRow(_ terminal: TerminalApp, label: String? = nil) -> some View { let isSelected = selectedTerminal == terminal return Button { selectedTerminal = terminal @@ -327,7 +365,7 @@ struct OnboardingView: View { HStack(spacing: 10) { terminalIcon(for: terminal) .frame(width: 20, height: 20) - Text(terminal.displayName) + Text(label ?? terminal.displayName) .font(.body) Spacer() if isSelected { diff --git a/src/views/WelcomeWindow.swift b/src/views/WelcomeWindow.swift index 5ecc950..680bf2e 100644 --- a/src/views/WelcomeWindow.swift +++ b/src/views/WelcomeWindow.swift @@ -31,9 +31,8 @@ struct WelcomeWindow: View { }) .task { // Show onboarding on first launch - if !appState.settingsStore.hasCompletedOnboarding { - showOnboarding = true - } + // TODO: revert — temporarily always show onboarding for testing + showOnboarding = true } .task { if appState.projectManager.projects.isEmpty { diff --git a/src/views/release/ASCOverview.swift b/src/views/release/ASCOverview.swift index 68c2704..10ab5d4 100644 --- a/src/views/release/ASCOverview.swift +++ b/src/views/release/ASCOverview.swift @@ -280,7 +280,22 @@ struct ASCOverview: View { let settings = SettingsService.shared let agent = AIAgent(rawValue: settings.defaultAgentCLI) ?? .claudeCode let terminal = settings.resolveDefaultTerminal().terminal - TerminalLauncher.launch(projectPath: projectPath, agent: agent, terminal: terminal, prompt: prompt, skipPermissions: settings.skipAgentPermissions) + + if terminal.isBuiltIn { + appState.showTerminal = true + let session = appState.terminalManager.createSession(projectPath: projectPath) + var command = agent.cliCommand + if settings.skipAgentPermissions, let flag = agent.skipPermissionsFlag { + command += " \(flag)" + } + let escaped = prompt.replacingOccurrences(of: "'", with: "'\\''") + command += " '\(escaped)'" + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + session.sendCommand(command) + } + } else { + TerminalLauncher.launch(projectPath: projectPath, agent: agent, terminal: terminal, prompt: prompt, skipPermissions: settings.skipAgentPermissions) + } } private var buildProgress: Double { From a30563d19a9f9e34254be7cdf4a0c460e9cd469b Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Mon, 23 Mar 2026 19:06:57 -0700 Subject: [PATCH 07/51] Add setting to whitelist all Blitz MCP tool calls for AI agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Allow all Blitz MCP tool calls" toggle (default on) injects all blitz-macos and blitz-iphone tool names into .claude/settings.local.json permissions.allow. AI agents auto-approve Blitz MCP calls without prompting — Blitz's own native approval alert still gates destructive operations. MCPToolRegistry.allToolNames() extracts tool names from the registry. Toggle available in both onboarding and settings. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AppState.swift | 2 +- src/BlitzApp.swift | 2 +- src/services/MCPToolRegistry.swift | 5 ++ src/services/SettingsService.swift | 3 ++ src/utilities/ProjectStorage.swift | 58 ++++++++++++++++----- src/views/ContentView.swift | 2 +- src/views/OnboardingView.swift | 16 ++++++ src/views/projects/ImportProjectSheet.swift | 2 +- src/views/settings/SettingsView.swift | 9 ++++ 9 files changed, 82 insertions(+), 17 deletions(-) diff --git a/src/AppState.swift b/src/AppState.swift index 8b8a318..2fe34b3 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -393,7 +393,7 @@ final class ProjectSetupManager { // (setup recreates the project dir, so these must be written after) let storage = ProjectStorage() storage.ensureMCPConfig(projectId: projectId) - storage.ensureClaudeFiles(projectId: projectId, projectType: projectType) + storage.ensureClaudeFiles(projectId: projectId, projectType: projectType, whitelistBlitzMCP: SettingsService.shared.whitelistBlitzMCPTools) isSettingUp = false } catch { errorMessage = error.localizedDescription diff --git a/src/BlitzApp.swift b/src/BlitzApp.swift index 33ee72a..94f41d1 100644 --- a/src/BlitzApp.swift +++ b/src/BlitzApp.swift @@ -14,7 +14,7 @@ final class MCPBootstrap { installBridgeScript() installClaudeSkills() updateIphoneMCP() - ProjectStorage().ensureGlobalMCPConfigs() + ProjectStorage().ensureGlobalMCPConfigs(whitelistBlitzMCP: appState.settingsStore.whitelistBlitzMCPTools) let server = MCPServerService(appState: appState) self.server = server diff --git a/src/services/MCPToolRegistry.swift b/src/services/MCPToolRegistry.swift index da43026..68590d8 100644 --- a/src/services/MCPToolRegistry.swift +++ b/src/services/MCPToolRegistry.swift @@ -372,6 +372,11 @@ enum MCPToolRegistry { // MARK: - Helper + /// Returns all tool names registered in the registry. + static func allToolNames() -> [String] { + allTools().compactMap { $0["name"] as? String } + } + private static func tool( name: String, description: String, diff --git a/src/services/SettingsService.swift b/src/services/SettingsService.swift index a4d4c07..7d48e90 100644 --- a/src/services/SettingsService.swift +++ b/src/services/SettingsService.swift @@ -32,6 +32,7 @@ final class SettingsService { var defaultAgentCLI: String = AIAgent.claudeCode.rawValue var sendDefaultPrompt: Bool = true var skipAgentPermissions: Bool = false + var whitelistBlitzMCPTools: Bool = true var terminalPosition: String = "bottom" // "bottom" or "right" init() { @@ -53,6 +54,7 @@ final class SettingsService { if let agent = json["defaultAgentCLI"] as? String { defaultAgentCLI = agent } if let sendPrompt = json["sendDefaultPrompt"] as? Bool { sendDefaultPrompt = sendPrompt } if let skipPerms = json["skipAgentPermissions"] as? Bool { skipAgentPermissions = skipPerms } + if let whitelist = json["whitelistBlitzMCPTools"] as? Bool { whitelistBlitzMCPTools = whitelist } if let termPos = json["terminalPosition"] as? String { terminalPosition = termPos } } @@ -66,6 +68,7 @@ final class SettingsService { "defaultAgentCLI": defaultAgentCLI, "sendDefaultPrompt": sendDefaultPrompt, "skipAgentPermissions": skipAgentPermissions, + "whitelistBlitzMCPTools": whitelistBlitzMCPTools, "terminalPosition": terminalPosition, ] if let udid = defaultSimulatorUDID { diff --git a/src/utilities/ProjectStorage.swift b/src/utilities/ProjectStorage.swift index 8d2d13c..b154e6c 100644 --- a/src/utilities/ProjectStorage.swift +++ b/src/utilities/ProjectStorage.swift @@ -144,7 +144,7 @@ struct ProjectStorage { /// Ensure ~/.blitz/mcps/ has MCP configs, CLAUDE.md, and skills so that /// agent sessions launched outside a project (e.g. onboarding ASC setup) can /// access Blitz MCP tools. Idempotent — safe to call on every launch. - func ensureGlobalMCPConfigs() { + func ensureGlobalMCPConfigs(whitelistBlitzMCP: Bool = true) { let fm = FileManager.default let mcpsDir = BlitzPaths.mcps @@ -157,14 +157,18 @@ struct ProjectStorage { let claudeDir = mcpsDir.appendingPathComponent(".claude") let settingsFile = claudeDir.appendingPathComponent("settings.local.json") try? fm.createDirectory(at: claudeDir, withIntermediateDirectories: true) + var allowList: [String] = [ + "mcp__blitz-macos__asc_set_credentials", + "mcp__blitz-macos__asc_web_auth", + "Bash(python3:*)", + ] + if whitelistBlitzMCP { + allowList = Self.allBlitzMCPToolPermissions() + } let settings: [String: Any] = [ "enabledMcpjsonServers": ["blitz-macos", "blitz-iphone"], "permissions": [ - "allow": [ - "mcp__blitz-macos__asc_set_credentials", - "mcp__blitz-macos__asc_web_auth", - "Bash(python3:*)", - ] + "allow": allowList ] ] if let data = try? JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) { @@ -278,8 +282,21 @@ struct ProjectStorage { } } + /// All Blitz MCP tool permission strings for both blitz-macos and blitz-iphone servers. + static func allBlitzMCPToolPermissions() -> [String] { + // blitz-macos tools — from MCPToolRegistry + let macTools = MCPToolRegistry.allToolNames().map { "mcp__blitz-macos__\($0)" } + // blitz-iphone tools — from @blitzdev/iphone-mcp + let iphoneTools = [ + "list_devices", "setup_device", "launch_app", "list_apps", + "get_screenshot", "scan_ui", "describe_screen", "device_action", + "device_actions", "get_execution_context", + ].map { "mcp__blitz-iphone__\($0)" } + return macTools + iphoneTools + } + /// Ensure CLAUDE.md, .claude/settings.local.json, and .claude/rules/ exist for a project. - func ensureClaudeFiles(projectId: String, projectType: ProjectType) { + func ensureClaudeFiles(projectId: String, projectType: ProjectType, whitelistBlitzMCP: Bool = true) { let fm = FileManager.default let projectDir = baseDirectory.appendingPathComponent(projectId) let claudeDir = projectDir.appendingPathComponent(".claude") @@ -299,19 +316,34 @@ struct ProjectStorage { if var perms = existing["permissions"] as? [String: Any], var allow = perms["allow"] as? [String] { allow.removeAll { $0.contains("blitz-ios") } + // Inject whitelist if enabled + if whitelistBlitzMCP { + let blitzTools = Self.allBlitzMCPToolPermissions() + for tool in blitzTools where !allow.contains(tool) { + allow.append(tool) + } + } perms["allow"] = allow existing["permissions"] = perms } settings = existing } else { + var defaultAllow: [String] = [ + "Bash(curl:*)", + "Bash(xcrun simctl terminate:*)", + "Bash(xcrun simctl launch:*)", + "mcp__blitz-macos__app_get_state", + ] + if whitelistBlitzMCP { + defaultAllow = Self.allBlitzMCPToolPermissions() + [ + "Bash(curl:*)", + "Bash(xcrun simctl terminate:*)", + "Bash(xcrun simctl launch:*)", + ] + } settings = [ "permissions": [ - "allow": [ - "Bash(curl:*)", - "Bash(xcrun simctl terminate:*)", - "Bash(xcrun simctl launch:*)", - "mcp__blitz-macos__app_get_state", - ] + "allow": defaultAllow ], "enabledMcpjsonServers": correctServers, ] diff --git a/src/views/ContentView.swift b/src/views/ContentView.swift index 6b8eb4d..2256939 100644 --- a/src/views/ContentView.swift +++ b/src/views/ContentView.swift @@ -164,7 +164,7 @@ struct ContentView: View { let storage = ProjectStorage() storage.ensureMCPConfig(projectId: newId) storage.ensureTeenybaseBackend(projectId: newId, projectType: project.type) - storage.ensureClaudeFiles(projectId: newId, projectType: project.type) + storage.ensureClaudeFiles(projectId: newId, projectType: project.type, whitelistBlitzMCP: appState.settingsStore.whitelistBlitzMCPTools) } await startPendingSetupIfNeeded() appState.ascManager.clearForProjectSwitch() diff --git a/src/views/OnboardingView.swift b/src/views/OnboardingView.swift index 9b27f80..cd9559f 100644 --- a/src/views/OnboardingView.swift +++ b/src/views/OnboardingView.swift @@ -111,11 +111,13 @@ struct OnboardingView: View { @State private var showCustomPicker = false @State private var showExternalTerminals = false @State private var skipAgentPermissions: Bool + @State private var whitelistBlitzMCPTools: Bool init(appState: AppState, onComplete: @escaping () -> Void) { self.appState = appState self.onComplete = onComplete _skipAgentPermissions = State(initialValue: appState.settingsStore.skipAgentPermissions) + _whitelistBlitzMCPTools = State(initialValue: appState.settingsStore.whitelistBlitzMCPTools) } // ASC setup state @@ -279,6 +281,19 @@ struct OnboardingView: View { .controlSize(.small) } + // Whitelist Blitz MCP tools + Toggle(isOn: $whitelistBlitzMCPTools) { + VStack(alignment: .leading, spacing: 1) { + Text("Allow all Blitz MCP tool calls") + .font(.callout) + Text("AI agents run Blitz tools without asking. Blitz still shows its own approval for destructive actions.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.switch) + .controlSize(.small) + Divider() // Terminal selection @@ -908,6 +923,7 @@ struct OnboardingView: View { settings.defaultTerminal = selectedTerminal.settingsValue settings.defaultAgentCLI = selectedAgent.rawValue settings.skipAgentPermissions = skipAgentPermissions + settings.whitelistBlitzMCPTools = whitelistBlitzMCPTools settings.hasCompletedOnboarding = true settings.save() diff --git a/src/views/projects/ImportProjectSheet.swift b/src/views/projects/ImportProjectSheet.swift index abeb94c..21d5b4a 100644 --- a/src/views/projects/ImportProjectSheet.swift +++ b/src/views/projects/ImportProjectSheet.swift @@ -168,7 +168,7 @@ struct ImportProjectSheet: View { let projectId = try storage.openProject(at: url) storage.ensureMCPConfig(projectId: projectId) storage.ensureTeenybaseBackend(projectId: projectId, projectType: projectType) - storage.ensureClaudeFiles(projectId: projectId, projectType: projectType) + storage.ensureClaudeFiles(projectId: projectId, projectType: projectType, whitelistBlitzMCP: appState.settingsStore.whitelistBlitzMCPTools) await appState.projectManager.loadProjects() appState.activeProjectId = projectId isPresented = false diff --git a/src/views/settings/SettingsView.swift b/src/views/settings/SettingsView.swift index 58a3009..485db73 100644 --- a/src/views/settings/SettingsView.swift +++ b/src/views/settings/SettingsView.swift @@ -234,6 +234,15 @@ struct SettingsView: View { .fixedSize() } + // Whitelist Blitz MCP tools + Toggle("Allow all Blitz MCP tool calls", isOn: Binding( + get: { settings.whitelistBlitzMCPTools }, + set: { newValue in + settings.whitelistBlitzMCPTools = newValue + settings.save() + } + )) + // Send default prompt toggle VStack(alignment: .leading, spacing: 4) { Toggle("Send tab-specific prompt on launch", isOn: Binding( From 17df88605643985a67b1794390ffdc28d11fb511 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Mon, 23 Mar 2026 19:23:35 -0700 Subject: [PATCH 08/51] Fix terminal session tab rendering --- src/views/build/IntegratedTerminalView.swift | 13 +++++++++++++ src/views/build/TerminalPanelView.swift | 18 +++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/views/build/IntegratedTerminalView.swift b/src/views/build/IntegratedTerminalView.swift index e1d7e0b..b8a935a 100644 --- a/src/views/build/IntegratedTerminalView.swift +++ b/src/views/build/IntegratedTerminalView.swift @@ -6,6 +6,7 @@ import SwiftTerm /// across show/hide cycles and tab switches. struct TerminalSessionView: NSViewRepresentable { let session: TerminalSession + let isActive: Bool func makeNSView(context: Context) -> NSView { let container = NSView(frame: .zero) @@ -21,6 +22,18 @@ struct TerminalSessionView: NSViewRepresentable { nsView.subviews.forEach { $0.removeFromSuperview() } embed(termView, in: nsView) } + + guard isActive else { return } + + termView.needsLayout = true + termView.needsDisplay = true + termView.displayIfNeeded() + + if nsView.window?.firstResponder !== termView { + DispatchQueue.main.async { + nsView.window?.makeFirstResponder(termView) + } + } } static func dismantleNSView(_ nsView: NSView, coordinator: ()) { diff --git a/src/views/build/TerminalPanelView.swift b/src/views/build/TerminalPanelView.swift index 855a94c..ce20854 100644 --- a/src/views/build/TerminalPanelView.swift +++ b/src/views/build/TerminalPanelView.swift @@ -110,9 +110,7 @@ struct TerminalPanelView: View { @ViewBuilder private var terminalContent: some View { - if let session = manager.activeSession { - TerminalSessionView(session: session) - } else { + if manager.sessions.isEmpty { VStack(spacing: 8) { Text("No terminal sessions") .font(.callout) @@ -124,6 +122,20 @@ struct TerminalPanelView: View { .controlSize(.small) } .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ZStack { + // Keep each session's host NSView alive so switching tabs does not require + // SwiftUI to reparent a single LocalProcessTerminalView between containers. + ForEach(manager.sessions) { session in + let isActive = session.id == manager.activeSessionId + + TerminalSessionView(session: session, isActive: isActive) + .opacity(isActive ? 1 : 0) + .allowsHitTesting(isActive) + .zIndex(isActive ? 1 : 0) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } } From 5c9dc830e945006c0070e599a037bdc0b7cda689 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Mon, 23 Mar 2026 22:45:26 -0700 Subject: [PATCH 09/51] Replace MCP HTTP bridge with direct stdio helper over Unix socket - Add blitz-macos-mcp executable target that speaks MCP stdio and forwards JSON-RPC to the app over a Unix domain socket - Replace the localhost HTTP server in MCPServerService with a Unix socket listener, fixing sandbox compatibility with Codex/Claude Code - Fix O_NONBLOCK inheritance on accepted client sockets that truncated large responses (e.g. tools/list) at 8192 bytes - Echo back client's MCP protocolVersion instead of hardcoding 2024-11-05 - Fix npm install prefix so iphone-mcp updates go to ~/.blitz/node-runtime - Add BlitzMCPCommon shared library for transport path constants - Bundle helper binary into Contents/Helpers and install to ~/.blitz on launch - Keep blitz-mcp-bridge.sh as compatibility shim that execs the helper --- Package.swift | 13 +- .../BlitzMCPTransportPaths.swift | 22 + Sources/BlitzMCPHelper/main.swift | 251 +++++++++++ scripts/blitz-mcp-bridge.sh | 51 +-- scripts/bundle.sh | 18 +- src/BlitzApp.swift | 87 ++-- src/BlitzPaths.swift | 12 +- src/services/MCPServerService.swift | 412 +++++++++--------- src/utilities/ProjectStorage.swift | 8 +- src/views/settings/MCPSetupSection.swift | 7 +- 10 files changed, 567 insertions(+), 314 deletions(-) create mode 100644 Sources/BlitzMCPCommon/BlitzMCPTransportPaths.swift create mode 100644 Sources/BlitzMCPHelper/main.swift mode change 100755 => 100644 scripts/blitz-mcp-bridge.sh diff --git a/Package.swift b/Package.swift index 983a4dd..86803c0 100644 --- a/Package.swift +++ b/Package.swift @@ -8,15 +8,21 @@ let package = Package( .macOS(.v14) ], products: [ + .library(name: "BlitzMCPCommon", targets: ["BlitzMCPCommon"]), .executable(name: "Blitz", targets: ["Blitz"]), + .executable(name: "blitz-macos-mcp", targets: ["BlitzMCPHelper"]), ], dependencies: [ .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0"), ], targets: [ + .target( + name: "BlitzMCPCommon", + path: "Sources/BlitzMCPCommon" + ), .executableTarget( name: "Blitz", - dependencies: ["SwiftTerm"], + dependencies: ["SwiftTerm", "BlitzMCPCommon"], path: "src", exclude: ["metal"], resources: [.process("resources"), .copy("templates")], @@ -31,6 +37,11 @@ let package = Package( .linkedFramework("WebKit"), ] ), + .executableTarget( + name: "BlitzMCPHelper", + dependencies: ["BlitzMCPCommon"], + path: "Sources/BlitzMCPHelper" + ), .testTarget( name: "BlitzTests", dependencies: ["Blitz"], diff --git a/Sources/BlitzMCPCommon/BlitzMCPTransportPaths.swift b/Sources/BlitzMCPCommon/BlitzMCPTransportPaths.swift new file mode 100644 index 0000000..9c14dd5 --- /dev/null +++ b/Sources/BlitzMCPCommon/BlitzMCPTransportPaths.swift @@ -0,0 +1,22 @@ +import Darwin +import Foundation + +public enum BlitzMCPTransportPaths { + public static var root: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".blitz") + } + + public static var helper: URL { + root.appendingPathComponent("blitz-macos-mcp") + } + + public static var bridgeScript: URL { + root.appendingPathComponent("blitz-mcp-bridge.sh") + } + + public static var socket: URL { + URL(fileURLWithPath: "/tmp", isDirectory: true) + .appendingPathComponent("blitz-mcp-\(getuid()).sock") + } +} diff --git a/Sources/BlitzMCPHelper/main.swift b/Sources/BlitzMCPHelper/main.swift new file mode 100644 index 0000000..10b4a23 --- /dev/null +++ b/Sources/BlitzMCPHelper/main.swift @@ -0,0 +1,251 @@ +import BlitzMCPCommon +import Darwin +import Foundation + +@main +struct BlitzMCPHelper { + private struct RequestMetadata { + let expectsResponse: Bool + let id: Any + let startupTimeout: TimeInterval + let responseTimeout: TimeInterval + } + + private enum HelperError: LocalizedError { + case bridgeUnavailable + case responseTimeout + case emptyResponse + case invalidSocketPath + case socketCreateFailed + case writeFailed + + var errorDescription: String? { + switch self { + case .bridgeUnavailable: + return "Cannot connect to Blitz. Is it running?" + case .responseTimeout: + return "Timed out waiting for a response from Blitz." + case .emptyResponse: + return "Blitz returned an empty MCP response." + case .invalidSocketPath: + return "Blitz MCP socket path is invalid." + case .socketCreateFailed: + return "Failed to open the Blitz MCP socket." + case .writeFailed: + return "Failed to send the MCP request to Blitz." + } + } + } + + static func main() async { + do { + for try await line in FileHandle.standardInput.bytes.lines { + try handleLine(String(line)) + } + } catch { + log("MCP helper stopped: \(error.localizedDescription)") + exit(1) + } + } + + private static func handleLine(_ line: String) throws { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + let metadata = parseRequestMetadata(trimmed) + + do { + if let response = try sendRequest(trimmed, metadata: metadata) { + try writeLine(response, to: STDOUT_FILENO) + } + } catch { + if metadata.expectsResponse { + let response = errorResponse(id: metadata.id, message: error.localizedDescription) + try writeLine(response, to: STDOUT_FILENO) + } else { + log("Notification failed: \(error.localizedDescription)") + } + } + } + + private static func parseRequestMetadata(_ line: String) -> RequestMetadata { + guard let data = line.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return RequestMetadata(expectsResponse: true, id: NSNull(), startupTimeout: 10, responseTimeout: 30) + } + + let method = json["method"] as? String ?? "" + return RequestMetadata( + expectsResponse: json["id"] != nil, + id: json["id"] ?? NSNull(), + startupTimeout: method == "initialize" ? 30 : 10, + responseTimeout: method == "initialize" ? 30 : 300 + ) + } + + private static func sendRequest(_ line: String, metadata: RequestMetadata) throws -> String? { + let deadline = Date().addingTimeInterval(metadata.startupTimeout) + let socketPath = BlitzMCPTransportPaths.socket.path + + while true { + do { + return try sendRequestOnce( + line, + expectsResponse: metadata.expectsResponse, + responseTimeout: metadata.responseTimeout, + socketPath: socketPath + ) + } catch HelperError.bridgeUnavailable { + guard Date() < deadline else { throw HelperError.bridgeUnavailable } + usleep(250_000) + } catch let error as POSIXError where shouldRetry(error.code) { + guard Date() < deadline else { throw HelperError.bridgeUnavailable } + usleep(250_000) + } catch { + throw error + } + } + } + + private static func sendRequestOnce( + _ line: String, + expectsResponse: Bool, + responseTimeout: TimeInterval, + socketPath: String + ) throws -> String? { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw HelperError.socketCreateFailed } + defer { Darwin.close(fd) } + + var timeout = timeval( + tv_sec: Int(responseTimeout.rounded(.up)), + tv_usec: 0 + ) + withUnsafePointer(to: &timeout) { pointer in + _ = setsockopt( + fd, + SOL_SOCKET, + SO_RCVTIMEO, + pointer, + socklen_t(MemoryLayout.size) + ) + } + + var address = try makeSocketAddress(path: socketPath) + let addressLength = socklen_t(address.sun_len) + let connectResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + Darwin.connect(fd, sockaddrPointer, addressLength) + } + } + guard connectResult == 0 else { + throw mapConnectError(errno) + } + + try writeLine(line, to: fd) + guard expectsResponse else { return nil } + + guard let response = try readLine(from: fd) else { + throw HelperError.emptyResponse + } + return response + } + + private static func shouldRetry(_ code: POSIXErrorCode) -> Bool { + switch code { + case .ENOENT, .ECONNREFUSED, .EAGAIN, .ETIMEDOUT, .ECONNRESET: + return true + default: + return false + } + } + + private static func mapConnectError(_ code: Int32) -> Error { + guard let posixCode = POSIXErrorCode(rawValue: code) else { + return HelperError.bridgeUnavailable + } + if shouldRetry(posixCode) { + return POSIXError(posixCode) + } + return HelperError.bridgeUnavailable + } + + private static func makeSocketAddress(path: String) throws -> sockaddr_un { + var address = sockaddr_un() + let pathLength = path.utf8.count + let maxPathLength = MemoryLayout.size(ofValue: address.sun_path) - 1 + + guard pathLength <= maxPathLength else { + throw HelperError.invalidSocketPath + } + + address.sun_len = UInt8(MemoryLayout.size) + address.sun_family = sa_family_t(AF_UNIX) + + withUnsafeMutableBytes(of: &address.sun_path) { destination in + path.withCString { source in + destination.copyBytes(from: UnsafeRawBufferPointer(start: source, count: pathLength + 1)) + } + } + + return address + } + + private static func readLine(from fd: Int32) throws -> String? { + var data = Data() + var byte: UInt8 = 0 + + while true { + let count = Darwin.read(fd, &byte, 1) + if count == 0 { + return data.isEmpty ? nil : String(data: data, encoding: .utf8) + } + if count < 0 { + if errno == EWOULDBLOCK || errno == EAGAIN { + throw HelperError.responseTimeout + } + throw HelperError.bridgeUnavailable + } + if byte == 0x0A { + return String(data: data, encoding: .utf8) + } + data.append(byte) + } + } + + private static func errorResponse(id: Any, message: String) -> String { + let payload: [String: Any] = [ + "jsonrpc": "2.0", + "id": id, + "error": [ + "code": -32000, + "message": message + ] as [String: Any] + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { + return #"{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"Unknown Blitz MCP error."}}"# + } + return json + } + + private static func log(_ message: String) { + try? writeLine(message, to: STDERR_FILENO) + } + + private static func writeLine(_ line: String, to fd: Int32) throws { + let data = Data((line + "\n").utf8) + try data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return } + var bytesWritten = 0 + while bytesWritten < rawBuffer.count { + let nextPointer = baseAddress.advanced(by: bytesWritten) + let result = Darwin.write(fd, nextPointer, rawBuffer.count - bytesWritten) + if result < 0 { + throw HelperError.writeFailed + } + bytesWritten += result + } + } + } +} diff --git a/scripts/blitz-mcp-bridge.sh b/scripts/blitz-mcp-bridge.sh old mode 100755 new mode 100644 index ba21441..326c0c0 --- a/scripts/blitz-mcp-bridge.sh +++ b/scripts/blitz-mcp-bridge.sh @@ -1,51 +1,12 @@ #!/bin/bash -# Blitz MCP Bridge: stdio → HTTP forwarder for Claude Code -# Reads JSON-RPC from stdin, POSTs to Blitz's MCP server, writes response to stdout -PORT_FILE="$HOME/.blitz/mcp-port" +# Compatibility shim for older Blitz MCP configs. +# New Codex and .mcp.json entries should launch ~/.blitz/blitz-macos-mcp directly. -# Wait up to 10 seconds for Blitz to start and write the port file -WAITED=0 -while [ ! -f "$PORT_FILE" ] && [ "$WAITED" -lt 10 ]; do - sleep 1 - WAITED=$((WAITED + 1)) -done +HELPER="$HOME/.blitz/blitz-macos-mcp" -if [ ! -f "$PORT_FILE" ]; then - echo '{"jsonrpc":"2.0","id":1,"error":{"code":-1,"message":"Blitz is not running. Please start Blitz first."}}' >&2 +if [ ! -x "$HELPER" ]; then + echo '{"jsonrpc":"2.0","id":null,"error":{"code":-1,"message":"Blitz MCP helper is not installed. Start Blitz first."}}' >&2 exit 1 fi -PORT=$(cat "$PORT_FILE") - -# Wait up to 5 more seconds for the HTTP server to accept connections -WAITED=0 -while ! curl -s -o /dev/null -w '' "http://127.0.0.1:${PORT}/mcp" 2>/dev/null && [ "$WAITED" -lt 5 ]; do - sleep 1 - WAITED=$((WAITED + 1)) -done - -while IFS= read -r line; do - [ -z "$line" ] && continue - - # Notifications (no "id" field) don't expect a response in MCP protocol. - # Still forward to server but discard the HTTP response to avoid - # injecting unexpected lines into the stdout stream. - case "$line" in - *'"id"'*) ;; # has id — normal request, will echo response below - *) - curl -s -o /dev/null -X POST "http://127.0.0.1:${PORT}/mcp" \ - -H "Content-Type: application/json" \ - --max-time 5 -d "$line" 2>/dev/null - continue - ;; - esac - - response=$(curl -s --max-time 120 -X POST "http://127.0.0.1:${PORT}/mcp" \ - -H "Content-Type: application/json" \ - -d "$line" 2>/dev/null) - if [ $? -ne 0 ]; then - echo '{"jsonrpc":"2.0","id":null,"error":{"code":-1,"message":"Cannot connect to Blitz. Is it running?"}}' >&2 - exit 1 - fi - echo "$response" -done +exec "$HELPER" "$@" diff --git a/scripts/bundle.sh b/scripts/bundle.sh index 3fd9255..6cb86fd 100755 --- a/scripts/bundle.sh +++ b/scripts/bundle.sh @@ -31,15 +31,27 @@ VERSION=$(node -e "const p=JSON.parse(require('fs').readFileSync('$ROOT_DIR/pack echo "Building $APP_NAME.app v$VERSION ($CONFIG)..." # Build -swift build -c "$CONFIG" +swift build -c "$CONFIG" --product Blitz +swift build -c "$CONFIG" --product blitz-macos-mcp # Create .app structure mkdir -p "$BUNDLE_DIR/Contents/MacOS" mkdir -p "$BUNDLE_DIR/Contents/Resources" +mkdir -p "$BUNDLE_DIR/Contents/Helpers" # Copy binary cp ".build/${CONFIG}/${APP_NAME}" "$BUNDLE_DIR/Contents/MacOS/${APP_NAME}" +# Copy the standalone MCP helper that Codex launches directly over stdio. +HELPER_BINARY=".build/${CONFIG}/blitz-macos-mcp" +if [ -f "$HELPER_BINARY" ]; then + cp "$HELPER_BINARY" "$BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" + chmod 755 "$BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" + echo "Copied blitz-macos-mcp helper into app bundle" +else + echo "WARNING: blitz-macos-mcp helper was not built; MCP integration will be unavailable." +fi + # Generate app icon (.icns) from PNG ICON_PNG="$ROOT_DIR/src/resources/blitz-icon.png" ICON_ICNS="$BUNDLE_DIR/Contents/Resources/AppIcon.icns" @@ -167,6 +179,10 @@ if [ "$SIGNING_IDENTITY" != "-" ]; then codesign_bundle_path "$f" 2>/dev/null || true echo " Signed: $f" done + if [ -f "$BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" ]; then + codesign_bundle_path "$BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" + echo " Signed: $BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" + fi fi # Sign the .app bundle (must be after nested signing) diff --git a/src/BlitzApp.swift b/src/BlitzApp.swift index 94f41d1..91ad1d1 100644 --- a/src/BlitzApp.swift +++ b/src/BlitzApp.swift @@ -11,7 +11,7 @@ final class MCPBootstrap { guard !started else { return } started = true - installBridgeScript() + installMCPHelper() installClaudeSkills() updateIphoneMCP() ProjectStorage().ensureGlobalMCPConfigs(whitelistBlitzMCP: appState.settingsStore.whitelistBlitzMCPTools) @@ -74,7 +74,7 @@ final class MCPBootstrap { do { let process = Process() process.executableURL = URL(fileURLWithPath: npm) - process.arguments = ["install", "-g", "@blitzdev/iphone-mcp@latest"] + process.arguments = ["install", "-g", "--prefix", BlitzPaths.root.appendingPathComponent("node-runtime").path, "@blitzdev/iphone-mcp@latest"] process.environment = [ "PATH": "\(BlitzPaths.nodeDir.path):/usr/bin:/bin", "HOME": FileManager.default.homeDirectoryForCurrentUser.path @@ -94,49 +94,54 @@ final class MCPBootstrap { } } - private func installBridgeScript() { + private func installMCPHelper() { + let fm = FileManager.default let destDir = BlitzPaths.root - let destFile = BlitzPaths.mcpBridge - if let bundlePath = Bundle.main.path(forResource: "blitz-mcp-bridge", ofType: "sh") { - try? FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) - try? FileManager.default.removeItem(at: destFile) - try? FileManager.default.copyItem(atPath: bundlePath, toPath: destFile.path) - try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destFile.path) + try? fm.createDirectory(at: destDir, withIntermediateDirectories: true) + + if let sourceURL = bundledMCPHelperURL() { + try? fm.removeItem(at: BlitzPaths.mcpHelper) + try? fm.copyItem(at: sourceURL, to: BlitzPaths.mcpHelper) + try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: BlitzPaths.mcpHelper.path) } else { - let script = """ - #!/bin/bash - PORT_FILE="$HOME/.blitz/mcp-port" - WAITED=0 - while [ ! -f "$PORT_FILE" ] && [ "$WAITED" -lt 10 ]; do - sleep 1 - WAITED=$((WAITED + 1)) - done - if [ ! -f "$PORT_FILE" ]; then - echo '{"jsonrpc":"2.0","id":1,"error":{"code":-1,"message":"Blitz is not running."}}' >&2 - exit 1 - fi - PORT=$(cat "$PORT_FILE") - WAITED=0 - while ! curl -s -o /dev/null -w '' "http://127.0.0.1:${PORT}/mcp" 2>/dev/null && [ "$WAITED" -lt 5 ]; do - sleep 1 - WAITED=$((WAITED + 1)) - done - while IFS= read -r line; do - [ -z "$line" ] && continue - response=$(curl -s -X POST "http://127.0.0.1:${PORT}/mcp" \\ - -H "Content-Type: application/json" -d "$line" 2>/dev/null) - if [ $? -ne 0 ]; then - echo '{"jsonrpc":"2.0","id":null,"error":{"code":-1,"message":"Cannot connect to Blitz."}}' >&2 - exit 1 - fi - echo "$response" - done - """ - try? FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) - try? script.write(to: destFile, atomically: true, encoding: .utf8) - try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destFile.path) + print("[MCP] Failed to locate bundled blitz-macos-mcp helper") + } + + // Keep the old script path working for manually created configs while + // new project configs point directly at the helper executable. + let bridgeScript = """ + #!/bin/bash + HELPER="$HOME/.blitz/blitz-macos-mcp" + if [ ! -x "$HELPER" ]; then + echo '{"jsonrpc":"2.0","id":null,"error":{"code":-1,"message":"Blitz MCP helper is not installed. Start Blitz first."}}' >&2 + exit 1 + fi + exec "$HELPER" "$@" + """ + try? bridgeScript.write(to: BlitzPaths.mcpBridge, atomically: true, encoding: .utf8) + try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: BlitzPaths.mcpBridge.path) + } + + private func bundledMCPHelperURL() -> URL? { + let fm = FileManager.default + + let bundledHelper = Bundle.main.bundleURL + .appendingPathComponent("Contents/Helpers/blitz-macos-mcp") + if fm.isExecutableFile(atPath: bundledHelper.path) { + return bundledHelper + } + + if let executableURL = Bundle.main.executableURL { + let siblingHelper = executableURL + .deletingLastPathComponent() + .appendingPathComponent("blitz-macos-mcp") + if fm.isExecutableFile(atPath: siblingHelper.path) { + return siblingHelper + } } + + return nil } } diff --git a/src/BlitzPaths.swift b/src/BlitzPaths.swift index c66c972..58fb5ba 100644 --- a/src/BlitzPaths.swift +++ b/src/BlitzPaths.swift @@ -1,3 +1,4 @@ +import BlitzMCPCommon import Foundation /// Central source of truth for all ~/.blitz/ paths used across the app. @@ -18,11 +19,14 @@ enum BlitzPaths { /// Settings file: ~/.blitz/settings.json static var settings: URL { root.appendingPathComponent("settings.json") } - /// MCP port file: ~/.blitz/mcp-port - static var mcpPort: URL { root.appendingPathComponent("mcp-port") } + /// MCP helper executable: ~/.blitz/blitz-macos-mcp + static var mcpHelper: URL { BlitzMCPTransportPaths.helper } - /// MCP bridge script: ~/.blitz/blitz-mcp-bridge.sh - static var mcpBridge: URL { root.appendingPathComponent("blitz-mcp-bridge.sh") } + /// Compatibility bridge script: ~/.blitz/blitz-mcp-bridge.sh + static var mcpBridge: URL { BlitzMCPTransportPaths.bridgeScript } + + /// Local Unix socket used by the app-owned MCP executor. + static var mcpSocket: URL { BlitzMCPTransportPaths.socket } /// Signing base directory: ~/.blitz/signing/ static var signing: URL { root.appendingPathComponent("signing") } diff --git a/src/services/MCPServerService.swift b/src/services/MCPServerService.swift index 44e4cb4..409c791 100644 --- a/src/services/MCPServerService.swift +++ b/src/services/MCPServerService.swift @@ -1,218 +1,226 @@ +import Darwin import Foundation -/// MCP (Model Context Protocol) HTTP server for Claude Code integration -/// Port of server/mcp/mcp-server.ts +/// MCP server endpoint owned by the Blitz app. +/// Codex launches a separate stdio helper, and the helper forwards each JSON-RPC +/// request over a Unix domain socket to the running app. actor MCPServerService { private var acceptSource: DispatchSourceRead? private var serverSocket: Int32 = -1 - private(set) var port: Int = 0 private(set) var isRunning = false private let toolExecutor: MCPToolExecutor - private static var portFileURL: URL { - BlitzPaths.mcpPort - } - init(appState: AppState) { self.toolExecutor = MCPToolExecutor(appState: appState) - // Store executor reference in AppState for approval resolution Task { @MainActor in appState.toolExecutor = self.toolExecutor } } - /// Start the MCP server on a free port func start() async throws { - let assignedPort = PortAllocator.findFreePort() - guard assignedPort > 0 else { - throw MCPError.noPortAvailable + guard !isRunning else { return } + + let socketFD = try createServerSocket() + serverSocket = socketFD + isRunning = true + + let source = DispatchSource.makeReadSource( + fileDescriptor: socketFD, + queue: DispatchQueue(label: "blitz.mcp.accept") + ) + source.setEventHandler { [weak self] in + guard let self else { return } + Task { + await self.acceptPendingConnections() + } } - self.port = Int(assignedPort) + source.resume() + acceptSource = source - // Create TCP socket - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { throw MCPError.socketCreationFailed } + print("[MCP] Server listening on Unix socket \(BlitzPaths.mcpSocket.path)") + } - var reuse: Int32 = 1 - setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) + func stop() { + acceptSource?.cancel() + acceptSource = nil - var addr = sockaddr_in() - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = UInt16(assignedPort).bigEndian - addr.sin_addr.s_addr = UInt32(0x7F000001).bigEndian + if serverSocket >= 0 { + Darwin.close(serverSocket) + serverSocket = -1 + } - let bindResult = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in - bind(fd, sockPtr, socklen_t(MemoryLayout.size)) - } + isRunning = false + removeSocketFile() + } + + private func createServerSocket() throws -> Int32 { + removeSocketFile() + + let socketFD = socket(AF_UNIX, SOCK_STREAM, 0) + guard socketFD >= 0 else { + throw MCPError.socketCreationFailed } + var address = try makeSocketAddress() + let addressLength = socklen_t(address.sun_len) + let bindResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + Darwin.bind(socketFD, sockaddrPointer, addressLength) + } + } guard bindResult == 0 else { - close(fd) - throw MCPError.bindFailed + Darwin.close(socketFD) + throw MCPError.bindFailed(code: errno) } - guard listen(fd, 5) == 0 else { - close(fd) - throw MCPError.listenFailed + guard Darwin.listen(socketFD, SOMAXCONN) == 0 else { + Darwin.close(socketFD) + throw MCPError.listenFailed(code: errno) } - serverSocket = fd - isRunning = true - - // Write port file for bridge script - writePortFile(port: Int(assignedPort)) + let currentFlags = fcntl(socketFD, F_GETFL, 0) + if currentFlags >= 0 { + _ = fcntl(socketFD, F_SETFL, currentFlags | O_NONBLOCK) + } - print("[MCP] Server listening on port \(assignedPort)") + _ = chmod(BlitzPaths.mcpSocket.path, mode_t(0o600)) + return socketFD + } - // Accept connections in background using DispatchSource - let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: .global(qos: .userInitiated)) - source.setEventHandler { [weak self] in - var clientAddr = sockaddr_in() - var addrLen = socklen_t(MemoryLayout.size) - let clientFd = withUnsafeMutablePointer(to: &clientAddr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in - accept(fd, sockPtr, &addrLen) + private func acceptPendingConnections() async { + while serverSocket >= 0 { + let clientFD = Darwin.accept(serverSocket, nil, nil) + if clientFD < 0 { + if errno == EWOULDBLOCK || errno == EAGAIN { + return } + print("[MCP] accept() failed: \(errno)") + return + } + + // Client sockets inherit O_NONBLOCK from the listening socket. + // Reset to blocking so large responses (e.g. tools/list) don't + // fail with EAGAIN when the send buffer fills. + let flags = fcntl(clientFD, F_GETFL, 0) + if flags >= 0 { + _ = fcntl(clientFD, F_SETFL, flags & ~O_NONBLOCK) } - if clientFd < 0 { return } - Task { [weak self] in - await self?.handleConnection(clientFd) + + Task.detached { [weak self] in + await self?.handleConnection(clientFD) } } - source.resume() - self.acceptSource = source as? DispatchSource } - /// Stop the MCP server - func stop() { - acceptSource?.cancel() - acceptSource = nil - if serverSocket >= 0 { - close(serverSocket) - serverSocket = -1 + private func handleConnection(_ clientFD: Int32) async { + defer { Darwin.close(clientFD) } + + do { + guard let line = try readLine(from: clientFD) else { return } + if let response = await processMCPLine(line) { + try writeLine(response, to: clientFD) + } + } catch { + print("[MCP] Socket client failed: \(error.localizedDescription)") } - isRunning = false - removePortFile() } - /// Handle a single client connection - private func handleConnection(_ fd: Int32) async { - defer { close(fd) } - - // Read HTTP request with larger buffer for tool arguments - var requestData = Data() - let bufSize = 65536 - let buf = UnsafeMutablePointer.allocate(capacity: bufSize) - defer { buf.deallocate() } + private func readLine(from fd: Int32) throws -> String? { + var timeout = timeval(tv_sec: 30, tv_usec: 0) + withUnsafePointer(to: &timeout) { pointer in + _ = setsockopt( + fd, + SOL_SOCKET, + SO_RCVTIMEO, + pointer, + socklen_t(MemoryLayout.size) + ) + } - // Read until we have the full body - var totalRead = 0 - var contentLength = -1 + var data = Data() + var byte: UInt8 = 0 while true { - let bytesRead = recv(fd, buf, bufSize, 0) - guard bytesRead > 0 else { break } - requestData.append(buf, count: bytesRead) - totalRead += bytesRead - - // Parse content-length from headers if not yet found - if contentLength < 0, let str = String(data: requestData, encoding: .utf8) { - if let range = str.range(of: "\r\n\r\n") { - let headers = String(str[..= contentLength { break } - } else { - continue // Haven't received full headers yet - } - } else if contentLength >= 0 { - // Check if we have enough body data - if let str = String(data: requestData, encoding: .utf8), - let range = str.range(of: "\r\n\r\n") { - let headerSize = str.distance(from: str.startIndex, to: range.upperBound) - let bodySize = requestData.count - headerSize - if bodySize >= contentLength { break } + let count = Darwin.read(fd, &byte, 1) + if count == 0 { + return data.isEmpty ? nil : String(data: data, encoding: .utf8) + } + if count < 0 { + throw MCPError.readFailed(code: errno) + } + if byte == 0x0A { + return String(data: data, encoding: .utf8) + } + data.append(byte) + } + } + + private func writeLine(_ line: String, to fd: Int32) throws { + let data = Data((line + "\n").utf8) + try data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return } + var bytesWritten = 0 + + while bytesWritten < rawBuffer.count { + let pointer = baseAddress.advanced(by: bytesWritten) + let result = Darwin.write(fd, pointer, rawBuffer.count - bytesWritten) + if result < 0 { + throw MCPError.writeFailed(code: errno) } - } else { - break + bytesWritten += result } } + } - guard let requestStr = String(data: requestData, encoding: .utf8) else { return } + private func makeSocketAddress() throws -> sockaddr_un { + var address = sockaddr_un() + let path = BlitzPaths.mcpSocket.path + let pathLength = path.utf8.count + let maxPathLength = MemoryLayout.size(ofValue: address.sun_path) - 1 - // Parse HTTP request line - let lines = requestStr.components(separatedBy: "\r\n") - guard let requestLine = lines.first else { return } - let parts = requestLine.components(separatedBy: " ") - guard parts.count >= 2 else { return } + guard pathLength <= maxPathLength else { + throw MCPError.invalidSocketPath + } - let method = parts[0] - let path = parts[1] + address.sun_len = UInt8(MemoryLayout.size) + address.sun_family = sa_family_t(AF_UNIX) - // Extract body (after \r\n\r\n) - var body: Data? - if let range = requestStr.range(of: "\r\n\r\n") { - let bodyStr = String(requestStr[range.upperBound...]) - if !bodyStr.isEmpty { - body = Data(bodyStr.utf8) + withUnsafeMutableBytes(of: &address.sun_path) { destination in + path.withCString { source in + destination.copyBytes(from: UnsafeRawBufferPointer(start: source, count: pathLength + 1)) } } - // Route request - let responseBody: String - do { - responseBody = try await routeRequest(method: method, path: path, body: body) - } catch { - let escapedError = error.localizedDescription - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - let errorJson = "{\"error\": \"\(escapedError)\"}" - sendHTTPResponse(fd: fd, statusCode: 500, body: errorJson) - return - } + return address + } - sendHTTPResponse(fd: fd, statusCode: 200, body: responseBody) + private func removeSocketFile() { + try? FileManager.default.removeItem(at: BlitzPaths.mcpSocket) } - /// Route MCP requests to handlers - private func routeRequest(method: String, path: String, body: Data?) async throws -> String { - // MCP Streamable HTTP transport — handle JSON-RPC messages - if path == "/mcp" && method == "POST" { - guard let body else { throw MCPError.missingBody } - return try await handleMCPRequest(body) + private func processMCPLine(_ line: String) async -> String? { + guard let body = line.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any] else { + return errorResponse(id: NSNull(), code: -32700, message: "Invalid MCP JSON.") } - return "{\"error\": \"Not found\"}" - } - - /// Handle MCP JSON-RPC request - private func handleMCPRequest(_ body: Data) async throws -> String { - guard let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], - let methodName = json["method"] as? String else { - throw MCPError.invalidRequest + guard let methodName = json["method"] as? String else { + return errorResponse(id: json["id"] ?? NSNull(), code: -32600, message: "Invalid MCP request.") } - // Notifications (method starts with "notifications/") have no id and - // expect no response per JSON-RPC 2.0. Accept them gracefully. let id: Any = json["id"] ?? NSNull() - let isNotification = methodName.hasPrefix("notifications/") - + let hasResponseID = json["id"] != nil + let isNotification = !hasResponseID || methodName.hasPrefix("notifications/") let params = json["params"] as? [String: Any] ?? [:] let result: Any switch methodName { case "initialize": + let clientVersion = params["protocolVersion"] as? String ?? "2024-11-05" result = [ - "protocolVersion": "2024-11-05", + "protocolVersion": clientVersion, "capabilities": [ "tools": ["listChanged": false] ], @@ -223,8 +231,7 @@ actor MCPServerService { ] as [String: Any] case "notifications/initialized": - // Client acknowledgment — return empty result - result = [:] as [String: Any] + return nil case "tools/list": result = [ @@ -237,91 +244,72 @@ actor MCPServerService { do { result = try await toolExecutor.execute(name: toolName, arguments: toolArgs) } catch { - // Return a proper JSON-RPC error response instead of letting the error - // propagate to the HTTP layer (which sends a non-JSON-RPC 500 body) - let errorResponse: [String: Any] = [ - "jsonrpc": "2.0", - "id": id, - "error": [ - "code": -32603, - "message": error.localizedDescription - ] as [String: Any] - ] - let data = try JSONSerialization.data(withJSONObject: errorResponse) - return String(data: data, encoding: .utf8) ?? "{}" + return errorResponse(id: id, code: -32603, message: error.localizedDescription) } default: - if isNotification { - // Unknown notification — accept silently - result = [:] as [String: Any] - } else { - throw MCPError.unknownMethod(methodName) - } + if isNotification { return nil } + return errorResponse(id: id, code: -32601, message: "Unknown MCP method: \(methodName)") } + if isNotification { return nil } + let response: [String: Any] = [ "jsonrpc": "2.0", "id": id, "result": result ] - - let data = try JSONSerialization.data(withJSONObject: response) - return String(data: data, encoding: .utf8) ?? "{}" - } - - /// Send HTTP response - private func sendHTTPResponse(fd: Int32, statusCode: Int, body: String) { - let statusText = statusCode == 200 ? "OK" : "Error" - let response = """ - HTTP/1.1 \(statusCode) \(statusText)\r - Content-Type: application/json\r - Content-Length: \(body.utf8.count)\r - Connection: close\r - \r - \(body) - """ - let data = Data(response.utf8) - data.withUnsafeBytes { buf in - _ = send(fd, buf.baseAddress!, buf.count, 0) + guard let data = try? JSONSerialization.data(withJSONObject: response), + let json = String(data: data, encoding: .utf8) else { + return errorResponse(id: id, code: -32603, message: "Failed to encode MCP response.") } + return json } - // MARK: - Port File - - private func writePortFile(port: Int) { - let url = Self.portFileURL - let dir = url.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - try? "\(port)".write(to: url, atomically: true, encoding: .utf8) - } - - private func removePortFile() { - try? FileManager.default.removeItem(at: Self.portFileURL) + private func errorResponse(id: Any, code: Int, message: String) -> String { + let payload: [String: Any] = [ + "jsonrpc": "2.0", + "id": id, + "error": [ + "code": code, + "message": message + ] as [String: Any] + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { + return #"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"MCP error."}}"# + } + return json } enum MCPError: Error, LocalizedError { - case noPortAvailable case socketCreationFailed - case bindFailed - case listenFailed - case missingBody - case invalidRequest - case unknownMethod(String) + case bindFailed(code: Int32) + case listenFailed(code: Int32) + case invalidSocketPath + case readFailed(code: Int32) + case writeFailed(code: Int32) case unknownTool(String) case invalidToolArgs var errorDescription: String? { switch self { - case .noPortAvailable: return "No port available for MCP server" - case .socketCreationFailed: return "Failed to create MCP server socket" - case .bindFailed: return "Failed to bind MCP server port" - case .listenFailed: return "Failed to listen on MCP server port" - case .missingBody: return "Missing request body" - case .invalidRequest: return "Invalid MCP request" - case .unknownMethod(let m): return "Unknown MCP method: \(m)" - case .unknownTool(let t): return "Unknown MCP tool: \(t)" - case .invalidToolArgs: return "Invalid tool arguments" + case .socketCreationFailed: + return "Failed to create the Blitz MCP socket." + case .bindFailed(let code): + return "Failed to bind Blitz MCP socket (\(code))." + case .listenFailed(let code): + return "Failed to listen on Blitz MCP socket (\(code))." + case .invalidSocketPath: + return "Invalid Blitz MCP socket path." + case .readFailed(let code): + return "Failed to read from Blitz MCP socket (\(code))." + case .writeFailed(let code): + return "Failed to write to Blitz MCP socket (\(code))." + case .unknownTool(let tool): + return "Unknown MCP tool: \(tool)" + case .invalidToolArgs: + return "Invalid tool arguments." } } } diff --git a/src/utilities/ProjectStorage.swift b/src/utilities/ProjectStorage.swift index b154e6c..554e40d 100644 --- a/src/utilities/ProjectStorage.swift +++ b/src/utilities/ProjectStorage.swift @@ -223,11 +223,10 @@ struct ProjectStorage { /// Shared implementation: writes .mcp.json and .codex/config.toml into `directory`. func ensureMCPConfig(in directory: URL) { let mcpFile = directory.appendingPathComponent(".mcp.json") - let bridgePath = BlitzPaths.mcpBridge.path + let helperPath = BlitzPaths.mcpHelper.path let blitzMacosEntry: [String: Any] = [ - "command": "bash", - "args": [bridgePath] + "command": helperPath ] // Use full path to npx from Blitz's bundled Node.js runtime. // Also set PATH env so that #!/usr/bin/env node resolves correctly — @@ -270,8 +269,7 @@ struct ProjectStorage { let codexConfig = codexDir.appendingPathComponent("config.toml") let toml = """ [mcp_servers.blitz_macos] - command = "bash" - args = ["\(bridgePath)"] + command = "\(helperPath)" cwd = "\(directory.path)" """ do { diff --git a/src/views/settings/MCPSetupSection.swift b/src/views/settings/MCPSetupSection.swift index b98a61b..e5e498f 100644 --- a/src/views/settings/MCPSetupSection.swift +++ b/src/views/settings/MCPSetupSection.swift @@ -4,7 +4,6 @@ import SwiftUI struct MCPSetupSection: View { let mcpServer: MCPServerService? - @State private var serverPort: Int = 0 @State private var serverRunning: Bool = false @State private var copied = false @@ -18,7 +17,7 @@ struct MCPSetupSection: View { Circle() .fill(.green) .frame(width: 8, height: 8) - Text("Running on port \(serverPort)") + Text("Ready via local socket") .foregroundStyle(.secondary) } } else { @@ -55,7 +54,6 @@ struct MCPSetupSection: View { private func refreshStatus() async { guard let server = mcpServer else { return } - serverPort = await server.port serverRunning = await server.isRunning } @@ -64,8 +62,7 @@ struct MCPSetupSection: View { let config = """ { "blitz-macos": { - "command": "bash", - "args": ["\(home)/.blitz/blitz-mcp-bridge.sh"] + "command": "\(home)/.blitz/blitz-macos-mcp" } } """ From 3658462876b121a33a52e45446860784de210915 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Mon, 23 Mar 2026 23:07:43 -0700 Subject: [PATCH 10/51] Fix dashboard project switching performance --- src/views/ContentView.swift | 27 ++-- src/views/DashboardView.swift | 60 ++----- src/views/shared/ProjectAppIconView.swift | 182 ++++++++++++++++++++++ src/views/sidebar/SidebarView.swift | 29 +--- 4 files changed, 219 insertions(+), 79 deletions(-) create mode 100644 src/views/shared/ProjectAppIconView.swift diff --git a/src/views/ContentView.swift b/src/views/ContentView.swift index 2256939..3f559fb 100644 --- a/src/views/ContentView.swift +++ b/src/views/ContentView.swift @@ -89,6 +89,20 @@ struct ContentView: View { ) } + private func refreshProjectFiles(projectId: String, projectType: ProjectType) { + let whitelistBlitzMCP = appState.settingsStore.whitelistBlitzMCPTools + Task.detached(priority: .utility) { + let storage = ProjectStorage() + storage.ensureMCPConfig(projectId: projectId) + storage.ensureTeenybaseBackend(projectId: projectId, projectType: projectType) + storage.ensureClaudeFiles( + projectId: projectId, + projectType: projectType, + whitelistBlitzMCP: whitelistBlitzMCP + ) + } + } + var body: some View { NavigationSplitView { SidebarView(appState: appState) @@ -156,16 +170,11 @@ struct ContentView: View { mainWindow?.close() } else { // Project switched → ensure config files, run pending setup, reload ASC credentials + if let newId = newValue, let projectType = appState.activeProject?.type { + refreshProjectFiles(projectId: newId, projectType: projectType) + } + Task { - if let newId = newValue, let project = appState.activeProject { - // Ensure config files are up to date on every open. - // Handles Tauri migration, first-open of imported projects, - // and ensures Teenybase backend files are scaffolded. - let storage = ProjectStorage() - storage.ensureMCPConfig(projectId: newId) - storage.ensureTeenybaseBackend(projectId: newId, projectType: project.type) - storage.ensureClaudeFiles(projectId: newId, projectType: project.type, whitelistBlitzMCP: appState.settingsStore.whitelistBlitzMCPTools) - } await startPendingSetupIfNeeded() appState.ascManager.clearForProjectSwitch() if let newId = newValue, let project = appState.activeProject { diff --git a/src/views/DashboardView.swift b/src/views/DashboardView.swift index 4489e8f..b966901 100644 --- a/src/views/DashboardView.swift +++ b/src/views/DashboardView.swift @@ -88,8 +88,15 @@ struct DashboardView: View { let isSelected = project.id == appState.activeProjectId return VStack(spacing: 8) { - appIconView(project: project) - .frame(width: 56, height: 56) + ProjectAppIconView(project: project, size: 56, cornerRadius: 12) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(projectColor(project).opacity(0.15)) + Image(systemName: projectIcon(project)) + .font(.system(size: 24)) + .foregroundStyle(projectColor(project)) + } + } Text(project.name) .font(.callout.weight(.medium)) @@ -111,31 +118,14 @@ struct DashboardView: View { .contentShape(Rectangle()) } - private func appIconView(project: Project) -> some View { - Group { - if let icon = Self.loadAppIcon(projectId: project.id) { - Image(nsImage: icon) - .resizable() - .aspectRatio(contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } else { - ZStack { - RoundedRectangle(cornerRadius: 12) - .fill(projectColor(project).opacity(0.15)) - Image(systemName: projectIcon(project)) - .font(.system(size: 24)) - .foregroundStyle(projectColor(project)) - } - } - } - } - // MARK: - Actions private func selectProject(_ project: Project) { - let storage = ProjectStorage() - storage.updateLastOpened(projectId: project.id) appState.activeProjectId = project.id + let projectId = project.id + Task.detached(priority: .utility) { + ProjectStorage().updateLastOpened(projectId: projectId) + } } // MARK: - Stats (placeholder counts from project metadata) @@ -171,28 +161,4 @@ struct DashboardView: View { case .flutter: return .blue } } - - static func loadAppIcon(projectId: String) -> NSImage? { - let home = FileManager.default.homeDirectoryForCurrentUser.path - let blitzPath = "\(home)/.blitz/projects/\(projectId)/assets/AppIcon/icon_1024.png" - if let image = NSImage(contentsOfFile: blitzPath) { return image } - - let projectDir = "\(home)/.blitz/projects/\(projectId)" - let fm = FileManager.default - guard let enumerator = fm.enumerator(atPath: projectDir) else { return nil } - while let file = enumerator.nextObject() as? String { - guard file.hasSuffix("AppIcon.appiconset/Contents.json") else { continue } - let contentsPath = "\(projectDir)/\(file)" - guard let data = fm.contents(atPath: contentsPath), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let images = json["images"] as? [[String: Any]] else { continue } - for entry in images { - if let filename = entry["filename"] as? String { - let iconDir = (contentsPath as NSString).deletingLastPathComponent - if let image = NSImage(contentsOfFile: "\(iconDir)/\(filename)") { return image } - } - } - } - return nil - } } diff --git a/src/views/shared/ProjectAppIconView.swift b/src/views/shared/ProjectAppIconView.swift new file mode 100644 index 0000000..e911fb7 --- /dev/null +++ b/src/views/shared/ProjectAppIconView.swift @@ -0,0 +1,182 @@ +import AppKit +import SwiftUI + +private enum ProjectAppIconLookupState { + case unresolved + case resolved(String) + case missing +} + +enum ProjectAppIconLoader { + private static let imageCache = NSCache() + private static let lock = NSLock() + private static var pathCache: [String: ProjectAppIconLookupState] = [:] + private static let skippedDirectories: Set = [ + "node_modules", + "Pods", + ".git", + ".build", + "DerivedData", + "build" + ] + + static func cachedImage(for projectId: String) -> NSImage? { + imageCache.object(forKey: projectId as NSString) + } + + static func loadImage(for projectId: String) async -> NSImage? { + if let cached = cachedImage(for: projectId) { + return cached + } + + guard let path = await loadPath(for: projectId), + let image = NSImage(contentsOfFile: path) else { + return nil + } + + imageCache.setObject(image, forKey: projectId as NSString) + return image + } + + private static func loadPath(for projectId: String) async -> String? { + switch cachedPath(for: projectId) { + case .resolved(let path): + return path + case .missing: + return nil + case .unresolved: + break + } + + let path = await Task.detached(priority: .utility) { + findIconPath(for: projectId) + }.value + + cachePath(path, for: projectId) + return path + } + + private static func cachedPath(for projectId: String) -> ProjectAppIconLookupState { + lock.lock() + defer { lock.unlock() } + return pathCache[projectId] ?? .unresolved + } + + private static func cachePath(_ path: String?, for projectId: String) { + lock.lock() + defer { lock.unlock() } + pathCache[projectId] = path.map(ProjectAppIconLookupState.resolved) ?? .missing + } + + private static func findIconPath(for projectId: String) -> String? { + let fm = FileManager.default + let home = fm.homeDirectoryForCurrentUser.path + let projectDir = URL(fileURLWithPath: "\(home)/.blitz/projects/\(projectId)") + + let generatedIcon = projectDir.appendingPathComponent("assets/AppIcon/icon_1024.png") + if fm.fileExists(atPath: generatedIcon.path) { + return generatedIcon.path + } + + let searchRoots = [ + projectDir.appendingPathComponent("ios"), + projectDir.appendingPathComponent("macos"), + projectDir + ] + + for root in searchRoots where fm.fileExists(atPath: root.path) { + if let path = findIconPath(in: root, using: fm) { + return path + } + } + + return nil + } + + private static func findIconPath(in root: URL, using fm: FileManager) -> String? { + guard let enumerator = fm.enumerator( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { + return nil + } + + while let entry = enumerator.nextObject() as? URL { + let name = entry.lastPathComponent + + if skippedDirectories.contains(name) { + enumerator.skipDescendants() + continue + } + + guard name == "Contents.json", + entry.deletingLastPathComponent().lastPathComponent == "AppIcon.appiconset", + let data = fm.contents(atPath: entry.path), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let images = json["images"] as? [[String: Any]] else { + continue + } + + for image in images { + guard let filename = image["filename"] as? String else { continue } + let iconPath = entry.deletingLastPathComponent().appendingPathComponent(filename).path + if fm.fileExists(atPath: iconPath) { + return iconPath + } + } + } + + return nil + } +} + +struct ProjectAppIconView: View { + let project: Project + let size: CGFloat + let cornerRadius: CGFloat + let placeholder: () -> Placeholder + + @State private var icon: NSImage? + + init( + project: Project, + size: CGFloat, + cornerRadius: CGFloat, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.project = project + self.size = size + self.cornerRadius = cornerRadius + self.placeholder = placeholder + _icon = State(initialValue: ProjectAppIconLoader.cachedImage(for: project.id)) + } + + var body: some View { + Group { + if let icon { + Image(nsImage: icon) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + } else { + placeholder() + } + } + .frame(width: size, height: size) + .task(id: project.id) { + await loadIcon() + } + } + + @MainActor + private func loadIcon() async { + if let cached = ProjectAppIconLoader.cachedImage(for: project.id) { + icon = cached + return + } + + icon = nil + icon = await ProjectAppIconLoader.loadImage(for: project.id) + } +} diff --git a/src/views/sidebar/SidebarView.swift b/src/views/sidebar/SidebarView.swift index 205e12d..5c065aa 100644 --- a/src/views/sidebar/SidebarView.swift +++ b/src/views/sidebar/SidebarView.swift @@ -2,7 +2,6 @@ import SwiftUI struct SidebarView: View { @Bindable var appState: AppState - @State private var appIcon: NSImage? var body: some View { List(selection: $appState.activeTab) { @@ -13,16 +12,12 @@ struct SidebarView: View { // App tab — shows dynamic project icon + name HStack(spacing: 8) { - if let icon = appIcon { - Image(nsImage: icon) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 18, height: 18) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } else if let project = appState.activeProject { - Image(systemName: projectIcon(project)) - .foregroundStyle(projectColor(project)) - .frame(width: 18, height: 18) + if let project = appState.activeProject { + ProjectAppIconView(project: project, size: 18, cornerRadius: 4) { + Image(systemName: projectIcon(project)) + .foregroundStyle(projectColor(project)) + .frame(width: 18, height: 18) + } } else { Image(systemName: "app") .frame(width: 18, height: 18) @@ -32,10 +27,6 @@ struct SidebarView: View { } .tag(AppTab.app) } - .onChange(of: appState.activeProjectId) { _, _ in - reloadAppIcon() - } - .onAppear { reloadAppIcon() } // Release group Section("Release") { @@ -87,12 +78,4 @@ struct SidebarView: View { case .flutter: return .blue } } - - private func reloadAppIcon() { - guard let projectId = appState.activeProjectId else { - appIcon = nil - return - } - appIcon = DashboardView.loadAppIcon(projectId: projectId) - } } From 1fa82b17a37178abe7f2c97d4f14c76466a2de27 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Mon, 23 Mar 2026 23:35:51 -0700 Subject: [PATCH 11/51] Optimize ASC overview hydration and caching --- src/models/ASCModels.swift | 15 +- src/services/ASCManager.swift | 476 +++++++++++++++++++---- src/views/ContentView.swift | 11 +- src/views/release/ASCOverview.swift | 55 +-- src/views/shared/asc/ASCTabContent.swift | 22 +- 5 files changed, 459 insertions(+), 120 deletions(-) diff --git a/src/models/ASCModels.swift b/src/models/ASCModels.swift index ab5f317..2860fd2 100644 --- a/src/models/ASCModels.swift +++ b/src/models/ASCModels.swift @@ -421,14 +421,23 @@ struct SubmissionReadiness { let id: String let label: String let value: String? + let isLoading: Bool let required: Bool let actionUrl: String? // If set, shows an "Open in ASC" button let hint: String? // Agent-visible guidance for resolving this field - init(label: String, value: String?, required: Bool = true, actionUrl: String? = nil, hint: String? = nil) { + init( + label: String, + value: String?, + isLoading: Bool = false, + required: Bool = true, + actionUrl: String? = nil, + hint: String? = nil + ) { self.id = label self.label = label self.value = value + self.isLoading = isLoading self.required = required self.actionUrl = actionUrl self.hint = hint @@ -438,11 +447,11 @@ struct SubmissionReadiness { var fields: [FieldStatus] var isComplete: Bool { - fields.filter(\.required).allSatisfy { $0.value != nil && !($0.value!.isEmpty) } + fields.filter(\.required).allSatisfy { !$0.isLoading && $0.value != nil && !($0.value!.isEmpty) } } var missingRequired: [FieldStatus] { - fields.filter { $0.required && ($0.value == nil || $0.value!.isEmpty) } + fields.filter { $0.required && !$0.isLoading && ($0.value == nil || $0.value!.isEmpty) } } } diff --git a/src/services/ASCManager.swift b/src/services/ASCManager.swift index 45aad70..a265c31 100644 --- a/src/services/ASCManager.swift +++ b/src/services/ASCManager.swift @@ -28,6 +28,148 @@ struct LocalScreenshotAsset: Identifiable { @MainActor @Observable final class ASCManager { + private struct OverviewSnapshot { + let projectId: String + let app: ASCApp? + let appStoreVersions: [ASCAppStoreVersion] + let localizations: [ASCVersionLocalization] + let screenshotSets: [ASCScreenshotSet] + let screenshots: [String: [ASCScreenshot]] + let builds: [ASCBuild] + let inAppPurchases: [ASCInAppPurchase] + let subscriptionGroups: [ASCSubscriptionGroup] + let subscriptionsPerGroup: [String: [ASCSubscription]] + let currentAppPricePointId: String? + let scheduledAppPricePointId: String? + let scheduledAppPriceEffectiveDate: String? + let appInfo: ASCAppInfo? + let appInfoLocalization: ASCAppInfoLocalization? + let ageRatingDeclaration: ASCAgeRatingDeclaration? + let reviewDetail: ASCReviewDetail? + let reviewSubmissions: [ASCReviewSubmission] + let reviewSubmissionItemsBySubmissionId: [String: [ASCReviewSubmissionItem]] + let latestSubmissionItems: [ASCReviewSubmissionItem] + let submissionHistoryEvents: [ASCSubmissionHistoryEvent] + let attachedSubmissionItemIDs: Set + let resolutionCenterThreads: [IrisResolutionCenterThread] + let rejectionMessages: [IrisResolutionCenterMessage] + let rejectionReasons: [IrisReviewRejection] + let cachedFeedback: IrisFeedbackCache? + let appIconStatus: String? + let monetizationStatus: String? + let loadedTabs: Set + let tabLoadedAt: [AppTab: Date] + + @MainActor + init(manager: ASCManager, projectId: String) { + self.projectId = projectId + app = manager.app + appStoreVersions = manager.appStoreVersions + localizations = manager.localizations + screenshotSets = manager.screenshotSets + screenshots = manager.screenshots + builds = manager.builds + inAppPurchases = manager.inAppPurchases + subscriptionGroups = manager.subscriptionGroups + subscriptionsPerGroup = manager.subscriptionsPerGroup + currentAppPricePointId = manager.currentAppPricePointId + scheduledAppPricePointId = manager.scheduledAppPricePointId + scheduledAppPriceEffectiveDate = manager.scheduledAppPriceEffectiveDate + appInfo = manager.appInfo + appInfoLocalization = manager.appInfoLocalization + ageRatingDeclaration = manager.ageRatingDeclaration + reviewDetail = manager.reviewDetail + reviewSubmissions = manager.reviewSubmissions + reviewSubmissionItemsBySubmissionId = manager.reviewSubmissionItemsBySubmissionId + latestSubmissionItems = manager.latestSubmissionItems + submissionHistoryEvents = manager.submissionHistoryEvents + attachedSubmissionItemIDs = manager.attachedSubmissionItemIDs + resolutionCenterThreads = manager.resolutionCenterThreads + rejectionMessages = manager.rejectionMessages + rejectionReasons = manager.rejectionReasons + cachedFeedback = manager.cachedFeedback + appIconStatus = manager.appIconStatus + monetizationStatus = manager.monetizationStatus + let cachedLoadedTabs = manager.loadedTabs.intersection([.app]) + loadedTabs = cachedLoadedTabs + tabLoadedAt = manager.tabLoadedAt.filter { cachedLoadedTabs.contains($0.key) } + } + + @MainActor + func apply(to manager: ASCManager) { + manager.app = app + manager.appStoreVersions = appStoreVersions + manager.localizations = localizations + manager.screenshotSets = screenshotSets + manager.screenshots = screenshots + manager.builds = builds + manager.inAppPurchases = inAppPurchases + manager.subscriptionGroups = subscriptionGroups + manager.subscriptionsPerGroup = subscriptionsPerGroup + manager.currentAppPricePointId = currentAppPricePointId + manager.scheduledAppPricePointId = scheduledAppPricePointId + manager.scheduledAppPriceEffectiveDate = scheduledAppPriceEffectiveDate + manager.appInfo = appInfo + manager.appInfoLocalization = appInfoLocalization + manager.ageRatingDeclaration = ageRatingDeclaration + manager.reviewDetail = reviewDetail + manager.reviewSubmissions = reviewSubmissions + manager.reviewSubmissionItemsBySubmissionId = reviewSubmissionItemsBySubmissionId + manager.latestSubmissionItems = latestSubmissionItems + manager.submissionHistoryEvents = submissionHistoryEvents + manager.attachedSubmissionItemIDs = attachedSubmissionItemIDs + manager.resolutionCenterThreads = resolutionCenterThreads + manager.rejectionMessages = rejectionMessages + manager.rejectionReasons = rejectionReasons + manager.cachedFeedback = cachedFeedback + manager.appIconStatus = appIconStatus + manager.monetizationStatus = monetizationStatus + manager.loadedTabs = loadedTabs + manager.tabLoadedAt = tabLoadedAt + manager.loadedProjectId = projectId + manager.tabError = [:] + manager.isLoadingTab = [:] + manager.isLoadingApp = false + manager.isLoadingIrisFeedback = false + manager.irisFeedbackError = nil + manager.writeError = nil + manager.submissionError = nil + manager.overviewReadinessLoadingFields = [] + } + } + + private static let overviewCacheFreshness: TimeInterval = 120 + private static let overviewLocalizationFieldLabels: Set = [ + "App Name", + "Description", + "Keywords", + "Support URL" + ] + private static let overviewVersionFieldLabels: Set = ["Copyright"] + private static let overviewAppInfoFieldLabels: Set = ["Primary Category"] + private static let overviewMetadataFieldLabels: Set = [ + "Privacy Policy URL", + "Age Rating" + ] + private static let overviewReviewFieldLabels: Set = [ + "Review Contact First Name", + "Review Contact Last Name", + "Review Contact Email", + "Review Contact Phone", + "Demo Account Name", + "Demo Account Password" + ] + private static let overviewBuildFieldLabels: Set = ["Build"] + private static let overviewPricingFieldLabels: Set = [ + "Pricing", + "In-App Purchases & Subscriptions" + ] + private static let overviewScreenshotFieldLabels: Set = [ + "Mac Screenshots", + "iPhone Screenshots", + "iPad Screenshots" + ] + nonisolated init() {} // Credentials & service @@ -161,30 +303,47 @@ final class ASCManager { "https://appstoreconnect.apple.com/apps/\($0.id)/distribution/privacy" } + func readinessField( + label: String, + value: String?, + required: Bool = true, + actionUrl: String? = nil, + hint: String? = nil + ) -> SubmissionReadiness.FieldStatus { + SubmissionReadiness.FieldStatus( + label: label, + value: value, + isLoading: overviewReadinessLoadingFields.contains(label) && (value == nil || value?.isEmpty == true), + required: required, + actionUrl: actionUrl, + hint: hint + ) + } + var fields: [SubmissionReadiness.FieldStatus] = [ - .init(label: "App Name", value: info?.attributes.name ?? loc?.attributes.title), - .init(label: "Description", value: loc?.attributes.description), - .init(label: "Keywords", value: loc?.attributes.keywords), - .init(label: "Support URL", value: loc?.attributes.supportUrl), - .init(label: "Privacy Policy URL", value: info?.attributes.privacyPolicyUrl), - .init(label: "Copyright", value: version?.attributes.copyright), - .init(label: "Content Rights", value: app?.contentRightsDeclaration), - .init(label: "Primary Category", value: appInfo?.primaryCategoryId), - .init(label: "Age Rating", value: ageRatingIsConfigured ? "Configured" : nil), - .init(label: "Pricing", value: monetizationStatus), - .init(label: "Review Contact First Name", value: review?.attributes.contactFirstName), - .init(label: "Review Contact Last Name", value: review?.attributes.contactLastName), - .init(label: "Review Contact Email", value: review?.attributes.contactEmail), - .init(label: "Review Contact Phone", value: review?.attributes.contactPhone), + readinessField(label: "App Name", value: info?.attributes.name ?? loc?.attributes.title), + readinessField(label: "Description", value: loc?.attributes.description), + readinessField(label: "Keywords", value: loc?.attributes.keywords), + readinessField(label: "Support URL", value: loc?.attributes.supportUrl), + readinessField(label: "Privacy Policy URL", value: info?.attributes.privacyPolicyUrl), + readinessField(label: "Copyright", value: version?.attributes.copyright), + readinessField(label: "Content Rights", value: app?.contentRightsDeclaration), + readinessField(label: "Primary Category", value: appInfo?.primaryCategoryId), + readinessField(label: "Age Rating", value: ageRatingIsConfigured ? "Configured" : nil), + readinessField(label: "Pricing", value: monetizationStatus), + readinessField(label: "Review Contact First Name", value: review?.attributes.contactFirstName), + readinessField(label: "Review Contact Last Name", value: review?.attributes.contactLastName), + readinessField(label: "Review Contact Email", value: review?.attributes.contactEmail), + readinessField(label: "Review Contact Phone", value: review?.attributes.contactPhone), ] // Conditional: demo credentials required when demoAccountRequired is set if demoRequired { - fields.append(.init(label: "Demo Account Name", value: review?.attributes.demoAccountName)) - fields.append(.init(label: "Demo Account Password", value: review?.attributes.demoAccountPassword)) + fields.append(readinessField(label: "Demo Account Name", value: review?.attributes.demoAccountName)) + fields.append(readinessField(label: "Demo Account Password", value: review?.attributes.demoAccountPassword)) } - fields.append(.init(label: "App Icon", value: appIconStatus)) + fields.append(readinessField(label: "App Icon", value: appIconStatus)) // Count only non-failed screenshots for readiness func validCount(for set: ASCScreenshotSet?) -> Int { @@ -197,17 +356,17 @@ final class ASCManager { if isMacApp { let macCount = validCount(for: macScreenshots) - fields.append(.init(label: "Mac Screenshots", value: macCount > 0 ? "\(macCount) screenshot(s)" : nil)) + fields.append(readinessField(label: "Mac Screenshots", value: macCount > 0 ? "\(macCount) screenshot(s)" : nil)) } else { let iphoneCount = validCount(for: iphoneScreenshots) let ipadCount = validCount(for: ipadScreenshots) - fields.append(.init(label: "iPhone Screenshots", value: iphoneCount > 0 ? "\(iphoneCount) screenshot(s)" : nil)) - fields.append(.init(label: "iPad Screenshots", value: ipadCount > 0 ? "\(ipadCount) screenshot(s)" : nil)) + fields.append(readinessField(label: "iPhone Screenshots", value: iphoneCount > 0 ? "\(iphoneCount) screenshot(s)" : nil)) + fields.append(readinessField(label: "iPad Screenshots", value: ipadCount > 0 ? "\(ipadCount) screenshot(s)" : nil)) } fields.append(contentsOf: [ - .init(label: "Privacy Nutrition Labels", value: nil, required: false, actionUrl: privacyUrl), - .init(label: "Build", value: builds.first?.attributes.version), + readinessField(label: "Privacy Nutrition Labels", value: nil, required: false, actionUrl: privacyUrl), + readinessField(label: "Build", value: builds.first?.attributes.version), ]) // Conditional: first-time IAP/subscription attachment @@ -231,7 +390,7 @@ final class ASCManager { let iapUrl: String? = app.map { "https://appstoreconnect.apple.com/apps/\($0.id)/distribution/ios/version/inflight" } - fields.append(.init( + fields.append(readinessField( label: "In-App Purchases & Subscriptions", value: nil, required: true, @@ -252,6 +411,10 @@ final class ASCManager { var isLoadingTab: [AppTab: Bool] = [:] var tabError: [AppTab: String] = [:] private var loadedTabs: Set = [] + private var tabLoadedAt: [AppTab: Date] = [:] + private var overviewSnapshots: [String: OverviewSnapshot] = [:] + private var overviewHydrationTask: Task? + private var overviewReadinessLoadingFields: Set = [] var loadedProjectId: String? @@ -298,33 +461,16 @@ final class ASCManager { checkAppIcon(projectId: projectId) } - // MARK: - Project Lifecycle - - func loadCredentials(for projectId: String, bundleId: String?) async { - guard loadedProjectId != projectId else { return } - - isLoadingCredentials = true - credentialsError = nil - - let creds = ASCCredentials.load() - - credentials = creds - isLoadingCredentials = false - loadedProjectId = projectId - refreshAppIconStatusIfNeeded(for: projectId) + private func resetProjectData(preserveCredentials: Bool) { + overviewHydrationTask?.cancel() + overviewHydrationTask = nil + overviewReadinessLoadingFields = [] - if let creds { - service = AppStoreConnectService(credentials: creds) + if !preserveCredentials { + credentials = nil + service = nil } - if let bundleId, !bundleId.isEmpty, creds != nil { - await fetchApp(bundleId: bundleId) - } - } - - func clearForProjectSwitch() { - credentials = nil - service = nil app = nil isLoadingCredentials = false credentialsError = nil @@ -365,7 +511,10 @@ final class ASCManager { isLoadingTab = [:] tabError = [:] loadedTabs = [] - loadedProjectId = nil + tabLoadedAt = [:] + if !preserveCredentials { + loadedProjectId = nil + } // Clear iris data but keep session (it's account-wide, not project-specific) resolutionCenterThreads = [] rejectionMessages = [] @@ -376,6 +525,89 @@ final class ASCManager { cancelPendingWebAuth() } + private func cacheCurrentOverviewSnapshot() { + guard let projectId = loadedProjectId else { return } + guard app != nil || !appStoreVersions.isEmpty || loadedTabs.contains(.app) else { return } + overviewSnapshots[projectId] = OverviewSnapshot(manager: self, projectId: projectId) + } + + private func startOverviewReadinessLoading(_ fields: Set) { + overviewReadinessLoadingFields = fields + } + + private func finishOverviewReadinessLoading(_ fields: Set) { + overviewReadinessLoadingFields.subtract(fields) + } + + private func shouldRefreshOverviewCache() -> Bool { + guard loadedTabs.contains(.app) else { return true } + guard let loadedAt = tabLoadedAt[.app] else { return true } + return Date().timeIntervalSince(loadedAt) > Self.overviewCacheFreshness + } + + private func isCurrentProject(_ projectId: String?) -> Bool { + guard let projectId else { return false } + return loadedProjectId == projectId + } + + func prepareForProjectSwitch(to projectId: String) { + cacheCurrentOverviewSnapshot() + resetProjectData(preserveCredentials: true) + + if let snapshot = overviewSnapshots[projectId] { + snapshot.apply(to: self) + } else { + loadedProjectId = projectId + } + } + + func ensureTabData(_ tab: AppTab) async { + guard credentials != nil else { return } + + if loadedTabs.contains(tab) { + if tab == .app && shouldRefreshOverviewCache() { + await refreshTabData(tab) + } + return + } + + await fetchTabData(tab) + } + + // MARK: - Project Lifecycle + + func loadCredentials(for projectId: String, bundleId: String?) async { + let needsCredentialReload = credentials == nil || service == nil + let shouldSkip = loadedProjectId == projectId + && !needsCredentialReload + && (bundleId == nil || app != nil) + guard !shouldSkip else { return } + + credentialsError = nil + + if needsCredentialReload { + isLoadingCredentials = true + let creds = ASCCredentials.load() + credentials = creds + isLoadingCredentials = false + + if let creds { + service = AppStoreConnectService(credentials: creds) + } + } + + loadedProjectId = projectId + refreshAppIconStatusIfNeeded(for: projectId) + + if let bundleId, !bundleId.isEmpty, credentials != nil, app == nil { + await fetchApp(bundleId: bundleId) + } + } + + func clearForProjectSwitch() { + resetProjectData(preserveCredentials: false) + } + // MARK: - Iris Session (Apple ID auth for rejection feedback) private func irisLog(_ msg: String) { @@ -987,6 +1219,7 @@ final class ASCManager { try await loadData(for: tab, service: service) isLoadingTab[tab] = false loadedTabs.insert(tab) + tabLoadedAt[tab] = Date() } catch { isLoadingTab[tab] = false tabError[tab] = error.localizedDescription @@ -998,6 +1231,7 @@ final class ASCManager { func resetTabState() { tabError.removeAll() loadedTabs.removeAll() + tabLoadedAt.removeAll() } func refreshTabData(_ tab: AppTab) async { @@ -1012,6 +1246,7 @@ final class ASCManager { try await loadData(for: tab, service: service) isLoadingTab[tab] = false loadedTabs.insert(tab) + tabLoadedAt[tab] = Date() } catch { isLoadingTab[tab] = false tabError[tab] = error.localizedDescription @@ -1023,6 +1258,77 @@ final class ASCManager { await refreshAttachedSubmissionItemIDs() } + private func hydrateOverviewSecondaryData( + projectId: String?, + appId: String, + firstLocalizationId: String?, + appInfoId: String?, + service: AppStoreConnectService + ) async { + if let firstLocalizationId { + do { + let fetchedSets = try await service.fetchScreenshotSets(localizationId: firstLocalizationId) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + screenshotSets = fetchedSets + + let fetchedScreenshots = try await withThrowingTaskGroup(of: (String, [ASCScreenshot]).self) { group in + for set in fetchedSets { + group.addTask { + let screenshots = try await service.fetchScreenshots(setId: set.id) + return (set.id, screenshots) + } + } + + var pairs: [(String, [ASCScreenshot])] = [] + for try await pair in group { + pairs.append(pair) + } + return pairs + } + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + screenshots = Dictionary(uniqueKeysWithValues: fetchedScreenshots) + finishOverviewReadinessLoading(Self.overviewScreenshotFieldLabels) + } catch { + print("Failed to hydrate overview screenshots: \(error)") + finishOverviewReadinessLoading(Self.overviewScreenshotFieldLabels) + } + } else { + finishOverviewReadinessLoading(Self.overviewScreenshotFieldLabels) + } + + if let appInfoId { + async let ageRatingTask: ASCAgeRatingDeclaration? = try? service.fetchAgeRating(appInfoId: appInfoId) + async let appInfoLocalizationTask: ASCAppInfoLocalization? = try? service.fetchAppInfoLocalization(appInfoId: appInfoId) + + let fetchedAgeRating = await ageRatingTask + let fetchedAppInfoLocalization = await appInfoLocalizationTask + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + ageRatingDeclaration = fetchedAgeRating + appInfoLocalization = fetchedAppInfoLocalization + finishOverviewReadinessLoading(Self.overviewMetadataFieldLabels) + } else { + finishOverviewReadinessLoading(Self.overviewMetadataFieldLabels) + } + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + await refreshReviewSubmissionData(appId: appId, service: service) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + rebuildSubmissionHistory(appId: appId) + refreshSubmissionFeedbackIfNeeded() + + if monetizationStatus == nil { + let hasPricing = await service.fetchPricingConfigured(appId: appId) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + monetizationStatus = hasPricing ? "Configured" : nil + } + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + await refreshSubmissionReadinessData() + finishOverviewReadinessLoading(Self.overviewPricingFieldLabels) + } + private func loadData(for tab: AppTab, service: AppStoreConnectService) async throws { guard let appId = app?.id else { throw ASCError.notFound("App — check your bundle ID in project settings") @@ -1031,39 +1337,61 @@ final class ASCManager { switch tab { case .app: refreshAppIconStatusIfNeeded(for: loadedProjectId) - let versions = try await service.fetchAppStoreVersions(appId: appId) + startOverviewReadinessLoading( + Self.overviewLocalizationFieldLabels + .union(Self.overviewVersionFieldLabels) + .union(Self.overviewAppInfoFieldLabels) + .union(Self.overviewMetadataFieldLabels) + .union(Self.overviewReviewFieldLabels) + .union(Self.overviewBuildFieldLabels) + .union(Self.overviewPricingFieldLabels) + .union(Self.overviewScreenshotFieldLabels) + ) + async let versionsTask = service.fetchAppStoreVersions(appId: appId) + async let appInfoTask: ASCAppInfo? = try? service.fetchAppInfo(appId: appId) + async let buildsTask = service.fetchBuilds(appId: appId) + + let versions = try await versionsTask appStoreVersions = versions - appInfo = try? await service.fetchAppInfo(appId: appId) - // Fetch all data needed for submission readiness + finishOverviewReadinessLoading(Self.overviewVersionFieldLabels) + appInfo = await appInfoTask + finishOverviewReadinessLoading(Self.overviewAppInfoFieldLabels) + builds = try await buildsTask + finishOverviewReadinessLoading(Self.overviewBuildFieldLabels) + + var firstLocalizationId: String? if let latestId = versions.first?.id { - localizations = try await service.fetchLocalizations(versionId: latestId) - reviewDetail = try? await service.fetchReviewDetail(versionId: latestId) - let locs = localizations - if let firstLocId = locs.first?.id { - let sets = try await service.fetchScreenshotSets(localizationId: firstLocId) - screenshotSets = sets - for set in sets { - screenshots[set.id] = try await service.fetchScreenshots(setId: set.id) - } - } - } - if let infoId = appInfo?.id { - ageRatingDeclaration = try? await service.fetchAgeRating(appInfoId: infoId) - appInfoLocalization = try? await service.fetchAppInfoLocalization(appInfoId: infoId) + async let localizationsTask = service.fetchLocalizations(versionId: latestId) + async let reviewDetailTask: ASCReviewDetail? = try? service.fetchReviewDetail(versionId: latestId) + + let fetchedLocalizations = try await localizationsTask + localizations = fetchedLocalizations + firstLocalizationId = fetchedLocalizations.first?.id + finishOverviewReadinessLoading(Self.overviewLocalizationFieldLabels) + reviewDetail = await reviewDetailTask + finishOverviewReadinessLoading(Self.overviewReviewFieldLabels) + } else { + finishOverviewReadinessLoading( + Self.overviewLocalizationFieldLabels + .union(Self.overviewReviewFieldLabels) + ) } - builds = try await service.fetchBuilds(appId: appId) - await refreshReviewSubmissionData(appId: appId, service: service) - rebuildSubmissionHistory(appId: appId) + refreshSubmissionFeedbackIfNeeded() - // Check monetization status — skip if already set (avoids race with in-flight fetches overwriting optimistic updates from setPriceFree/setAppPrice) - if monetizationStatus == nil { - let hasPricing = await service.fetchPricingConfigured(appId: appId) - monetizationStatus = hasPricing ? "Configured" : nil + overviewHydrationTask?.cancel() + let projectId = loadedProjectId + let currentAppInfoId = appInfo?.id + overviewHydrationTask = Task { + await self.hydrateOverviewSecondaryData( + projectId: projectId, + appId: appId, + firstLocalizationId: firstLocalizationId, + appInfoId: currentAppInfoId, + service: service + ) } - await refreshSubmissionReadinessData() - case .storeListing: let versions = try await service.fetchAppStoreVersions(appId: appId) appStoreVersions = versions diff --git a/src/views/ContentView.swift b/src/views/ContentView.swift index 3f559fb..9afb355 100644 --- a/src/views/ContentView.swift +++ b/src/views/ContentView.swift @@ -158,7 +158,7 @@ struct ContentView: View { if appState.activeTab.isASCTab { await appState.ascManager.fetchTabData(appState.activeTab) } else if appState.activeTab == .app && appState.activeAppSubTab == .overview { - await appState.ascManager.fetchTabData(.app) + await appState.ascManager.ensureTabData(.app) } } } @@ -170,13 +170,16 @@ struct ContentView: View { mainWindow?.close() } else { // Project switched → ensure config files, run pending setup, reload ASC credentials + if let newId = newValue { + appState.ascManager.prepareForProjectSwitch(to: newId) + } + if let newId = newValue, let projectType = appState.activeProject?.type { refreshProjectFiles(projectId: newId, projectType: projectType) } Task { await startPendingSetupIfNeeded() - appState.ascManager.clearForProjectSwitch() if let newId = newValue, let project = appState.activeProject { await appState.ascManager.loadCredentials( for: newId, @@ -185,7 +188,7 @@ struct ContentView: View { if appState.activeTab.isASCTab { await appState.ascManager.fetchTabData(appState.activeTab) } else if appState.activeTab == .app && appState.activeAppSubTab == .overview { - await appState.ascManager.fetchTabData(.app) + await appState.ascManager.ensureTabData(.app) } } } @@ -237,7 +240,7 @@ struct ContentView: View { } // Fetch ASC overview data when entering overview sub-tab if newSub == .overview { - await appState.ascManager.fetchTabData(.app) + await appState.ascManager.ensureTabData(.app) } } } diff --git a/src/views/release/ASCOverview.swift b/src/views/release/ASCOverview.swift index 10ab5d4..3576378 100644 --- a/src/views/release/ASCOverview.swift +++ b/src/views/release/ASCOverview.swift @@ -5,7 +5,6 @@ struct ASCOverview: View { private var asc: ASCManager { appState.ascManager } @State private var showPreview = false - @State private var appIcon: NSImage? var body: some View { ASCCredentialGate( @@ -18,11 +17,7 @@ struct ASCOverview: View { } } .task(id: appState.activeProjectId) { - if let pid = appState.activeProjectId { - asc.checkAppIcon(projectId: pid) - appIcon = Self.loadAppIcon(projectId: pid) - } - await asc.fetchTabData(.app) + await asc.ensureTabData(.app) } .sheet(isPresented: $showPreview) { SubmitPreviewSheet(appState: appState) @@ -41,12 +36,13 @@ struct ASCOverview: View { VStack(alignment: .leading, spacing: 12) { if let app = asc.app { HStack(spacing: 10) { - if let icon = appIcon { - Image(nsImage: icon) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 40, height: 40) - .clipShape(RoundedRectangle(cornerRadius: 9)) + if let project = appState.activeProject { + ProjectAppIconView(project: project, size: 40, cornerRadius: 9) { + Image(systemName: "app.fill") + .font(.system(size: 30)) + .foregroundStyle(.blue) + .frame(width: 40, height: 40) + } } else { Image(systemName: "app.fill") .font(.system(size: 30)) @@ -156,6 +152,12 @@ struct ASCOverview: View { Text(field.label) .font(.callout) .foregroundStyle(.orange) + } else if field.isLoading { + ProgressView() + .controlSize(.small) + Text(field.label) + .font(.callout) + .foregroundStyle(.secondary) } else if field.required && (field.value == nil || field.value!.isEmpty) { Image(systemName: "exclamationmark.circle.fill") .foregroundStyle(.red) @@ -194,6 +196,10 @@ struct ASCOverview: View { .frame(maxWidth: 200, alignment: .trailing) } } + } else if field.isLoading { + Text("Loading…") + .font(.callout) + .foregroundStyle(.secondary) } else if let url = field.actionUrl, let nsUrl = URL(string: url) { if field.label != "Privacy Nutrition Labels" { Button { @@ -388,29 +394,4 @@ struct ASCOverview: View { return ("Removed", .secondary) } } - - - private static func loadAppIcon(projectId: String) -> NSImage? { - let home = FileManager.default.homeDirectoryForCurrentUser.path - let blitzPath = "\(home)/.blitz/projects/\(projectId)/assets/AppIcon/icon_1024.png" - if let image = NSImage(contentsOfFile: blitzPath) { return image } - - let projectDir = "\(home)/.blitz/projects/\(projectId)" - let fm = FileManager.default - guard let enumerator = fm.enumerator(atPath: projectDir) else { return nil } - while let file = enumerator.nextObject() as? String { - guard file.hasSuffix("AppIcon.appiconset/Contents.json") else { continue } - let contentsPath = "\(projectDir)/\(file)" - guard let data = fm.contents(atPath: contentsPath), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let images = json["images"] as? [[String: Any]] else { continue } - for entry in images { - if let filename = entry["filename"] as? String { - let iconDir = (contentsPath as NSString).deletingLastPathComponent - if let image = NSImage(contentsOfFile: "\(iconDir)/\(filename)") { return image } - } - } - } - return nil - } } diff --git a/src/views/shared/asc/ASCTabContent.swift b/src/views/shared/asc/ASCTabContent.swift index cd4db3e..b5437a6 100644 --- a/src/views/shared/asc/ASCTabContent.swift +++ b/src/views/shared/asc/ASCTabContent.swift @@ -7,8 +7,16 @@ struct ASCTabContent: View { var platform: ProjectPlatform = .iOS @ViewBuilder var content: () -> Content + private var isLoading: Bool { + asc.isLoadingTab[tab] == true || asc.isLoadingApp + } + + private var shouldRenderOverviewWhileLoading: Bool { + tab == .app && asc.credentials != nil + } + var body: some View { - if asc.isLoadingTab[tab] == true || asc.isLoadingApp { + if isLoading && !shouldRenderOverviewWhileLoading { VStack(spacing: 12) { ProgressView() Text("Loading\u{2026}") @@ -16,7 +24,7 @@ struct ASCTabContent: View { .font(.callout) } .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if asc.app == nil && asc.credentials != nil { + } else if asc.app == nil && asc.credentials != nil && !isLoading { // App not found — show bundle ID setup instead of flashing content BundleIDSetupView(asc: asc, tab: tab, platform: platform) } else if let error = asc.tabError[tab] { @@ -39,6 +47,16 @@ struct ASCTabContent: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else { content() + .overlay(alignment: .topTrailing) { + if isLoading && shouldRenderOverviewWhileLoading { + ProgressView() + .controlSize(.small) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(.background.secondary, in: Capsule()) + .padding(12) + } + } } } } From 68048513410fd62bace6f392ae1a31af3c30459a Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Tue, 24 Mar 2026 00:58:52 -0700 Subject: [PATCH 12/51] Optimize ASC tab loading and refresh flows --- docs/codex-migration-prompt.md | 77 +++ docs/migration-to-shared-asc-package.md | 567 +++++++++++++++++++++++ package.json | 2 +- scripts/build-pkg.sh | 6 + src/services/ASCManager.swift | 374 ++++++++++++--- src/views/ContentView.swift | 6 +- src/views/insights/AnalyticsView.swift | 3 +- src/views/insights/ReviewsView.swift | 46 +- src/views/release/ASCOverview.swift | 15 +- src/views/release/AppDetailsView.swift | 27 +- src/views/release/PricingView.swift | 196 ++++---- src/views/release/ReviewView.swift | 51 +- src/views/release/ScreenshotsView.swift | 37 +- src/views/release/StoreListingView.swift | 23 +- src/views/shared/asc/ASCTabContent.swift | 78 +++- src/views/testflight/BetaInfoView.swift | 20 +- src/views/testflight/BuildsView.swift | 84 ++-- src/views/testflight/FeedbackView.swift | 92 ++-- src/views/testflight/GroupsView.swift | 58 ++- 19 files changed, 1478 insertions(+), 284 deletions(-) create mode 100644 docs/codex-migration-prompt.md create mode 100644 docs/migration-to-shared-asc-package.md diff --git a/docs/codex-migration-prompt.md b/docs/codex-migration-prompt.md new file mode 100644 index 0000000..56a6178 --- /dev/null +++ b/docs/codex-migration-prompt.md @@ -0,0 +1,77 @@ +# Task: Replace blitz-macos's AppStoreConnectService with the ascd helper daemon + +## Goal + +Delete `src/services/AppStoreConnectService.swift` from blitz-macos. Replace it with a client that talks to the `ascd` helper daemon — a long-lived Go process that keeps App Store Connect auth and HTTP connections warm. + +The app must compile and run at every intermediate step. No big-bang rewrite. + +## Why + +`AppStoreConnectService.swift` is 1,716 lines of hand-rolled JWT generation, HTTP client code, and 70+ API methods with zero tests. It breaks, it's unmaintainable, and it duplicates work already done well by the Go App Store Connect CLI (2+ years, 3,054 commits). We want to stop maintaining the API layer entirely and focus on GUI/UX. + +## The two projects + +**blitz-macos** (`~/superapp/blitz-macos`): +- Native macOS SwiftUI app for iOS development +- `src/services/AppStoreConnectService.swift` — the thing being deleted (1,716 lines, 70+ API methods, manual JWT, no protocol, no tests) +- `src/models/ASCModels.swift` — 36 `Decodable` structs that decode JSON:API responses (770 lines). These should survive mostly unchanged. +- `src/services/ASCManager.swift` — `@Observable @MainActor` state holder. Calls `service.fetchX()` / `service.patchX()`. This gets rewired to use the new client. +- 23 view files consume `ASCManager` state via `.attributes.` access. These should be untouched. +- `src/services/MCPToolExecutor.swift` — MCP tools call ASCManager, not AppStoreConnectService. Should be untouched. +- `src/services/IrisService.swift` — private Apple API with cookie auth, completely separate from ASC JWT auth. Stays as-is. +- Credentials stored at `~/.blitz/asc-credentials.json` (fields: `issuerId`, `keyId`, `privateKey`) +- `CLAUDE.md` has the full architecture overview + +**ascd helper daemon** (`~/superapp/asc-cli/forks/App-Store-Connect-CLI-helper`): +- Fork of `github.com/rudrankriyam/App-Store-Connect-CLI` with one additive commit (`5c4feee0`) +- Binary at `cmd/ascd/main.go` — long-lived process, JSON-line protocol over stdin/stdout +- `docs/long-lived-helper-fork.md` — architecture and protocol reference +- `internal/helper/protocol.go` — all request/response types +- `internal/helper/service.go` — method dispatch, session management +- `internal/asc/raw_request.go` — generic authenticated HTTP through warm `asc.Client` + +### ascd protocol summary + +One JSON object per line in, one per line out. Five methods: + +- `ping` — health check +- `session.open` — resolves credentials, constructs warm HTTP client with cached JWT +- `session.close` — tears down session +- `session.request` — sends arbitrary ASC REST request (method, path, headers, body, timeoutMs) through the warm client. Returns raw HTTP response (statusCode, headers, contentType, body). **This is the fast path that replaces AppStoreConnectService.** +- `cli.exec` — runs the full upstream CLI as a child process. Returns exitCode, stdout, stderr. **Compatibility fallback.** + +The response body from `session.request` is the raw JSON:API payload from Apple — the same bytes `URLSession` would return. This means `ASCModels.swift` should decode it without changes. + +Auth: the Go CLI reads `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_PRIVATE_KEY_PATH` (or `ASC_PRIVATE_KEY`) env vars, or `~/.asc/credentials.json`, or macOS Keychain. + +## Requirements + +1. **Delete `AppStoreConnectService.swift` by the end.** Every API call it makes must be handled by `ascd` instead. +2. **`ASCModels.swift` survives with minimal changes.** The JSON:API format is identical. +3. **All 23 view files are untouched.** They consume `ASCManager`, not the service. +4. **`ASCManager` stays `@Observable @MainActor`.** It just calls a different backend. +5. **MCP tools keep working throughout.** +6. **Zero external Swift package dependencies.** `ascd` is a subprocess, not linked. +7. **Credential bridge.** blitz-macos stores creds at `~/.blitz/asc-credentials.json`; `ascd` reads env vars or `~/.asc/credentials.json`. Make them talk. +8. **The app compiles and runs at every intermediate step.** Old and new can coexist during migration. + +## What to read + +Read these files thoroughly before planning: + +**blitz-macos:** +- `src/services/AppStoreConnectService.swift` — every method, HTTP verb, endpoint, request body +- `src/services/ASCManager.swift` — every `service.` call site and the call chains per tab +- `src/models/ASCModels.swift` — every model type + +**ascd (`~/superapp/asc-cli/forks/App-Store-Connect-CLI-helper`):** +- `docs/long-lived-helper-fork.md` — architecture and protocol +- `internal/helper/protocol.go` — request/response types +- `internal/helper/service.go` — method dispatch +- `internal/asc/raw_request.go` — the fast-path HTTP layer +- `cmd/ascd/main.go` — entry point + +## What to produce + +A migration plan \ No newline at end of file diff --git a/docs/migration-to-shared-asc-package.md b/docs/migration-to-shared-asc-package.md new file mode 100644 index 0000000..684a58a --- /dev/null +++ b/docs/migration-to-shared-asc-package.md @@ -0,0 +1,567 @@ +# Migration: blitz-macos → Shared ASC Domain/Infrastructure Package + +## Goal + +Replace blitz-macos's custom App Store Connect models (`ASCModels.swift`, 770 lines) and API service (`AppStoreConnectService.swift`, 1,716 lines) with asc-cli's battle-tested Domain and Infrastructure layers, consumed as a Swift package dependency. + +## Why + +blitz-macos and asc-cli both implement the App Store Connect API independently. asc-cli's implementation is superior in every measurable way: + +- **226 domain types** (vs 36 flat structs in one file) with parent ID injection, semantic booleans, type-safe state enums, custom Codable, and CAEOAS affordances +- **54 `@Mockable` repository protocols** — blitz-macos has zero testable abstractions; `AppStoreConnectService` is a concrete 1,716-line class with no protocol +- **6,545 lines of domain logic** with full test coverage (Chicago School TDD) vs 0 tests in blitz-macos's ASC layer +- **Parent IDs on every model** — the ASC API doesn't return parent IDs; asc-cli's Infrastructure injects them at the mapper layer. blitz-macos works around this by relying on `ASCManager` knowing which app/version is selected, which breaks when passing models between contexts or serializing them + +Unifying means blitz-macos deletes ~2,500 lines of hand-rolled API code, gains testability, and automatically inherits every future asc-cli domain addition (Game Center, Xcode Cloud, diagnostics, etc.) for free. + +--- + +## Architecture Overview + +### Before + +``` +blitz-macos (zero external Swift dependencies) +├── ASCModels.swift — 36 flat Decodable structs, raw string states +├── AppStoreConnectService — 1,716 lines, manual JWT, 70+ methods, no protocol +├── ASCManager — @Observable, holds all state, string comparisons +└── 23 views — access .attributes.fieldName, hardcode state strings +``` + +### After + +``` +asc-cli repo +├── Sources/Domain/ — 165 files, pure value types, @Mockable protocols +├── Sources/Infrastructure/ — 69 files, SDK adapters with parent ID injection +└── Sources/ASCCommand/ — CLI (unchanged, still depends on Domain + Infra) + +blitz-macos (depends on asc-cli's Domain + Infrastructure via SPM) +├── ASCModels.swift — DELETED (replaced by Domain types) +├── AppStoreConnectService — DELETED (replaced by Infrastructure repositories) +├── ASCManager — REFACTORED: holds repository protocols, uses Domain types +├── BlitzAuthProvider — NEW: thin adapter bridging ~/.blitz/asc-credentials.json to AuthProvider protocol +├── 23 views — REFACTORED: .isLive instead of .attributes.appStoreState == "READY_FOR_SALE" +└── Iris/MCP/Simulator — UNCHANGED (blitz-specific, not in shared package) +``` + +--- + +## Prerequisites + +### Step 0: Make asc-cli's Domain and Infrastructure consumable as library products + +In `/Users/minjunes/superapp/asc-cli/Package.swift`, add library products so blitz-macos can depend on them: + +```swift +products: [ + .executable(name: "asc", targets: ["ASCCommand"]), + // NEW: library products for external consumers + .library(name: "ASCDomain", targets: ["Domain"]), + .library(name: "ASCInfrastructure", targets: ["Infrastructure"]), +], +``` + +No code changes needed in asc-cli — just exposing existing targets as libraries. + +In `/Users/minjunes/superapp/blitz-macos/Package.swift`, add the local dependency: + +```swift +dependencies: [ + .package(path: "../asc-cli"), +], +targets: [ + .executableTarget( + name: "blitz", + dependencies: [ + .product(name: "ASCDomain", package: "asc-swift"), + .product(name: "ASCInfrastructure", package: "asc-swift"), + ], + // ... + ), +] +``` + +> Note: `asc-swift` is the package name in asc-cli's Package.swift. Using a local `path:` dependency during development; switch to a git URL for release. + +This introduces transitive dependencies: `appstoreconnect-swift-sdk`, `Mockable`, `SweetCookieKit`. This is the cost of unification. blitz-macos's zero-dependency constraint is relaxed for its own sibling package only — no third-party code is imported directly. + +--- + +## Phase 1: Auth Bridge + +**Goal:** blitz-macos can construct asc-cli's Infrastructure repositories using its own credential store. + +### What exists + +- asc-cli defines `AuthProvider` protocol in Domain, with `FileAuthProvider`, `EnvironmentAuthProvider`, `CompositeAuthProvider` in Infrastructure +- asc-cli stores credentials at `~/.asc/credentials.json` +- blitz-macos stores credentials at `~/.blitz/asc-credentials.json` with a different JSON schema +- blitz-macos's `ASCCredentials` struct has `issuerId`, `keyId`, `privateKey` fields +- asc-cli's `AuthCredentials` struct has `keyID`, `issuerID`, `privateKeyPEM`, `vendorNumber` fields + +### What to do + +Create `src/services/BlitzAuthProvider.swift`: + +```swift +import Domain // from asc-cli + +/// Bridges blitz-macos credential storage to asc-cli's AuthProvider protocol. +struct BlitzAuthProvider: AuthProvider { + func resolve() throws -> AuthCredentials { + // Read from existing ~/.blitz/asc-credentials.json + let url = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".blitz/asc-credentials.json") + let data = try Data(contentsOf: url) + + struct BlitzCreds: Decodable { + let issuerId: String + let keyId: String + let privateKey: String + } + + let creds = try JSONDecoder().decode(BlitzCreds.self, from: data) + return AuthCredentials( + keyID: creds.keyId, + issuerID: creds.issuerId, + privateKeyPEM: creds.privateKey, + vendorNumber: nil + ) + } +} +``` + +### What to delete + +Nothing yet. This phase is additive — both auth paths coexist. + +### Verification + +Write a test that constructs `BlitzAuthProvider`, loads credentials from a temp file, and asserts the returned `AuthCredentials` fields match. This is your first blitz-macos test using asc-cli's domain types. + +--- + +## Phase 2: Repository Wiring + +**Goal:** blitz-macos can call asc-cli's repository implementations to fetch data, getting back rich Domain types. + +### What exists + +- asc-cli's `Infrastructure/Client/ClientFactory.swift` creates all SDK repository implementations given an `AuthProvider` +- Each repository (e.g., `SDKVersionRepository`) needs an `APIClient` (which wraps `AppStoreConnect_Swift_SDK.APIProvider`) + +### What to do + +Create `src/services/ASCClientFactory.swift` — a blitz-specific factory that: + +1. Takes `BlitzAuthProvider` +2. Constructs `AppStoreConnect_Swift_SDK.APIProvider` (JWT-based) +3. Returns typed repositories + +```swift +import Infrastructure // from asc-cli +import Domain + +struct ASCClientFactory { + private let authProvider: BlitzAuthProvider + + /// Returns all repositories blitz-macos needs. + func makeRepositories() throws -> ASCRepositories { + let credentials = try authProvider.resolve() + // Use asc-cli's ClientFactory or replicate its wiring + let config = APIConfiguration( + issuerID: credentials.issuerID, + privateKeyID: credentials.keyID, + privateKey: credentials.privateKeyPEM + ) + let provider = APIProvider(configuration: config) + return ASCRepositories( + apps: SDKAppRepository(client: provider), + versions: SDKVersionRepository(client: provider), + builds: OpenAPIBuildRepository(client: provider), + localizations: SDKLocalizationRepository(client: provider), + screenshots: OpenAPIScreenshotRepository(client: provider), + appInfo: SDKAppInfoRepository(client: provider), + reviews: SDKCustomerReviewRepository(client: provider), + submissions: OpenAPISubmissionRepository(client: provider), + testFlight: OpenAPITestFlightRepository(client: provider), + // ... add repositories as needed per phase + ) + } +} + +/// Container for all ASC repositories blitz-macos consumes. +struct ASCRepositories { + let apps: any AppRepository + let versions: any VersionRepository + let builds: any BuildRepository + let localizations: any VersionLocalizationRepository + let screenshots: any ScreenshotRepository + let appInfo: any AppInfoRepository + let reviews: any CustomerReviewRepository + let submissions: any SubmissionRepository + let testFlight: any TestFlightRepository +} +``` + +> Check asc-cli's `ClientFactory.swift` for exact constructor signatures — the repository implementations may require specific `APIClient` protocol conformance rather than raw `APIProvider`. + +### Verification + +In a test or debug build, call `factory.makeRepositories().apps.listApps(limit: 5)` and assert you get back `[Domain.App]` with `.name`, `.bundleId`, `.affordances`. + +--- + +## Phase 3: Migrate ASCManager (Incremental, One Repository at a Time) + +**Goal:** `ASCManager` uses asc-cli repository protocols instead of `AppStoreConnectService`. + +This is the core of the migration. Do it **one feature area at a time** so the app stays functional throughout. + +### Migration order (by blast radius, smallest first) + +#### 3a. Apps + +**Before (ASCManager):** +```swift +var app: ASCApp? +// ... +app = try await service.fetchApp(bundleId: bundleId) +// Views: app.name, app.bundleId, app.attributes.vendorNumber +``` + +**After:** +```swift +var app: Domain.App? +// ... +let response = try await repos.apps.listApps(limit: nil) +app = response.data.first { $0.bundleId == bundleId } +// Views: app.name, app.bundleId (same property names — minimal view changes) +``` + +**View changes:** `app.attributes.name` → `app.name`, `app.attributes.bundleId` → `app.bundleId`. The Domain model flattens `.attributes.` away. + +#### 3b. Versions + +**Before:** +```swift +var appStoreVersions: [ASCAppStoreVersion] = [] +appStoreVersions = try await service.fetchAppStoreVersions(appId: app!.id) +// Views: v.attributes.appStoreState == "READY_FOR_SALE" +``` + +**After:** +```swift +var appStoreVersions: [Domain.AppStoreVersion] = [] +appStoreVersions = try await repos.versions.listVersions(appId: app!.id) +// Views: v.isLive (semantic boolean) +``` + +**View changes — this is the big win.** Every string comparison becomes a boolean: + +| Before (23 view files) | After | +|---|---| +| `v.attributes.appStoreState == "READY_FOR_SALE"` | `v.isLive` | +| `v.attributes.appStoreState == "PREPARE_FOR_SUBMISSION"` | `v.state == .prepareForSubmission` | +| `!nonSubmittableStates.contains(v.attributes.appStoreState)` | `v.isEditable` | +| `v.attributes.appStoreState == "WAITING_FOR_REVIEW"` | `v.state == .waitingForReview` | +| `v.attributes.appStoreState == "REJECTED"` | `v.state == .rejected` | +| `v.attributes.appStoreState` (display) | `v.state.rawValue` (same strings) | + +**Parent ID bonus:** Every `AppStoreVersion` now carries `.appId` — no need to pass app context separately. + +#### 3c. Builds + +**Before:** +```swift +var builds: [ASCBuild] = [] +builds = try await service.fetchBuilds(appId: app!.id) +// Views: b.attributes.processingState == "VALID" && b.attributes.expired != true +``` + +**After:** +```swift +var builds: [Domain.Build] = [] +builds = try await repos.builds.listBuilds(appId: app!.id) +// Views: b.isUsable +``` + +**View changes:** + +| Before | After | +|---|---| +| `b.attributes.processingState == "VALID" && b.attributes.expired != true` | `b.isUsable` | +| `b.attributes.processingState` (badge) | `b.processingState.rawValue` | +| `b.attributes.expired == true` | `b.expired` | +| `b.attributes.version` | `b.version` | + +#### 3d. Version Localizations + +**Before:** +```swift +var localizations: [ASCVersionLocalization] = [] +localizations = try await service.fetchVersionLocalizations(versionId: versionId) +// Views: loc.attributes.description, loc.attributes.whatsNew +``` + +**After:** +```swift +var localizations: [Domain.AppStoreVersionLocalization] = [] +localizations = try await repos.localizations.listLocalizations(versionId: versionId) +// Views: loc.description, loc.whatsNew (flattened — no .attributes.) +``` + +#### 3e. Screenshots + +**Before:** +```swift +var screenshotSets: [ASCScreenshotSet] = [] +var screenshots: [String: [ASCScreenshot]] = [:] +screenshotSets = try await service.fetchScreenshotSets(localizationId: locId) +screenshots[set.id] = try await service.fetchScreenshots(setId: set.id) +``` + +**After:** +```swift +var screenshotSets: [Domain.AppScreenshotSet] = [] +var screenshots: [String: [Domain.AppScreenshot]] = [:] +screenshotSets = try await repos.screenshots.listScreenshotSets(localizationId: locId) +screenshots[set.id] = try await repos.screenshots.listScreenshots(setId: set.id) +// Bonus: each screenshot carries .setId (parent ID) +// Bonus: screenshot.isComplete replaces assetDeliveryState string checks +``` + +#### 3f. App Info, Age Rating, Review Detail + +Same pattern. Replace `service.fetchAppInfo()` calls with `repos.appInfo.listAppInfos()`. Models gain parent IDs and affordances. + +#### 3g. Customer Reviews + +```swift +// Before +var customerReviews: [ASCCustomerReview] = [] +customerReviews = try await service.fetchReviews(appId: app!.id) + +// After +var customerReviews: [Domain.CustomerReview] = [] +let response = try await repos.reviews.listReviews(appId: app!.id) +customerReviews = response.data +``` + +#### 3h. TestFlight (Beta Groups, Testers) + +```swift +// Before +var betaGroups: [ASCBetaGroup] = [] +betaGroups = try await service.fetchBetaGroups(appId: app!.id) + +// After +var betaGroups: [Domain.BetaGroup] = [] +betaGroups = try await repos.testFlight.listBetaGroups(appId: app!.id) +// Bonus: each group carries .appId, .affordances +``` + +#### 3i. Submissions + +```swift +// Before: service.submitForReview(versionId:) +// After: repos.submissions.createSubmission(versionId:) +``` + +#### 3j. In-App Purchases & Subscriptions + +These have dedicated repositories in asc-cli (`InAppPurchaseRepository`, `SubscriptionRepository`, `SubscriptionGroupRepository`). Add them to `ASCRepositories` and wire into `ASCManager`. + +#### 3k. Write Operations (PATCH/POST/DELETE) + +asc-cli's repositories expose write methods too. For example: + +```swift +// VersionLocalizationRepository +func updateLocalization(id: String, whatsNew: String?, description: String?, ...) async throws -> AppStoreVersionLocalization + +// ScreenshotRepository +func uploadScreenshot(setId: String, fileName: String, fileData: Data) async throws -> AppScreenshot +func deleteScreenshot(id: String) async throws +``` + +Replace `service.patchLocalization(...)`, `service.uploadScreenshot(...)`, etc. with the corresponding repository method. + +--- + +## Phase 4: Delete Dead Code + +Once all `AppStoreConnectService` call sites are replaced: + +### Delete entirely +- `src/models/ASCModels.swift` — all 36 model types replaced by Domain imports +- `src/services/AppStoreConnectService.swift` — all 70+ methods replaced by repository calls + +### Keep but simplify +- `src/services/ASCManager.swift` — still needed as `@Observable` state holder, but now typed with `Domain.*` models and injected with repository protocols + +### Keep unchanged +- `src/services/IrisService.swift` — Iris private API is blitz-specific, not in asc-cli. Keep as-is. If Iris models overlap with Domain types (e.g., app creation), consider thin adapters later. +- `src/services/MCPToolExecutor.swift` — MCP tools read/write ASCManager state. Since ASCManager's public interface changes (new types), MCP tools need type updates but logic stays the same. +- `src/services/BuildPipelineService.swift` — uses xcodebuild, not ASC API. Unchanged. +- All simulator, database, project scaffolding code — unrelated to ASC. + +--- + +## Phase 5: View Refactoring Checklist + +Every view file that accesses `.attributes.` needs updating. The pattern is mechanical: + +### Property access flattening + +```swift +// BEFORE // AFTER +thing.attributes.fieldName thing.fieldName +thing.attributes.appStoreState thing.state.rawValue (for display) +thing.attributes.appStoreState == "X" thing.state == .x (for comparison) +thing.attributes.processingState == "VALID" thing.processingState == .valid +``` + +### Files to update (23 files) + +**Release views:** +- `src/views/release/ASCOverview.swift` — version state filtering, rejection display +- `src/views/release/StoreListingView.swift` — localization field access +- `src/views/release/ScreenshotsView.swift` — screenshot set/screenshot types +- `src/views/release/ReviewView.swift` — age rating, review detail, build selection, state checks +- `src/views/release/SubmitPreviewSheet.swift` — nonSubmittableStates → `!version.isEditable` +- `src/views/release/AppDetailsView.swift` — app info localization fields +- `src/views/release/PricingView.swift` — IAP/subscription state checks + +**TestFlight views:** +- `src/views/testflight/BuildsView.swift` — processingState badge, expired check +- `src/views/testflight/GroupsView.swift` — beta group fields +- `src/views/testflight/BetaInfoView.swift` — beta localization fields +- `src/views/testflight/FeedbackView.swift` — beta feedback fields + +**Insights views:** +- `src/views/insights/ReviewsView.swift` — customer review fields +- `src/views/insights/AnalyticsView.swift` — if it touches ASC models + +**Shared views:** +- `src/views/shared/asc/RejectionCardView.swift` — rejection reason display +- `src/views/shared/asc/BundleIDSetupView.swift` — bundle ID fields +- `src/views/shared/asc/ASCCredentialForm.swift` — credential entry +- `src/views/shared/asc/ASCTabContent.swift` — tab routing +- `src/views/shared/asc/ASCCredentialGate.swift` — auth state + +**Other:** +- `src/views/settings/SettingsView.swift` — credential display +- `src/views/OnboardingView.swift` — credential entry + +--- + +## Phase 6: MCP Tool Adaptation + +MCP tools in `MCPToolExecutor.swift` read and write `ASCManager` state. Since ASCManager's stored types change from `ASCApp` → `Domain.App`, etc., the MCP tool implementations need type updates. + +### Pattern + +```swift +// BEFORE +if let app = appState.ascManager.app { + return ["name": app.name, "bundleId": app.bundleId, + "state": app.attributes.appStoreState ?? "unknown"] +} + +// AFTER +if let app = appState.ascManager.app { + return ["name": app.name, "bundleId": app.bundleId] + // state is on the version, not the app — which is correct +} + +// BEFORE +let version = appState.ascManager.appStoreVersions.first { + $0.attributes.appStoreState != "READY_FOR_SALE" +} + +// AFTER +let version = appState.ascManager.appStoreVersions.first { !$0.isLive } +``` + +### Affordances in MCP responses + +New opportunity: MCP tool responses can now include `model.affordances` — giving the agent state-aware CLI commands alongside GUI actions. This is optional but powerful for hybrid workflows where Claude Code uses both MCP tools and `asc` CLI. + +```swift +// Optional enhancement: include affordances in MCP tool results +func getTabState() -> [String: Any] { + var result: [String: Any] = [...] + if let version = currentVersion { + result["affordances"] = version.affordances // free from Domain + } + return result +} +``` + +--- + +## Phase 7: Credential Unification (Optional, Future) + +Once Phase 1-6 are stable, consider whether blitz-macos should read from `~/.asc/credentials.json` (asc-cli's format) instead of `~/.blitz/asc-credentials.json`. Benefits: + +- Single credential store — `asc auth login` works for both tools +- `asc auth check` validates what blitz-macos will use +- Environment variable fallback via `CompositeAuthProvider` + +Cost: migration path for existing blitz-macos users who have credentials in `~/.blitz/`. Could do a one-time migration on first launch, or support both with a composite provider. + +--- + +## What NOT To Migrate + +| blitz-macos concern | Why it stays | +|---|---| +| `IrisService` + `IrisSession` + `IrisFeedbackCache` | Iris is a private Apple API using cookie auth. asc-cli has its own Iris implementation but the auth flows differ (browser cookies vs Keychain). Keep separate. | +| `ASCSubmissionHistoryCache` | Local persistence of version state transitions. Could move to shared package later, but not required — it's a UI convenience, not a domain concern. | +| `TrackSlot` model (screenshot tracks) | UI-specific: tracks local images vs ASC-sourced screenshots for the drag-reorder UI. Not a domain concept. | +| `SubmissionReadiness` (field checklist) | UI-specific readiness display. asc-cli has `VersionReadiness` in Domain which is richer — consider adopting it, but not required for migration. | +| `pendingFormValues` / `pendingCreateValues` | MCP form pre-fill state. Pure UI concern. | +| `BuildPipelineService` | Uses xcodebuild, not ASC API. | +| Simulator, Database, Project scaffolding | Unrelated to ASC. | + +--- + +## Verification Strategy + +After each phase, verify: + +1. **Build:** `swift build` succeeds for blitz-macos +2. **Manual test:** Launch the app, navigate to the affected tab, confirm data loads +3. **Type check:** No `ASCApp`, `ASCBuild`, etc. references remain for migrated types (grep for the old type name) +4. **New tests:** For each migrated area, write at least one test using `@Mockable` repository mocks to verify ASCManager state transitions + +### Final verification (after Phase 6) + +```bash +# No references to old ASC models should remain +cd /Users/minjunes/superapp/blitz-macos +grep -r "ASCApp\b" src/ --include="*.swift" # should return 0 +grep -r "ASCAppStoreVersion\b" src/ --include="*.swift" # should return 0 +grep -r "ASCBuild\b" src/ --include="*.swift" # should return 0 +grep -r "\.attributes\." src/ --include="*.swift" # should return 0 +grep -r "AppStoreConnectService" src/ --include="*.swift" # should return 0 + +# Old files should be gone +test ! -f src/models/ASCModels.swift +test ! -f src/services/AppStoreConnectService.swift +``` + +--- + +## Risk Mitigation + +| Risk | Mitigation | +|---|---| +| asc-cli Domain types missing fields blitz-macos needs | Add fields to asc-cli's Domain types — they're the source of truth. PR to asc-cli. | +| `appstoreconnect-swift-sdk` version conflicts | blitz-macos inherits asc-cli's pinned version transitively. No conflict possible. | +| Sendable/concurrency mismatch | asc-cli Domain types are all `Sendable`. ASCManager is `@MainActor`. Repository calls are `async` — use `Task { }` from MainActor as blitz-macos already does. | +| Breaking change in asc-cli Domain | Pin to a specific asc-cli commit/tag during development. Update deliberately. | +| Migration takes too long | Each phase is independently shippable. Phase 3a-3k can be done one sub-phase per PR. The app works with a mix of old and new types during migration. | diff --git a/package.json b/package.json index 829b2a1..5c407f5 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "private": true, "scripts": { - "app": "npm run build:app:debug", + "app": "sudo npm run build:app:debug", "build:app": "killall Blitz 2>/dev/null; BUILD_CONFIG=${BUILD_CONFIG:-release} CODESIGN_TIMESTAMP=${CODESIGN_TIMESTAMP:-auto} bash scripts/bundle.sh ${BUILD_CONFIG:-release} && open .build/Blitz.app", "build:app:debug": "killall Blitz 2>/dev/null; CODESIGN_TIMESTAMP=none bash scripts/bundle.sh debug && open .build/Blitz.app", "build:app:release": "killall Blitz 2>/dev/null; bash scripts/bundle.sh release && open .build/Blitz.app", diff --git a/scripts/build-pkg.sh b/scripts/build-pkg.sh index ca0110e..a73610d 100755 --- a/scripts/build-pkg.sh +++ b/scripts/build-pkg.sh @@ -62,6 +62,12 @@ if [ -n "$APP_SIGNING_IDENTITY" ]; then --entitlements "$ENTITLEMENTS" \ "$f" done + if [ -f "$APP_PAYLOAD/Contents/Helpers/blitz-macos-mcp" ]; then + codesign --force --options runtime --timestamp \ + --sign "$APP_SIGNING_IDENTITY" \ + --entitlements "$ENTITLEMENTS" \ + "$APP_PAYLOAD/Contents/Helpers/blitz-macos-mcp" + fi # Re-sign the main app bundle (must be last) codesign --force --options runtime --timestamp \ diff --git a/src/services/ASCManager.swift b/src/services/ASCManager.swift index a265c31..9153533 100644 --- a/src/services/ASCManager.swift +++ b/src/services/ASCManager.swift @@ -28,17 +28,23 @@ struct LocalScreenshotAsset: Identifiable { @MainActor @Observable final class ASCManager { - private struct OverviewSnapshot { + private struct ProjectSnapshot { let projectId: String let app: ASCApp? let appStoreVersions: [ASCAppStoreVersion] let localizations: [ASCVersionLocalization] let screenshotSets: [ASCScreenshotSet] let screenshots: [String: [ASCScreenshot]] + let customerReviews: [ASCCustomerReview] let builds: [ASCBuild] + let betaGroups: [ASCBetaGroup] + let betaLocalizations: [ASCBetaLocalization] + let betaFeedback: [String: [ASCBetaFeedback]] + let selectedBuildId: String? let inAppPurchases: [ASCInAppPurchase] let subscriptionGroups: [ASCSubscriptionGroup] let subscriptionsPerGroup: [String: [ASCSubscription]] + let appPricePoints: [ASCPricePoint] let currentAppPricePointId: String? let scheduledAppPricePointId: String? let scheduledAppPriceEffectiveDate: String? @@ -55,6 +61,9 @@ final class ASCManager { let rejectionMessages: [IrisResolutionCenterMessage] let rejectionReasons: [IrisReviewRejection] let cachedFeedback: IrisFeedbackCache? + let trackSlots: [String: [TrackSlot?]] + let savedTrackState: [String: [TrackSlot?]] + let localScreenshotAssets: [LocalScreenshotAsset] let appIconStatus: String? let monetizationStatus: String? let loadedTabs: Set @@ -68,10 +77,16 @@ final class ASCManager { localizations = manager.localizations screenshotSets = manager.screenshotSets screenshots = manager.screenshots + customerReviews = manager.customerReviews builds = manager.builds + betaGroups = manager.betaGroups + betaLocalizations = manager.betaLocalizations + betaFeedback = manager.betaFeedback + selectedBuildId = manager.selectedBuildId inAppPurchases = manager.inAppPurchases subscriptionGroups = manager.subscriptionGroups subscriptionsPerGroup = manager.subscriptionsPerGroup + appPricePoints = manager.appPricePoints currentAppPricePointId = manager.currentAppPricePointId scheduledAppPricePointId = manager.scheduledAppPricePointId scheduledAppPriceEffectiveDate = manager.scheduledAppPriceEffectiveDate @@ -88,9 +103,12 @@ final class ASCManager { rejectionMessages = manager.rejectionMessages rejectionReasons = manager.rejectionReasons cachedFeedback = manager.cachedFeedback + trackSlots = manager.trackSlots + savedTrackState = manager.savedTrackState + localScreenshotAssets = manager.localScreenshotAssets appIconStatus = manager.appIconStatus monetizationStatus = manager.monetizationStatus - let cachedLoadedTabs = manager.loadedTabs.intersection([.app]) + let cachedLoadedTabs = manager.loadedTabs.intersection(ASCManager.cachedProjectTabs) loadedTabs = cachedLoadedTabs tabLoadedAt = manager.tabLoadedAt.filter { cachedLoadedTabs.contains($0.key) } } @@ -102,10 +120,16 @@ final class ASCManager { manager.localizations = localizations manager.screenshotSets = screenshotSets manager.screenshots = screenshots + manager.customerReviews = customerReviews manager.builds = builds + manager.betaGroups = betaGroups + manager.betaLocalizations = betaLocalizations + manager.betaFeedback = betaFeedback + manager.selectedBuildId = selectedBuildId manager.inAppPurchases = inAppPurchases manager.subscriptionGroups = subscriptionGroups manager.subscriptionsPerGroup = subscriptionsPerGroup + manager.appPricePoints = appPricePoints manager.currentAppPricePointId = currentAppPricePointId manager.scheduledAppPricePointId = scheduledAppPricePointId manager.scheduledAppPriceEffectiveDate = scheduledAppPriceEffectiveDate @@ -122,6 +146,9 @@ final class ASCManager { manager.rejectionMessages = rejectionMessages manager.rejectionReasons = rejectionReasons manager.cachedFeedback = cachedFeedback + manager.trackSlots = trackSlots + manager.savedTrackState = savedTrackState + manager.localScreenshotAssets = localScreenshotAssets manager.appIconStatus = appIconStatus manager.monetizationStatus = monetizationStatus manager.loadedTabs = loadedTabs @@ -131,6 +158,7 @@ final class ASCManager { manager.isLoadingTab = [:] manager.isLoadingApp = false manager.isLoadingIrisFeedback = false + manager.loadingFeedbackBuildIds = [] manager.irisFeedbackError = nil manager.writeError = nil manager.submissionError = nil @@ -138,7 +166,21 @@ final class ASCManager { } } - private static let overviewCacheFreshness: TimeInterval = 120 + private static let projectCacheFreshness: TimeInterval = 120 + private static let cachedProjectTabs: Set = [ + .app, + .storeListing, + .screenshots, + .appDetails, + .monetization, + .review, + .analytics, + .reviews, + .builds, + .groups, + .betaInfo, + .feedback, + ] private static let overviewLocalizationFieldLabels: Set = [ "App Name", "Description", @@ -412,9 +454,10 @@ final class ASCManager { var tabError: [AppTab: String] = [:] private var loadedTabs: Set = [] private var tabLoadedAt: [AppTab: Date] = [:] - private var overviewSnapshots: [String: OverviewSnapshot] = [:] - private var overviewHydrationTask: Task? + private var projectSnapshots: [String: ProjectSnapshot] = [:] + private var tabHydrationTasks: [AppTab: Task] = [:] private var overviewReadinessLoadingFields: Set = [] + private var loadingFeedbackBuildIds: Set = [] var loadedProjectId: String? @@ -461,10 +504,29 @@ final class ASCManager { checkAppIcon(projectId: projectId) } + private func cancelBackgroundHydration(for tab: AppTab) { + tabHydrationTasks[tab]?.cancel() + tabHydrationTasks.removeValue(forKey: tab) + } + + private func cancelBackgroundHydrationTasks() { + for task in tabHydrationTasks.values { + task.cancel() + } + tabHydrationTasks.removeAll() + } + + private func startBackgroundHydration(for tab: AppTab, operation: @escaping @MainActor () async -> Void) { + cancelBackgroundHydration(for: tab) + tabHydrationTasks[tab] = Task { + await operation() + } + } + private func resetProjectData(preserveCredentials: Bool) { - overviewHydrationTask?.cancel() - overviewHydrationTask = nil + cancelBackgroundHydrationTasks() overviewReadinessLoadingFields = [] + loadingFeedbackBuildIds = [] if !preserveCredentials { credentials = nil @@ -525,10 +587,10 @@ final class ASCManager { cancelPendingWebAuth() } - private func cacheCurrentOverviewSnapshot() { + private func cacheCurrentProjectSnapshot() { guard let projectId = loadedProjectId else { return } - guard app != nil || !appStoreVersions.isEmpty || loadedTabs.contains(.app) else { return } - overviewSnapshots[projectId] = OverviewSnapshot(manager: self, projectId: projectId) + guard app != nil || !loadedTabs.isEmpty else { return } + projectSnapshots[projectId] = ProjectSnapshot(manager: self, projectId: projectId) } private func startOverviewReadinessLoading(_ fields: Set) { @@ -539,10 +601,10 @@ final class ASCManager { overviewReadinessLoadingFields.subtract(fields) } - private func shouldRefreshOverviewCache() -> Bool { - guard loadedTabs.contains(.app) else { return true } - guard let loadedAt = tabLoadedAt[.app] else { return true } - return Date().timeIntervalSince(loadedAt) > Self.overviewCacheFreshness + private func shouldRefreshTabCache(_ tab: AppTab) -> Bool { + guard loadedTabs.contains(tab) else { return true } + guard let loadedAt = tabLoadedAt[tab] else { return true } + return Date().timeIntervalSince(loadedAt) > Self.projectCacheFreshness } private func isCurrentProject(_ projectId: String?) -> Bool { @@ -551,10 +613,10 @@ final class ASCManager { } func prepareForProjectSwitch(to projectId: String) { - cacheCurrentOverviewSnapshot() + cacheCurrentProjectSnapshot() resetProjectData(preserveCredentials: true) - if let snapshot = overviewSnapshots[projectId] { + if let snapshot = projectSnapshots[projectId] { snapshot.apply(to: self) } else { loadedProjectId = projectId @@ -565,7 +627,7 @@ final class ASCManager { guard credentials != nil else { return } if loadedTabs.contains(tab) { - if tab == .app && shouldRefreshOverviewCache() { + if shouldRefreshTabCache(tab) { await refreshTabData(tab) } return @@ -574,6 +636,21 @@ final class ASCManager { await fetchTabData(tab) } + func hasLoadedTabData(_ tab: AppTab) -> Bool { + loadedTabs.contains(tab) + } + + func isTabLoading(_ tab: AppTab) -> Bool { + isLoadingTab[tab] == true || isLoadingApp + } + + func isFeedbackLoading(for buildId: String?) -> Bool { + guard let buildId, !buildId.isEmpty else { + return isLoadingTab[.feedback] == true + } + return loadingFeedbackBuildIds.contains(buildId) + } + // MARK: - Project Lifecycle func loadCredentials(for projectId: String, bundleId: String?) async { @@ -1170,7 +1247,12 @@ final class ASCManager { credentials = creds service = AppStoreConnectService(credentials: creds) credentialsError = nil + cancelBackgroundHydrationTasks() loadedTabs = [] // force re-fetch after new credentials + tabLoadedAt = [:] + tabError = [:] + isLoadingTab = [:] + loadingFeedbackBuildIds = [] if let bundleId, !bundleId.isEmpty { await fetchApp(bundleId: bundleId) @@ -1212,6 +1294,7 @@ final class ASCManager { guard !loadedTabs.contains(tab) else { return } guard isLoadingTab[tab] != true else { return } + cancelBackgroundHydration(for: tab) isLoadingTab[tab] = true tabError.removeValue(forKey: tab) @@ -1229,16 +1312,19 @@ final class ASCManager { /// Called after bundle ID setup completes and the app is confirmed in ASC. /// Clears all tab errors and forces data to be re-fetched. func resetTabState() { + cancelBackgroundHydrationTasks() tabError.removeAll() loadedTabs.removeAll() tabLoadedAt.removeAll() + loadingFeedbackBuildIds = [] } func refreshTabData(_ tab: AppTab) async { guard let service else { return } guard credentials != nil else { return } - loadedTabs.remove(tab) + let hadLoadedData = loadedTabs.contains(tab) + cancelBackgroundHydration(for: tab) isLoadingTab[tab] = true tabError.removeValue(forKey: tab) @@ -1249,6 +1335,10 @@ final class ASCManager { tabLoadedAt[tab] = Date() } catch { isLoadingTab[tab] = false + if !hadLoadedData { + loadedTabs.remove(tab) + tabLoadedAt.removeValue(forKey: tab) + } tabError[tab] = error.localizedDescription } } @@ -1258,6 +1348,21 @@ final class ASCManager { await refreshAttachedSubmissionItemIDs() } + func refreshBetaFeedback(buildId: String) async { + guard let service else { return } + guard !buildId.isEmpty else { return } + + loadingFeedbackBuildIds.insert(buildId) + defer { loadingFeedbackBuildIds.remove(buildId) } + + do { + betaFeedback[buildId] = try await service.fetchBetaFeedback(buildId: buildId) + } catch { + // Feedback may not be available for all apps; non-fatal. + betaFeedback[buildId] = [] + } + } + private func hydrateOverviewSecondaryData( projectId: String?, appId: String, @@ -1329,6 +1434,114 @@ final class ASCManager { finishOverviewReadinessLoading(Self.overviewPricingFieldLabels) } + private func hydrateScreenshotsSecondaryData( + projectId: String?, + localizationId: String, + service: AppStoreConnectService + ) async { + do { + let fetchedSets = try await service.fetchScreenshotSets(localizationId: localizationId) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + screenshotSets = fetchedSets + + let fetchedScreenshots = try await withThrowingTaskGroup(of: (String, [ASCScreenshot]).self) { group in + for set in fetchedSets { + group.addTask { + let screenshots = try await service.fetchScreenshots(setId: set.id) + return (set.id, screenshots) + } + } + + var pairs: [(String, [ASCScreenshot])] = [] + for try await pair in group { + pairs.append(pair) + } + return pairs + } + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + screenshots = Dictionary(uniqueKeysWithValues: fetchedScreenshots) + } catch { + print("Failed to hydrate screenshots: \(error)") + } + } + + private func hydrateReviewSecondaryData( + projectId: String?, + appId: String, + appInfoId: String?, + service: AppStoreConnectService + ) async { + if let appInfoId { + let fetchedAgeRating = try? await service.fetchAgeRating(appInfoId: appInfoId) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + ageRatingDeclaration = fetchedAgeRating + } else { + ageRatingDeclaration = nil + } + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + await refreshReviewSubmissionData(appId: appId, service: service) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + rebuildSubmissionHistory(appId: appId) + refreshSubmissionFeedbackIfNeeded() + } + + private func hydrateMonetizationSecondaryData( + projectId: String?, + appId: String, + groups: [ASCSubscriptionGroup], + service: AppStoreConnectService + ) async { + do { + let fetchedSubscriptions = try await withThrowingTaskGroup(of: (String, [ASCSubscription]).self) { taskGroup in + for subscriptionGroup in groups { + taskGroup.addTask { + let subscriptions = try await service.fetchSubscriptionsInGroup(groupId: subscriptionGroup.id) + return (subscriptionGroup.id, subscriptions) + } + } + + var pairs: [(String, [ASCSubscription])] = [] + for try await pair in taskGroup { + pairs.append(pair) + } + return pairs + } + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + subscriptionsPerGroup = Dictionary(uniqueKeysWithValues: fetchedSubscriptions) + } catch { + print("Failed to hydrate monetization subscriptions: \(error)") + } + + if currentAppPricePointId == nil && scheduledAppPricePointId == nil && monetizationStatus == nil { + let hasPricing = await service.fetchPricingConfigured(appId: appId) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + monetizationStatus = hasPricing ? "Configured" : nil + } + } + + private func hydrateFeedbackSecondaryData( + projectId: String?, + buildId: String, + service: AppStoreConnectService + ) async { + guard isCurrentProject(projectId) else { return } + guard !Task.isCancelled else { return } + loadingFeedbackBuildIds.insert(buildId) + defer { loadingFeedbackBuildIds.remove(buildId) } + + do { + let items = try await service.fetchBetaFeedback(buildId: buildId) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + betaFeedback[buildId] = items + } catch { + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + betaFeedback[buildId] = [] + } + } + private func loadData(for tab: AppTab, service: AppStoreConnectService) async throws { guard let appId = app?.id else { throw ASCError.notFound("App — check your bundle ID in project settings") @@ -1379,10 +1592,9 @@ final class ASCManager { refreshSubmissionFeedbackIfNeeded() - overviewHydrationTask?.cancel() let projectId = loadedProjectId let currentAppInfoId = appInfo?.id - overviewHydrationTask = Task { + startBackgroundHydration(for: .app) { await self.hydrateOverviewSecondaryData( projectId: projectId, appId: appId, @@ -1393,17 +1605,21 @@ final class ASCManager { } case .storeListing: - let versions = try await service.fetchAppStoreVersions(appId: appId) + async let versionsTask = service.fetchAppStoreVersions(appId: appId) + async let appInfoTask: ASCAppInfo? = try? await service.fetchAppInfo(appId: appId) + + let versions = try await versionsTask appStoreVersions = versions if let latestId = versions.first?.id { localizations = try await service.fetchLocalizations(versionId: latestId) + } else { + localizations = [] } - // Also fetch appInfoLocalization for privacy policy URL - if appInfo == nil { - appInfo = try? await service.fetchAppInfo(appId: appId) - } - if let infoId = appInfo?.id, appInfoLocalization == nil { + appInfo = await appInfoTask + if let infoId = appInfo?.id { appInfoLocalization = try? await service.fetchAppInfoLocalization(appInfoId: infoId) + } else { + appInfoLocalization = nil } case .screenshots: @@ -1413,47 +1629,76 @@ final class ASCManager { let locs = try await service.fetchLocalizations(versionId: latestId) localizations = locs if let firstLocId = locs.first?.id { - let sets = try await service.fetchScreenshotSets(localizationId: firstLocId) - screenshotSets = sets - for set in sets { - let shots = try await service.fetchScreenshots(setId: set.id) - screenshots[set.id] = shots + let projectId = loadedProjectId + startBackgroundHydration(for: .screenshots) { + await self.hydrateScreenshotsSecondaryData( + projectId: projectId, + localizationId: firstLocId, + service: service + ) } + } else { + screenshotSets = [] + screenshots = [:] } + } else { + localizations = [] + screenshotSets = [] + screenshots = [:] } case .appDetails: - let versions = try await service.fetchAppStoreVersions(appId: appId) - appStoreVersions = versions - appInfo = try? await service.fetchAppInfo(appId: appId) + async let versionsTask = service.fetchAppStoreVersions(appId: appId) + async let appInfoTask: ASCAppInfo? = try? await service.fetchAppInfo(appId: appId) + + appStoreVersions = try await versionsTask + appInfo = await appInfoTask case .review: - let versions = try await service.fetchAppStoreVersions(appId: appId) + async let versionsTask = service.fetchAppStoreVersions(appId: appId) + async let appInfoTask: ASCAppInfo? = try? await service.fetchAppInfo(appId: appId) + async let buildsTask = service.fetchBuilds(appId: appId) + + let versions = try await versionsTask appStoreVersions = versions if let latestId = versions.first?.id { reviewDetail = try? await service.fetchReviewDetail(versionId: latestId) + } else { + reviewDetail = nil } - appInfo = try? await service.fetchAppInfo(appId: appId) - if let infoId = appInfo?.id { - ageRatingDeclaration = try? await service.fetchAgeRating(appInfoId: infoId) + appInfo = await appInfoTask + builds = try await buildsTask + let projectId = loadedProjectId + let currentAppInfoId = appInfo?.id + startBackgroundHydration(for: .review) { + await self.hydrateReviewSecondaryData( + projectId: projectId, + appId: appId, + appInfoId: currentAppInfoId, + service: service + ) } - builds = try await service.fetchBuilds(appId: appId) - await refreshReviewSubmissionData(appId: appId, service: service) - rebuildSubmissionHistory(appId: appId) case .monetization: - appPricePoints = try await service.fetchAppPricePoints(appId: appId) - let pricingState = (try? await service.fetchAppPricingState(appId: appId)) + async let pricePointsTask = service.fetchAppPricePoints(appId: appId) + async let pricingStateTask = (try? await service.fetchAppPricingState(appId: appId)) ?? ASCAppPricingState(currentPricePointId: nil, scheduledPricePointId: nil, scheduledEffectiveDate: nil) - applyAppPricingState(pricingState) - inAppPurchases = try await service.fetchInAppPurchases(appId: appId) - subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) - for group in subscriptionGroups { - subscriptionsPerGroup[group.id] = try await service.fetchSubscriptionsInGroup(groupId: group.id) - } - if currentAppPricePointId == nil && scheduledAppPricePointId == nil && monetizationStatus == nil { - let hasPricing = await service.fetchPricingConfigured(appId: appId) - monetizationStatus = hasPricing ? "Configured" : nil + async let iapTask = service.fetchInAppPurchases(appId: appId) + async let groupsTask = service.fetchSubscriptionGroups(appId: appId) + + appPricePoints = try await pricePointsTask + applyAppPricingState(await pricingStateTask) + inAppPurchases = try await iapTask + let groups = try await groupsTask + subscriptionGroups = groups + let projectId = loadedProjectId + startBackgroundHydration(for: .monetization) { + await self.hydrateMonetizationSecondaryData( + projectId: projectId, + appId: appId, + groups: groups, + service: service + ) } case .analytics: @@ -1474,14 +1719,25 @@ final class ASCManager { case .feedback: let fetched = try await service.fetchBuilds(appId: appId) builds = fetched - if let first = fetched.first { - selectedBuildId = first.id - do { - betaFeedback[first.id] = try await service.fetchBetaFeedback(buildId: first.id) - } catch { - // Feedback may not be available for all apps; non-fatal - betaFeedback[first.id] = [] + let resolvedBuildId: String? + if let currentSelectedBuildId = selectedBuildId, + fetched.contains(where: { $0.id == currentSelectedBuildId }) { + resolvedBuildId = currentSelectedBuildId + } else { + resolvedBuildId = fetched.first?.id + } + selectedBuildId = resolvedBuildId + if let resolvedBuildId { + let projectId = loadedProjectId + startBackgroundHydration(for: .feedback) { + await self.hydrateFeedbackSecondaryData( + projectId: projectId, + buildId: resolvedBuildId, + service: service + ) } + } else { + betaFeedback = [:] } default: diff --git a/src/views/ContentView.swift b/src/views/ContentView.swift index 9afb355..aa73a99 100644 --- a/src/views/ContentView.swift +++ b/src/views/ContentView.swift @@ -156,7 +156,7 @@ struct ContentView: View { bundleId: project.metadata.bundleIdentifier ) if appState.activeTab.isASCTab { - await appState.ascManager.fetchTabData(appState.activeTab) + await appState.ascManager.ensureTabData(appState.activeTab) } else if appState.activeTab == .app && appState.activeAppSubTab == .overview { await appState.ascManager.ensureTabData(.app) } @@ -186,7 +186,7 @@ struct ContentView: View { bundleId: project.metadata.bundleIdentifier ) if appState.activeTab.isASCTab { - await appState.ascManager.fetchTabData(appState.activeTab) + await appState.ascManager.ensureTabData(appState.activeTab) } else if appState.activeTab == .app && appState.activeAppSubTab == .overview { await appState.ascManager.ensureTabData(.app) } @@ -216,7 +216,7 @@ struct ContentView: View { } // Fetch ASC data when entering any ASC tab if newTab.isASCTab { - await appState.ascManager.fetchTabData(newTab) + await appState.ascManager.ensureTabData(newTab) } } } diff --git a/src/views/insights/AnalyticsView.swift b/src/views/insights/AnalyticsView.swift index 6e873e3..36c878d 100644 --- a/src/views/insights/AnalyticsView.swift +++ b/src/views/insights/AnalyticsView.swift @@ -23,7 +23,7 @@ struct AnalyticsView: View { analyticsContent } } - .task { await asc.fetchTabData(.analytics) } + .task(id: appState.activeProjectId) { await asc.ensureTabData(.analytics) } } @ViewBuilder @@ -43,6 +43,7 @@ struct AnalyticsView: View { } .pickerStyle(.segmented) .frame(width: 240) + ASCTabRefreshButton(asc: asc, tab: .analytics, helpText: "Refresh analytics tab") } if !hasVendor { diff --git a/src/views/insights/ReviewsView.swift b/src/views/insights/ReviewsView.swift index 0d22828..f97bb87 100644 --- a/src/views/insights/ReviewsView.swift +++ b/src/views/insights/ReviewsView.swift @@ -15,24 +15,44 @@ struct ReviewsView: View { reviewsContent } } - .task { await asc.fetchTabData(.reviews) } + .task(id: appState.activeProjectId) { await asc.ensureTabData(.reviews) } } @ViewBuilder private var reviewsContent: some View { - if asc.customerReviews.isEmpty { - ContentUnavailableView( - "No Reviews", - systemImage: "bubble.left.and.bubble.right", - description: Text("No customer reviews found for this app.") - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List(asc.customerReviews) { review in - reviewRow(review) - .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + VStack(spacing: 0) { + HStack { + Text("Reviews") + .font(.title2.weight(.semibold)) + Spacer() + ASCTabRefreshButton(asc: asc, tab: .reviews, helpText: "Refresh reviews") + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + + Divider() + + if asc.customerReviews.isEmpty { + if asc.isTabLoading(.reviews) { + ASCTabLoadingPlaceholder( + title: "Loading Reviews", + message: "Fetching customer ratings and review text." + ) + } else { + ContentUnavailableView( + "No Reviews", + systemImage: "bubble.left.and.bubble.right", + description: Text("No customer reviews found for this app.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else { + List(asc.customerReviews) { review in + reviewRow(review) + .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + } + .listStyle(.plain) } - .listStyle(.plain) } } diff --git a/src/views/release/ASCOverview.swift b/src/views/release/ASCOverview.swift index 3576378..5702cfe 100644 --- a/src/views/release/ASCOverview.swift +++ b/src/views/release/ASCOverview.swift @@ -34,6 +34,13 @@ struct ASCOverview: View { private var overviewContent: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Overview") + .font(.title2.weight(.semibold)) + Spacer() + ASCTabRefreshButton(asc: asc, tab: .app, helpText: "Refresh overview data") + } + if let app = asc.app { HStack(spacing: 10) { if let project = appState.activeProject { @@ -118,14 +125,6 @@ struct ASCOverview: View { Text("Submission Readiness") .font(.headline) - Button { - Task { await asc.refreshTabData(.app) } - } label: { - Image(systemName: "arrow.clockwise") - } - .buttonStyle(.borderless) - .help("Refresh submission readiness") - Spacer() let versionState = asc.appStoreVersions.first(where: { let s = $0.attributes.appStoreState ?? "" diff --git a/src/views/release/AppDetailsView.swift b/src/views/release/AppDetailsView.swift index 847debf..aab28e7 100644 --- a/src/views/release/AppDetailsView.swift +++ b/src/views/release/AppDetailsView.swift @@ -53,13 +53,22 @@ struct AppDetailsView: View { detailsContent } } - .task { await asc.fetchTabData(.appDetails) } + .task(id: appState.activeProjectId) { await asc.ensureTabData(.appDetails) } } @ViewBuilder private var detailsContent: some View { + let isLoading = asc.isTabLoading(.appDetails) ScrollView { VStack(alignment: .leading, spacing: 0) { + HStack { + Text("App Details") + .font(.title2.weight(.semibold)) + Spacer() + ASCTabRefreshButton(asc: asc, tab: .appDetails, helpText: "Refresh app details") + } + .padding(.bottom, 20) + sectionHeader("App Identity") if let app = asc.app { @@ -82,10 +91,20 @@ struct AppDetailsView: View { .padding(.top, 20) if asc.appStoreVersions.isEmpty { - Text("No versions found") - .font(.callout) - .foregroundStyle(.secondary) + if isLoading { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading version information…") + .font(.callout) + .foregroundStyle(.secondary) + } .padding(.vertical, 8) + } else { + Text("No versions found") + .font(.callout) + .foregroundStyle(.secondary) + .padding(.vertical, 8) + } } else { ForEach(Array(asc.appStoreVersions.prefix(5).enumerated()), id: \.element.id) { idx, version in HStack { diff --git a/src/views/release/PricingView.swift b/src/views/release/PricingView.swift index eb78662..aaf38dd 100644 --- a/src/views/release/PricingView.swift +++ b/src/views/release/PricingView.swift @@ -16,18 +16,19 @@ struct MonetizationView: View { monetizationContent } } - .task { await asc.fetchTabData(.monetization) } + .task(id: appState.activeProjectId) { await asc.ensureTabData(.monetization) } } @ViewBuilder private var monetizationContent: some View { + let isLoading = asc.isTabLoading(.monetization) ScrollView { VStack(alignment: .leading, spacing: 24) { HStack { Text("Monetization") .font(.title2.weight(.semibold)) Spacer() - RefreshButton(asc: asc) + ASCTabRefreshButton(asc: asc, tab: .monetization, helpText: "Refresh monetization data") } if let err = asc.writeError { @@ -40,6 +41,18 @@ struct MonetizationView: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } + if isLoading + && asc.appPricePoints.isEmpty + && asc.inAppPurchases.isEmpty + && asc.subscriptionGroups.isEmpty { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading pricing, products, and subscriptions…") + .font(.callout) + .foregroundStyle(.secondary) + } + } + AppPricingSection(asc: asc) InAppPurchasesSection(asc: asc) SubscriptionsSection(asc: asc) @@ -149,73 +162,83 @@ private struct AppPricingSection: View { @State private var scheduledPricePointId = "" var body: some View { + let isLoading = asc.isTabLoading(.monetization) SectionCard { Label("Pricing & Availability", systemImage: "dollarsign.circle") .font(.headline) - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Free App").font(.body.weight(.medium)) - Text("Your app will be available for free on the App Store.") - .font(.callout).foregroundStyle(.secondary) + if isLoading && asc.appPricePoints.isEmpty && asc.currentAppPricePointId == nil && asc.scheduledAppPricePointId == nil { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading pricing state…") + .font(.callout) + .foregroundStyle(.secondary) } - Spacer() - Toggle("", isOn: Binding( - get: { isFree }, - set: { newValue in - guard newValue != isFree else { return } - isFree = newValue - guard newValue else { return } - isSaving = true - selectedPricePointId = "" - Task { - await asc.setPriceFree() - isSaving = false - } + } else { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Free App").font(.body.weight(.medium)) + Text("Your app will be available for free on the App Store.") + .font(.callout).foregroundStyle(.secondary) } - )) - .labelsHidden() - } + Spacer() + Toggle("", isOn: Binding( + get: { isFree }, + set: { newValue in + guard newValue != isFree else { return } + isFree = newValue + guard newValue else { return } + isSaving = true + selectedPricePointId = "" + Task { + await asc.setPriceFree() + isSaving = false + } + } + )) + .labelsHidden() + } - if !isFree { - PricePicker(pricePoints: asc.appPricePoints, selectedPointId: $selectedPricePointId) + if !isFree { + PricePicker(pricePoints: asc.appPricePoints, selectedPointId: $selectedPricePointId) - if !selectedPricePointId.isEmpty { - Button("Set Price") { - isSaving = true - Task { - await asc.setAppPrice(pricePointId: selectedPricePointId) - isSaving = false + if !selectedPricePointId.isEmpty { + Button("Set Price") { + isSaving = true + Task { + await asc.setAppPrice(pricePointId: selectedPricePointId) + isSaving = false + } } + .buttonStyle(.borderedProminent).controlSize(.small) } - .buttonStyle(.borderedProminent).controlSize(.small) - } - Divider() - - DisclosureGroup("Schedule Price Change", isExpanded: $showScheduled) { - VStack(alignment: .leading, spacing: 12) { - DatePicker("Effective Date", selection: $scheduledDate, in: Date()..., displayedComponents: .date) - PricePicker(pricePoints: asc.appPricePoints, selectedPointId: $scheduledPricePointId) + Divider() - if !scheduledPricePointId.isEmpty { - Button("Create Price Change") { - isSaving = true - let currentId = selectedPricePointId.isEmpty ? freePointId : selectedPricePointId - let dateStr = formatDate(scheduledDate) - Task { - await asc.setScheduledAppPrice( - currentPricePointId: currentId, - futurePricePointId: scheduledPricePointId, - effectiveDate: dateStr - ) - isSaving = false + DisclosureGroup("Schedule Price Change", isExpanded: $showScheduled) { + VStack(alignment: .leading, spacing: 12) { + DatePicker("Effective Date", selection: $scheduledDate, in: Date()..., displayedComponents: .date) + PricePicker(pricePoints: asc.appPricePoints, selectedPointId: $scheduledPricePointId) + + if !scheduledPricePointId.isEmpty { + Button("Create Price Change") { + isSaving = true + let currentId = selectedPricePointId.isEmpty ? freePointId : selectedPricePointId + let dateStr = formatDate(scheduledDate) + Task { + await asc.setScheduledAppPrice( + currentPricePointId: currentId, + futurePricePointId: scheduledPricePointId, + effectiveDate: dateStr + ) + isSaving = false + } } + .buttonStyle(.borderedProminent).controlSize(.small) } - .buttonStyle(.borderedProminent).controlSize(.small) } + .padding(.top, 8) } - .padding(.top, 8) } } @@ -306,6 +329,7 @@ private struct InAppPurchasesSection: View { } var body: some View { + let isLoading = asc.isTabLoading(.monetization) SectionCard { HStack { Label("In-App Purchases", systemImage: "cart") @@ -319,8 +343,17 @@ private struct InAppPurchasesSection: View { } if asc.inAppPurchases.isEmpty && !showCreateForm { - Text("No in-app purchases configured.") - .font(.callout).foregroundStyle(.secondary) + if isLoading { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading in-app purchases…") + .font(.callout) + .foregroundStyle(.secondary) + } + } else { + Text("No in-app purchases configured.") + .font(.callout).foregroundStyle(.secondary) + } } else { ForEach(asc.inAppPurchases) { iap in IAPDetailRow( @@ -611,6 +644,7 @@ private struct SubscriptionsSection: View { } var body: some View { + let isLoading = asc.isTabLoading(.monetization) SectionCard { HStack { Label("Subscriptions", systemImage: "arrow.triangle.2.circlepath") @@ -624,8 +658,17 @@ private struct SubscriptionsSection: View { } if asc.subscriptionGroups.isEmpty && !showCreateForm { - Text("No subscriptions configured.") - .font(.callout).foregroundStyle(.secondary) + if isLoading { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading subscriptions…") + .font(.callout) + .foregroundStyle(.secondary) + } + } else { + Text("No subscriptions configured.") + .font(.callout).foregroundStyle(.secondary) + } } else { ForEach(asc.subscriptionGroups) { group in SubscriptionGroupRow( @@ -798,8 +841,17 @@ private struct SubscriptionGroupRow: View { let subs = asc.subscriptionsPerGroup[group.id] ?? [] if subs.isEmpty { - Text("No subscriptions in this group.") - .font(.caption).foregroundStyle(.tertiary) + if asc.isTabLoading(.monetization) { + HStack(spacing: 8) { + ProgressView().controlSize(.mini) + Text("Loading subscriptions in this group…") + .font(.caption) + .foregroundStyle(.tertiary) + } + } else { + Text("No subscriptions in this group.") + .font(.caption).foregroundStyle(.tertiary) + } } ForEach(subs) { sub in SubscriptionDetailRow( @@ -942,32 +994,6 @@ private struct SubscriptionDetailRow: View { } } -// MARK: - Refresh Button - -private struct RefreshButton: View { - var asc: ASCManager - @State private var isRefreshing = false - - var body: some View { - Button { - isRefreshing = true - Task { - await asc.refreshMonetization() - isRefreshing = false - } - } label: { - if isRefreshing { - ProgressView().controlSize(.small) - } else { - Image(systemName: "arrow.clockwise") - } - } - .buttonStyle(.borderless) - .disabled(isRefreshing) - .help("Refresh IAP & subscription states") - } -} - // MARK: - Submit Button private struct SubmitForReviewButton: View { diff --git a/src/views/release/ReviewView.swift b/src/views/release/ReviewView.swift index 512f8ae..b321512 100644 --- a/src/views/release/ReviewView.swift +++ b/src/views/release/ReviewView.swift @@ -68,7 +68,7 @@ struct ReviewView: View { reviewContent } } - .task { await asc.fetchTabData(.review) } + .task(id: appState.activeProjectId) { await asc.ensureTabData(.review) } .onChange(of: asc.appStoreVersions.map(\.id)) { _, _ in guard let appId = asc.app?.id else { return } // Load cached rejection feedback for the pending version @@ -86,9 +86,17 @@ struct ReviewView: View { @ViewBuilder private var reviewContent: some View { let latest = asc.appStoreVersions.first + let isLoading = asc.isTabLoading(.review) ScrollView { VStack(alignment: .leading, spacing: 24) { + HStack { + Text("Review") + .font(.title2.weight(.semibold)) + Spacer() + ASCTabRefreshButton(asc: asc, tab: .review, helpText: "Refresh review data") + } + // Current version status card if let version = latest { VStack(alignment: .leading, spacing: 12) { @@ -142,6 +150,13 @@ struct ReviewView: View { .background(Color.green.opacity(0.15)) .foregroundStyle(.green) .clipShape(Capsule()) + } else if isLoading { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Loading…") + .font(.caption) + .foregroundStyle(.secondary) + } } else { Text("Not set") .font(.caption) @@ -173,6 +188,13 @@ struct ReviewView: View { Text("\(rd.attributes.contactFirstName ?? "") \(rd.attributes.contactLastName ?? "")") .font(.caption) .foregroundStyle(.secondary) + } else if isLoading { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Loading…") + .font(.caption) + .foregroundStyle(.secondary) + } } else { Text("Not configured") .font(.caption) @@ -190,9 +212,18 @@ struct ReviewView: View { .font(.headline) if asc.builds.isEmpty { - Text("No builds available. Upload a build via Xcode or Transporter.") - .font(.callout) - .foregroundStyle(.secondary) + if isLoading { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading builds and review history…") + .font(.callout) + .foregroundStyle(.secondary) + } + } else { + Text("No builds available. Upload a build via Xcode or Transporter.") + .font(.callout) + .foregroundStyle(.secondary) + } } else { Picker("Build", selection: $selectedBuild) { Text("Select a build…").tag("") @@ -264,10 +295,12 @@ struct ReviewView: View { populateAgeRating() populateContact() applyPendingValues() + syncSelectedBuild() } .onChange(of: asc.ageRatingDeclaration?.id) { _, _ in populateAgeRating() } .onChange(of: asc.reviewDetail?.id) { _, _ in populateContact() } .onChange(of: asc.pendingFormVersion) { _, _ in applyPendingValues() } + .onChange(of: asc.builds.map(\.id)) { _, _ in syncSelectedBuild() } .onChange(of: contactFocused) { _, _ in // Don't auto-save contact — requires all required fields. // User saves explicitly via the "Save Contact" button. @@ -619,4 +652,14 @@ struct ReviewView: View { return false } + private func syncSelectedBuild() { + if !selectedBuild.isEmpty, + asc.builds.contains(where: { $0.id == selectedBuild }) { + return + } + selectedBuild = asc.builds.first(where: { $0.attributes.processingState == "VALID" })?.id + ?? asc.builds.first?.id + ?? "" + } + } diff --git a/src/views/release/ScreenshotsView.swift b/src/views/release/ScreenshotsView.swift index c834641..3f9c5a2 100644 --- a/src/views/release/ScreenshotsView.swift +++ b/src/views/release/ScreenshotsView.swift @@ -101,20 +101,33 @@ struct ScreenshotsView: View { bundleId: appState.activeProject?.metadata.bundleIdentifier ) { ASCTabContent(asc: asc, tab: .screenshots, platform: appState.activeProject?.platform ?? .iOS) { - HStack(spacing: 0) { - assetLibraryPanel - .frame(width: 220) + VStack(spacing: 0) { + HStack { + Text("Screenshots") + .font(.title2.weight(.semibold)) + Spacer() + ASCTabRefreshButton(asc: asc, tab: .screenshots, helpText: "Refresh screenshots") + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + Divider() - VStack(spacing: 0) { - detailView + + HStack(spacing: 0) { + assetLibraryPanel + .frame(width: 220) Divider() - trackView - .frame(minHeight: 200) + VStack(spacing: 0) { + detailView + Divider() + trackView + .frame(minHeight: 200) + } } } } } - .task { await loadData() } + .task(id: appState.activeProjectId) { await loadData() } .onChange(of: selectedDevice) { _, _ in loadTrackForDevice() } .alert("Import Error", isPresented: Binding( get: { importError != nil }, @@ -532,13 +545,14 @@ struct ScreenshotsView: View { selectedDevice = first } - await asc.fetchTabData(.screenshots) - - // Scan local assets if let projectId = appState.activeProjectId { asc.scanLocalAssets(projectId: projectId) } + loadTrackForDevice() + + await asc.ensureTabData(.screenshots) + // Load track from ASC loadTrackForDevice() } @@ -649,7 +663,6 @@ struct ScreenshotsView: View { for provider in providers { if provider.canLoadObject(ofClass: NSURL.self) { hasValidProvider = true - let ascRef = asc provider.loadObject(ofClass: NSURL.self) { reading, _ in guard let url = reading as? URL, url.isFileURL, diff --git a/src/views/release/StoreListingView.swift b/src/views/release/StoreListingView.swift index b4f7f7e..03d6cf2 100644 --- a/src/views/release/StoreListingView.swift +++ b/src/views/release/StoreListingView.swift @@ -31,7 +31,7 @@ struct StoreListingView: View { listingContent } } - .task { await asc.fetchTabData(.storeListing) } + .task(id: appState.activeProjectId) { await asc.ensureTabData(.storeListing) } .onDisappear { Task { await flushChanges() } } @@ -42,6 +42,7 @@ struct StoreListingView: View { let locales = asc.localizations let current = locales.first { $0.attributes.locale == selectedLocale } ?? locales.first + let isLoading = asc.isTabLoading(.storeListing) VStack(spacing: 0) { // Toolbar @@ -79,6 +80,7 @@ struct StoreListingView: View { } .help("Open in App Store Connect") } + ASCTabRefreshButton(asc: asc, tab: .storeListing, helpText: "Refresh store listing data") } .padding(.horizontal, 20) .padding(.vertical, 10) @@ -101,12 +103,19 @@ struct StoreListingView: View { } .padding(24) } else if asc.localizations.isEmpty { - ContentUnavailableView( - "No Localizations", - systemImage: "text.page", - description: Text("No localizations found for the latest version.") - ) - .padding(.top, 60) + if isLoading { + ASCTabLoadingPlaceholder( + title: "Loading Store Listing", + message: "Fetching localizations and editable metadata." + ) + } else { + ContentUnavailableView( + "No Localizations", + systemImage: "text.page", + description: Text("No localizations found for the latest version.") + ) + .padding(.top, 60) + } } } } diff --git a/src/views/shared/asc/ASCTabContent.swift b/src/views/shared/asc/ASCTabContent.swift index b5437a6..7af72d0 100644 --- a/src/views/shared/asc/ASCTabContent.swift +++ b/src/views/shared/asc/ASCTabContent.swift @@ -8,15 +8,15 @@ struct ASCTabContent: View { @ViewBuilder var content: () -> Content private var isLoading: Bool { - asc.isLoadingTab[tab] == true || asc.isLoadingApp + asc.isTabLoading(tab) } - private var shouldRenderOverviewWhileLoading: Bool { - tab == .app && asc.credentials != nil + private var shouldRenderContentWhileLoading: Bool { + asc.credentials != nil } var body: some View { - if isLoading && !shouldRenderOverviewWhileLoading { + if isLoading && !shouldRenderContentWhileLoading { VStack(spacing: 12) { ProgressView() Text("Loading\u{2026}") @@ -27,7 +27,7 @@ struct ASCTabContent: View { } else if asc.app == nil && asc.credentials != nil && !isLoading { // App not found — show bundle ID setup instead of flashing content BundleIDSetupView(asc: asc, tab: tab, platform: platform) - } else if let error = asc.tabError[tab] { + } else if let error = asc.tabError[tab], !asc.hasLoadedTabData(tab) { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 32)) @@ -48,7 +48,7 @@ struct ASCTabContent: View { } else { content() .overlay(alignment: .topTrailing) { - if isLoading && shouldRenderOverviewWhileLoading { + if isLoading && shouldRenderContentWhileLoading { ProgressView() .controlSize(.small) .padding(.horizontal, 10) @@ -57,7 +57,73 @@ struct ASCTabContent: View { .padding(12) } } + .overlay(alignment: .topLeading) { + if let error = asc.tabError[tab], asc.hasLoadedTabData(tab) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(error) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + Button("Retry") { + Task { await asc.refreshTabData(tab) } + } + .buttonStyle(.bordered) + .controlSize(.mini) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(.background.secondary, in: RoundedRectangle(cornerRadius: 12)) + .padding(12) + } + } + } + } +} + +struct ASCTabLoadingPlaceholder: View { + var title: String + var message: String + + var body: some View { + VStack(spacing: 10) { + ProgressView() + Text(title) + .font(.callout.weight(.medium)) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(32) + } +} + +struct ASCTabRefreshButton: View { + var asc: ASCManager + var tab: AppTab + var helpText: String = "Refresh this tab" + + private var isRefreshing: Bool { + asc.isLoadingTab[tab] == true + } + + var body: some View { + Button { + Task { await asc.refreshTabData(tab) } + } label: { + if isRefreshing { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + } } + .buttonStyle(.borderless) + .disabled(isRefreshing) + .help(helpText) } } diff --git a/src/views/testflight/BetaInfoView.swift b/src/views/testflight/BetaInfoView.swift index 3c6761e..b9fb46d 100644 --- a/src/views/testflight/BetaInfoView.swift +++ b/src/views/testflight/BetaInfoView.swift @@ -23,7 +23,7 @@ struct BetaInfoView: View { betaInfoContent } } - .task { await asc.fetchTabData(.betaInfo) } + .task(id: appState.activeProjectId) { await asc.ensureTabData(.betaInfo) } } @ViewBuilder @@ -83,6 +83,7 @@ struct BetaInfoView: View { } .buttonStyle(.borderedProminent) .disabled(isSaving || current == nil) + ASCTabRefreshButton(asc: asc, tab: .betaInfo, helpText: "Refresh beta info") } .padding(.horizontal, 20) .padding(.vertical, 10) @@ -93,11 +94,18 @@ struct BetaInfoView: View { if current == nil && !locs.isEmpty { ContentUnavailableView("Select a locale", systemImage: "doc.text") } else if locs.isEmpty { - ContentUnavailableView( - "No Localizations", - systemImage: "doc.text", - description: Text("No beta app localizations found.") - ) + if asc.isTabLoading(.betaInfo) { + ASCTabLoadingPlaceholder( + title: "Loading Beta Info", + message: "Fetching beta app localizations and tester-facing copy." + ) + } else { + ContentUnavailableView( + "No Localizations", + systemImage: "doc.text", + description: Text("No beta app localizations found.") + ) + } } else { ScrollView { VStack(alignment: .leading, spacing: 20) { diff --git a/src/views/testflight/BuildsView.swift b/src/views/testflight/BuildsView.swift index 62a80b8..dd14d3d 100644 --- a/src/views/testflight/BuildsView.swift +++ b/src/views/testflight/BuildsView.swift @@ -16,40 +16,62 @@ struct BuildsView: View { buildsContent } } - .task { await asc.fetchTabData(.builds) } + .task(id: appState.activeProjectId) { await asc.ensureTabData(.builds) } + .onAppear { syncSelectedBuild() } + .onChange(of: asc.builds.map(\.id)) { _, _ in syncSelectedBuild() } } @ViewBuilder private var buildsContent: some View { - if asc.builds.isEmpty { - ContentUnavailableView( - "No Builds", - systemImage: "hammer", - description: Text("No TestFlight builds found. Upload a build from Xcode.") - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - HStack(spacing: 0) { - // Build list - List(selection: $selectedBuildId) { - ForEach(asc.builds) { build in - buildRow(build) - .tag(build.id) - } - } - .listStyle(.inset) - .frame(width: 300) + VStack(spacing: 0) { + HStack { + Text("Builds") + .font(.title2.weight(.semibold)) + Spacer() + ASCTabRefreshButton(asc: asc, tab: .builds, helpText: "Refresh builds") + } + .padding(.horizontal, 20) + .padding(.vertical, 12) - Divider() + Divider() - // Detail panel - if let bid = selectedBuildId, - let build = asc.builds.first(where: { $0.id == bid }) { - buildDetail(build) - .frame(maxWidth: .infinity, maxHeight: .infinity) + if asc.builds.isEmpty { + if asc.isTabLoading(.builds) { + ASCTabLoadingPlaceholder( + title: "Loading Builds", + message: "Fetching TestFlight build metadata." + ) } else { - ContentUnavailableView("Select a Build", systemImage: "hammer") - .frame(maxWidth: .infinity, maxHeight: .infinity) + ContentUnavailableView( + "No Builds", + systemImage: "hammer", + description: Text("No TestFlight builds found. Upload a build from Xcode.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else { + HStack(spacing: 0) { + // Build list + List(selection: $selectedBuildId) { + ForEach(asc.builds) { build in + buildRow(build) + .tag(build.id) + } + } + .listStyle(.inset) + .frame(width: 300) + + Divider() + + // Detail panel + if let bid = selectedBuildId, + let build = asc.builds.first(where: { $0.id == bid }) { + buildDetail(build) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView("Select a Build", systemImage: "hammer") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } } } @@ -127,4 +149,12 @@ struct BuildsView: View { .foregroundStyle(color) .clipShape(Capsule()) } + + private func syncSelectedBuild() { + if let selectedBuildId, + asc.builds.contains(where: { $0.id == selectedBuildId }) { + return + } + selectedBuildId = asc.builds.first?.id + } } diff --git a/src/views/testflight/FeedbackView.swift b/src/views/testflight/FeedbackView.swift index 77e6366..42e1284 100644 --- a/src/views/testflight/FeedbackView.swift +++ b/src/views/testflight/FeedbackView.swift @@ -16,16 +16,15 @@ struct FeedbackView: View { feedbackContent } } - .task { await asc.fetchTabData(.feedback) } + .task(id: appState.activeProjectId) { await asc.ensureTabData(.feedback) } } @ViewBuilder private var feedbackContent: some View { let builds = asc.builds - let effectiveBuildId = localSelectedBuildId.isEmpty - ? (asc.selectedBuildId ?? builds.first?.id ?? "") - : localSelectedBuildId + let effectiveBuildId = resolvedBuildId(from: builds) let feedback = asc.betaFeedback[effectiveBuildId] ?? [] + let isLoadingFeedback = asc.isFeedbackLoading(for: effectiveBuildId) VStack(spacing: 0) { // Build picker toolbar @@ -49,20 +48,11 @@ struct FeedbackView: View { } } Spacer() - Button { - Task { - guard let service = asc.service, !effectiveBuildId.isEmpty else { return } - do { - let items = try await service.fetchBetaFeedback(buildId: effectiveBuildId) - asc.betaFeedback[effectiveBuildId] = items - } catch { - // Silently fail — feedback may be unavailable - } - } - } label: { - Image(systemName: "arrow.clockwise") + if isLoadingFeedback { + ProgressView() + .controlSize(.small) } - .buttonStyle(.borderless) + ASCTabRefreshButton(asc: asc, tab: .feedback, helpText: "Refresh feedback tab") } .padding(.horizontal, 20) .padding(.vertical, 10) @@ -71,19 +61,33 @@ struct FeedbackView: View { Divider() if builds.isEmpty { - ContentUnavailableView( - "No Builds", - systemImage: "hammer", - description: Text("Upload a TestFlight build to receive tester feedback.") - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) + if asc.isTabLoading(.feedback) { + ASCTabLoadingPlaceholder( + title: "Loading Feedback", + message: "Fetching builds and the latest tester feedback." + ) + } else { + ContentUnavailableView( + "No Builds", + systemImage: "hammer", + description: Text("Upload a TestFlight build to receive tester feedback.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } else if feedback.isEmpty { - ContentUnavailableView( - "No Feedback", - systemImage: "exclamationmark.bubble", - description: Text("No tester feedback for this build yet.") - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) + if isLoadingFeedback { + ASCTabLoadingPlaceholder( + title: "Loading Feedback", + message: "Fetching tester comments and screenshots for this build." + ) + } else { + ContentUnavailableView( + "No Feedback", + systemImage: "exclamationmark.bubble", + description: Text("No tester feedback for this build yet.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } else { List(feedback) { item in feedbackRow(item) @@ -92,6 +96,18 @@ struct FeedbackView: View { .listStyle(.plain) } } + .onChange(of: localSelectedBuildId) { _, newValue in + guard !newValue.isEmpty else { return } + asc.selectedBuildId = newValue + guard asc.betaFeedback[newValue] == nil else { return } + Task { await asc.refreshBetaFeedback(buildId: newValue) } + } + .onAppear { + syncSelectedBuild(with: builds) + } + .onChange(of: builds.map(\.id)) { _, _ in + syncSelectedBuild(with: builds) + } } private func feedbackRow(_ item: ASCBetaFeedback) -> some View { @@ -155,4 +171,22 @@ struct FeedbackView: View { } } } + + private func resolvedBuildId(from builds: [ASCBuild]) -> String { + if !localSelectedBuildId.isEmpty, + builds.contains(where: { $0.id == localSelectedBuildId }) { + return localSelectedBuildId + } + if let selectedBuildId = asc.selectedBuildId, + builds.contains(where: { $0.id == selectedBuildId }) { + return selectedBuildId + } + return builds.first?.id ?? "" + } + + private func syncSelectedBuild(with builds: [ASCBuild]) { + let effectiveBuildId = resolvedBuildId(from: builds) + localSelectedBuildId = effectiveBuildId + asc.selectedBuildId = effectiveBuildId.isEmpty ? nil : effectiveBuildId + } } diff --git a/src/views/testflight/GroupsView.swift b/src/views/testflight/GroupsView.swift index a22e42d..9a329a2 100644 --- a/src/views/testflight/GroupsView.swift +++ b/src/views/testflight/GroupsView.swift @@ -15,32 +15,52 @@ struct GroupsView: View { groupsContent } } - .task { await asc.fetchTabData(.groups) } + .task(id: appState.activeProjectId) { await asc.ensureTabData(.groups) } } @ViewBuilder private var groupsContent: some View { - if asc.betaGroups.isEmpty { - ContentUnavailableView( - "No Beta Groups", - systemImage: "person.3", - description: Text("No beta testing groups found for this app.") - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ScrollView { - VStack(spacing: 12) { - let internal_ = asc.betaGroups.filter { $0.attributes.isInternalGroup == true } - let external = asc.betaGroups.filter { $0.attributes.isInternalGroup != true } + VStack(spacing: 0) { + HStack { + Text("Groups") + .font(.title2.weight(.semibold)) + Spacer() + ASCTabRefreshButton(asc: asc, tab: .groups, helpText: "Refresh groups") + } + .padding(.horizontal, 20) + .padding(.vertical, 12) - if !internal_.isEmpty { - groupSection("Internal Groups", groups: internal_, color: .blue) - } - if !external.isEmpty { - groupSection("External Groups", groups: external, color: .green) + Divider() + + if asc.betaGroups.isEmpty { + if asc.isTabLoading(.groups) { + ASCTabLoadingPlaceholder( + title: "Loading Beta Groups", + message: "Fetching internal and external TestFlight groups." + ) + } else { + ContentUnavailableView( + "No Beta Groups", + systemImage: "person.3", + description: Text("No beta testing groups found for this app.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else { + ScrollView { + VStack(spacing: 12) { + let internal_ = asc.betaGroups.filter { $0.attributes.isInternalGroup == true } + let external = asc.betaGroups.filter { $0.attributes.isInternalGroup != true } + + if !internal_.isEmpty { + groupSection("Internal Groups", groups: internal_, color: .blue) + } + if !external.isEmpty { + groupSection("External Groups", groups: external, color: .green) + } } + .padding(20) } - .padding(20) } } } From aac0f30c60c055000757cc92c5f97000d0011f68 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Tue, 24 Mar 2026 12:25:52 -0700 Subject: [PATCH 13/51] fallback to term --- src/views/OnboardingView.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/views/OnboardingView.swift b/src/views/OnboardingView.swift index cd9559f..5547853 100644 --- a/src/views/OnboardingView.swift +++ b/src/views/OnboardingView.swift @@ -678,9 +678,18 @@ struct OnboardingView: View { } } + /// Resolve the terminal for onboarding context where the built-in split pane + /// is unavailable due to the small window size. + private var onboardingTerminal: TerminalApp { + let resolved = selectedTerminal.resolvedFallback + guard resolved.isBuiltIn else { return resolved } + // Built-in can't render in the onboarding window — fall back to Terminal.app + return .terminal + } + private func launchASCSetupWithAI() { let agent = selectedAgent - let terminal = selectedTerminal.resolvedFallback + let terminal = onboardingTerminal let prompt = "Use the /asc-team-key-create skill to create a new App Store Connect API key, then call the asc_set_credentials MCP tool to fill the form so I can verify and save." TerminalLauncher.launch( projectPath: BlitzPaths.mcps.path, @@ -713,7 +722,7 @@ struct OnboardingView: View { VStack(spacing: 6) { slideHeader( title: "Ask AI from Any Tab", - subtitle: "Click \"Ask AI\" to launch \(selectedAgent.displayName) in \(selectedTerminal.displayName)." + subtitle: "Click \"Ask AI\" to launch \(selectedAgent.displayName) in \(selectedTerminal.isBuiltIn ? "the built-in terminal" : selectedTerminal.displayName)." ) // Demo video — transparent background, aspect fit From c5b1184a686181d69925f78254d5bb47d9437e88 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Tue, 24 Mar 2026 15:02:06 -0700 Subject: [PATCH 14/51] Replace ASC service with asc-cli go binary communicated via stdio/stdout JSON RPC --- scripts/build-pkg.sh | 12 + scripts/bundle.sh | 48 ++ src/models/ASCSupplementalModels.swift | 77 +++ src/services/ASCDaemonClient.swift | 621 ++++++++++++++++++ src/services/ASCDaemonLogger.swift | 52 ++ src/services/ASCError.swift | 55 ++ ...eConnectService.swift => ASCService.swift} | 463 +++++-------- 7 files changed, 1034 insertions(+), 294 deletions(-) create mode 100644 src/models/ASCSupplementalModels.swift create mode 100644 src/services/ASCDaemonClient.swift create mode 100644 src/services/ASCDaemonLogger.swift create mode 100644 src/services/ASCError.swift rename src/services/{AppStoreConnectService.swift => ASCService.swift} (83%) diff --git a/scripts/build-pkg.sh b/scripts/build-pkg.sh index a73610d..31a125d 100755 --- a/scripts/build-pkg.sh +++ b/scripts/build-pkg.sh @@ -30,6 +30,12 @@ if [ ! -d "$SOURCE_APP" ]; then exit 1 fi +if [ ! -x "$SOURCE_APP/Contents/Helpers/ascd" ]; then + echo "ERROR: $SOURCE_APP does not contain a bundled ascd helper." + echo "Rebuild the app bundle after installing or building ascd." + exit 1 +fi + # Clean build dir and stale .pkg files rm -rf "$BUILD_DIR" rm -f "$ROOT_DIR/build/$APP_NAME-"*.pkg @@ -68,6 +74,12 @@ if [ -n "$APP_SIGNING_IDENTITY" ]; then --entitlements "$ENTITLEMENTS" \ "$APP_PAYLOAD/Contents/Helpers/blitz-macos-mcp" fi + if [ -f "$APP_PAYLOAD/Contents/Helpers/ascd" ]; then + codesign --force --options runtime --timestamp \ + --sign "$APP_SIGNING_IDENTITY" \ + --entitlements "$ENTITLEMENTS" \ + "$APP_PAYLOAD/Contents/Helpers/ascd" + fi # Re-sign the main app bundle (must be last) codesign --force --options runtime --timestamp \ diff --git a/scripts/bundle.sh b/scripts/bundle.sh index 6cb86fd..1495fb1 100755 --- a/scripts/bundle.sh +++ b/scripts/bundle.sh @@ -15,6 +15,38 @@ SIGNING_IDENTITY="${APPLE_SIGNING_IDENTITY:-}" ENTITLEMENTS="$ROOT_DIR/scripts/Entitlements.plist" TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}" +resolve_ascd_path() { + local candidates=() + + if [ -n "${BLITZ_ASCD_PATH:-}" ]; then + candidates+=("$BLITZ_ASCD_PATH") + fi + + if command -v ascd >/dev/null 2>&1; then + candidates+=("$(command -v ascd)") + fi + + candidates+=( + "$HOME/.blitz/ascd" + "$HOME/.local/bin/ascd" + "/opt/homebrew/bin/ascd" + "/usr/local/bin/ascd" + "/opt/local/bin/ascd" + "$HOME/superapp/asc-cli/forks/App-Store-Connect-CLI-helper/build/ascd" + ) + + local candidate + for candidate in "${candidates[@]}"; do + [ -n "$candidate" ] || continue + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + if [ "$CONFIG" = "debug" ] && [ "$TIMESTAMP_MODE" = "auto" ]; then TIMESTAMP_MODE="none" fi @@ -52,6 +84,18 @@ else echo "WARNING: blitz-macos-mcp helper was not built; MCP integration will be unavailable." fi +ASC_HELPER_BINARY="$(resolve_ascd_path || true)" +if [ -n "$ASC_HELPER_BINARY" ]; then + cp "$ASC_HELPER_BINARY" "$BUNDLE_DIR/Contents/Helpers/ascd" + chmod 755 "$BUNDLE_DIR/Contents/Helpers/ascd" + echo "Copied ascd helper into app bundle from $ASC_HELPER_BINARY" +else + echo "ERROR: ascd helper not found." + echo " Set BLITZ_ASCD_PATH, install ascd on PATH, or build it at:" + echo " $HOME/superapp/asc-cli/forks/App-Store-Connect-CLI-helper/build/ascd" + exit 1 +fi + # Generate app icon (.icns) from PNG ICON_PNG="$ROOT_DIR/src/resources/blitz-icon.png" ICON_ICNS="$BUNDLE_DIR/Contents/Resources/AppIcon.icns" @@ -183,6 +227,10 @@ if [ "$SIGNING_IDENTITY" != "-" ]; then codesign_bundle_path "$BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" echo " Signed: $BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" fi + if [ -f "$BUNDLE_DIR/Contents/Helpers/ascd" ]; then + codesign_bundle_path "$BUNDLE_DIR/Contents/Helpers/ascd" + echo " Signed: $BUNDLE_DIR/Contents/Helpers/ascd" + fi fi # Sign the .app bundle (must be after nested signing) diff --git a/src/models/ASCSupplementalModels.swift b/src/models/ASCSupplementalModels.swift new file mode 100644 index 0000000..1d9529f --- /dev/null +++ b/src/models/ASCSupplementalModels.swift @@ -0,0 +1,77 @@ +import Foundation + +struct ASCPricePoint: Decodable, Identifiable { + let id: String + + struct Attributes: Decodable { + let customerPrice: String? + } + + let attributes: Attributes +} + +struct ASCScreenshotReservation: Decodable, Identifiable { + let id: String + + struct Attributes: Decodable { + let sourceFileChecksum: String? + let uploadOperations: [UploadOperation]? + } + + let attributes: Attributes + + struct UploadOperation: Decodable { + let method: String + let url: String + let offset: Int + let length: Int + let requestHeaders: [Header] + + struct Header: Decodable { + let name: String + let value: String + } + } +} + +struct ASCReviewSubmission: Decodable, Identifiable { + let id: String + + struct Attributes: Decodable { + let state: String? + let submittedDate: String? + let platform: String? + } + + let attributes: Attributes +} + +struct ASCReviewSubmissionItem: Decodable, Identifiable { + let id: String + + struct Attributes: Decodable { + let state: String? + let resolved: Bool? + let createdDate: String? + } + + let attributes: Attributes + let relationships: Relationships? + + struct Relationships: Decodable { + let appStoreVersion: ToOneRelationship? + + struct ToOneRelationship: Decodable { + let data: ResourceIdentifier? + } + + struct ResourceIdentifier: Decodable { + let type: String + let id: String + } + } + + var appStoreVersionId: String? { + relationships?.appStoreVersion?.data?.id + } +} diff --git a/src/services/ASCDaemonClient.swift b/src/services/ASCDaemonClient.swift new file mode 100644 index 0000000..b512389 --- /dev/null +++ b/src/services/ASCDaemonClient.swift @@ -0,0 +1,621 @@ +import Foundation + +actor ASCDaemonClient { + private struct PendingRequest { + let continuation: CheckedContinuation + let summary: String + let startedAt: Date + } + + struct HTTPResponse: Sendable { + let statusCode: Int + let headers: [String: [String]] + let contentType: String + let body: Data + } + + private struct DaemonResponse: Decodable { + let id: String? + let result: Result? + let error: DaemonErrorPayload? + } + + private struct DaemonErrorPayload: Decodable { + let code: Int + let message: String + let data: String? + } + + private struct SessionOpenResult: Decodable { + let session: SessionInfo + } + + private struct SessionInfo: Decodable { + let profile: String? + let usesInMemoryKey: Bool + } + + private struct SessionRequestResult: Decodable { + let statusCode: Int + let headers: [String: [String]]? + let contentType: String? + let body: String? + } + + enum Error: LocalizedError { + case helperNotFound(String) + case helperLaunchFailed(String) + case invalidResponse + case processExited(Int32, String) + case helperError(String, String?) + case invalidRequestBody + case responseTimeout(String) + + var errorDescription: String? { + switch self { + case .helperNotFound(let message), + .helperLaunchFailed(let message): + return message + case .invalidResponse: + return "Invalid response from ascd" + case .processExited(let code, let stderr): + if stderr.isEmpty { + return "ascd exited with status \(code)" + } + return "ascd exited with status \(code): \(stderr)" + case .helperError(let message, let data): + if let data, !data.isEmpty { + return "\(message): \(data)" + } + return message + case .invalidRequestBody: + return "Request body must be valid JSON" + case .responseTimeout(let summary): + return "Timed out waiting for ascd response: \(summary)" + } + } + } + + private let credentials: ASCCredentials + private let fileManager = FileManager.default + private let decoder = JSONDecoder() + private let logger = ASCDaemonLogger.shared + private let responseTimeoutSeconds: TimeInterval = 45 + + private var process: Process? + private var stdinHandle: FileHandle? + private var waitTask: Task? + private var stdoutReadHandle: FileHandle? + private var stderrReadHandle: FileHandle? + private var stdoutBuffer = Data() + private var stderrBuffer = Data() + private var pendingResponses: [String: PendingRequest] = [:] + private var recentStderr: [String] = [] + private var requestCounter = 0 + private var sessionOpen = false + + init(credentials: ASCCredentials) { + self.credentials = credentials + Task { + await logger.info("ASCDaemonClient initialized keyId=\(Self.redact(credentials.keyId)) issuerId=\(Self.redact(credentials.issuerId))") + } + } + + deinit { + stdoutReadHandle?.readabilityHandler = nil + stderrReadHandle?.readabilityHandler = nil + waitTask?.cancel() + process?.terminate() + } + + func request( + method: String, + path: String, + headers: [String: String] = [:], + body: Data? = nil, + timeoutMs: Int = 30_000, + expectedStatusCodes: Set = [] + ) async throws -> HTTPResponse { + try await ensureSessionOpen() + + var params: [String: Any] = [ + "method": method, + "path": path, + ] + if !headers.isEmpty { + params["headers"] = headers + } + if timeoutMs > 0 { + params["timeoutMs"] = timeoutMs + } + if let body { + params["body"] = try jsonObject(from: body) + } + + let result: SessionRequestResult = try await send(method: "session.request", params: params) + let response = HTTPResponse( + statusCode: result.statusCode, + headers: result.headers ?? [:], + contentType: result.contentType ?? "", + body: Data((result.body ?? "").utf8) + ) + if (200..<300).contains(response.statusCode) || expectedStatusCodes.contains(response.statusCode) { + await logger.debug("session.request \(method.uppercased()) \(Self.truncate(path)) -> \(response.statusCode) bytes=\(response.body.count)") + } else { + let bodySnippet = Self.truncate(String(decoding: response.body, as: UTF8.self), limit: 1200) + await logger.error("session.request \(method.uppercased()) \(Self.truncate(path)) -> \(response.statusCode) contentType=\(response.contentType) body=\(bodySnippet)") + } + return response + } + + func cliExec(args: [String]) async throws -> (exitCode: Int, stdout: String, stderr: String) { + struct CLIExecResult: Decodable { + let exitCode: Int + let stdout: String? + let stderr: String? + } + + try await ensureProcessRunning() + let result: CLIExecResult = try await send(method: "cli.exec", params: ["args": args]) + return (result.exitCode, result.stdout ?? "", result.stderr ?? "") + } + + private func ensureSessionOpen() async throws { + try await ensureProcessRunning() + guard !sessionOpen else { return } + _ = try await send(method: "session.open", params: nil) as SessionOpenResult + sessionOpen = true + await logger.info("ascd session opened") + } + + private func ensureProcessRunning() async throws { + if let process, process.isRunning, stdinHandle != nil { + return + } + try startProcess() + } + + private func startProcess() throws { + let executablePath = try resolveExecutablePath() + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [executablePath] + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + process.environment = helperEnvironment() + + do { + try process.run() + } catch { + Task { + await logger.error("Failed to launch ascd at \(executablePath): \(error.localizedDescription)") + } + throw Error.helperLaunchFailed("Failed to launch ascd at \(executablePath): \(error.localizedDescription)") + } + + self.process = process + self.stdinHandle = stdinPipe.fileHandleForWriting + self.stdoutReadHandle = stdoutPipe.fileHandleForReading + self.stderrReadHandle = stderrPipe.fileHandleForReading + self.sessionOpen = false + self.recentStderr = [] + self.stdoutBuffer = Data() + self.stderrBuffer = Data() + + Task { + await logger.info("Started ascd pid=\(process.processIdentifier) path=\(executablePath)") + } + + stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + Task { + await self?.handlePipeData(data, isStdout: true) + } + } + + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + Task { + await self?.handlePipeData(data, isStdout: false) + } + } + + waitTask = Task { + await withCheckedContinuation { (continuation: CheckedContinuation) in + process.terminationHandler = { _ in + continuation.resume() + } + } + await self.handleProcessExit(status: process.terminationStatus) + } + } + + private func resolveExecutablePath() throws -> String { + let candidates = helperExecutableCandidates() + + for candidate in candidates where !candidate.isEmpty { + if fileManager.isExecutableFile(atPath: candidate) { + Task { + await logger.debug("Resolved ascd executable path: \(candidate)") + } + return candidate + } + } + + let searched = candidates.isEmpty ? "(no candidates)" : candidates.joined(separator: ", ") + Task { + await logger.error("ascd executable not found. searched=\(searched)") + } + throw Error.helperNotFound( + "ascd not found. Set BLITZ_ASCD_PATH, install ascd on PATH, or use a bundled helper. Searched: \(searched)" + ) + } + + private func helperExecutableCandidates() -> [String] { + var candidates: [String] = [] + var seen = Set() + + func appendCandidate(_ rawValue: String?) { + guard let rawValue else { return } + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + let expanded = NSString(string: trimmed).expandingTildeInPath + let normalized: String + if expanded.hasPrefix("/") { + normalized = URL(fileURLWithPath: expanded).standardizedFileURL.path + } else { + normalized = expanded + } + + guard !normalized.isEmpty, seen.insert(normalized).inserted else { return } + candidates.append(normalized) + } + + appendCandidate(ProcessInfo.processInfo.environment["BLITZ_ASCD_PATH"]) + appendCandidate(Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/ascd").path) + appendCandidate(Bundle.main.bundleURL.appendingPathComponent("ascd").path) + appendCandidate(Bundle.main.executableURL?.deletingLastPathComponent().appendingPathComponent("ascd").path) + appendCandidate(Bundle.main.resourceURL?.appendingPathComponent("ascd").path) + appendCandidate(fileManager.homeDirectoryForCurrentUser.appendingPathComponent(".blitz/ascd").path) + appendCandidate(fileManager.homeDirectoryForCurrentUser.appendingPathComponent(".local/bin/ascd").path) + appendCandidate("/opt/homebrew/bin/ascd") + appendCandidate("/usr/local/bin/ascd") + appendCandidate("/opt/local/bin/ascd") + + let pathEntries = (ProcessInfo.processInfo.environment["PATH"] ?? "") + .split(separator: ":") + .map(String.init) + for entry in pathEntries { + appendCandidate(URL(fileURLWithPath: entry).appendingPathComponent("ascd").path) + } + + appendCandidate( + fileManager.homeDirectoryForCurrentUser + .appendingPathComponent("superapp/asc-cli/forks/App-Store-Connect-CLI-helper/build/ascd").path + ) + + return candidates + } + + private func helperEnvironment() -> [String: String] { + var environment = ProcessInfo.processInfo.environment + environment["ASC_KEY_ID"] = credentials.keyId + environment["ASC_ISSUER_ID"] = credentials.issuerId + environment["ASC_PRIVATE_KEY"] = credentials.privateKey + environment["ASC_PRIVATE_KEY_PATH"] = nil + environment["ASC_PRIVATE_KEY_B64"] = nil + environment["ASC_BYPASS_KEYCHAIN"] = "1" + if environment["ASC_DEBUG"]?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true { + environment["ASC_DEBUG"] = "api" + } + + let isolatedConfigPath = fileManager.temporaryDirectory + .appendingPathComponent("blitz-ascd-\(UUID().uuidString).json") + try? fileManager.removeItem(at: isolatedConfigPath) + environment["ASC_CONFIG_PATH"] = isolatedConfigPath.path + Task { + await logger.debug( + "Prepared ascd environment keyId=\(Self.redact(credentials.keyId)) " + + "issuerId=\(Self.redact(credentials.issuerId)) " + + "configPath=\(isolatedConfigPath.path) " + + "ascDebug=\(environment["ASC_DEBUG"] ?? "")" + ) + } + return environment + } + + private func handlePipeData(_ data: Data, isStdout: Bool) async { + if data.isEmpty { + if isStdout { + await flushBufferedPipeLine(isStdout: true) + } else { + await flushBufferedPipeLine(isStdout: false) + } + return + } + + if isStdout { + stdoutBuffer.append(data) + await drainBufferedPipeLines(isStdout: true) + } else { + stderrBuffer.append(data) + await drainBufferedPipeLines(isStdout: false) + } + } + + private func drainBufferedPipeLines(isStdout: Bool) async { + while let lineData = nextBufferedPipeLine(isStdout: isStdout) { + if let line = String(data: lineData, encoding: .utf8) { + if isStdout { + await handleStdoutLine(line) + } else { + await handleStderrLine(line) + } + } else { + await logger.error("Received non-UTF8 pipe output: \(lineData.count) bytes") + } + } + } + + private func flushBufferedPipeLine(isStdout: Bool) async { + let buffer = isStdout ? stdoutBuffer : stderrBuffer + guard !buffer.isEmpty else { return } + + if isStdout { + stdoutBuffer.removeAll(keepingCapacity: false) + } else { + stderrBuffer.removeAll(keepingCapacity: false) + } + + if let line = String(data: buffer, encoding: .utf8) { + if isStdout { + await handleStdoutLine(line) + } else { + await handleStderrLine(line) + } + } else { + await logger.error("Received trailing non-UTF8 pipe output: \(buffer.count) bytes") + } + } + + private func nextBufferedPipeLine(isStdout: Bool) -> Data? { + let newline: UInt8 = 0x0A + + if isStdout { + guard let newlineIndex = stdoutBuffer.firstIndex(of: newline) else { return nil } + let line = stdoutBuffer.prefix(upTo: newlineIndex).filter { $0 != 0x0D } + stdoutBuffer.removeSubrange(...newlineIndex) + return Data(line) + } + + guard let newlineIndex = stderrBuffer.firstIndex(of: newline) else { return nil } + let line = stderrBuffer.prefix(upTo: newlineIndex).filter { $0 != 0x0D } + stderrBuffer.removeSubrange(...newlineIndex) + return Data(line) + } + + private func handleStdoutLine(_ line: String) async { + guard let data = line.data(using: .utf8) else { + await logger.error("Received non-UTF8 stdout from ascd") + return + } + + let metadata = extractResponseMetadata(from: data) + guard let id = metadata.id else { + await logger.error("Received stdout line without response id: \(Self.truncate(line, limit: 1200))") + return + } + + guard let pending = pendingResponses.removeValue(forKey: id) else { + await logger.error("Received unmatched response id=\(id) summary=\(metadata.summary) raw=\(Self.truncate(line, limit: 1200))") + return + } + + let elapsed = Date().timeIntervalSince(pending.startedAt) + await logger.debug("<- [\(id)] \(pending.summary) response=\(metadata.summary) elapsed=\(String(format: "%.3f", elapsed))s") + pending.continuation.resume(returning: data) + } + + private func handleStderrLine(_ line: String) async { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + recentStderr.append(trimmed) + if recentStderr.count > 30 { + recentStderr.removeFirst(recentStderr.count - 30) + } + await logger.error("ascd stderr: \(trimmed)") + } + + private func handleProcessExit(status: Int32) async { + let message = recentStderr.joined(separator: "\n") + let error = Error.processExited(status, message) + await logger.error("ascd exited status=\(status) stderr=\(Self.truncate(message, limit: 1200))") + + for (_, pending) in pendingResponses { + pending.continuation.resume(throwing: error) + } + pendingResponses.removeAll() + + process = nil + stdinHandle = nil + sessionOpen = false + stdoutReadHandle?.readabilityHandler = nil + stderrReadHandle?.readabilityHandler = nil + waitTask?.cancel() + stdoutReadHandle = nil + stderrReadHandle = nil + stdoutBuffer = Data() + stderrBuffer = Data() + waitTask = nil + } + + private func send(method: String, params: [String: Any]?, as type: Result.Type = Result.self) async throws -> Result { + try await ensureProcessRunning() + + requestCounter += 1 + let id = "ascd-\(requestCounter)" + let summary = Self.requestSummary(method: method, params: params) + + var request: [String: Any] = [ + "id": id, + "method": method, + ] + if let params { + request["params"] = params + } + + let requestData = try JSONSerialization.data(withJSONObject: request, options: []) + await logger.debug("-> [\(id)] \(summary)") + + let timeoutSeconds = self.responseTimeoutSeconds + let timeoutTask = Task { [weak self] in + do { + try await Task.sleep(for: .seconds(timeoutSeconds)) + await self?.failPendingRequest(id: id, error: Error.responseTimeout(summary)) + } catch {} + } + + let rawResponse = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + pendingResponses[id] = PendingRequest( + continuation: continuation, + summary: summary, + startedAt: Date() + ) + do { + try writeRequestLine(requestData) + } catch { + pendingResponses.removeValue(forKey: id) + continuation.resume(throwing: error) + } + } + timeoutTask.cancel() + + let response: DaemonResponse + do { + response = try decoder.decode(DaemonResponse.self, from: rawResponse) + } catch { + await logger.error("Failed to decode response for [\(id)] \(summary): \(error.localizedDescription) raw=\(Self.truncate(String(decoding: rawResponse, as: UTF8.self), limit: 1200))") + throw error + } + if let error = response.error { + await logger.error("Helper returned error for [\(id)] \(summary): code=\(error.code) message=\(error.message) data=\(error.data ?? "")") + throw Error.helperError(error.message, error.data) + } + guard let result = response.result else { + await logger.error("Missing result payload for [\(id)] \(summary)") + throw Error.invalidResponse + } + return result + } + + private func failPendingRequest(id: String, error: Swift.Error) async { + guard let pending = pendingResponses.removeValue(forKey: id) else { return } + await logger.error("Timed out waiting for [\(id)] \(pending.summary)") + pending.continuation.resume(throwing: error) + await restartProcessAfterTimeout(id: id, summary: pending.summary) + } + + private func restartProcessAfterTimeout(id: String, summary: String) async { + guard let process else { return } + await logger.error("Terminating ascd after timeout for [\(id)] \(summary) pid=\(process.processIdentifier)") + sessionOpen = false + stdinHandle = nil + process.terminate() + } + + private func writeRequestLine(_ requestData: Data) throws { + guard let stdinHandle else { + throw Error.helperLaunchFailed("ascd stdin is unavailable") + } + var line = requestData + line.append(0x0A) + try stdinHandle.write(contentsOf: line) + } + + private func extractResponseMetadata(from data: Data) -> (id: String?, summary: String) { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return (nil, "invalid-json") + } + + let id = json["id"] as? String + + if let error = json["error"] as? [String: Any] { + let code = error["code"] as? Int ?? 0 + let message = error["message"] as? String ?? "unknown" + return (id, "error code=\(code) message=\(Self.truncate(message, limit: 200))") + } + + if let result = json["result"] as? [String: Any] { + if let statusCode = result["statusCode"] as? Int { + let contentType = result["contentType"] as? String ?? "" + return (id, "statusCode=\(statusCode) contentType=\(Self.truncate(contentType, limit: 120))") + } + let keys = result.keys.sorted().joined(separator: ",") + return (id, "result keys=\(keys)") + } + + return (id, "unknown-payload") + } + + private func jsonObject(from data: Data) throws -> Any { + guard !data.isEmpty else { return NSNull() } + return try JSONSerialization.jsonObject(with: data) + } + + private static func requestSummary(method: String, params: [String: Any]?) -> String { + switch method { + case "session.open": + let profile = (params?["profile"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return profile.isEmpty ? "session.open" : "session.open profile=\(profile)" + case "session.request": + let httpMethod = (params?["method"] as? String)?.uppercased() ?? "UNKNOWN" + let path = truncate(params?["path"] as? String ?? "") + let timeoutDescription: String + if let timeoutMs = params?["timeoutMs"] as? Int, timeoutMs > 0 { + timeoutDescription = " timeoutMs=\(timeoutMs)" + } else { + timeoutDescription = "" + } + let bodyDescription: String + if let body = params?["body"] { + if let data = try? JSONSerialization.data(withJSONObject: body) { + bodyDescription = " bodyBytes=\(data.count)" + } else { + bodyDescription = " body=unserializable" + } + } else { + bodyDescription = "" + } + return "session.request \(httpMethod) \(path)\(timeoutDescription)\(bodyDescription)" + case "cli.exec": + let args = params?["args"] as? [String] ?? [] + return "cli.exec args=\(truncate(args.joined(separator: " "), limit: 200))" + default: + return params == nil ? method : "\(method) params" + } + } + + private static func truncate(_ value: String, limit: Int = 300) -> String { + let normalized = value.replacingOccurrences(of: "\n", with: "\\n") + if normalized.count <= limit { + return normalized + } + return String(normalized.prefix(limit)) + "..." + } + + private static func redact(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 8 else { + return String(repeating: "*", count: max(trimmed.count, 1)) + } + let prefix = trimmed.prefix(4) + let suffix = trimmed.suffix(4) + return "\(prefix)...\(suffix)" + } +} diff --git a/src/services/ASCDaemonLogger.swift b/src/services/ASCDaemonLogger.swift new file mode 100644 index 0000000..bea7fe2 --- /dev/null +++ b/src/services/ASCDaemonLogger.swift @@ -0,0 +1,52 @@ +import Foundation + +actor ASCDaemonLogger { + static let shared = ASCDaemonLogger() + + private let fileManager = FileManager.default + private let logURL: URL + private let formatter: ISO8601DateFormatter + + init() { + let home = fileManager.homeDirectoryForCurrentUser + self.logURL = home.appendingPathComponent(".blitz/logs/ascd-client.log") + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.formatter = formatter + } + + func info(_ message: String) async { + await write(level: "INFO", message: message) + } + + func error(_ message: String) async { + await write(level: "ERROR", message: message) + } + + func debug(_ message: String) async { + await write(level: "DEBUG", message: message) + } + + private func write(level: String, message: String) async { + let directoryURL = logURL.deletingLastPathComponent() + try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + + let timestamp = formatter.string(from: Date()) + let line = "[\(timestamp)] [\(level)] \(message)\n" + guard let data = line.data(using: .utf8) else { return } + + if fileManager.fileExists(atPath: logURL.path) { + do { + let handle = try FileHandle(forWritingTo: logURL) + try handle.seekToEnd() + try handle.write(contentsOf: data) + try handle.close() + } catch { + try? data.write(to: logURL, options: .atomic) + } + } else { + try? data.write(to: logURL, options: .atomic) + } + } +} diff --git a/src/services/ASCError.swift b/src/services/ASCError.swift new file mode 100644 index 0000000..4b39a4a --- /dev/null +++ b/src/services/ASCError.swift @@ -0,0 +1,55 @@ +import Foundation + +enum ASCError: LocalizedError { + case invalidURL + case notFound(String) + case httpError(Int, String) + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid URL" + case .notFound(let what): + return "\(what) not found" + case .httpError(let code, let body): + return "HTTP \(code): \(Self.parseErrorMessages(body))" + } + } + + var isConflict: Bool { + if case .httpError(409, _) = self { return true } + return false + } + + private static func parseErrorMessages(_ body: String) -> String { + guard let data = body.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errors = json["errors"] as? [[String: Any]] else { + return String(body.prefix(300)) + } + + var messages: [String] = [] + for error in errors { + if let detail = error["detail"] as? String { + messages.append(detail) + } else if let title = error["title"] as? String { + messages.append(title) + } + + if let meta = error["meta"] as? [String: Any], + let associatedErrors = meta["associatedErrors"] as? [String: [[String: Any]]] { + for (_, subErrors) in associatedErrors { + for subError in subErrors { + if let detail = subError["detail"] as? String { + messages.append(detail) + } else if let title = subError["title"] as? String { + messages.append(title) + } + } + } + } + } + + return messages.isEmpty ? String(body.prefix(300)) : messages.joined(separator: "\n") + } +} diff --git a/src/services/AppStoreConnectService.swift b/src/services/ASCService.swift similarity index 83% rename from src/services/AppStoreConnectService.swift rename to src/services/ASCService.swift index f6f0610..05c9de1 100644 --- a/src/services/AppStoreConnectService.swift +++ b/src/services/ASCService.swift @@ -1,187 +1,94 @@ import Foundation -import CryptoKit - -// MARK: - Error - -enum ASCError: LocalizedError { - case invalidURL - case notFound(String) - case httpError(Int, String) - - var errorDescription: String? { - switch self { - case .invalidURL: return "Invalid URL" - case .notFound(let what): return "\(what) not found" - case .httpError(let code, let body): return "HTTP \(code): \(Self.parseErrorMessages(body))" - } - } - - var isConflict: Bool { - if case .httpError(409, _) = self { return true } - return false - } - - /// Extract human-readable error messages from ASC JSON error responses. - private static func parseErrorMessages(_ body: String) -> String { - guard let data = body.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let errors = json["errors"] as? [[String: Any]] else { - return String(body.prefix(300)) - } - var messages: [String] = [] - for error in errors { - if let detail = error["detail"] as? String { - messages.append(detail) - } else if let title = error["title"] as? String { - messages.append(title) - } - // Parse associatedErrors for richer context - if let meta = error["meta"] as? [String: Any], - let assoc = meta["associatedErrors"] as? [String: [[String: Any]]] { - for (_, subErrors) in assoc { - for sub in subErrors { - if let detail = sub["detail"] as? String { - messages.append(detail) - } else if let title = sub["title"] as? String { - messages.append(title) - } - } - } - } - } - return messages.isEmpty ? String(body.prefix(300)) : messages.joined(separator: "\n") - } -} - -// MARK: - Service final class AppStoreConnectService { - private let credentials: ASCCredentials - private var cachedToken: String? - private var tokenExpiry: Date? - - private let baseHost = "api.appstoreconnect.apple.com" + private let client: ASCDaemonClient private let session = URLSession.shared init(credentials: ASCCredentials) { - self.credentials = credentials + self.client = ASCDaemonClient(credentials: credentials) } - // MARK: - JWT - - private func generateJWT() throws -> String { - let now = Date() - let expiry = now.addingTimeInterval(1200) - - let header: [String: Any] = [ - "alg": "ES256", - "kid": credentials.keyId, - "typ": "JWT" - ] - let payload: [String: Any] = [ - "iss": credentials.issuerId, - "iat": Int(now.timeIntervalSince1970), - "exp": Int(expiry.timeIntervalSince1970), - "aud": "appstoreconnect-v1" - ] - - let headerData = try JSONSerialization.data(withJSONObject: header) - let payloadData = try JSONSerialization.data(withJSONObject: payload) - let headerEncoded = base64urlEncode(headerData) - let payloadEncoded = base64urlEncode(payloadData) - let message = "\(headerEncoded).\(payloadEncoded)" - - let privateKey = try P256.Signing.PrivateKey(pemRepresentation: credentials.privateKey) - let signature = try privateKey.signature(for: Data(message.utf8)) - let signatureEncoded = base64urlEncode(signature.rawRepresentation) + // MARK: - HTTP - tokenExpiry = expiry - return "\(message).\(signatureEncoded)" - } + private func resolvedPath(_ rawPath: String, queryItems: [URLQueryItem] = []) throws -> String { + let trimmedPath = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPath.isEmpty else { throw ASCError.invalidURL } - private func validToken() throws -> String { - if let token = cachedToken, let expiry = tokenExpiry, - Date().addingTimeInterval(60) < expiry { - return token + if trimmedPath.hasPrefix("http://") || trimmedPath.hasPrefix("https://") { + guard var components = URLComponents(string: trimmedPath) else { + throw ASCError.invalidURL + } + if !queryItems.isEmpty { + components.queryItems = (components.queryItems ?? []) + queryItems + } + guard let path = components.string else { throw ASCError.invalidURL } + return path } - let token = try generateJWT() - cachedToken = token - return token - } - - private func base64urlEncode(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .trimmingCharacters(in: CharacterSet(charactersIn: "=")) - } - - // MARK: - HTTP - private func makeRequest(path: String, queryItems: [URLQueryItem] = []) throws -> URLRequest { var components = URLComponents() - components.scheme = "https" - components.host = baseHost - components.path = "/v1/\(path)" + components.path = trimmedPath.hasPrefix("/") ? trimmedPath : "/v1/\(trimmedPath)" if !queryItems.isEmpty { components.queryItems = queryItems } - guard let url = components.url else { throw ASCError.invalidURL } - - var request = URLRequest(url: url) - request.timeoutInterval = 30 - request.setValue("Bearer \(try validToken())", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Accept") - return request + guard let path = components.string else { throw ASCError.invalidURL } + return path } private func get(_ path: String, queryItems: [URLQueryItem] = [], as type: T.Type) async throws -> T { - let request = try makeRequest(path: path, queryItems: queryItems) - let (data, response) = try await session.data(for: request) - - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - let body = String(data: data, encoding: .utf8) ?? "" - throw ASCError.httpError(http.statusCode, body) + let response = try await client.request( + method: "GET", + path: try resolvedPath(path, queryItems: queryItems), + headers: ["Accept": "application/json"] + ) + if !(200..<300).contains(response.statusCode) { + let body = String(data: response.body, encoding: .utf8) ?? "" + throw ASCError.httpError(response.statusCode, body) } - - return try JSONDecoder().decode(T.self, from: data) + return try JSONDecoder().decode(T.self, from: response.body) } private func patch(path: String, body: [String: Any]) async throws { - var request = try makeRequest(path: path) - request.httpMethod = "PATCH" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await session.data(for: request) - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - let body = String(data: data, encoding: .utf8) ?? "" - throw ASCError.httpError(http.statusCode, body) + let response = try await client.request( + method: "PATCH", + path: try resolvedPath(path), + headers: [ + "Accept": "application/json", + "Content-Type": "application/json", + ], + body: try JSONSerialization.data(withJSONObject: body) + ) + if !(200..<300).contains(response.statusCode) { + let body = String(data: response.body, encoding: .utf8) ?? "" + throw ASCError.httpError(response.statusCode, body) } } private func post(path: String, body: [String: Any]) async throws -> Data { - var request = try makeRequest(path: path) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await session.data(for: request) - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - let body = String(data: data, encoding: .utf8) ?? "" - throw ASCError.httpError(http.statusCode, body) + let response = try await client.request( + method: "POST", + path: try resolvedPath(path), + headers: [ + "Accept": "application/json", + "Content-Type": "application/json", + ], + body: try JSONSerialization.data(withJSONObject: body) + ) + if !(200..<300).contains(response.statusCode) { + let body = String(data: response.body, encoding: .utf8) ?? "" + throw ASCError.httpError(response.statusCode, body) } - return data + return response.body } private func delete(path: String) async throws { - var request = try makeRequest(path: path) - request.httpMethod = "DELETE" - - let (data, response) = try await session.data(for: request) - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - let body = String(data: data, encoding: .utf8) ?? "" - throw ASCError.httpError(http.statusCode, body) + let response = try await client.request( + method: "DELETE", + path: try resolvedPath(path), + headers: ["Accept": "application/json"] + ) + if !(200..<300).contains(response.statusCode) { + let body = String(data: response.body, encoding: .utf8) ?? "" + throw ASCError.httpError(response.statusCode, body) } } @@ -189,73 +96,20 @@ final class AppStoreConnectService { try await delete(path: "appScreenshots/\(screenshotId)") } - // MARK: - Versioned-Path HTTP Helpers (for /v2, /v3 endpoints) - - private func makeRequest(fullPath: String, queryItems: [URLQueryItem] = []) throws -> URLRequest { - var components = URLComponents() - components.scheme = "https" - components.host = baseHost - components.path = fullPath - if !queryItems.isEmpty { - components.queryItems = queryItems - } - guard let url = components.url else { throw ASCError.invalidURL } - - var request = URLRequest(url: url) - request.timeoutInterval = 30 - request.setValue("Bearer \(try validToken())", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Accept") - return request - } - private func get(fullPath: String, queryItems: [URLQueryItem] = [], as type: T.Type) async throws -> T { - let request = try makeRequest(fullPath: fullPath, queryItems: queryItems) - let (data, response) = try await session.data(for: request) - - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - let body = String(data: data, encoding: .utf8) ?? "" - throw ASCError.httpError(http.statusCode, body) - } - - return try JSONDecoder().decode(T.self, from: data) + try await get(fullPath, queryItems: queryItems, as: type) } private func post(fullPath: String, body: [String: Any]) async throws -> Data { - var request = try makeRequest(fullPath: fullPath) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await session.data(for: request) - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - let body = String(data: data, encoding: .utf8) ?? "" - throw ASCError.httpError(http.statusCode, body) - } - return data + try await post(path: fullPath, body: body) } private func patch(fullPath: String, body: [String: Any]) async throws { - var request = try makeRequest(fullPath: fullPath) - request.httpMethod = "PATCH" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, response) = try await session.data(for: request) - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - let body = String(data: data, encoding: .utf8) ?? "" - throw ASCError.httpError(http.statusCode, body) - } + try await patch(path: fullPath, body: body) } private func delete(fullPath: String) async throws { - var request = try makeRequest(fullPath: fullPath) - request.httpMethod = "DELETE" - - let (data, response) = try await session.data(for: request) - if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - let body = String(data: data, encoding: .utf8) ?? "" - throw ASCError.httpError(http.statusCode, body) - } + try await delete(path: fullPath) } private func upload(url: URL, method: String, headers: [String: String], body: Data) async throws { @@ -615,14 +469,30 @@ final class AppStoreConnectService { "apps/\(appId)/appPriceSchedule", as: ASCSingleResponse.self ) - let prices = try await get( - "appPriceSchedules/\(schedule.data.id)/manualPrices", - queryItems: [ - URLQueryItem(name: "include", value: "appPricePoint"), - URLQueryItem(name: "limit", value: "200") - ], - as: ASCListResponse.self + let pricesResponse = try await client.request( + method: "GET", + path: try resolvedPath( + "appPriceSchedules/\(schedule.data.id)/manualPrices", + queryItems: [ + URLQueryItem(name: "include", value: "appPricePoint"), + URLQueryItem(name: "limit", value: "200") + ] + ), + headers: ["Accept": "application/json"], + expectedStatusCodes: [404] ) + if pricesResponse.statusCode == 404 { + return ASCAppPricingState( + currentPricePointId: nil, + scheduledPricePointId: nil, + scheduledEffectiveDate: nil + ) + } + guard (200..<300).contains(pricesResponse.statusCode) else { + let body = String(data: pricesResponse.body, encoding: .utf8) ?? "" + throw ASCError.httpError(pricesResponse.statusCode, body) + } + let prices = try JSONDecoder().decode(ASCListResponse.self, from: pricesResponse.body) let today = Self.isoDateString(referenceDate) let sortedByStartDesc = prices.data.sorted { lhs, rhs in @@ -1351,7 +1221,7 @@ final class AppStoreConnectService { // MARK: - Write: Build Encryption func patchBuildEncryption(buildId: String, usesNonExemptEncryption: Bool) async throws { - let body: [String: Any] = [ + let requestBody: [String: Any] = [ "data": [ "type": "builds", "id": buildId, @@ -1360,7 +1230,29 @@ final class AppStoreConnectService { ] ] ] - try await patch(path: "builds/\(buildId)", body: body) + let response = try await client.request( + method: "PATCH", + path: try resolvedPath("builds/\(buildId)"), + headers: [ + "Accept": "application/json", + "Content-Type": "application/json", + ], + body: try JSONSerialization.data(withJSONObject: requestBody), + expectedStatusCodes: [409] + ) + if (200..<300).contains(response.statusCode) { + return + } + if response.statusCode == 409 { + let responseBody = String(data: response.body, encoding: .utf8) ?? "" + if responseBody.contains("You cannot update when the value is already set.") + || responseBody.contains("/data/attributes/usesNonExemptEncryption") { + return + } + throw ASCError.httpError(response.statusCode, responseBody) + } + let responseBody = String(data: response.body, encoding: .utf8) ?? "" + throw ASCError.httpError(response.statusCode, responseBody) } // MARK: - Fetch: AppInfo @@ -1423,11 +1315,23 @@ final class AppStoreConnectService { as: ASCSingleResponse.self ) // Then check if it has manual prices configured - let prices = try await get( - "appPriceSchedules/\(schedule.data.id)/manualPrices", - queryItems: [URLQueryItem(name: "limit", value: "1")], - as: ASCListResponse.self + let pricesResponse = try await client.request( + method: "GET", + path: try resolvedPath( + "appPriceSchedules/\(schedule.data.id)/manualPrices", + queryItems: [URLQueryItem(name: "limit", value: "1")] + ), + headers: ["Accept": "application/json"], + expectedStatusCodes: [404] ) + if pricesResponse.statusCode == 404 { + return false + } + guard (200..<300).contains(pricesResponse.statusCode) else { + let body = String(data: pricesResponse.body, encoding: .utf8) ?? "" + throw ASCError.httpError(pricesResponse.statusCode, body) + } + let prices = try JSONDecoder().decode(ASCListResponse.self, from: pricesResponse.body) return !prices.data.isEmpty } catch { return false @@ -1584,10 +1488,9 @@ final class AppStoreConnectService { func fetchReviewSubmissions(appId: String) async throws -> [ASCReviewSubmission] { let resp = try await get("reviewSubmissions", queryItems: [ URLQueryItem(name: "filter[app]", value: appId), - URLQueryItem(name: "sort", value: "-submittedDate"), URLQueryItem(name: "limit", value: "10") ], as: ASCPaginatedResponse.self) - return resp.data + return sortReviewSubmissions(resp.data) } func fetchReviewSubmissionItems(submissionId: String) async throws -> [ASCReviewSubmissionItem] { @@ -1597,6 +1500,46 @@ final class AppStoreConnectService { return resp.data } + private func sortReviewSubmissions(_ submissions: [ASCReviewSubmission]) -> [ASCReviewSubmission] { + let fractionalFormatter = ISO8601DateFormatter() + fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + let standardFormatter = ISO8601DateFormatter() + standardFormatter.formatOptions = [.withInternetDateTime] + + return submissions + .enumerated() + .sorted { lhs, rhs in + let lhsDate = reviewSubmissionDate( + lhs.element.attributes.submittedDate, + fractionalFormatter: fractionalFormatter, + standardFormatter: standardFormatter + ) + let rhsDate = reviewSubmissionDate( + rhs.element.attributes.submittedDate, + fractionalFormatter: fractionalFormatter, + standardFormatter: standardFormatter + ) + + if lhsDate != rhsDate { + return lhsDate > rhsDate + } + return lhs.offset < rhs.offset + } + .map(\.element) + } + + private func reviewSubmissionDate( + _ value: String?, + fractionalFormatter: ISO8601DateFormatter, + standardFormatter: ISO8601DateFormatter + ) -> Date { + guard let value, !value.isEmpty else { return .distantPast } + return fractionalFormatter.date(from: value) + ?? standardFormatter.date(from: value) + ?? .distantPast + } + // MARK: - Submit for Review func submitForReview(appId: String, versionId: String) async throws { @@ -1637,37 +1580,7 @@ final class AppStoreConnectService { } } -// MARK: - Supporting Types for Upload/Submission - -struct ASCPricePoint: Decodable, Identifiable { - let id: String - struct Attributes: Decodable { - let customerPrice: String? - } - let attributes: Attributes -} - -struct ASCScreenshotReservation: Decodable, Identifiable { - let id: String - struct Attributes: Decodable { - let sourceFileChecksum: String? - let uploadOperations: [UploadOperation]? - } - let attributes: Attributes - - struct UploadOperation: Decodable { - let method: String - let url: String - let offset: Int - let length: Int - let requestHeaders: [Header] - - struct Header: Decodable { - let name: String - let value: String - } - } -} +// MARK: - Supporting Types private actor ProgressCounter { private var count = 0 @@ -1676,41 +1589,3 @@ private actor ProgressCounter { return count } } - -struct ASCReviewSubmission: Decodable, Identifiable { - let id: String - struct Attributes: Decodable { - let state: String? - let submittedDate: String? - let platform: String? - } - let attributes: Attributes -} - -struct ASCReviewSubmissionItem: Decodable, Identifiable { - let id: String - struct Attributes: Decodable { - let state: String? - let resolved: Bool? - let createdDate: String? - } - let attributes: Attributes - let relationships: Relationships? - - struct Relationships: Decodable { - let appStoreVersion: ToOneRelationship? - - struct ToOneRelationship: Decodable { - let data: ResourceIdentifier? - } - - struct ResourceIdentifier: Decodable { - let type: String - let id: String - } - } - - var appStoreVersionId: String? { - relationships?.appStoreVersion?.data?.id - } -} From 1d207a2a66e20a417d343fbce1a1a7c183efe177 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Tue, 24 Mar 2026 15:02:39 -0700 Subject: [PATCH 15/51] in auto-update skip ruby/python/idb update steps --- scripts/pkg-scripts/postinstall | 332 ++++++++++++++------------- scripts/pkg-scripts/preinstall | 4 - src/services/AutoUpdateService.swift | 4 +- 3 files changed, 173 insertions(+), 167 deletions(-) diff --git a/scripts/pkg-scripts/postinstall b/scripts/pkg-scripts/postinstall index c1053e4..ef33b85 100755 --- a/scripts/pkg-scripts/postinstall +++ b/scripts/pkg-scripts/postinstall @@ -196,211 +196,221 @@ echo "TIMING: create user dirs took $(( $(date +%s) - T_STEP ))s" >> "$LOG" # ============================================================================ T_RUBY_START=$(date +%s) -RV_VERSION="v0.4.3" -BLITZ_BIN_DIR="$CONSOLE_HOME/.blitz/rubies" -RV_BIN="$BLITZ_BIN_DIR/rv" -BLITZ_RUBIES_DIR="$CONSOLE_HOME/.blitz/rubies" -POD_PATH="" - -# Clean up old Ruby install from previous Blitz versions -OLD_RUBY_DIR="$CONSOLE_HOME/.blitz/ruby" -if [ -d "$OLD_RUBY_DIR" ] && [ ! -L "$OLD_RUBY_DIR" ]; then - echo "Cleaning up old Ruby install at $OLD_RUBY_DIR..." >> "$LOG" - rm -rf "$OLD_RUBY_DIR" -fi - -# [1] Download rv binary if not present or wrong version -RV_NEEDED=true -if [ -x "$RV_BIN" ]; then - EXISTING_RV=$("$RV_BIN" --version 2>/dev/null || echo "") - if echo "$EXISTING_RV" | grep -q "${RV_VERSION#v}"; then - echo "OK: rv $RV_VERSION already installed at $RV_BIN" >> "$LOG" - RV_NEEDED=false - else - echo "rv version mismatch ($EXISTING_RV vs $RV_VERSION), updating..." >> "$LOG" +if [ "${BLITZ_UPDATE_CONTEXT:-}" = "auto-update" ]; then + echo "Skipping Ruby and CocoaPods bootstrap during auto-update" >> "$LOG" + echo "TIMING: ruby + cocoapods took 0s" >> "$LOG" +else + RV_VERSION="v0.4.3" + BLITZ_BIN_DIR="$CONSOLE_HOME/.blitz/rubies" + RV_BIN="$BLITZ_BIN_DIR/rv" + BLITZ_RUBIES_DIR="$CONSOLE_HOME/.blitz/rubies" + POD_PATH="" + + # Clean up old Ruby install from previous Blitz versions + OLD_RUBY_DIR="$CONSOLE_HOME/.blitz/ruby" + if [ -d "$OLD_RUBY_DIR" ] && [ ! -L "$OLD_RUBY_DIR" ]; then + echo "Cleaning up old Ruby install at $OLD_RUBY_DIR..." >> "$LOG" + rm -rf "$OLD_RUBY_DIR" fi -fi - -if [ "$RV_NEEDED" = true ]; then - echo "Installing rv $RV_VERSION..." >> "$LOG" - show_progress "Installing Ruby version manager..." - ARCH=$(uname -m) - if [ "$ARCH" = "arm64" ]; then - RV_URL="https://github.com/spinel-coop/rv/releases/download/$RV_VERSION/rv-aarch64-apple-darwin.tar.xz" - else - RV_URL="https://github.com/spinel-coop/rv/releases/download/$RV_VERSION/rv-x86_64-apple-darwin.tar.xz" + # [1] Download rv binary if not present or wrong version + RV_NEEDED=true + if [ -x "$RV_BIN" ]; then + EXISTING_RV=$("$RV_BIN" --version 2>/dev/null || echo "") + if echo "$EXISTING_RV" | grep -q "${RV_VERSION#v}"; then + echo "OK: rv $RV_VERSION already installed at $RV_BIN" >> "$LOG" + RV_NEEDED=false + else + echo "rv version mismatch ($EXISTING_RV vs $RV_VERSION), updating..." >> "$LOG" + fi fi - RV_TMP="/tmp/blitz-rv-$$" - rm -rf "$RV_TMP" - mkdir -p "$RV_TMP" "$BLITZ_BIN_DIR" - - if curl -fsSL "$RV_URL" -o "$RV_TMP/rv.tar.xz" 2>> "$LOG"; then - tar -xJf "$RV_TMP/rv.tar.xz" -C "$RV_TMP" 2>> "$LOG" - cp "$RV_TMP"/rv-*/rv "$RV_BIN" 2>> "$LOG" - chmod 755 "$RV_BIN" - if [ "$IS_ROOT" = true ]; then chown "$CONSOLE_USER" "$BLITZ_BIN_DIR" "$RV_BIN"; fi - echo "rv installed to $RV_BIN" >> "$LOG" - else - fail "Failed to download rv from $RV_URL. Check /tmp/blitz_install.log for details." - fi + if [ "$RV_NEEDED" = true ]; then + echo "Installing rv $RV_VERSION..." >> "$LOG" + show_progress "Installing Ruby version manager..." - rm -rf "$RV_TMP" -fi + ARCH=$(uname -m) + if [ "$ARCH" = "arm64" ]; then + RV_URL="https://github.com/spinel-coop/rv/releases/download/$RV_VERSION/rv-aarch64-apple-darwin.tar.xz" + else + RV_URL="https://github.com/spinel-coop/rv/releases/download/$RV_VERSION/rv-x86_64-apple-darwin.tar.xz" + fi -# [2] Install Ruby via rv -RUBY_VERSION="3.4" -mkdir -p "$BLITZ_RUBIES_DIR" -if [ "$IS_ROOT" = true ]; then chown "$CONSOLE_USER" "$BLITZ_RUBIES_DIR" 2>/dev/null || true; fi + RV_TMP="/tmp/blitz-rv-$$" + rm -rf "$RV_TMP" + mkdir -p "$RV_TMP" "$BLITZ_BIN_DIR" -BLITZ_RUBY=$(run_as_user \ - env "HOME=$CONSOLE_HOME" "RUBIES_PATH=$BLITZ_RUBIES_DIR" \ - "$RV_BIN" ruby find "$RUBY_VERSION" 2>/dev/null || true) + if curl -fsSL "$RV_URL" -o "$RV_TMP/rv.tar.xz" 2>> "$LOG"; then + tar -xJf "$RV_TMP/rv.tar.xz" -C "$RV_TMP" 2>> "$LOG" + cp "$RV_TMP"/rv-*/rv "$RV_BIN" 2>> "$LOG" + chmod 755 "$RV_BIN" + if [ "$IS_ROOT" = true ]; then chown "$CONSOLE_USER" "$BLITZ_BIN_DIR" "$RV_BIN"; fi + echo "rv installed to $RV_BIN" >> "$LOG" + else + fail "Failed to download rv from $RV_URL. Check /tmp/blitz_install.log for details." + fi -if [ -n "$BLITZ_RUBY" ] && [ -x "$BLITZ_RUBY" ]; then - echo "OK: Ruby already installed at $BLITZ_RUBY" >> "$LOG" -else - show_progress "Installing Ruby..." - echo "Installing Ruby $RUBY_VERSION via rv..." >> "$LOG" - if run_as_user \ - env "HOME=$CONSOLE_HOME" "RUBIES_PATH=$BLITZ_RUBIES_DIR" \ - "$RV_BIN" ruby install "$RUBY_VERSION" >> "$LOG" 2>&1; then - echo "Ruby installed via rv" >> "$LOG" - else - fail "Failed to install Ruby via rv. Check /tmp/blitz_install.log for details." + rm -rf "$RV_TMP" fi + # [2] Install Ruby via rv + RUBY_VERSION="3.4" + mkdir -p "$BLITZ_RUBIES_DIR" + if [ "$IS_ROOT" = true ]; then chown "$CONSOLE_USER" "$BLITZ_RUBIES_DIR" 2>/dev/null || true; fi + BLITZ_RUBY=$(run_as_user \ env "HOME=$CONSOLE_HOME" "RUBIES_PATH=$BLITZ_RUBIES_DIR" \ "$RV_BIN" ruby find "$RUBY_VERSION" 2>/dev/null || true) -fi - -if [ -z "$BLITZ_RUBY" ] || [ ! -x "$BLITZ_RUBY" ]; then - fail "Ruby was installed but binary not found. Check /tmp/blitz_install.log for details." -fi -echo "Ruby ready at: $BLITZ_RUBY ($($BLITZ_RUBY --version 2>/dev/null))" >> "$LOG" -# Create stable symlink: ~/.blitz/ruby → rv-managed Ruby dir -RUBY_INSTALL_DIR="$(dirname "$(dirname "$BLITZ_RUBY")")" -ln -sfn "$RUBY_INSTALL_DIR" "$CONSOLE_HOME/.blitz/ruby" -if [ "$IS_ROOT" = true ]; then chown -h "$CONSOLE_USER" "$CONSOLE_HOME/.blitz/ruby" 2>/dev/null || true; fi -echo "Symlinked $CONSOLE_HOME/.blitz/ruby -> $RUBY_INSTALL_DIR" >> "$LOG" + if [ -n "$BLITZ_RUBY" ] && [ -x "$BLITZ_RUBY" ]; then + echo "OK: Ruby already installed at $BLITZ_RUBY" >> "$LOG" + else + show_progress "Installing Ruby..." + echo "Installing Ruby $RUBY_VERSION via rv..." >> "$LOG" + if run_as_user \ + env "HOME=$CONSOLE_HOME" "RUBIES_PATH=$BLITZ_RUBIES_DIR" \ + "$RV_BIN" ruby install "$RUBY_VERSION" >> "$LOG" 2>&1; then + echo "Ruby installed via rv" >> "$LOG" + else + fail "Failed to install Ruby via rv. Check /tmp/blitz_install.log for details." + fi -# [3] Install CocoaPods via gem -BLITZ_GEM="$(dirname "$BLITZ_RUBY")/gem" -BLITZ_GEM_BIN="$(dirname "$BLITZ_RUBY")" + BLITZ_RUBY=$(run_as_user \ + env "HOME=$CONSOLE_HOME" "RUBIES_PATH=$BLITZ_RUBIES_DIR" \ + "$RV_BIN" ruby find "$RUBY_VERSION" 2>/dev/null || true) + fi -if [ -x "$BLITZ_GEM_BIN/pod" ]; then - POD_PATH="$BLITZ_GEM_BIN/pod" - echo "OK: CocoaPods already installed at $POD_PATH" >> "$LOG" -else - show_progress "Installing CocoaPods..." - echo "Installing CocoaPods..." >> "$LOG" - if run_as_user \ - "$BLITZ_GEM" install cocoapods --no-document > /dev/null 2>> "$LOG"; then - echo "CocoaPods installed successfully" >> "$LOG" - else - fail "Failed to install CocoaPods. Check /tmp/blitz_install.log for details." + if [ -z "$BLITZ_RUBY" ] || [ ! -x "$BLITZ_RUBY" ]; then + fail "Ruby was installed but binary not found. Check /tmp/blitz_install.log for details." fi + echo "Ruby ready at: $BLITZ_RUBY ($($BLITZ_RUBY --version 2>/dev/null))" >> "$LOG" + + # Create stable symlink: ~/.blitz/ruby → rv-managed Ruby dir + RUBY_INSTALL_DIR="$(dirname "$(dirname "$BLITZ_RUBY")")" + ln -sfn "$RUBY_INSTALL_DIR" "$CONSOLE_HOME/.blitz/ruby" + if [ "$IS_ROOT" = true ]; then chown -h "$CONSOLE_USER" "$CONSOLE_HOME/.blitz/ruby" 2>/dev/null || true; fi + echo "Symlinked $CONSOLE_HOME/.blitz/ruby -> $RUBY_INSTALL_DIR" >> "$LOG" + + # [3] Install CocoaPods via gem + BLITZ_GEM="$(dirname "$BLITZ_RUBY")/gem" + BLITZ_GEM_BIN="$(dirname "$BLITZ_RUBY")" if [ -x "$BLITZ_GEM_BIN/pod" ]; then POD_PATH="$BLITZ_GEM_BIN/pod" + echo "OK: CocoaPods already installed at $POD_PATH" >> "$LOG" else - fail "CocoaPods gem installed but pod binary not found. Check /tmp/blitz_install.log for details." + show_progress "Installing CocoaPods..." + echo "Installing CocoaPods..." >> "$LOG" + if run_as_user \ + "$BLITZ_GEM" install cocoapods --no-document > /dev/null 2>> "$LOG"; then + echo "CocoaPods installed successfully" >> "$LOG" + else + fail "Failed to install CocoaPods. Check /tmp/blitz_install.log for details." + fi + + if [ -x "$BLITZ_GEM_BIN/pod" ]; then + POD_PATH="$BLITZ_GEM_BIN/pod" + else + fail "CocoaPods gem installed but pod binary not found. Check /tmp/blitz_install.log for details." + fi fi -fi -echo "Pod ready at: $POD_PATH ($($POD_PATH --version 2>/dev/null))" >> "$LOG" -echo "TIMING: ruby + cocoapods took $(( $(date +%s) - T_RUBY_START ))s" >> "$LOG" + echo "Pod ready at: $POD_PATH ($($POD_PATH --version 2>/dev/null))" >> "$LOG" + echo "TIMING: ruby + cocoapods took $(( $(date +%s) - T_RUBY_START ))s" >> "$LOG" +fi # ============================================================================ # Install Python 3 + idb (Facebook iOS Development Bridge) # ============================================================================ T_IDB_START=$(date +%s) -BLITZ_PYTHON_DIR="$CONSOLE_HOME/.blitz/python" -BLITZ_IDB_COMPANION_DIR="$CONSOLE_HOME/.blitz/idb-companion" -IDB_COMPANION_BIN="$BLITZ_IDB_COMPANION_DIR/bin/idb_companion" -IDB_CLI_PATH="$BLITZ_PYTHON_DIR/bin/idb" - -if [ -x "$IDB_CLI_PATH" ] && "$IDB_CLI_PATH" --help >/dev/null 2>&1; then - echo "OK: idb already installed at $IDB_CLI_PATH" >> "$LOG" +if [ "${BLITZ_UPDATE_CONTEXT:-}" = "auto-update" ]; then + echo "Skipping Python and idb bootstrap during auto-update" >> "$LOG" + echo "TIMING: python + idb took 0s" >> "$LOG" else - echo "Installing Python 3 + idb..." >> "$LOG" - show_progress "Installing idb (iOS Development Bridge)..." + BLITZ_PYTHON_DIR="$CONSOLE_HOME/.blitz/python" + BLITZ_IDB_COMPANION_DIR="$CONSOLE_HOME/.blitz/idb-companion" + IDB_COMPANION_BIN="$BLITZ_IDB_COMPANION_DIR/bin/idb_companion" + IDB_CLI_PATH="$BLITZ_PYTHON_DIR/bin/idb" - # [1] Download pre-built Python 3 - if [ ! -x "$BLITZ_PYTHON_DIR/bin/python3" ]; then - echo "Downloading pre-built Python 3..." >> "$LOG" - ARCH=$(uname -m) - if [ "$ARCH" = "arm64" ]; then - PYTHON_URL="https://github.com/indygreg/python-build-standalone/releases/download/20241206/cpython-3.12.8+20241206-aarch64-apple-darwin-install_only.tar.gz" + if [ -x "$IDB_CLI_PATH" ] && "$IDB_CLI_PATH" --help >/dev/null 2>&1; then + echo "OK: idb already installed at $IDB_CLI_PATH" >> "$LOG" + else + echo "Installing Python 3 + idb..." >> "$LOG" + show_progress "Installing idb (iOS Development Bridge)..." + + # [1] Download pre-built Python 3 + if [ ! -x "$BLITZ_PYTHON_DIR/bin/python3" ]; then + echo "Downloading pre-built Python 3..." >> "$LOG" + ARCH=$(uname -m) + if [ "$ARCH" = "arm64" ]; then + PYTHON_URL="https://github.com/indygreg/python-build-standalone/releases/download/20241206/cpython-3.12.8+20241206-aarch64-apple-darwin-install_only.tar.gz" + else + PYTHON_URL="https://github.com/indygreg/python-build-standalone/releases/download/20241206/cpython-3.12.8+20241206-x86_64-apple-darwin-install_only.tar.gz" + fi + + PYTHON_TMP="/tmp/blitz-python-$$" + rm -rf "$PYTHON_TMP" + mkdir -p "$PYTHON_TMP" + + if curl -fsSL "$PYTHON_URL" -o "$PYTHON_TMP/python.tar.gz" 2>> "$LOG"; then + mkdir -p "$BLITZ_PYTHON_DIR" + tar -xzf "$PYTHON_TMP/python.tar.gz" -C "$BLITZ_PYTHON_DIR" --strip-components=1 2>> "$LOG" + if [ "$IS_ROOT" = true ]; then chown -R "$CONSOLE_USER" "$BLITZ_PYTHON_DIR"; fi + echo "Python 3 installed to $BLITZ_PYTHON_DIR ($($BLITZ_PYTHON_DIR/bin/python3 --version 2>/dev/null))" >> "$LOG" + else + echo "WARNING: Failed to download Python 3" >> "$LOG" + fi + + rm -rf "$PYTHON_TMP" else - PYTHON_URL="https://github.com/indygreg/python-build-standalone/releases/download/20241206/cpython-3.12.8+20241206-x86_64-apple-darwin-install_only.tar.gz" + echo "OK: Python 3 already installed at $BLITZ_PYTHON_DIR/bin/python3" >> "$LOG" fi - PYTHON_TMP="/tmp/blitz-python-$$" - rm -rf "$PYTHON_TMP" - mkdir -p "$PYTHON_TMP" - - if curl -fsSL "$PYTHON_URL" -o "$PYTHON_TMP/python.tar.gz" 2>> "$LOG"; then - mkdir -p "$BLITZ_PYTHON_DIR" - tar -xzf "$PYTHON_TMP/python.tar.gz" -C "$BLITZ_PYTHON_DIR" --strip-components=1 2>> "$LOG" - if [ "$IS_ROOT" = true ]; then chown -R "$CONSOLE_USER" "$BLITZ_PYTHON_DIR"; fi - echo "Python 3 installed to $BLITZ_PYTHON_DIR ($($BLITZ_PYTHON_DIR/bin/python3 --version 2>/dev/null))" >> "$LOG" + # [2] Download pre-built idb-companion + if [ ! -x "$IDB_COMPANION_BIN" ]; then + echo "Downloading idb-companion..." >> "$LOG" + IDB_COMPANION_URL="https://github.com/facebook/idb/releases/download/v1.1.8/idb-companion.universal.tar.gz" + + IDB_TMP="/tmp/blitz-idb-companion-$$" + rm -rf "$IDB_TMP" + mkdir -p "$IDB_TMP" + + if curl -fsSL "$IDB_COMPANION_URL" -o "$IDB_TMP/idb-companion.tar.gz" 2>> "$LOG"; then + mkdir -p "$BLITZ_IDB_COMPANION_DIR" + tar -xzf "$IDB_TMP/idb-companion.tar.gz" -C "$BLITZ_IDB_COMPANION_DIR" --strip-components=1 2>> "$LOG" + if [ "$IS_ROOT" = true ]; then chown -R "$CONSOLE_USER" "$BLITZ_IDB_COMPANION_DIR"; fi + echo "idb-companion installed to $BLITZ_IDB_COMPANION_DIR" >> "$LOG" + else + echo "WARNING: Failed to download idb-companion" >> "$LOG" + fi + + rm -rf "$IDB_TMP" else - echo "WARNING: Failed to download Python 3" >> "$LOG" + echo "OK: idb-companion already installed at $IDB_COMPANION_BIN" >> "$LOG" fi - rm -rf "$PYTHON_TMP" - else - echo "OK: Python 3 already installed at $BLITZ_PYTHON_DIR/bin/python3" >> "$LOG" - fi - - # [2] Download pre-built idb-companion - if [ ! -x "$IDB_COMPANION_BIN" ]; then - echo "Downloading idb-companion..." >> "$LOG" - IDB_COMPANION_URL="https://github.com/facebook/idb/releases/download/v1.1.8/idb-companion.universal.tar.gz" - - IDB_TMP="/tmp/blitz-idb-companion-$$" - rm -rf "$IDB_TMP" - mkdir -p "$IDB_TMP" - - if curl -fsSL "$IDB_COMPANION_URL" -o "$IDB_TMP/idb-companion.tar.gz" 2>> "$LOG"; then - mkdir -p "$BLITZ_IDB_COMPANION_DIR" - tar -xzf "$IDB_TMP/idb-companion.tar.gz" -C "$BLITZ_IDB_COMPANION_DIR" --strip-components=1 2>> "$LOG" - if [ "$IS_ROOT" = true ]; then chown -R "$CONSOLE_USER" "$BLITZ_IDB_COMPANION_DIR"; fi - echo "idb-companion installed to $BLITZ_IDB_COMPANION_DIR" >> "$LOG" + # [3] pip install fb-idb + if [ -x "$BLITZ_PYTHON_DIR/bin/pip3" ]; then + echo "Installing fb-idb via pip..." >> "$LOG" + if run_as_user \ + "$BLITZ_PYTHON_DIR/bin/pip3" install fb-idb > /dev/null 2>> "$LOG"; then + echo "fb-idb installed successfully" >> "$LOG" + else + echo "WARNING: pip install fb-idb failed" >> "$LOG" + fi else - echo "WARNING: Failed to download idb-companion" >> "$LOG" + echo "WARNING: pip3 not found, cannot install fb-idb" >> "$LOG" fi - rm -rf "$IDB_TMP" - else - echo "OK: idb-companion already installed at $IDB_COMPANION_BIN" >> "$LOG" - fi - - # [3] pip install fb-idb - if [ -x "$BLITZ_PYTHON_DIR/bin/pip3" ]; then - echo "Installing fb-idb via pip..." >> "$LOG" - if run_as_user \ - "$BLITZ_PYTHON_DIR/bin/pip3" install fb-idb > /dev/null 2>> "$LOG"; then - echo "fb-idb installed successfully" >> "$LOG" + if [ -x "$IDB_CLI_PATH" ]; then + echo "OK: idb CLI ready at $IDB_CLI_PATH" >> "$LOG" else - echo "WARNING: pip install fb-idb failed" >> "$LOG" + echo "WARNING: idb CLI not found at $IDB_CLI_PATH after installation" >> "$LOG" fi - else - echo "WARNING: pip3 not found, cannot install fb-idb" >> "$LOG" - fi - - if [ -x "$IDB_CLI_PATH" ]; then - echo "OK: idb CLI ready at $IDB_CLI_PATH" >> "$LOG" - else - echo "WARNING: idb CLI not found at $IDB_CLI_PATH after installation" >> "$LOG" fi + echo "TIMING: python + idb took $(( $(date +%s) - T_IDB_START ))s" >> "$LOG" fi -echo "TIMING: python + idb took $(( $(date +%s) - T_IDB_START ))s" >> "$LOG" # ============================================================================ # Launch Blitz.app diff --git a/scripts/pkg-scripts/preinstall b/scripts/pkg-scripts/preinstall index c5eac06..982fb8b 100755 --- a/scripts/pkg-scripts/preinstall +++ b/scripts/pkg-scripts/preinstall @@ -285,10 +285,6 @@ Xcode -> Settings -> Platforms") fi echo "TIMING: simulator check took $(( $(date +%s) - T_STEP ))s" >> "$LOG" -# ============================================================================ -# Report results -# ============================================================================ - if [ ${#ERRORS[@]} -gt 0 ]; then MSG="Blitz requires the following to be fixed before installation:\n\n" for i in "${!ERRORS[@]}"; do diff --git a/src/services/AutoUpdateService.swift b/src/services/AutoUpdateService.swift index bb12296..5a3aa00 100644 --- a/src/services/AutoUpdateService.swift +++ b/src/services/AutoUpdateService.swift @@ -182,11 +182,11 @@ final class AutoUpdateManager { APP_SRC=$(find \\"$UNZIP_DIR\\" -maxdepth 1 -name '*.app' -type d | head -1); \ if [ -z \\"$APP_SRC\\" ]; then rm -rf \\"$UNZIP_DIR\\"; exit 1; fi; \ PREINSTALL=\\"$APP_SRC/Contents/Resources/pkg-scripts/preinstall\\"; \ - if [ -x \\"$PREINSTALL\\" ]; then \\"$PREINSTALL\\" '' '' '/' >> /tmp/blitz_install.log 2>&1 || true; fi; \ + if [ -x \\"$PREINSTALL\\" ]; then BLITZ_UPDATE_CONTEXT='auto-update' \\"$PREINSTALL\\" '' '' '/' >> /tmp/blitz_install.log 2>&1 || true; fi; \ rm -rf /Applications/Blitz.app; \ mv \\"$APP_SRC\\" /Applications/Blitz.app; \ POSTINSTALL='/Applications/Blitz.app/Contents/Resources/pkg-scripts/postinstall'; \ - if [ -x \\"$POSTINSTALL\\" ]; then \\"$POSTINSTALL\\" '' '' '/' >> /tmp/blitz_install.log 2>&1 || true; fi; \ + if [ -x \\"$POSTINSTALL\\" ]; then BLITZ_UPDATE_CONTEXT='auto-update' \\"$POSTINSTALL\\" '' '' '/' >> /tmp/blitz_install.log 2>&1 || true; fi; \ rm -rf \\"$UNZIP_DIR\\" \\"$TMPZIP\\"\ " """ From 824a778edc5600289378d78e4043349defcf402b Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Tue, 24 Mar 2026 16:14:03 -0700 Subject: [PATCH 16/51] Bundle pinned ascd helper and add release smoke workflow --- .github/workflows/build.yml | 28 ++++ .github/workflows/release-smoke.yml | 203 ++++++++++++++++++++++++++++ .gitmodules | 3 + CLAUDE.md | 5 +- README.md | 6 + deps/App-Store-Connect-CLI-helper | 1 + scripts/bundle.sh | 80 +++++++++-- src/services/ASCDaemonClient.swift | 27 +--- 8 files changed, 317 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/release-smoke.yml create mode 100644 .gitmodules create mode 160000 deps/App-Store-Connect-CLI-helper diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a2c54d7..466435c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,6 +12,13 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: deps/App-Store-Connect-CLI-helper/go.mod - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer @@ -45,6 +52,13 @@ jobs: runs-on: macos-15-intel steps: - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: deps/App-Store-Connect-CLI-helper/go.mod - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer @@ -82,6 +96,13 @@ jobs: contents: write steps: - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: deps/App-Store-Connect-CLI-helper/go.mod - name: Setup Node.js uses: actions/setup-node@v4 @@ -224,6 +245,13 @@ jobs: contents: write steps: - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: deps/App-Store-Connect-CLI-helper/go.mod - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml new file mode 100644 index 0000000..36482f4 --- /dev/null +++ b/.github/workflows/release-smoke.yml @@ -0,0 +1,203 @@ +name: Release Smoke Test + +on: + workflow_dispatch: + inputs: + notarize_pkg: + description: "Notarize the arm64 pkg when Apple notary secrets are available" + required: false + default: true + type: boolean + +run-name: "Release Smoke Test (${{ github.ref_name }}) #${{ github.run_number }}" + +jobs: + smoke_arm64: + runs-on: macos-15 + permissions: + contents: read + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }} + APPLE_INSTALLER_IDENTITY: ${{ secrets.APPLE_INSTALLER_IDENTITY || '' }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: deps/App-Store-Connect-CLI-helper/go.mod + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer + + - name: Import signing certificate + if: ${{ env.APPLE_CERTIFICATE_BASE64 != '' }} + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + CERT_PATH=$RUNNER_TEMP/certificate.p12 + echo "$APPLE_CERTIFICATE_BASE64" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: \ + -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + + - name: Get version + id: version + run: echo "version=$(node -e 'process.stdout.write(require(\"./package.json\").version)')" >> "$GITHUB_OUTPUT" + + - name: Build release .app + run: | + swift build -c release + bash scripts/bundle.sh release + + - name: Build .pkg + run: bash scripts/build-pkg.sh + + - name: Notarize .pkg + if: ${{ inputs.notarize_pkg && env.APPLE_API_KEY != '' }} + env: + APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey.p8 + run: | + echo "$APPLE_API_KEY_BASE64" | base64 --decode > "$APPLE_API_KEY_PATH" + VERSION="${{ steps.version.outputs.version }}" + xcrun notarytool submit "build/Blitz-$VERSION.pkg" \ + --key "$APPLE_API_KEY_PATH" \ + --key-id "$APPLE_API_KEY" \ + --issuer "$APPLE_API_ISSUER" \ + --wait + xcrun stapler staple "build/Blitz-$VERSION.pkg" + + - name: Create smoke artifacts + run: | + cd .build + ditto -c -k --sequesterRsrc --keepParent Blitz.app Blitz.app.zip + shasum -a 256 Blitz.app.zip > SHA256SUMS.txt + find Blitz.app/Contents/MacOS -type f -perm +111 -exec shasum -a 256 {} + >> SHA256SUMS.txt + PKG_PATH="../build/Blitz-${{ steps.version.outputs.version }}.pkg" + if [ -f "$PKG_PATH" ]; then + shasum -a 256 "$PKG_PATH" >> SHA256SUMS.txt + fi + cat SHA256SUMS.txt + + - name: Upload arm64 smoke artifacts + uses: actions/upload-artifact@v4 + with: + name: Blitz-smoke-arm64-${{ steps.version.outputs.version }}-${{ github.run_number }} + path: | + .build/Blitz.app.zip + .build/SHA256SUMS.txt + build/Blitz-${{ steps.version.outputs.version }}.pkg + retention-days: 14 + + - name: Write summary + run: | + { + echo "## arm64 smoke artifacts" + echo "" + echo "- Version: ${{ steps.version.outputs.version }}" + echo "- Bundled app zip: .build/Blitz.app.zip" + echo "- Pkg: build/Blitz-${{ steps.version.outputs.version }}.pkg" + echo "- Checksums: .build/SHA256SUMS.txt" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Cleanup keychain + if: always() + run: security delete-keychain $RUNNER_TEMP/app-signing.keychain-db 2>/dev/null || true + + smoke_x86_64: + runs-on: macos-15-intel + permissions: + contents: read + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: deps/App-Store-Connect-CLI-helper/go.mod + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer + + - name: Import signing certificate + if: ${{ env.APPLE_CERTIFICATE_BASE64 != '' }} + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + CERT_PATH=$RUNNER_TEMP/certificate.p12 + echo "$APPLE_CERTIFICATE_BASE64" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: \ + -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + + - name: Get version + id: version + run: echo "version=$(node -e 'process.stdout.write(require(\"./package.json\").version)')" >> "$GITHUB_OUTPUT" + + - name: Build x86_64 .app artifact + run: | + swift build -c release + bash scripts/bundle.sh release + mkdir -p build + ditto -c -k --sequesterRsrc --keepParent .build/Blitz.app "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip" + shasum -a 256 "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip" > "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip.sha256" + + - name: Upload x86_64 smoke artifacts + uses: actions/upload-artifact@v4 + with: + name: Blitz-smoke-x86_64-${{ steps.version.outputs.version }}-${{ github.run_number }} + path: | + build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip + build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip.sha256 + retention-days: 14 + + - name: Write summary + run: | + { + echo "## x86_64 smoke artifacts" + echo "" + echo "- Version: ${{ steps.version.outputs.version }}" + echo "- App zip: build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip" + echo "- Checksum: build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip.sha256" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Cleanup keychain + if: always() + run: security delete-keychain $RUNNER_TEMP/app-signing.keychain-db 2>/dev/null || true diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6b16a90 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "deps/App-Store-Connect-CLI-helper"] + path = deps/App-Store-Connect-CLI-helper + url = https://github.com/pythonlearner1025/App-Store-Connect-CLI.git diff --git a/CLAUDE.md b/CLAUDE.md index 8e20eee..1ddb28e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,9 @@ swift build # Release build swift build -c release +# Fetch pinned helper dependency +git submodule update --init --recursive + # Bundle as macOS .app (signs with Developer ID) bash scripts/bundle.sh release @@ -29,7 +32,7 @@ bash scripts/build-pkg.sh ## Architecture -**Blitz** is a native macOS SwiftUI app (requires macOS 14+) for iOS development. It provides simulator management, screen capture, database browsing, App Store Connect integration, and an MCP server for Claude Code integration. Built with Swift Package Manager (no Xcode project). +**Blitz** is a native macOS SwiftUI app (requires macOS 14+) for iOS development. It provides simulator management, screen capture, database browsing, App Store Connect integration, and an MCP server for Claude Code integration. Built with Swift Package Manager (no Xcode project). Source bundling also depends on the pinned ASC helper submodule in `deps/App-Store-Connect-CLI-helper` and a local Go toolchain. ### Single-target structure diff --git a/README.md b/README.md index 568ad1a..8516abc 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ https://github.com/user-attachments/assets/07364d9f-f6a7-4375-acc8-b7ab46dcc60e - macOS 14+ (Sonoma) - Xcode 16+ (Swift 5.10+) - Node.js 18+ (for build scripts and sidecar) +- Go 1.26+ (for source builds that bundle the pinned `ascd` helper) ## Download @@ -43,6 +44,9 @@ https://github.com/user-attachments/assets/07364d9f-f6a7-4375-acc8-b7ab46dcc60e git clone https://github.com/blitzdotdev/blitz-mac.git cd blitz-mac +# Fetch the pinned App Store Connect helper fork +git submodule update --init --recursive + # Debug build swift build @@ -62,6 +66,8 @@ For signed builds, copy `.env.example` to `.env` and fill in your Apple Develope bash scripts/bundle.sh release ``` +The ASC helper binary bundled into the app is built from the pinned submodule at `deps/App-Store-Connect-CLI-helper`. If you need to override that source during development or CI, set `BLITZ_ASCD_SOURCE_DIR` or point `BLITZ_ASCD_PATH` at a prebuilt compatible helper binary. + ## Verify a release binary Every GitHub release includes `SHA256SUMS.txt` with checksums of the CI-built binary. To verify: diff --git a/deps/App-Store-Connect-CLI-helper b/deps/App-Store-Connect-CLI-helper new file mode 160000 index 0000000..5c4feee --- /dev/null +++ b/deps/App-Store-Connect-CLI-helper @@ -0,0 +1 @@ +Subproject commit 5c4feee0bd288aa110392e16d73caa434492e181 diff --git a/scripts/bundle.sh b/scripts/bundle.sh index 1495fb1..7366ee3 100755 --- a/scripts/bundle.sh +++ b/scripts/bundle.sh @@ -16,29 +16,27 @@ ENTITLEMENTS="$ROOT_DIR/scripts/Entitlements.plist" TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}" resolve_ascd_path() { - local candidates=() + local candidate="${BLITZ_ASCD_PATH:-}" + [ -n "$candidate" ] || return 1 + [ -x "$candidate" ] || return 1 + printf '%s\n' "$candidate" +} - if [ -n "${BLITZ_ASCD_PATH:-}" ]; then - candidates+=("$BLITZ_ASCD_PATH") - fi +resolve_ascd_source_dir() { + local candidates=() - if command -v ascd >/dev/null 2>&1; then - candidates+=("$(command -v ascd)") + if [ -n "${BLITZ_ASCD_SOURCE_DIR:-}" ]; then + candidates+=("$BLITZ_ASCD_SOURCE_DIR") fi candidates+=( - "$HOME/.blitz/ascd" - "$HOME/.local/bin/ascd" - "/opt/homebrew/bin/ascd" - "/usr/local/bin/ascd" - "/opt/local/bin/ascd" - "$HOME/superapp/asc-cli/forks/App-Store-Connect-CLI-helper/build/ascd" + "$ROOT_DIR/deps/App-Store-Connect-CLI-helper" ) local candidate for candidate in "${candidates[@]}"; do [ -n "$candidate" ] || continue - if [ -x "$candidate" ]; then + if [ -f "$candidate/cmd/ascd/main.go" ]; then printf '%s\n' "$candidate" return 0 fi @@ -47,6 +45,40 @@ resolve_ascd_path() { return 1 } +build_ascd_helper() { + local source_dir="$1" + local output_dir="$ROOT_DIR/.build/ascd-helper" + local output_path="$output_dir/ascd" + + if ! command -v go >/dev/null 2>&1; then + echo "ERROR: Go is required to build the bundled ascd helper." >&2 + echo " Install Go, or set BLITZ_ASCD_PATH to a prebuilt compatible helper binary." >&2 + return 1 + fi + + mkdir -p "$output_dir" + echo "Building ascd helper from $source_dir" >&2 + ( + cd "$source_dir" + go build -o "$output_path" ./cmd/ascd + ) + printf '%s\n' "$output_path" +} + +verify_ascd_helper() { + local helper_path="$1" + local response + + response="$(printf '{"id":"bundle-check","method":"ping"}\n' | "$helper_path" 2>/dev/null | head -1 || true)" + case "$response" in + *'"id":"bundle-check"'*'"result"'*) + return 0 + ;; + esac + + return 1 +} + if [ "$CONFIG" = "debug" ] && [ "$TIMESTAMP_MODE" = "auto" ]; then TIMESTAMP_MODE="none" fi @@ -67,6 +99,7 @@ swift build -c "$CONFIG" --product Blitz swift build -c "$CONFIG" --product blitz-macos-mcp # Create .app structure +rm -rf "$BUNDLE_DIR" mkdir -p "$BUNDLE_DIR/Contents/MacOS" mkdir -p "$BUNDLE_DIR/Contents/Resources" mkdir -p "$BUNDLE_DIR/Contents/Helpers" @@ -85,14 +118,31 @@ else fi ASC_HELPER_BINARY="$(resolve_ascd_path || true)" +if [ -z "$ASC_HELPER_BINARY" ]; then + ASC_HELPER_SOURCE_DIR="$(resolve_ascd_source_dir || true)" + if [ -n "$ASC_HELPER_SOURCE_DIR" ]; then + ASC_HELPER_BINARY="$(build_ascd_helper "$ASC_HELPER_SOURCE_DIR")" + fi +fi + if [ -n "$ASC_HELPER_BINARY" ]; then + if ! verify_ascd_helper "$ASC_HELPER_BINARY"; then + echo "ERROR: ascd helper is not compatible with Blitz:" + echo " $ASC_HELPER_BINARY" + echo " Expected the forked helper binary with the JSON-line long-lived protocol." + exit 1 + fi cp "$ASC_HELPER_BINARY" "$BUNDLE_DIR/Contents/Helpers/ascd" chmod 755 "$BUNDLE_DIR/Contents/Helpers/ascd" echo "Copied ascd helper into app bundle from $ASC_HELPER_BINARY" else echo "ERROR: ascd helper not found." - echo " Set BLITZ_ASCD_PATH, install ascd on PATH, or build it at:" - echo " $HOME/superapp/asc-cli/forks/App-Store-Connect-CLI-helper/build/ascd" + echo " Set BLITZ_ASCD_PATH to a built forked helper binary, or" + echo " set BLITZ_ASCD_SOURCE_DIR to the App-Store-Connect-CLI-helper fork checkout." + echo " Source builds can also place the fork at:" + echo " $ROOT_DIR/deps/App-Store-Connect-CLI-helper" + echo " If you cloned Blitz from git, run:" + echo " git submodule update --init --recursive" exit 1 fi diff --git a/src/services/ASCDaemonClient.swift b/src/services/ASCDaemonClient.swift index b512389..33c6f45 100644 --- a/src/services/ASCDaemonClient.swift +++ b/src/services/ASCDaemonClient.swift @@ -252,7 +252,7 @@ actor ASCDaemonClient { await logger.error("ascd executable not found. searched=\(searched)") } throw Error.helperNotFound( - "ascd not found. Set BLITZ_ASCD_PATH, install ascd on PATH, or use a bundled helper. Searched: \(searched)" + "ascd not found. Bundle the signed helper at Contents/Helpers/ascd or set BLITZ_ASCD_PATH to the forked helper binary. Searched: \(searched)" ) } @@ -279,26 +279,13 @@ actor ASCDaemonClient { appendCandidate(ProcessInfo.processInfo.environment["BLITZ_ASCD_PATH"]) appendCandidate(Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/ascd").path) - appendCandidate(Bundle.main.bundleURL.appendingPathComponent("ascd").path) - appendCandidate(Bundle.main.executableURL?.deletingLastPathComponent().appendingPathComponent("ascd").path) - appendCandidate(Bundle.main.resourceURL?.appendingPathComponent("ascd").path) - appendCandidate(fileManager.homeDirectoryForCurrentUser.appendingPathComponent(".blitz/ascd").path) - appendCandidate(fileManager.homeDirectoryForCurrentUser.appendingPathComponent(".local/bin/ascd").path) - appendCandidate("/opt/homebrew/bin/ascd") - appendCandidate("/usr/local/bin/ascd") - appendCandidate("/opt/local/bin/ascd") - - let pathEntries = (ProcessInfo.processInfo.environment["PATH"] ?? "") - .split(separator: ":") - .map(String.init) - for entry in pathEntries { - appendCandidate(URL(fileURLWithPath: entry).appendingPathComponent("ascd").path) - } - appendCandidate( - fileManager.homeDirectoryForCurrentUser - .appendingPathComponent("superapp/asc-cli/forks/App-Store-Connect-CLI-helper/build/ascd").path + Bundle.main.executableURL? + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Helpers/ascd").path ) + appendCandidate(Bundle.main.privateFrameworksURL?.deletingLastPathComponent().appendingPathComponent("Helpers/ascd").path) return candidates } @@ -312,7 +299,7 @@ actor ASCDaemonClient { environment["ASC_PRIVATE_KEY_B64"] = nil environment["ASC_BYPASS_KEYCHAIN"] = "1" if environment["ASC_DEBUG"]?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true { - environment["ASC_DEBUG"] = "api" + environment["ASC_DEBUG"] = nil } let isolatedConfigPath = fileManager.temporaryDirectory From 28dcdc49bdefcc305df6ca72d4ec207df554604a Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Tue, 24 Mar 2026 16:23:47 -0700 Subject: [PATCH 17/51] trigger on pr to master --- .github/workflows/release-smoke.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index 36482f4..4540a8c 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -1,6 +1,8 @@ name: Release Smoke Test on: + pull_request: + branches: [master, main] workflow_dispatch: inputs: notarize_pkg: From 9205a86df6f51682059833e0e8fdf3ece813edef Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Tue, 24 Mar 2026 16:39:27 -0700 Subject: [PATCH 18/51] update --- .github/workflows/release-smoke.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index 4540a8c..16b8be3 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -64,7 +64,7 @@ jobs: - name: Get version id: version - run: echo "version=$(node -e 'process.stdout.write(require(\"./package.json\").version)')" >> "$GITHUB_OUTPUT" + run: echo "version=$(node -e \"process.stdout.write(require('./package.json').version)\")" >> "$GITHUB_OUTPUT" - name: Build release .app run: | @@ -100,6 +100,13 @@ jobs: fi cat SHA256SUMS.txt + - name: Verify arm64 smoke outputs + run: | + test -f .build/Blitz.app.zip + test -f .build/SHA256SUMS.txt + test -f "build/Blitz-${{ steps.version.outputs.version }}.pkg" + ls -lh .build/Blitz.app.zip .build/SHA256SUMS.txt "build/Blitz-${{ steps.version.outputs.version }}.pkg" + - name: Upload arm64 smoke artifacts uses: actions/upload-artifact@v4 with: @@ -109,6 +116,7 @@ jobs: .build/SHA256SUMS.txt build/Blitz-${{ steps.version.outputs.version }}.pkg retention-days: 14 + if-no-files-found: error - name: Write summary run: | @@ -171,7 +179,7 @@ jobs: - name: Get version id: version - run: echo "version=$(node -e 'process.stdout.write(require(\"./package.json\").version)')" >> "$GITHUB_OUTPUT" + run: echo "version=$(node -e \"process.stdout.write(require('./package.json').version)\")" >> "$GITHUB_OUTPUT" - name: Build x86_64 .app artifact run: | @@ -181,6 +189,12 @@ jobs: ditto -c -k --sequesterRsrc --keepParent .build/Blitz.app "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip" shasum -a 256 "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip" > "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip.sha256" + - name: Verify x86_64 smoke outputs + run: | + test -f "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip" + test -f "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip.sha256" + ls -lh "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip" "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip.sha256" + - name: Upload x86_64 smoke artifacts uses: actions/upload-artifact@v4 with: @@ -189,6 +203,7 @@ jobs: build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip.sha256 retention-days: 14 + if-no-files-found: error - name: Write summary run: | From debe77db99f04ce87319e70bc354758631ceec18 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Tue, 24 Mar 2026 16:58:47 -0700 Subject: [PATCH 19/51] Retrigger PR workflows From 94b25f65a826e4b34322cdf61ff0f0c2f1b1b4f0 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Tue, 24 Mar 2026 17:10:23 -0700 Subject: [PATCH 20/51] Fix workflow version outputs --- .github/workflows/build.yml | 8 ++++++-- .github/workflows/release-smoke.yml | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 466435c..a6f2e38 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -186,7 +186,9 @@ jobs: - name: Get version id: version - run: echo "version=$(node -e "process.stdout.write(require('./package.json').version)")" >> "$GITHUB_OUTPUT" + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Extract changelog notes id: changelog @@ -288,7 +290,9 @@ jobs: - name: Get version id: version - run: echo "version=$(node -e "process.stdout.write(require('./package.json').version)")" >> "$GITHUB_OUTPUT" + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Build x86_64 .app artifact env: diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index 16b8be3..294db0f 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -64,7 +64,9 @@ jobs: - name: Get version id: version - run: echo "version=$(node -e \"process.stdout.write(require('./package.json').version)\")" >> "$GITHUB_OUTPUT" + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Build release .app run: | @@ -179,7 +181,9 @@ jobs: - name: Get version id: version - run: echo "version=$(node -e \"process.stdout.write(require('./package.json').version)\")" >> "$GITHUB_OUTPUT" + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Build x86_64 .app artifact run: | From 465cea53c3a1578914a0b05544828ea2e742295c Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Tue, 24 Mar 2026 17:14:58 -0700 Subject: [PATCH 21/51] Fix onboarding toggle alignment and "Setup with AI" built-in terminal support Right-align toggle switches in onboarding by expanding labels to full width. Thread appState through ASCCredentialGate/ASCTabContent/BundleIDSetupView so "Setup with AI" buttons can open the built-in terminal panel instead of silently failing when built-in terminal is selected. --- src/views/OnboardingView.swift | 2 ++ src/views/insights/AnalyticsView.swift | 3 +- src/views/insights/ReviewsView.swift | 3 +- src/views/release/ASCOverview.swift | 3 +- src/views/release/AppDetailsView.swift | 3 +- src/views/release/PricingView.swift | 3 +- src/views/release/ReviewView.swift | 3 +- src/views/release/ScreenshotsView.swift | 3 +- src/views/release/StoreListingView.swift | 3 +- src/views/shared/asc/ASCCredentialForm.swift | 30 +++++++++++++++----- src/views/shared/asc/ASCCredentialGate.swift | 2 ++ src/views/shared/asc/ASCTabContent.swift | 3 +- src/views/shared/asc/BundleIDSetupView.swift | 18 +++++++++++- src/views/testflight/BetaInfoView.swift | 3 +- src/views/testflight/BuildsView.swift | 3 +- src/views/testflight/FeedbackView.swift | 3 +- src/views/testflight/GroupsView.swift | 3 +- 17 files changed, 70 insertions(+), 21 deletions(-) diff --git a/src/views/OnboardingView.swift b/src/views/OnboardingView.swift index 5547853..b074c71 100644 --- a/src/views/OnboardingView.swift +++ b/src/views/OnboardingView.swift @@ -276,6 +276,7 @@ struct OnboardingView: View { .font(.caption2) .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity, alignment: .leading) } .toggleStyle(.switch) .controlSize(.small) @@ -290,6 +291,7 @@ struct OnboardingView: View { .font(.caption2) .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity, alignment: .leading) } .toggleStyle(.switch) .controlSize(.small) diff --git a/src/views/insights/AnalyticsView.swift b/src/views/insights/AnalyticsView.swift index 36c878d..f9b88f5 100644 --- a/src/views/insights/AnalyticsView.swift +++ b/src/views/insights/AnalyticsView.swift @@ -15,11 +15,12 @@ struct AnalyticsView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .analytics, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .analytics, platform: appState.activeProject?.platform ?? .iOS) { analyticsContent } } diff --git a/src/views/insights/ReviewsView.swift b/src/views/insights/ReviewsView.swift index f97bb87..ca0fedf 100644 --- a/src/views/insights/ReviewsView.swift +++ b/src/views/insights/ReviewsView.swift @@ -7,11 +7,12 @@ struct ReviewsView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .reviews, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .reviews, platform: appState.activeProject?.platform ?? .iOS) { reviewsContent } } diff --git a/src/views/release/ASCOverview.swift b/src/views/release/ASCOverview.swift index 5702cfe..991481f 100644 --- a/src/views/release/ASCOverview.swift +++ b/src/views/release/ASCOverview.swift @@ -8,11 +8,12 @@ struct ASCOverview: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .app, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .app, platform: appState.activeProject?.platform ?? .iOS) { overviewContent } } diff --git a/src/views/release/AppDetailsView.swift b/src/views/release/AppDetailsView.swift index aab28e7..6767419 100644 --- a/src/views/release/AppDetailsView.swift +++ b/src/views/release/AppDetailsView.swift @@ -45,11 +45,12 @@ struct AppDetailsView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .appDetails, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .appDetails, platform: appState.activeProject?.platform ?? .iOS) { detailsContent } } diff --git a/src/views/release/PricingView.swift b/src/views/release/PricingView.swift index aaf38dd..6048fc6 100644 --- a/src/views/release/PricingView.swift +++ b/src/views/release/PricingView.swift @@ -8,11 +8,12 @@ struct MonetizationView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .monetization, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .monetization, platform: appState.activeProject?.platform ?? .iOS) { monetizationContent } } diff --git a/src/views/release/ReviewView.swift b/src/views/release/ReviewView.swift index b321512..24b8c43 100644 --- a/src/views/release/ReviewView.swift +++ b/src/views/release/ReviewView.swift @@ -60,11 +60,12 @@ struct ReviewView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .review, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .review, platform: appState.activeProject?.platform ?? .iOS) { reviewContent } } diff --git a/src/views/release/ScreenshotsView.swift b/src/views/release/ScreenshotsView.swift index 3f9c5a2..0d71f23 100644 --- a/src/views/release/ScreenshotsView.swift +++ b/src/views/release/ScreenshotsView.swift @@ -96,11 +96,12 @@ struct ScreenshotsView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .screenshots, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .screenshots, platform: appState.activeProject?.platform ?? .iOS) { VStack(spacing: 0) { HStack { Text("Screenshots") diff --git a/src/views/release/StoreListingView.swift b/src/views/release/StoreListingView.swift index 03d6cf2..44e9030 100644 --- a/src/views/release/StoreListingView.swift +++ b/src/views/release/StoreListingView.swift @@ -23,11 +23,12 @@ struct StoreListingView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .storeListing, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .storeListing, platform: appState.activeProject?.platform ?? .iOS) { listingContent } } diff --git a/src/views/shared/asc/ASCCredentialForm.swift b/src/views/shared/asc/ASCCredentialForm.swift index 7c49d6a..7e435da 100644 --- a/src/views/shared/asc/ASCCredentialForm.swift +++ b/src/views/shared/asc/ASCCredentialForm.swift @@ -2,6 +2,7 @@ import SwiftUI import UniformTypeIdentifiers struct ASCCredentialForm: View { + var appState: AppState var ascManager: ASCManager var projectId: String var bundleId: String? @@ -155,13 +156,28 @@ struct ASCCredentialForm: View { let agent = AIAgent(rawValue: settings.defaultAgentCLI) ?? .claudeCode let terminal = settings.resolveDefaultTerminal().terminal let prompt = "Use the /asc-team-key-create skill to create a new App Store Connect API key, then call the asc_set_credentials MCP tool to fill the form so I can verify and save." - TerminalLauncher.launch( - projectPath: BlitzPaths.mcps.path, - agent: agent, - terminal: terminal, - prompt: prompt, - skipPermissions: settings.skipAgentPermissions - ) + + if terminal.isBuiltIn { + appState.showTerminal = true + let session = appState.terminalManager.createSession(projectPath: BlitzPaths.mcps.path) + var command = agent.cliCommand + if settings.skipAgentPermissions, let flag = agent.skipPermissionsFlag { + command += " \(flag)" + } + let escaped = prompt.replacingOccurrences(of: "'", with: "'\\''") + command += " '\(escaped)'" + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + session.sendCommand(command) + } + } else { + TerminalLauncher.launch( + projectPath: BlitzPaths.mcps.path, + agent: agent, + terminal: terminal, + prompt: prompt, + skipPermissions: settings.skipAgentPermissions + ) + } } label: { HStack(spacing: 6) { Image(systemName: "sparkles") diff --git a/src/views/shared/asc/ASCCredentialGate.swift b/src/views/shared/asc/ASCCredentialGate.swift index 3a5a506..644159a 100644 --- a/src/views/shared/asc/ASCCredentialGate.swift +++ b/src/views/shared/asc/ASCCredentialGate.swift @@ -4,6 +4,7 @@ import SwiftUI /// Shows a spinner while loading, the credential form when unconfigured, /// and the wrapped content once credentials are present. struct ASCCredentialGate: View { + var appState: AppState var ascManager: ASCManager var projectId: String var bundleId: String? @@ -20,6 +21,7 @@ struct ASCCredentialGate: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else if ascManager.credentials == nil { ASCCredentialForm( + appState: appState, ascManager: ascManager, projectId: projectId, bundleId: bundleId diff --git a/src/views/shared/asc/ASCTabContent.swift b/src/views/shared/asc/ASCTabContent.swift index 7af72d0..60381cb 100644 --- a/src/views/shared/asc/ASCTabContent.swift +++ b/src/views/shared/asc/ASCTabContent.swift @@ -2,6 +2,7 @@ import SwiftUI /// Wraps a tab's content with loading, error, and empty-app states. struct ASCTabContent: View { + var appState: AppState var asc: ASCManager var tab: AppTab var platform: ProjectPlatform = .iOS @@ -26,7 +27,7 @@ struct ASCTabContent: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else if asc.app == nil && asc.credentials != nil && !isLoading { // App not found — show bundle ID setup instead of flashing content - BundleIDSetupView(asc: asc, tab: tab, platform: platform) + BundleIDSetupView(appState: appState, asc: asc, tab: tab, platform: platform) } else if let error = asc.tabError[tab], !asc.hasLoadedTabData(tab) { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") diff --git a/src/views/shared/asc/BundleIDSetupView.swift b/src/views/shared/asc/BundleIDSetupView.swift index 09ebfe6..545317f 100644 --- a/src/views/shared/asc/BundleIDSetupView.swift +++ b/src/views/shared/asc/BundleIDSetupView.swift @@ -3,6 +3,7 @@ import SwiftUI /// Multi-phase inline view for registering a bundle ID, enabling capabilities, /// and guiding the user to create their app in App Store Connect. struct BundleIDSetupView: View { + var appState: AppState var asc: ASCManager var tab: AppTab var platform: ProjectPlatform = .iOS @@ -540,7 +541,22 @@ struct BundleIDSetupView: View { let settings = SettingsService.shared let agent = AIAgent(rawValue: settings.defaultAgentCLI) ?? .claudeCode let terminal = settings.resolveDefaultTerminal().terminal - TerminalLauncher.launch(projectPath: projectPath, agent: agent, terminal: terminal, prompt: prompt) + + if terminal.isBuiltIn { + appState.showTerminal = true + let session = appState.terminalManager.createSession(projectPath: projectPath) + var command = agent.cliCommand + if settings.skipAgentPermissions, let flag = agent.skipPermissionsFlag { + command += " \(flag)" + } + let escaped = prompt.replacingOccurrences(of: "'", with: "'\\''") + command += " '\(escaped)'" + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + session.sendCommand(command) + } + } else { + TerminalLauncher.launch(projectPath: projectPath, agent: agent, terminal: terminal, prompt: prompt, skipPermissions: settings.skipAgentPermissions) + } } // MARK: - Helpers diff --git a/src/views/testflight/BetaInfoView.swift b/src/views/testflight/BetaInfoView.swift index b9fb46d..a9c5f9d 100644 --- a/src/views/testflight/BetaInfoView.swift +++ b/src/views/testflight/BetaInfoView.swift @@ -15,11 +15,12 @@ struct BetaInfoView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .betaInfo, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .betaInfo, platform: appState.activeProject?.platform ?? .iOS) { betaInfoContent } } diff --git a/src/views/testflight/BuildsView.swift b/src/views/testflight/BuildsView.swift index dd14d3d..83f6da8 100644 --- a/src/views/testflight/BuildsView.swift +++ b/src/views/testflight/BuildsView.swift @@ -8,11 +8,12 @@ struct BuildsView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .builds, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .builds, platform: appState.activeProject?.platform ?? .iOS) { buildsContent } } diff --git a/src/views/testflight/FeedbackView.swift b/src/views/testflight/FeedbackView.swift index 42e1284..ad6ec4e 100644 --- a/src/views/testflight/FeedbackView.swift +++ b/src/views/testflight/FeedbackView.swift @@ -8,11 +8,12 @@ struct FeedbackView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .feedback, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .feedback, platform: appState.activeProject?.platform ?? .iOS) { feedbackContent } } diff --git a/src/views/testflight/GroupsView.swift b/src/views/testflight/GroupsView.swift index 9a329a2..94a85e3 100644 --- a/src/views/testflight/GroupsView.swift +++ b/src/views/testflight/GroupsView.swift @@ -7,11 +7,12 @@ struct GroupsView: View { var body: some View { ASCCredentialGate( + appState: appState, ascManager: asc, projectId: appState.activeProjectId ?? "", bundleId: appState.activeProject?.metadata.bundleIdentifier ) { - ASCTabContent(asc: asc, tab: .groups, platform: appState.activeProject?.platform ?? .iOS) { + ASCTabContent(appState: appState, asc: asc, tab: .groups, platform: appState.activeProject?.platform ?? .iOS) { groupsContent } } From 85900bf63384ac5f8476fc5f81d49f3da20ff407 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Tue, 24 Mar 2026 17:30:50 -0700 Subject: [PATCH 22/51] Fix ad-hoc helper signing in bundle script --- scripts/bundle.sh | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/scripts/bundle.sh b/scripts/bundle.sh index 7366ee3..19ab902 100755 --- a/scripts/bundle.sh +++ b/scripts/bundle.sh @@ -266,21 +266,19 @@ codesign_bundle_path() { fi } -# Sign nested native binaries first (inside-out — required for notarization) -if [ "$SIGNING_IDENTITY" != "-" ]; then - echo "Signing native dependencies..." - find "$BUNDLE_DIR/Contents/Resources" -type f \( -name "*.node" -o -name "*.dylib" \) 2>/dev/null | while read -r f; do - codesign_bundle_path "$f" 2>/dev/null || true - echo " Signed: $f" - done - if [ -f "$BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" ]; then - codesign_bundle_path "$BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" - echo " Signed: $BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" - fi - if [ -f "$BUNDLE_DIR/Contents/Helpers/ascd" ]; then - codesign_bundle_path "$BUNDLE_DIR/Contents/Helpers/ascd" - echo " Signed: $BUNDLE_DIR/Contents/Helpers/ascd" - fi +# Sign nested native binaries first (inside-out — also required for ad-hoc CI bundle verification) +echo "Signing native dependencies..." +find "$BUNDLE_DIR/Contents/Resources" -type f \( -name "*.node" -o -name "*.dylib" \) 2>/dev/null | while read -r f; do + codesign_bundle_path "$f" 2>/dev/null || true + echo " Signed: $f" +done +if [ -f "$BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" ]; then + codesign_bundle_path "$BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" + echo " Signed: $BUNDLE_DIR/Contents/Helpers/blitz-macos-mcp" +fi +if [ -f "$BUNDLE_DIR/Contents/Helpers/ascd" ]; then + codesign_bundle_path "$BUNDLE_DIR/Contents/Helpers/ascd" + echo " Signed: $BUNDLE_DIR/Contents/Helpers/ascd" fi # Sign the .app bundle (must be after nested signing) From 66606c950b40294dc9e5d4701a094b2678180ed6 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Tue, 24 Mar 2026 20:24:28 -0700 Subject: [PATCH 23/51] Make terminal tabs horizontally scrollable --- src/views/build/TerminalPanelView.swift | 60 ++++++++++++++++++++----- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/src/views/build/TerminalPanelView.swift b/src/views/build/TerminalPanelView.swift index ce20854..3259717 100644 --- a/src/views/build/TerminalPanelView.swift +++ b/src/views/build/TerminalPanelView.swift @@ -18,13 +18,42 @@ struct TerminalPanelView: View { // MARK: - Tab Bar private var tabBar: some View { - HStack(spacing: 0) { - // Session tabs - ForEach(manager.sessions) { session in - sessionTab(session) + HStack(spacing: 8) { + sessionTabStrip + tabBarControls + } + .padding(.leading, 8) + .padding(.vertical, 2) + .background(.bar) + } + + private var sessionTabStrip: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack(spacing: 0) { + ForEach(manager.sessions) { session in + sessionTab(session) + .id(session.id) + } + } + .padding(.trailing, 4) + } + .scrollIndicators(.hidden) + .frame(maxWidth: .infinity, alignment: .leading) + .onAppear { + scrollToActiveSession(using: proxy, animated: false) } + .onChange(of: manager.activeSessionId) { _, _ in + scrollToActiveSession(using: proxy) + } + .onChange(of: manager.sessions.map(\.id)) { _, _ in + scrollToActiveSession(using: proxy) + } + } + } - // New tab button + private var tabBarControls: some View { + HStack(spacing: 0) { Button { manager.createSession(projectPath: appState.activeProject?.path) } label: { @@ -36,9 +65,6 @@ struct TerminalPanelView: View { .buttonStyle(.plain) .help("New terminal") - Spacer() - - // Position toggle Button { let settings = appState.settingsStore settings.terminalPosition = isRight ? "bottom" : "right" @@ -52,7 +78,6 @@ struct TerminalPanelView: View { .buttonStyle(.plain) .help(isRight ? "Move to bottom" : "Move to right") - // Hide panel button Button { appState.showTerminal = false } label: { @@ -65,9 +90,6 @@ struct TerminalPanelView: View { .help("Hide terminal panel") .padding(.trailing, 8) } - .padding(.leading, 8) - .padding(.vertical, 2) - .background(.bar) } private func sessionTab(_ session: TerminalSession) -> some View { @@ -106,6 +128,20 @@ struct TerminalPanelView: View { } } + private func scrollToActiveSession(using proxy: ScrollViewProxy, animated: Bool = true) { + guard let activeSessionId = manager.activeSessionId else { return } + + DispatchQueue.main.async { + if animated { + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo(activeSessionId, anchor: .trailing) + } + } else { + proxy.scrollTo(activeSessionId, anchor: .trailing) + } + } + } + // MARK: - Content @ViewBuilder From eff9736c16b8dae0dee33231df7e05a65757d5d5 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Tue, 24 Mar 2026 20:32:53 -0700 Subject: [PATCH 24/51] Replace DisclosureGroup with custom chevron toggle for consistent expand/collapse behavior Review Contact and Schedule Price Change sections used DisclosureGroup where only the arrow was clickable. Now both the chevron and text label toggle expand/collapse, matching the pattern used by all other collapsible sections in the app. --- src/views/release/PricingView.swift | 59 +++++++++++++++++++---------- src/views/release/ReviewView.swift | 56 +++++++++++++++++---------- 2 files changed, 74 insertions(+), 41 deletions(-) diff --git a/src/views/release/PricingView.swift b/src/views/release/PricingView.swift index 6048fc6..a634653 100644 --- a/src/views/release/PricingView.swift +++ b/src/views/release/PricingView.swift @@ -17,7 +17,9 @@ struct MonetizationView: View { monetizationContent } } - .task(id: appState.activeProjectId) { await asc.ensureTabData(.monetization) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.monetization) + } } @ViewBuilder @@ -216,29 +218,46 @@ private struct AppPricingSection: View { Divider() - DisclosureGroup("Schedule Price Change", isExpanded: $showScheduled) { - VStack(alignment: .leading, spacing: 12) { - DatePicker("Effective Date", selection: $scheduledDate, in: Date()..., displayedComponents: .date) - PricePicker(pricePoints: asc.appPricePoints, selectedPointId: $scheduledPricePointId) - - if !scheduledPricePointId.isEmpty { - Button("Create Price Change") { - isSaving = true - let currentId = selectedPricePointId.isEmpty ? freePointId : selectedPricePointId - let dateStr = formatDate(scheduledDate) - Task { - await asc.setScheduledAppPrice( - currentPricePointId: currentId, - futurePricePointId: scheduledPricePointId, - effectiveDate: dateStr - ) - isSaving = false + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation { showScheduled.toggle() } + } label: { + HStack { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .rotationEffect(.degrees(showScheduled ? 90 : 0)) + .animation(.easeInOut(duration: 0.15), value: showScheduled) + Text("Schedule Price Change") + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if showScheduled { + VStack(alignment: .leading, spacing: 12) { + DatePicker("Effective Date", selection: $scheduledDate, in: Date()..., displayedComponents: .date) + PricePicker(pricePoints: asc.appPricePoints, selectedPointId: $scheduledPricePointId) + + if !scheduledPricePointId.isEmpty { + Button("Create Price Change") { + isSaving = true + let currentId = selectedPricePointId.isEmpty ? freePointId : selectedPricePointId + let dateStr = formatDate(scheduledDate) + Task { + await asc.setScheduledAppPrice( + currentPricePointId: currentId, + futurePricePointId: scheduledPricePointId, + effectiveDate: dateStr + ) + isSaving = false + } } + .buttonStyle(.borderedProminent).controlSize(.small) } - .buttonStyle(.borderedProminent).controlSize(.small) } + .padding(.top, 8) } - .padding(.top, 8) } } } diff --git a/src/views/release/ReviewView.swift b/src/views/release/ReviewView.swift index 24b8c43..2e7400b 100644 --- a/src/views/release/ReviewView.swift +++ b/src/views/release/ReviewView.swift @@ -69,7 +69,9 @@ struct ReviewView: View { reviewContent } } - .task(id: appState.activeProjectId) { await asc.ensureTabData(.review) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.review) + } .onChange(of: asc.appStoreVersions.map(\.id)) { _, _ in guard let appId = asc.app?.id else { return } // Load cached rejection feedback for the pending version @@ -177,30 +179,42 @@ struct ReviewView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) // Review Contact - DisclosureGroup(isExpanded: $contactExpanded) { - reviewContactForm - } label: { - HStack { - Text("Review Contact") - .font(.headline) - Spacer() - if let rd = asc.reviewDetail, - rd.attributes.contactFirstName != nil { - Text("\(rd.attributes.contactFirstName ?? "") \(rd.attributes.contactLastName ?? "")") - .font(.caption) - .foregroundStyle(.secondary) - } else if isLoading { - HStack(spacing: 6) { - ProgressView().controlSize(.small) - Text("Loading…") + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation { contactExpanded.toggle() } + } label: { + HStack { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .rotationEffect(.degrees(contactExpanded ? 90 : 0)) + .animation(.easeInOut(duration: 0.15), value: contactExpanded) + Text("Review Contact") + .font(.headline) + Spacer() + if let rd = asc.reviewDetail, + rd.attributes.contactFirstName != nil { + Text("\(rd.attributes.contactFirstName ?? "") \(rd.attributes.contactLastName ?? "")") .font(.caption) .foregroundStyle(.secondary) + } else if isLoading { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Loading…") + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + Text("Not configured") + .font(.caption) + .foregroundStyle(.orange) } - } else { - Text("Not configured") - .font(.caption) - .foregroundStyle(.orange) } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if contactExpanded { + reviewContactForm } } .padding(16) From 608311474ba01e7f042b8f6a53985b10e66317ba Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Tue, 24 Mar 2026 21:32:33 -0700 Subject: [PATCH 25/51] Show per-app ASC status icons in dashboard instead of bundle IDs --- src/AppState.swift | 1 + src/models/ASCModels.swift | 16 +++ src/services/ASCManager.swift | 18 ++- src/views/DashboardView.swift | 144 ++++++++++++++++++++--- src/views/insights/AnalyticsView.swift | 4 +- src/views/insights/ReviewsView.swift | 4 +- src/views/release/ASCOverview.swift | 2 +- src/views/release/AppDetailsView.swift | 4 +- src/views/release/ScreenshotsView.swift | 4 +- src/views/release/StoreListingView.swift | 4 +- src/views/settings/SettingsView.swift | 5 +- src/views/shared/asc/ASCTabContent.swift | 2 +- src/views/testflight/BetaInfoView.swift | 4 +- src/views/testflight/BuildsView.swift | 4 +- src/views/testflight/FeedbackView.swift | 4 +- src/views/testflight/GroupsView.swift | 4 +- 16 files changed, 191 insertions(+), 33 deletions(-) diff --git a/src/AppState.swift b/src/AppState.swift index 2fe34b3..15ac57d 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -160,6 +160,7 @@ var settingsStore = SettingsService.shared init() { // Boot MCP server eagerly — this runs before any SwiftUI view callback MCPBootstrap.shared.boot(appState: self) + ascManager.loadStoredCredentialsIfNeeded() } var activeProject: Project? { diff --git a/src/models/ASCModels.swift b/src/models/ASCModels.swift index 2860fd2..2624313 100644 --- a/src/models/ASCModels.swift +++ b/src/models/ASCModels.swift @@ -24,12 +24,28 @@ struct ASCCredentials: Codable { static func delete() { try? FileManager.default.removeItem(at: credentialsURL()) + cleanupLegacyPrivateKeys() } static func credentialsURL() -> URL { let home = FileManager.default.homeDirectoryForCurrentUser return home.appendingPathComponent(".blitz/asc-credentials.json") } + + private static func cleanupLegacyPrivateKeys() { + let fm = FileManager.default + let home = FileManager.default.homeDirectoryForCurrentUser + let blitzRoot = home.appendingPathComponent(".blitz") + guard let entries = try? fm.contentsOfDirectory( + at: blitzRoot, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { return } + + for entry in entries where entry.lastPathComponent.hasPrefix("AuthKey_") && entry.pathExtension == "p8" { + try? fm.removeItem(at: entry) + } + } } // MARK: - JSON:API Response Wrappers diff --git a/src/services/ASCManager.swift b/src/services/ASCManager.swift index 9153533..7f965fc 100644 --- a/src/services/ASCManager.swift +++ b/src/services/ASCManager.swift @@ -225,6 +225,9 @@ final class ASCManager { var isLoadingCredentials = false var credentialsError: String? var isLoadingApp = false + // Bumped after saving credentials so gated ASC tabs rerun their initial load task + // once the credential form disappears and the app lookup has completed. + var credentialActivationRevision = 0 // Per-tab data var appStoreVersions: [ASCAppStoreVersion] = [] @@ -623,6 +626,17 @@ final class ASCManager { } } + func loadStoredCredentialsIfNeeded() { + guard credentials == nil || service == nil else { return } + let creds = ASCCredentials.load() + credentials = creds + if let creds { + service = AppStoreConnectService(credentials: creds) + } else { + service = nil + } + } + func ensureTabData(_ tab: AppTab) async { guard credentials != nil else { return } @@ -1257,9 +1271,11 @@ final class ASCManager { if let bundleId, !bundleId.isEmpty { await fetchApp(bundleId: bundleId) } + + credentialActivationRevision += 1 } - func deleteCredentials(projectId: String) { + func deleteCredentials() { ASCCredentials.delete() let pid = loadedProjectId clearForProjectSwitch() diff --git a/src/views/DashboardView.swift b/src/views/DashboardView.swift index b966901..96931d8 100644 --- a/src/views/DashboardView.swift +++ b/src/views/DashboardView.swift @@ -2,8 +2,17 @@ import SwiftUI struct DashboardView: View { @Bindable var appState: AppState + @State private var dashboardSummary = DashboardSummaryStore.shared private var projects: [Project] { appState.projectManager.projects } + private var summaryHydrationKey: String { + let credentialsKey = appState.ascManager.credentials?.keyId ?? "no-creds" + let fingerprint = projects + .map { "\($0.id):\($0.metadata.bundleIdentifier ?? "")" } + .sorted() + .joined(separator: "|") + return "\(credentialsKey):\(appState.ascManager.credentialActivationRevision):\(fingerprint)" + } var body: some View { ScrollView { @@ -13,9 +22,24 @@ struct DashboardView: View { columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12 ) { - statCard(title: "Live on Store", value: "\(liveCount)", color: .green, icon: "checkmark.seal.fill") - statCard(title: "Pending Review", value: "\(pendingCount)", color: .orange, icon: "clock.fill") - statCard(title: "Rejected Apps", value: "\(rejectedCount)", color: .red, icon: "xmark.seal.fill") + statCard( + title: "Live on Store", + value: statValue(dashboardSummary.summary.liveCount), + color: .green, + icon: "checkmark.seal.fill" + ) + statCard( + title: "Pending Review", + value: statValue(dashboardSummary.summary.pendingCount), + color: .orange, + icon: "clock.fill" + ) + statCard( + title: "Rejected Apps", + value: statValue(dashboardSummary.summary.rejectedCount), + color: .red, + icon: "xmark.seal.fill" + ) } // App grid header @@ -54,11 +78,18 @@ struct DashboardView: View { .controlSize(.large) .padding(20) } - .task { - if projects.isEmpty { - await appState.projectManager.loadProjects() + .overlay(alignment: .topTrailing) { + if dashboardSummary.isLoadingSummary { + ProgressView() + .controlSize(.small) + .padding(12) + .background(.background.secondary, in: Capsule()) + .padding(20) } } + .task(id: summaryHydrationKey) { + await hydrateSummary() + } } // MARK: - Stat Card @@ -102,9 +133,8 @@ struct DashboardView: View { .font(.callout.weight(.medium)) .lineLimit(1) - Text(project.metadata.bundleIdentifier ?? project.type.rawValue) + statusLabel(for: project) .font(.caption2) - .foregroundStyle(.secondary) .lineLimit(1) } .padding(14) @@ -128,23 +158,97 @@ struct DashboardView: View { } } - // MARK: - Stats (placeholder counts from project metadata) + private func hydrateSummary() async { + if projects.isEmpty { + await appState.projectManager.loadProjects() + } - private var liveCount: Int { - // Placeholder — real implementation would query ASC per project - 0 - } + let hydrationKey = summaryHydrationKey + if dashboardSummary.isLoading(for: hydrationKey) || !dashboardSummary.shouldRefresh(for: hydrationKey) { + return + } - private var pendingCount: Int { - 0 - } + let eligibleProjects = appState.projectManager.projects.compactMap { project -> DashboardProjectInput? in + guard let bundleId = project.metadata.bundleIdentifier? + .trimmingCharacters(in: .whitespacesAndNewlines), + !bundleId.isEmpty else { + return nil + } + return DashboardProjectInput(bundleId: bundleId) + } + + guard !eligibleProjects.isEmpty else { + dashboardSummary.markEmpty(for: hydrationKey) + return + } + + guard let credentials = ASCCredentials.load() else { + dashboardSummary.markUnavailable(for: hydrationKey) + return + } + + dashboardSummary.beginLoading(for: hydrationKey) + var nextSummary = ASCDashboardSummary.empty + var nextStatuses: [String: ASCDashboardProjectStatus] = [:] + let service = AppStoreConnectService(credentials: credentials) + + for project in eligibleProjects { + if Task.isCancelled { + dashboardSummary.cancelLoading(for: hydrationKey) + return + } - private var rejectedCount: Int { - 0 + do { + let app = try await service.fetchApp(bundleId: project.bundleId) + let versions = try await service.fetchAppStoreVersions(appId: app.id) + let status = ASCDashboardProjectStatus(versions: versions) + nextSummary.include(status) + nextStatuses[project.bundleId] = status + } catch { + continue + } + } + + if Task.isCancelled { + dashboardSummary.cancelLoading(for: hydrationKey) + return + } + + dashboardSummary.store(summary: nextSummary, projectStatuses: nextStatuses, for: hydrationKey) } // MARK: - Helpers + private func statValue(_ count: Int) -> String { + dashboardSummary.hasLoadedSummary ? "\(count)" : (projects.isEmpty ? "0" : "-") + } + + @ViewBuilder + private func statusLabel(for project: Project) -> some View { + let bundleId = project.metadata.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if let status = dashboardSummary.projectStatuses[bundleId] { + if status.isRejected { + Label("Rejected", systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + } else if status.isPendingReview { + Label("In Review", systemImage: "clock.fill") + .foregroundStyle(.orange) + } else if status.isLiveOnStore { + Label("Live", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + Label("Preparing", systemImage: "pencil.circle.fill") + .foregroundStyle(.secondary) + } + } else if dashboardSummary.hasLoadedSummary || bundleId.isEmpty { + Text(project.type.rawValue) + .foregroundStyle(.secondary) + } else { + Text(bundleId) + .foregroundStyle(.secondary) + } + } + private func projectIcon(_ project: Project) -> String { if project.platform == .macOS { return "desktopcomputer" } switch project.type { @@ -161,4 +265,8 @@ struct DashboardView: View { case .flutter: return .blue } } + + private struct DashboardProjectInput: Sendable { + let bundleId: String + } } diff --git a/src/views/insights/AnalyticsView.swift b/src/views/insights/AnalyticsView.swift index f9b88f5..3ca722f 100644 --- a/src/views/insights/AnalyticsView.swift +++ b/src/views/insights/AnalyticsView.swift @@ -24,7 +24,9 @@ struct AnalyticsView: View { analyticsContent } } - .task(id: appState.activeProjectId) { await asc.ensureTabData(.analytics) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.analytics) + } } @ViewBuilder diff --git a/src/views/insights/ReviewsView.swift b/src/views/insights/ReviewsView.swift index ca0fedf..925955b 100644 --- a/src/views/insights/ReviewsView.swift +++ b/src/views/insights/ReviewsView.swift @@ -16,7 +16,9 @@ struct ReviewsView: View { reviewsContent } } - .task(id: appState.activeProjectId) { await asc.ensureTabData(.reviews) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.reviews) + } } @ViewBuilder diff --git a/src/views/release/ASCOverview.swift b/src/views/release/ASCOverview.swift index 991481f..3e4a863 100644 --- a/src/views/release/ASCOverview.swift +++ b/src/views/release/ASCOverview.swift @@ -17,7 +17,7 @@ struct ASCOverview: View { overviewContent } } - .task(id: appState.activeProjectId) { + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { await asc.ensureTabData(.app) } .sheet(isPresented: $showPreview) { diff --git a/src/views/release/AppDetailsView.swift b/src/views/release/AppDetailsView.swift index 6767419..0909909 100644 --- a/src/views/release/AppDetailsView.swift +++ b/src/views/release/AppDetailsView.swift @@ -54,7 +54,9 @@ struct AppDetailsView: View { detailsContent } } - .task(id: appState.activeProjectId) { await asc.ensureTabData(.appDetails) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.appDetails) + } } @ViewBuilder diff --git a/src/views/release/ScreenshotsView.swift b/src/views/release/ScreenshotsView.swift index 0d71f23..570016f 100644 --- a/src/views/release/ScreenshotsView.swift +++ b/src/views/release/ScreenshotsView.swift @@ -128,7 +128,9 @@ struct ScreenshotsView: View { } } } - .task(id: appState.activeProjectId) { await loadData() } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await loadData() + } .onChange(of: selectedDevice) { _, _ in loadTrackForDevice() } .alert("Import Error", isPresented: Binding( get: { importError != nil }, diff --git a/src/views/release/StoreListingView.swift b/src/views/release/StoreListingView.swift index 44e9030..904d3bb 100644 --- a/src/views/release/StoreListingView.swift +++ b/src/views/release/StoreListingView.swift @@ -32,7 +32,9 @@ struct StoreListingView: View { listingContent } } - .task(id: appState.activeProjectId) { await asc.ensureTabData(.storeListing) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.storeListing) + } .onDisappear { Task { await flushChanges() } } diff --git a/src/views/settings/SettingsView.swift b/src/views/settings/SettingsView.swift index 485db73..c4ce9f8 100644 --- a/src/views/settings/SettingsView.swift +++ b/src/views/settings/SettingsView.swift @@ -119,9 +119,7 @@ struct SettingsView: View { titleVisibility: .visible ) { Button("Clear Credentials", role: .destructive) { - if let projectId = appState.ascManager.loadedProjectId { - appState.ascManager.deleteCredentials(projectId: projectId) - } + appState.ascManager.deleteCredentials() } } message: { Text("This action cannot be undone. You will need to re-enter your API credentials to access App Store Connect data.") @@ -140,6 +138,7 @@ struct SettingsView: View { .frame(maxWidth: 500) .frame(maxWidth: .infinity, maxHeight: .infinity) .task { + appState.ascManager.loadStoredCredentialsIfNeeded() refreshTerminalResetWarning() } .fileImporter( diff --git a/src/views/shared/asc/ASCTabContent.swift b/src/views/shared/asc/ASCTabContent.swift index 60381cb..cdc9f21 100644 --- a/src/views/shared/asc/ASCTabContent.swift +++ b/src/views/shared/asc/ASCTabContent.swift @@ -13,7 +13,7 @@ struct ASCTabContent: View { } private var shouldRenderContentWhileLoading: Bool { - asc.credentials != nil + asc.credentials != nil && asc.app != nil } var body: some View { diff --git a/src/views/testflight/BetaInfoView.swift b/src/views/testflight/BetaInfoView.swift index a9c5f9d..185995e 100644 --- a/src/views/testflight/BetaInfoView.swift +++ b/src/views/testflight/BetaInfoView.swift @@ -24,7 +24,9 @@ struct BetaInfoView: View { betaInfoContent } } - .task(id: appState.activeProjectId) { await asc.ensureTabData(.betaInfo) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.betaInfo) + } } @ViewBuilder diff --git a/src/views/testflight/BuildsView.swift b/src/views/testflight/BuildsView.swift index 83f6da8..46f0e54 100644 --- a/src/views/testflight/BuildsView.swift +++ b/src/views/testflight/BuildsView.swift @@ -17,7 +17,9 @@ struct BuildsView: View { buildsContent } } - .task(id: appState.activeProjectId) { await asc.ensureTabData(.builds) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.builds) + } .onAppear { syncSelectedBuild() } .onChange(of: asc.builds.map(\.id)) { _, _ in syncSelectedBuild() } } diff --git a/src/views/testflight/FeedbackView.swift b/src/views/testflight/FeedbackView.swift index ad6ec4e..6849d83 100644 --- a/src/views/testflight/FeedbackView.swift +++ b/src/views/testflight/FeedbackView.swift @@ -17,7 +17,9 @@ struct FeedbackView: View { feedbackContent } } - .task(id: appState.activeProjectId) { await asc.ensureTabData(.feedback) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.feedback) + } } @ViewBuilder diff --git a/src/views/testflight/GroupsView.swift b/src/views/testflight/GroupsView.swift index 94a85e3..7da9583 100644 --- a/src/views/testflight/GroupsView.swift +++ b/src/views/testflight/GroupsView.swift @@ -16,7 +16,9 @@ struct GroupsView: View { groupsContent } } - .task(id: appState.activeProjectId) { await asc.ensureTabData(.groups) } + .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { + await asc.ensureTabData(.groups) + } } @ViewBuilder From 160ca7e19d84f506fd2b040d4fba4ad8ce018946 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Tue, 24 Mar 2026 22:27:30 -0700 Subject: [PATCH 26/51] Handle INVALID_BINARY as submission error in ASC submission history --- Tests/blitz_tests/ASCReleaseStatusTests.swift | 68 +++++++ src/models/ASCModels.swift | 3 + src/models/ASCReleaseStatus.swift | 180 ++++++++++++++++++ src/services/ASCManager.swift | 127 ++++++------ src/views/release/ASCOverview.swift | 15 +- src/views/release/ReviewView.swift | 1 + src/views/release/SubmitPreviewSheet.swift | 3 +- 7 files changed, 327 insertions(+), 70 deletions(-) create mode 100644 Tests/blitz_tests/ASCReleaseStatusTests.swift create mode 100644 src/models/ASCReleaseStatus.swift diff --git a/Tests/blitz_tests/ASCReleaseStatusTests.swift b/Tests/blitz_tests/ASCReleaseStatusTests.swift new file mode 100644 index 0000000..caf7f4b --- /dev/null +++ b/Tests/blitz_tests/ASCReleaseStatusTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import Blitz + +@Test func dashboardStatusIgnoresOlderRejectedVersionAfterLiveRelease() { + let status = ASCDashboardProjectStatus(versions: [ + makeVersion(id: "live", state: "READY_FOR_SALE", createdDate: "2026-03-20T00:00:00Z"), + makeVersion(id: "rejected", state: "REJECTED", createdDate: "2026-03-01T00:00:00Z"), + ]) + + #expect(status.isLiveOnStore) + #expect(!status.isPendingReview) + #expect(!status.isRejected) +} + +@Test func dashboardStatusCountsRejectedUpdateAlongsideLiveRelease() { + let status = ASCDashboardProjectStatus(versions: [ + makeVersion(id: "rejected", state: "REJECTED", createdDate: "2026-03-21T00:00:00Z"), + makeVersion(id: "live", state: "READY_FOR_SALE", createdDate: "2026-03-20T00:00:00Z"), + ]) + + #expect(status.isLiveOnStore) + #expect(!status.isPendingReview) + #expect(status.isRejected) +} + +@Test func dashboardStatusCountsPendingReviewUpdateAlongsideLiveRelease() { + let status = ASCDashboardProjectStatus(versions: [ + makeVersion(id: "review", state: "WAITING_FOR_REVIEW", createdDate: "2026-03-21T00:00:00Z"), + makeVersion(id: "live", state: "READY_FOR_SALE", createdDate: "2026-03-20T00:00:00Z"), + ]) + + #expect(status.isLiveOnStore) + #expect(status.isPendingReview) + #expect(!status.isRejected) +} + +@Test func releaseStatusSortsVersionsByNewestCreatedDateFirst() { + let sorted = ASCReleaseStatus.sortedVersionsByRecency([ + makeVersion(id: "old", state: "READY_FOR_SALE", createdDate: "2026-03-01T00:00:00Z"), + makeVersion(id: "new", state: "WAITING_FOR_REVIEW", createdDate: "2026-03-21T00:00:00Z"), + makeVersion(id: "middle", state: "REJECTED", createdDate: "2026-03-10T00:00:00Z"), + ]) + + #expect(sorted.map(\.id) == ["new", "middle", "old"]) +} + +@Test func submissionHistoryMapsInvalidBinaryToSubmissionError() { + #expect(ASCReleaseStatus.submissionHistoryEventType(forVersionState: "INVALID_BINARY") == .submissionError) + #expect(ASCReleaseStatus.reviewSubmissionEventType(forVersionState: "INVALID_BINARY") == .submissionError) +} + +@Test func reviewSubmissionStaysSubmittedForWaitingForReview() { + #expect(ASCReleaseStatus.reviewSubmissionEventType(forVersionState: "WAITING_FOR_REVIEW") == .submitted) +} + +private func makeVersion(id: String, state: String, createdDate: String) -> ASCAppStoreVersion { + ASCAppStoreVersion( + id: id, + attributes: ASCAppStoreVersion.Attributes( + versionString: id, + appStoreState: state, + releaseType: nil, + createdDate: createdDate, + copyright: nil + ) + ) +} diff --git a/src/models/ASCModels.swift b/src/models/ASCModels.swift index 2624313..3548357 100644 --- a/src/models/ASCModels.swift +++ b/src/models/ASCModels.swift @@ -20,11 +20,13 @@ struct ASCCredentials: Codable { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let data = try JSONEncoder().encode(self) try data.write(to: url, options: .atomic) + try ASCAuthBridge().syncCredentials(self) } static func delete() { try? FileManager.default.removeItem(at: credentialsURL()) cleanupLegacyPrivateKeys() + ASCAuthBridge().cleanup() } static func credentialsURL() -> URL { @@ -105,6 +107,7 @@ struct ASCAppStoreVersion: Decodable, Identifiable { enum ASCSubmissionHistoryEventType: String, Codable { case submitted + case submissionError case inReview case processing case accepted diff --git a/src/models/ASCReleaseStatus.swift b/src/models/ASCReleaseStatus.swift new file mode 100644 index 0000000..526310d --- /dev/null +++ b/src/models/ASCReleaseStatus.swift @@ -0,0 +1,180 @@ +import Foundation + +struct ASCDashboardProjectStatus: Sendable, Equatable { + let isLiveOnStore: Bool + let isPendingReview: Bool + let isRejected: Bool + + init(isLiveOnStore: Bool, isPendingReview: Bool, isRejected: Bool) { + self.isLiveOnStore = isLiveOnStore + self.isPendingReview = isPendingReview + self.isRejected = isRejected + } + + static let empty = ASCDashboardProjectStatus( + isLiveOnStore: false, + isPendingReview: false, + isRejected: false + ) + + init(versions: [ASCAppStoreVersion]) { + let sortedVersions = ASCReleaseStatus.sortedVersionsByRecency(versions) + let liveIndex = sortedVersions.firstIndex { + ASCReleaseStatus.liveStates.contains( + ASCReleaseStatus.normalize($0.attributes.appStoreState) + ) + } + let actionableIndex = sortedVersions.firstIndex { version in + let state = ASCReleaseStatus.normalize(version.attributes.appStoreState) + return ASCReleaseStatus.pendingReviewStates.contains(state) + || ASCReleaseStatus.rejectedStates.contains(state) + } + + isLiveOnStore = liveIndex != nil + + guard let actionableIndex else { + isPendingReview = false + isRejected = false + return + } + + let actionableState = ASCReleaseStatus.normalize( + sortedVersions[actionableIndex].attributes.appStoreState + ) + let actionableStateIsCurrent = liveIndex == nil || actionableIndex < liveIndex! + + isPendingReview = actionableStateIsCurrent + && ASCReleaseStatus.pendingReviewStates.contains(actionableState) + isRejected = actionableStateIsCurrent + && ASCReleaseStatus.rejectedStates.contains(actionableState) + } +} + +struct ASCDashboardSummary: Sendable, Equatable { + var liveCount: Int + var pendingCount: Int + var rejectedCount: Int + + static let empty = ASCDashboardSummary(liveCount: 0, pendingCount: 0, rejectedCount: 0) + + mutating func include(_ projectStatus: ASCDashboardProjectStatus) { + if projectStatus.isLiveOnStore { + liveCount += 1 + } + if projectStatus.isPendingReview { + pendingCount += 1 + } + if projectStatus.isRejected { + rejectedCount += 1 + } + } +} + +enum ASCReleaseStatus { + static let liveStates: Set = [ + "READY_FOR_SALE", + ] + + static let pendingReviewStates: Set = [ + "ACCEPTED", + "IN_REVIEW", + "PENDING_APPLE_RELEASE", + "PENDING_DEVELOPER_RELEASE", + "PROCESSING", + "PROCESSING_FOR_APP_STORE", + "PROCESSING_FOR_DISTRIBUTION", + "WAITING_FOR_REVIEW", + ] + + static let rejectedStates: Set = [ + "INVALID_BINARY", + "METADATA_REJECTED", + "REJECTED", + ] + + static func submissionHistoryEventType(forVersionState state: String?) -> ASCSubmissionHistoryEventType? { + switch normalize(state) { + case "WAITING_FOR_REVIEW": + return .submitted + case "IN_REVIEW": + return .inReview + case "PROCESSING", "PROCESSING_FOR_APP_STORE", "PROCESSING_FOR_DISTRIBUTION": + return .processing + case "ACCEPTED", "PENDING_DEVELOPER_RELEASE": + return .accepted + case "READY_FOR_SALE": + return .live + case "INVALID_BINARY": + return .submissionError + case "METADATA_REJECTED", "REJECTED": + return .rejected + case "DEVELOPER_REJECTED": + return .withdrawn + case "REMOVED_FROM_SALE", "DEVELOPER_REMOVED_FROM_SALE": + return .removed + default: + return nil + } + } + + static func reviewSubmissionEventType(forVersionState state: String?) -> ASCSubmissionHistoryEventType { + if normalize(state) == "INVALID_BINARY" { + return .submissionError + } + return .submitted + } + + static func normalize(_ state: String?) -> String { + state? + .trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased() ?? "" + } + + static func sortedVersionsByRecency(_ versions: [ASCAppStoreVersion]) -> [ASCAppStoreVersion] { + versions.sorted { lhs, rhs in + let dateComparison = compareDates(lhs.attributes.createdDate, rhs.attributes.createdDate) + if dateComparison != 0 { + return dateComparison > 0 + } + return lhs.id > rhs.id + } + } + + private static func compareDates(_ lhs: String?, _ rhs: String?) -> Int { + let lhsDate = parseDate(lhs) + let rhsDate = parseDate(rhs) + + switch (lhsDate, rhsDate) { + case let (.some(lhsDate), .some(rhsDate)): + if lhsDate > rhsDate { return 1 } + if lhsDate < rhsDate { return -1 } + return 0 + case (.some, .none): + return 1 + case (.none, .some): + return -1 + case (.none, .none): + let lhsValue = lhs?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let rhsValue = rhs?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if lhsValue > rhsValue { return 1 } + if lhsValue < rhsValue { return -1 } + return 0 + } + } + + private static func parseDate(_ value: String?) -> Date? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + + let fractionalFormatter = ISO8601DateFormatter() + fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let parsed = fractionalFormatter.date(from: trimmed) { + return parsed + } + + let formatter = ISO8601DateFormatter() + return formatter.date(from: trimmed) + } +} diff --git a/src/services/ASCManager.swift b/src/services/ASCManager.swift index 7f965fc..a579c74 100644 --- a/src/services/ASCManager.swift +++ b/src/services/ASCManager.swift @@ -2,7 +2,6 @@ import Foundation import AppKit import ImageIO import Security -import CryptoKit // MARK: - Screenshot Track Models @@ -629,6 +628,7 @@ final class ASCManager { func loadStoredCredentialsIfNeeded() { guard credentials == nil || service == nil else { return } let creds = ASCCredentials.load() + try? ASCAuthBridge().syncCredentials(creds) credentials = creds if let creds { service = AppStoreConnectService(credentials: creds) @@ -679,6 +679,7 @@ final class ASCManager { if needsCredentialReload { isLoadingCredentials = true let creds = ASCCredentials.load() + try? ASCAuthBridge().syncCredentials(creds) credentials = creds isLoadingCredentials = false @@ -732,6 +733,11 @@ final class ASCManager { } // No time-based expiry — we trust the session until a 401 proves otherwise irisLog("ASCManager.loadIrisSession: loaded session with \(loaded.cookies.count) cookies, capturedAt=\(loaded.capturedAt)") + do { + try Self.storeWebSessionToKeychain(loaded) + } catch { + irisLog("ASCManager.loadIrisSession: asc-web-session backfill FAILED: \(error)") + } irisSession = loaded irisService = IrisService(session: loaded) irisSessionState = .valid @@ -796,8 +802,9 @@ final class ASCManager { func clearIrisSession() { irisLog("ASCManager.clearIrisSession") + let currentSession = irisSession IrisSession.delete() - Self.deleteWebSessionFromKeychain() + Self.deleteWebSessionFromKeychain(email: currentSession?.email) irisSession = nil irisService = nil irisSessionState = .noSession @@ -811,47 +818,19 @@ final class ASCManager { // MARK: - Unified Web Session Keychain (for CLI skill scripts) - private static let webSessionService = "asc-web-session" - private static let webSessionAccount = "asc:web-session:store" + private static let webSessionService = ASCWebSessionStore.keychainService + private static let webSessionAccount = ASCWebSessionStore.keychainAccount /// Write session cookies in the format expected by CLI skill scripts /// (readable via `security find-generic-password -s "asc-web-session" -w`). private static func storeWebSessionToKeychain(_ session: IrisSession) throws { - var cookiesByDomain: [String: [[String: Any]]] = [:] - for cookie in session.cookies { - let domainKey = cookie.domain.hasPrefix(".") ? String(cookie.domain.dropFirst()) : cookie.domain - cookiesByDomain[domainKey, default: []].append([ - "name": cookie.name, - "value": cookie.value, - "domain": cookie.domain, - "path": cookie.path, - "secure": true, - "http_only": true, - ]) - } - - let normalizedEmail = (session.email ?? "unknown") - .lowercased() - .trimmingCharacters(in: .whitespaces) - let hashBytes = SHA256.hash(data: Data(normalizedEmail.utf8)) - let hashString = hashBytes.map { String(format: "%02x", $0) }.joined() - - let sessionEntry: [String: Any] = [ - "version": 1, - "updated_at": ISO8601DateFormatter().string(from: Date()), - "cookies": cookiesByDomain, - ] - - let store: [String: Any] = [ - "version": 1, - "last_key": hashString, - "sessions": [hashString: sessionEntry], - ] - - let data = try JSONSerialization.data(withJSONObject: store) - - deleteWebSessionFromKeychain() + let existingData = readKeychainItem(service: webSessionService, account: webSessionAccount) + let data = try ASCWebSessionStore.mergedData(storing: session, into: existingData) + removeWebSessionKeychainItem() + try writeWebSessionToKeychain(data) + } + private static func writeWebSessionToKeychain(_ data: Data) throws { let addQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: webSessionService, @@ -870,7 +849,35 @@ final class ASCManager { } } - private static func deleteWebSessionFromKeychain() { + private static func deleteWebSessionFromKeychain(email: String?) { + let existingData = readKeychainItem(service: webSessionService, account: webSessionAccount) + let updatedData: Data? + do { + updatedData = try ASCWebSessionStore.removingSession(email: email, from: existingData) + } catch { + return + } + + if let updatedData = updatedData { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: webSessionService, + kSecAttrAccount as String: webSessionAccount, + ] + let status = SecItemUpdate( + query as CFDictionary, + [kSecValueData as String: updatedData] as CFDictionary + ) + if status == errSecItemNotFound { + try? writeWebSessionToKeychain(updatedData) + } + return + } + + removeWebSessionKeychainItem() + } + + private static func removeWebSessionKeychainItem() { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: webSessionService, @@ -1021,26 +1028,7 @@ final class ASCManager { } private func historyEventType(forVersionState state: String) -> ASCSubmissionHistoryEventType? { - switch state { - case "WAITING_FOR_REVIEW": - return .submitted - case "IN_REVIEW": - return .inReview - case "PROCESSING": - return .processing - case "PENDING_DEVELOPER_RELEASE": - return .accepted - case "READY_FOR_SALE": - return .live - case "REJECTED": - return .rejected - case "DEVELOPER_REJECTED": - return .withdrawn - case "REMOVED_FROM_SALE", "DEVELOPER_REMOVED_FROM_SALE": - return .removed - default: - return nil - } + ASCReleaseStatus.submissionHistoryEventType(forVersionState: state) } private func historyCoverageKey( @@ -1072,6 +1060,17 @@ final class ASCManager { return versionSnapshots.values.first(where: { $0.versionString == versionString })?.versionId } + private func versionState( + for versionId: String?, + versionSnapshots: [String: ASCSubmissionHistoryCache.VersionSnapshot] + ) -> String? { + guard let versionId else { return nil } + if let version = appStoreVersions.first(where: { $0.id == versionId }) { + return version.attributes.appStoreState + } + return versionSnapshots[versionId]?.lastKnownState + } + private func refreshSubmissionHistoryCache(appId: String) -> ASCSubmissionHistoryCache { var cache = ASCSubmissionHistoryCache.load(appId: appId) let now = historyNowString() @@ -1129,12 +1128,14 @@ final class ASCManager { .compactMap(\.appStoreVersionId) .first let versionString = versionString(for: versionId, versionSnapshots: versionSnapshots) ?? "Unknown" + let versionState = versionState(for: versionId, versionSnapshots: versionSnapshots) + let eventType = ASCReleaseStatus.reviewSubmissionEventType(forVersionState: versionState) return ASCSubmissionHistoryEvent( id: "submission:\(submission.id)", versionId: versionId, versionString: versionString, - eventType: .submitted, - appleState: "WAITING_FOR_REVIEW", + eventType: eventType, + appleState: versionState ?? "WAITING_FOR_REVIEW", occurredAt: submittedAt, source: .reviewSubmission, accuracy: .exact, @@ -2298,7 +2299,7 @@ final class ASCManager { } private func ascWebSessionCookieHeader() -> String? { - guard let storeData = readKeychainItem(service: "asc-web-session", account: "asc:web-session:store"), + guard let storeData = Self.readKeychainItem(service: "asc-web-session", account: "asc:web-session:store"), let store = try? JSONSerialization.jsonObject(with: storeData) as? [String: Any], let lastKey = store["last_key"] as? String, let sessions = store["sessions"] as? [String: Any], @@ -2317,7 +2318,7 @@ final class ASCManager { return cookieHeader.isEmpty ? nil : cookieHeader } - private func readKeychainItem(service: String, account: String) -> Data? { + private static func readKeychainItem(service: String, account: String) -> Data? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, diff --git a/src/views/release/ASCOverview.swift b/src/views/release/ASCOverview.swift index 3e4a863..efb74b1 100644 --- a/src/views/release/ASCOverview.swift +++ b/src/views/release/ASCOverview.swift @@ -290,12 +290,12 @@ struct ASCOverview: View { if terminal.isBuiltIn { appState.showTerminal = true let session = appState.terminalManager.createSession(projectPath: projectPath) - var command = agent.cliCommand - if settings.skipAgentPermissions, let flag = agent.skipPermissionsFlag { - command += " \(flag)" - } - let escaped = prompt.replacingOccurrences(of: "'", with: "'\\''") - command += " '\(escaped)'" + let command = TerminalLauncher.buildAgentCommand( + projectPath: projectPath, + agent: agent, + prompt: prompt, + skipPermissions: settings.skipAgentPermissions + ) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { session.sendCommand(command) } @@ -367,6 +367,7 @@ struct ASCOverview: View { case "PENDING_DEVELOPER_RELEASE": return ("Pending Release", .yellow) case "IN_REVIEW": return ("In Review", .blue) case "WAITING_FOR_REVIEW": return ("Waiting", .blue) + case "INVALID_BINARY": return ("Submission Error", .red) case "REJECTED": return ("Rejected", .red) case "DEVELOPER_REJECTED": return ("Dev Rejected", .orange) case "DEVELOPER_REMOVED_FROM_SALE": return ("Removed", .secondary) @@ -378,6 +379,8 @@ struct ASCOverview: View { switch eventType { case .submitted: return ("Submitted", .blue) + case .submissionError: + return ("Submission Error", .red) case .inReview: return ("In Review", .blue) case .processing: diff --git a/src/views/release/ReviewView.swift b/src/views/release/ReviewView.swift index 2e7400b..86ad5be 100644 --- a/src/views/release/ReviewView.swift +++ b/src/views/release/ReviewView.swift @@ -646,6 +646,7 @@ struct ReviewView: View { case "PENDING_DEVELOPER_RELEASE": return ("Pending Release", .yellow) case "IN_REVIEW": return ("In Review", .blue) case "WAITING_FOR_REVIEW": return ("Waiting", .blue) + case "INVALID_BINARY": return ("Submission Error", .red) case "REJECTED": return ("Rejected", .red) case "DEVELOPER_REJECTED": return ("Dev Rejected", .orange) case "PREPARE_FOR_SUBMISSION": return ("Draft", .secondary) diff --git a/src/views/release/SubmitPreviewSheet.swift b/src/views/release/SubmitPreviewSheet.swift index 66936c8..4f6fb9d 100644 --- a/src/views/release/SubmitPreviewSheet.swift +++ b/src/views/release/SubmitPreviewSheet.swift @@ -267,6 +267,7 @@ struct SubmitPreviewSheet: View { case "PENDING_DEVELOPER_RELEASE": return ("Pending Release", .green) case "READY_FOR_SALE": return ("Ready for Sale", .green) case "PREPARE_FOR_SUBMISSION": return ("Preparing", .orange) + case "INVALID_BINARY": return ("Submission Error", .red) case "REJECTED": return ("Rejected", .red) case "DEVELOPER_REJECTED": return ("Developer Rejected", .orange) default: return (state.replacingOccurrences(of: "_", with: " ").capitalized, .secondary) @@ -279,7 +280,7 @@ struct SubmitPreviewSheet: View { case "IN_REVIEW": return "eye.fill" case "PENDING_DEVELOPER_RELEASE": return "checkmark.seal.fill" case "READY_FOR_SALE": return "checkmark.circle.fill" - case "REJECTED", "DEVELOPER_REJECTED": return "xmark.circle.fill" + case "INVALID_BINARY", "REJECTED", "DEVELOPER_REJECTED": return "xmark.circle.fill" default: return "info.circle.fill" } } From ed02d6ab025f84b89a17ea3fa2decc208113f6d5 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Tue, 24 Mar 2026 23:10:13 -0700 Subject: [PATCH 27/51] squenced pipe buffer for correctly sequencing received multi line data in stdout --- src/services/ASCDaemonClient.swift | 153 ++++++++++++++++------------- 1 file changed, 86 insertions(+), 67 deletions(-) diff --git a/src/services/ASCDaemonClient.swift b/src/services/ASCDaemonClient.swift index 33c6f45..435d47f 100644 --- a/src/services/ASCDaemonClient.swift +++ b/src/services/ASCDaemonClient.swift @@ -1,6 +1,65 @@ import Foundation +struct SequencedPipeBuffer { + private let newline: UInt8 = 0x0A + private var buffer = Data() + private var pendingChunks: [Int: Data] = [:] + private var nextSequence = 0 + + mutating func append(_ data: Data, sequence: Int) -> [Data] { + pendingChunks[sequence] = data + + var lines: [Data] = [] + while let nextChunk = pendingChunks.removeValue(forKey: nextSequence) { + nextSequence += 1 + + if nextChunk.isEmpty { + if !buffer.isEmpty { + lines.append(buffer) + buffer.removeAll(keepingCapacity: false) + } + continue + } + + buffer.append(nextChunk) + + while let newlineIndex = buffer.firstIndex(of: newline) { + let line = buffer.prefix(upTo: newlineIndex).filter { $0 != 0x0D } + buffer.removeSubrange(...newlineIndex) + lines.append(Data(line)) + } + } + + return lines + } + + mutating func reset() { + buffer.removeAll(keepingCapacity: false) + pendingChunks.removeAll() + nextSequence = 0 + } +} + actor ASCDaemonClient { + private final class SequenceCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func next() -> Int { + lock.lock() + defer { lock.unlock() } + let current = value + value += 1 + return current + } + + func reset() { + lock.lock() + value = 0 + lock.unlock() + } + } + private struct PendingRequest { let continuation: CheckedContinuation let summary: String @@ -87,12 +146,14 @@ actor ASCDaemonClient { private var waitTask: Task? private var stdoutReadHandle: FileHandle? private var stderrReadHandle: FileHandle? - private var stdoutBuffer = Data() - private var stderrBuffer = Data() + private var stdoutBuffer = SequencedPipeBuffer() + private var stderrBuffer = SequencedPipeBuffer() private var pendingResponses: [String: PendingRequest] = [:] private var recentStderr: [String] = [] private var requestCounter = 0 private var sessionOpen = false + private let stdoutSequenceCounter = SequenceCounter() + private let stderrSequenceCounter = SequenceCounter() init(credentials: ASCCredentials) { self.credentials = credentials @@ -204,24 +265,30 @@ actor ASCDaemonClient { self.stderrReadHandle = stderrPipe.fileHandleForReading self.sessionOpen = false self.recentStderr = [] - self.stdoutBuffer = Data() - self.stderrBuffer = Data() + self.stdoutBuffer.reset() + self.stderrBuffer.reset() + self.stdoutSequenceCounter.reset() + self.stderrSequenceCounter.reset() Task { await logger.info("Started ascd pid=\(process.processIdentifier) path=\(executablePath)") } - stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let stdoutSequenceCounter = self.stdoutSequenceCounter + stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self, stdoutSequenceCounter] handle in let data = handle.availableData + let sequence = stdoutSequenceCounter.next() Task { - await self?.handlePipeData(data, isStdout: true) + await self?.handlePipeData(data, isStdout: true, sequence: sequence) } } - stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let stderrSequenceCounter = self.stderrSequenceCounter + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self, stderrSequenceCounter] handle in let data = handle.availableData + let sequence = stderrSequenceCounter.next() Task { - await self?.handlePipeData(data, isStdout: false) + await self?.handlePipeData(data, isStdout: false, sequence: sequence) } } @@ -317,76 +384,26 @@ actor ASCDaemonClient { return environment } - private func handlePipeData(_ data: Data, isStdout: Bool) async { - if data.isEmpty { - if isStdout { - await flushBufferedPipeLine(isStdout: true) - } else { - await flushBufferedPipeLine(isStdout: false) - } - return - } - - if isStdout { - stdoutBuffer.append(data) - await drainBufferedPipeLines(isStdout: true) + private func handlePipeData(_ data: Data, isStdout: Bool, sequence: Int) async { + let lineData = if isStdout { + stdoutBuffer.append(data, sequence: sequence) } else { - stderrBuffer.append(data) - await drainBufferedPipeLines(isStdout: false) + stderrBuffer.append(data, sequence: sequence) } - } - private func drainBufferedPipeLines(isStdout: Bool) async { - while let lineData = nextBufferedPipeLine(isStdout: isStdout) { - if let line = String(data: lineData, encoding: .utf8) { + for entry in lineData { + if let line = String(data: entry, encoding: .utf8) { if isStdout { await handleStdoutLine(line) } else { await handleStderrLine(line) } } else { - await logger.error("Received non-UTF8 pipe output: \(lineData.count) bytes") + await logger.error("Received non-UTF8 pipe output: \(entry.count) bytes") } } } - private func flushBufferedPipeLine(isStdout: Bool) async { - let buffer = isStdout ? stdoutBuffer : stderrBuffer - guard !buffer.isEmpty else { return } - - if isStdout { - stdoutBuffer.removeAll(keepingCapacity: false) - } else { - stderrBuffer.removeAll(keepingCapacity: false) - } - - if let line = String(data: buffer, encoding: .utf8) { - if isStdout { - await handleStdoutLine(line) - } else { - await handleStderrLine(line) - } - } else { - await logger.error("Received trailing non-UTF8 pipe output: \(buffer.count) bytes") - } - } - - private func nextBufferedPipeLine(isStdout: Bool) -> Data? { - let newline: UInt8 = 0x0A - - if isStdout { - guard let newlineIndex = stdoutBuffer.firstIndex(of: newline) else { return nil } - let line = stdoutBuffer.prefix(upTo: newlineIndex).filter { $0 != 0x0D } - stdoutBuffer.removeSubrange(...newlineIndex) - return Data(line) - } - - guard let newlineIndex = stderrBuffer.firstIndex(of: newline) else { return nil } - let line = stderrBuffer.prefix(upTo: newlineIndex).filter { $0 != 0x0D } - stderrBuffer.removeSubrange(...newlineIndex) - return Data(line) - } - private func handleStdoutLine(_ line: String) async { guard let data = line.data(using: .utf8) else { await logger.error("Received non-UTF8 stdout from ascd") @@ -437,8 +454,10 @@ actor ASCDaemonClient { waitTask?.cancel() stdoutReadHandle = nil stderrReadHandle = nil - stdoutBuffer = Data() - stderrBuffer = Data() + stdoutBuffer.reset() + stderrBuffer.reset() + stdoutSequenceCounter.reset() + stderrSequenceCounter.reset() waitTask = nil } From cd75878c6e257563623b95bb78508d4eac58eb98 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Wed, 25 Mar 2026 13:34:28 -0700 Subject: [PATCH 28/51] hard fail on missing distribution signing creds --- .github/workflows/build.yml | 40 +++++++++++++++++++++++++---- .github/workflows/release-smoke.yml | 29 ++++++++++++++++----- scripts/build-pkg.sh | 13 ++++++++++ scripts/bundle.sh | 6 +++++ 4 files changed, 76 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a6f2e38..0e63e72 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -140,21 +140,40 @@ jobs: # Add to search list security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + - name: Validate production signing inputs + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_INSTALLER_IDENTITY: ${{ secrets.APPLE_INSTALLER_IDENTITY }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + run: | + [ -n "$APPLE_CERTIFICATE_BASE64" ] || { echo "Missing APPLE_CERTIFICATE_BASE64"; exit 1; } + [ -n "$APPLE_CERTIFICATE_PASSWORD" ] || { echo "Missing APPLE_CERTIFICATE_PASSWORD"; exit 1; } + [ -n "$APPLE_SIGNING_IDENTITY" ] || { echo "Missing APPLE_SIGNING_IDENTITY"; exit 1; } + [ -n "$APPLE_INSTALLER_IDENTITY" ] || { echo "Missing APPLE_INSTALLER_IDENTITY"; exit 1; } + [ -n "$APPLE_API_KEY" ] || { echo "Missing APPLE_API_KEY"; exit 1; } + [ -n "$APPLE_API_ISSUER" ] || { echo "Missing APPLE_API_ISSUER"; exit 1; } + [ -n "$APPLE_API_KEY_BASE64" ] || { echo "Missing APPLE_API_KEY_BASE64"; exit 1; } + - name: Build release .app env: - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + BLITZ_REQUIRE_SIGNED_RELEASE: "1" run: | swift build -c release bash scripts/bundle.sh release - name: Build .pkg env: - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }} - APPLE_INSTALLER_IDENTITY: ${{ secrets.APPLE_INSTALLER_IDENTITY || '' }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_INSTALLER_IDENTITY: ${{ secrets.APPLE_INSTALLER_IDENTITY }} + BLITZ_REQUIRE_SIGNED_RELEASE: "1" run: bash scripts/build-pkg.sh - name: Notarize .pkg - if: env.APPLE_API_KEY != '' env: APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey.p8 @@ -294,9 +313,20 @@ jobs: VERSION=$(node -p "require('./package.json').version") echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Validate x86_64 signing inputs + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + [ -n "$APPLE_CERTIFICATE_BASE64" ] || { echo "Missing APPLE_CERTIFICATE_BASE64"; exit 1; } + [ -n "$APPLE_CERTIFICATE_PASSWORD" ] || { echo "Missing APPLE_CERTIFICATE_PASSWORD"; exit 1; } + [ -n "$APPLE_SIGNING_IDENTITY" ] || { echo "Missing APPLE_SIGNING_IDENTITY"; exit 1; } + - name: Build x86_64 .app artifact env: - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + BLITZ_REQUIRE_SIGNED_RELEASE: "1" run: | swift build -c release bash scripts/bundle.sh release diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index 294db0f..06b9343 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -21,8 +21,8 @@ jobs: env: APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }} - APPLE_INSTALLER_IDENTITY: ${{ secrets.APPLE_INSTALLER_IDENTITY || '' }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_INSTALLER_IDENTITY: ${{ secrets.APPLE_INSTALLER_IDENTITY }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} @@ -62,6 +62,16 @@ jobs: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + - name: Validate production signing inputs + run: | + [ -n "$APPLE_CERTIFICATE_BASE64" ] || { echo "Missing APPLE_CERTIFICATE_BASE64"; exit 1; } + [ -n "$APPLE_CERTIFICATE_PASSWORD" ] || { echo "Missing APPLE_CERTIFICATE_PASSWORD"; exit 1; } + [ -n "$APPLE_SIGNING_IDENTITY" ] || { echo "Missing APPLE_SIGNING_IDENTITY"; exit 1; } + [ -n "$APPLE_INSTALLER_IDENTITY" ] || { echo "Missing APPLE_INSTALLER_IDENTITY"; exit 1; } + [ -n "$APPLE_API_KEY" ] || { echo "Missing APPLE_API_KEY"; exit 1; } + [ -n "$APPLE_API_ISSUER" ] || { echo "Missing APPLE_API_ISSUER"; exit 1; } + [ -n "$APPLE_API_KEY_BASE64" ] || { echo "Missing APPLE_API_KEY_BASE64"; exit 1; } + - name: Get version id: version run: | @@ -71,13 +81,12 @@ jobs: - name: Build release .app run: | swift build -c release - bash scripts/bundle.sh release + BLITZ_REQUIRE_SIGNED_RELEASE=1 bash scripts/bundle.sh release - name: Build .pkg - run: bash scripts/build-pkg.sh + run: BLITZ_REQUIRE_SIGNED_RELEASE=1 bash scripts/build-pkg.sh - name: Notarize .pkg - if: ${{ inputs.notarize_pkg && env.APPLE_API_KEY != '' }} env: APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey.p8 run: | @@ -142,7 +151,7 @@ jobs: env: APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY || '-' }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} steps: - uses: actions/checkout@v4 with: @@ -161,6 +170,12 @@ jobs: - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer + - name: Validate x86_64 signing inputs + run: | + [ -n "$APPLE_CERTIFICATE_BASE64" ] || { echo "Missing APPLE_CERTIFICATE_BASE64"; exit 1; } + [ -n "$APPLE_CERTIFICATE_PASSWORD" ] || { echo "Missing APPLE_CERTIFICATE_PASSWORD"; exit 1; } + [ -n "$APPLE_SIGNING_IDENTITY" ] || { echo "Missing APPLE_SIGNING_IDENTITY"; exit 1; } + - name: Import signing certificate if: ${{ env.APPLE_CERTIFICATE_BASE64 != '' }} run: | @@ -188,7 +203,7 @@ jobs: - name: Build x86_64 .app artifact run: | swift build -c release - bash scripts/bundle.sh release + BLITZ_REQUIRE_SIGNED_RELEASE=1 bash scripts/bundle.sh release mkdir -p build ditto -c -k --sequesterRsrc --keepParent .build/Blitz.app "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip" shasum -a 256 "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip" > "build/Blitz-${{ steps.version.outputs.version }}-x86_64.app.zip.sha256" diff --git a/scripts/build-pkg.sh b/scripts/build-pkg.sh index 31a125d..28c8685 100755 --- a/scripts/build-pkg.sh +++ b/scripts/build-pkg.sh @@ -22,6 +22,19 @@ PKG_SCRIPTS="$ROOT_DIR/scripts/pkg-scripts" ENTITLEMENTS="$ROOT_DIR/scripts/Entitlements.plist" BUILD_DIR="$ROOT_DIR/build/pkg" OUTPUT_PKG="$ROOT_DIR/build/$APP_NAME-$VERSION.pkg" +REQUIRE_SIGNED_RELEASE="${BLITZ_REQUIRE_SIGNED_RELEASE:-0}" + +# Require production signing inputs when strict mode is enabled. +if [ "$REQUIRE_SIGNED_RELEASE" = "1" ]; then + [ -n "${APPLE_SIGNING_IDENTITY:-}" ] || { + echo "ERROR: APPLE_SIGNING_IDENTITY is required for production pkg builds." >&2 + exit 1 + } + [ -n "${APPLE_INSTALLER_IDENTITY:-}" ] || { + echo "ERROR: APPLE_INSTALLER_IDENTITY is required for production pkg builds." >&2 + exit 1 + } +fi # Verify .app exists if [ ! -d "$SOURCE_APP" ]; then diff --git a/scripts/bundle.sh b/scripts/bundle.sh index 19ab902..524a0c5 100755 --- a/scripts/bundle.sh +++ b/scripts/bundle.sh @@ -14,6 +14,7 @@ ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" SIGNING_IDENTITY="${APPLE_SIGNING_IDENTITY:-}" ENTITLEMENTS="$ROOT_DIR/scripts/Entitlements.plist" TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}" +REQUIRE_SIGNED_RELEASE="${BLITZ_REQUIRE_SIGNED_RELEASE:-0}" resolve_ascd_path() { local candidate="${BLITZ_ASCD_PATH:-}" @@ -83,6 +84,11 @@ if [ "$CONFIG" = "debug" ] && [ "$TIMESTAMP_MODE" = "auto" ]; then TIMESTAMP_MODE="none" fi +if [ -z "$SIGNING_IDENTITY" ] && [ "$REQUIRE_SIGNED_RELEASE" = "1" ]; then + echo "ERROR: APPLE_SIGNING_IDENTITY is required for production release builds." >&2 + exit 1 +fi + if [ -z "$SIGNING_IDENTITY" ]; then echo "WARNING: APPLE_SIGNING_IDENTITY not set, falling back to ad-hoc signing." echo " TCC will require re-approval on every rebuild." From becb82fefb0883ba600b9d472532d8cef2e61aad Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Wed, 25 Mar 2026 13:34:39 -0700 Subject: [PATCH 29/51] default project to swift --- src/views/projects/NewProjectSheet.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/projects/NewProjectSheet.swift b/src/views/projects/NewProjectSheet.swift index b24e273..724f7e0 100644 --- a/src/views/projects/NewProjectSheet.swift +++ b/src/views/projects/NewProjectSheet.swift @@ -6,7 +6,7 @@ struct NewProjectSheet: View { @State private var projectName = "" @State private var platform: ProjectPlatform = .iOS - @State private var projectType: ProjectType = .reactNative + @State private var projectType: ProjectType = .swift @State private var errorMessage: String? var body: some View { From ce3fdb4d0cd0998358de9e8be293d1685b4f9054 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Wed, 25 Mar 2026 13:37:08 -0700 Subject: [PATCH 30/51] Bundle ASC auth shims and harden release updates --- Tests/blitz_tests/ASCAuthBridgeTests.swift | 126 ++++++++ .../blitz_tests/AutoUpdateServiceTests.swift | 16 + .../ShellIntegrationServiceTests.swift | 48 +++ src/AppState.swift | 4 + src/BlitzApp.swift | 6 + src/BlitzPaths.swift | 9 + src/services/ASCAuthBridge.swift | 294 ++++++++++++++++++ src/services/ASCWebSessionStore.swift | 179 +++++++++++ src/services/AutoUpdateService.swift | 48 ++- src/services/SettingsService.swift | 17 + src/services/ShellIntegrationService.swift | 251 +++++++++++++++ src/services/TerminalLauncher.swift | 52 +++- src/utilities/ProjectStorage.swift | 35 --- src/views/ContentView.swift | 15 +- src/views/build/ConnectAIPopover.swift | 7 +- src/views/settings/SettingsView.swift | 34 ++ src/views/shared/asc/ASCCredentialForm.swift | 12 +- src/views/shared/asc/BundleIDSetupView.swift | 12 +- 18 files changed, 1076 insertions(+), 89 deletions(-) create mode 100644 Tests/blitz_tests/ASCAuthBridgeTests.swift create mode 100644 Tests/blitz_tests/AutoUpdateServiceTests.swift create mode 100644 Tests/blitz_tests/ShellIntegrationServiceTests.swift create mode 100644 src/services/ASCAuthBridge.swift create mode 100644 src/services/ASCWebSessionStore.swift create mode 100644 src/services/ShellIntegrationService.swift diff --git a/Tests/blitz_tests/ASCAuthBridgeTests.swift b/Tests/blitz_tests/ASCAuthBridgeTests.swift new file mode 100644 index 0000000..9eba96d --- /dev/null +++ b/Tests/blitz_tests/ASCAuthBridgeTests.swift @@ -0,0 +1,126 @@ +import Foundation +import Testing +@testable import Blitz + +@Test func testASCAuthBridgeWritesManagedConfigForAgentSessions() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("asc-auth-bridge-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let bundledASCD = root.appendingPathComponent("Blitz.app/Contents/Helpers/ascd") + try fileManager.createDirectory( + at: bundledASCD.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "#!/bin/sh\nexit 0\n".write(to: bundledASCD, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: bundledASCD.path) + + let bridge = ASCAuthBridge( + blitzRoot: root, + fileManager: fileManager, + bundledASCDPathProvider: { bundledASCD.path } + ) + let credentials = ASCCredentials( + issuerId: "ISSUER-123", + keyId: "KEY-123", + privateKey: """ + -----BEGIN PRIVATE KEY----- + TESTKEY + -----END PRIVATE KEY----- + """ + ) + + try bridge.syncCredentials(credentials) + + let configData = try Data(contentsOf: bridge.configURL) + let configJSON = try JSONSerialization.jsonObject(with: configData) as? [String: Any] + + #expect(configJSON?["default_key_name"] as? String == "BlitzKey") + #expect(configJSON?["key_id"] as? String == "KEY-123") + #expect(configJSON?["issuer_id"] as? String == "ISSUER-123") + #expect(configJSON?["private_key_path"] as? String == bridge.privateKeyURL.path) + + let keys = configJSON?["keys"] as? [[String: Any]] + #expect(keys?.count == 1) + #expect(keys?.first?["name"] as? String == "BlitzKey") + + let persistedPrivateKey = try String(contentsOf: bridge.privateKeyURL, encoding: .utf8) + #expect(persistedPrivateKey.contains("BEGIN PRIVATE KEY")) + + let managedLaunchPath = root.appendingPathComponent("projects/demo").path + let env = bridge.environmentOverrides(forLaunchPath: managedLaunchPath) + #expect(env["PATH"]?.hasPrefix(bridge.binDirectory.path + ":") == true) + #expect(FileManager.default.isExecutableFile(atPath: bridge.ascWrapperURL.path)) + #expect(FileManager.default.isExecutableFile(atPath: bridge.ascdShimURL.path)) + + let wrapper = try String(contentsOf: bridge.ascWrapperURL, encoding: .utf8) + #expect(wrapper.contains("__ascd_run_cli__")) + #expect(wrapper.contains("ASC_CONFIG_PATH")) + #expect(wrapper.contains(bridge.configURL.path)) + #expect(wrapper.contains("${SELF_DIR}/ascd")) + + let shellExports = bridge.shellExportCommands(forLaunchPath: managedLaunchPath) + #expect(shellExports.contains { $0.contains("export PATH=") && $0.contains(bridge.binDirectory.path) }) + #expect(shellExports.count == 1) + + let unrelatedEnv = bridge.environmentOverrides(forLaunchPath: "/tmp/not-managed") + #expect(unrelatedEnv.isEmpty) +} + +@Test func testASCWebSessionStoreMatchesASCCacheShapeAndPreservesSessions() throws { + let firstSession = IrisSession( + cookies: [ + .init(name: "DES123", value: "alpha", domain: ".apple.com", path: "/"), + .init(name: "itctx", value: "beta", domain: ".appstoreconnect.apple.com", path: "/"), + ], + email: "first@example.com", + capturedAt: Date(timeIntervalSince1970: 1) + ) + let secondSession = IrisSession( + cookies: [ + .init(name: "myacinfo", value: "gamma", domain: ".apple.com", path: "/"), + ], + email: "second@example.com", + capturedAt: Date(timeIntervalSince1970: 2) + ) + + let firstData = try ASCWebSessionStore.mergedData( + storing: firstSession, + into: nil, + now: Date(timeIntervalSince1970: 10) + ) + let mergedData = try ASCWebSessionStore.mergedData( + storing: secondSession, + into: firstData, + now: Date(timeIntervalSince1970: 20) + ) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let mergedStore = try decoder.decode(ASCWebSessionStore.self, from: mergedData) + + #expect(mergedStore.version == 1) + #expect(mergedStore.sessions.count == 2) + #expect(mergedStore.lastKey != nil) + + let storedEmails = Set(mergedStore.sessions.values.compactMap(\.userEmail)) + #expect(storedEmails == Set(["first@example.com", "second@example.com"])) + + let firstStoredSession = mergedStore.sessions.values.first { $0.userEmail == "first@example.com" } + #expect(firstStoredSession?.cookies["https://appstoreconnect.apple.com/"]?.count == 2) + #expect(firstStoredSession?.cookies["https://idmsa.apple.com/"]?.count == 1) + #expect(firstStoredSession?.cookies["https://gsa.apple.com/"]?.count == 1) + + let removedData = try ASCWebSessionStore.removingSession( + email: "second@example.com", + from: mergedData + ) + #expect(removedData != nil) + + let removedStore = try decoder.decode(ASCWebSessionStore.self, from: try #require(removedData)) + #expect(removedStore.sessions.count == 1) + #expect(removedStore.sessions.values.first?.userEmail == "first@example.com") + #expect(removedStore.lastKey == removedStore.sessions.keys.first) +} diff --git a/Tests/blitz_tests/AutoUpdateServiceTests.swift b/Tests/blitz_tests/AutoUpdateServiceTests.swift new file mode 100644 index 0000000..f5e1e2c --- /dev/null +++ b/Tests/blitz_tests/AutoUpdateServiceTests.swift @@ -0,0 +1,16 @@ +import Foundation +import Testing +@testable import Blitz + +@Test @MainActor func testAppUpdateInstallScriptVerifiesSignatureAndFailsOnScriptErrors() { + let zipPath = URL(fileURLWithPath: "/tmp/Blitz.app.zip") + let script = AutoUpdateManager.appUpdateInstallScript(zipPath: zipPath) + + #expect(script.contains("/usr/bin/codesign --verify --deep --strict")) + #expect(script.contains("/usr/sbin/spctl --assess --verbose=4")) + #expect(script.contains("CFBundleIdentifier")) + #expect(script.contains("Contents/Helpers/ascd")) + #expect(script.contains("BLITZ_UPDATE_CONTEXT='auto-update'")) + #expect(!script.contains("PREINSTALL\\\" '' '' '/' >> \\\"$UPDATE_LOG\\\" 2>&1 || true")) + #expect(!script.contains("POSTINSTALL\\\" '' '' '/' >> \\\"$UPDATE_LOG\\\" 2>&1 || true")) +} diff --git a/Tests/blitz_tests/ShellIntegrationServiceTests.swift b/Tests/blitz_tests/ShellIntegrationServiceTests.swift new file mode 100644 index 0000000..9736185 --- /dev/null +++ b/Tests/blitz_tests/ShellIntegrationServiceTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import Blitz + +@Test func testShellIntegrationInstallsAndRemovesManagedZshBlock() throws { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory + .appendingPathComponent("shell-integration-home-\(UUID().uuidString)", isDirectory: true) + let blitzRoot = home.appendingPathComponent(".blitz", isDirectory: true) + + try fileManager.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: home) } + + let bundledASCD = home.appendingPathComponent("Blitz.app/Contents/Helpers/ascd") + try fileManager.createDirectory( + at: bundledASCD.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "#!/bin/sh\nexit 0\n".write(to: bundledASCD, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: bundledASCD.path) + + let shellService = ShellIntegrationService( + homeDirectory: home, + blitzRoot: blitzRoot, + fileManager: fileManager, + bundledASCDPathProvider: { bundledASCD.path }, + loginShellPathProvider: { "/bin/zsh" } + ) + + try shellService.sync(enabled: true) + + let zshrc = home.appendingPathComponent(".zshrc") + let zshrcContents = try String(contentsOf: zshrc, encoding: .utf8) + #expect(zshrcContents.contains("Blitz shell integration")) + #expect(zshrcContents.contains(". \"$HOME/.blitz/shell/init.sh\"")) + + let initScript = try String(contentsOf: shellService.initScriptURL, encoding: .utf8) + #expect(initScript.contains("BLITZ_BIN")) + #expect(initScript.contains(".blitz/bin")) + #expect(FileManager.default.isExecutableFile(atPath: blitzRoot.appendingPathComponent("bin/ascd").path)) + #expect(FileManager.default.isExecutableFile(atPath: blitzRoot.appendingPathComponent("bin/asc").path)) + + try shellService.sync(enabled: false) + + let cleanedZshrc = try String(contentsOf: zshrc, encoding: .utf8) + #expect(!cleanedZshrc.contains("Blitz shell integration")) + #expect(!fileManager.fileExists(atPath: shellService.initScriptURL.path)) +} diff --git a/src/AppState.swift b/src/AppState.swift index 15ac57d..b24ef02 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -616,6 +616,10 @@ final class TerminalSession: Identifiable { let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" var env = ProcessInfo.processInfo.environment env["TERM"] = "xterm-256color" + let authEnvironment = ASCAuthBridge().environmentOverrides(forLaunchPath: projectPath) + for (key, value) in authEnvironment { + env[key] = value + } let envPairs = env.map { "\($0.key)=\($0.value)" } termView.startProcess( diff --git a/src/BlitzApp.swift b/src/BlitzApp.swift index 91ad1d1..cddb7a3 100644 --- a/src/BlitzApp.swift +++ b/src/BlitzApp.swift @@ -12,6 +12,7 @@ final class MCPBootstrap { started = true installMCPHelper() + installASCEnvironment(settings: appState.settingsStore) installClaudeSkills() updateIphoneMCP() ProjectStorage().ensureGlobalMCPConfigs(whitelistBlitzMCP: appState.settingsStore.whitelistBlitzMCPTools) @@ -123,6 +124,11 @@ final class MCPBootstrap { try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: BlitzPaths.mcpBridge.path) } + private func installASCEnvironment(settings: SettingsService) { + try? ASCAuthBridge().installCLIShims() + try? ShellIntegrationService().sync(enabled: settings.enableASCShellIntegration) + } + private func bundledMCPHelperURL() -> URL? { let fm = FileManager.default diff --git a/src/BlitzPaths.swift b/src/BlitzPaths.swift index 58fb5ba..964e611 100644 --- a/src/BlitzPaths.swift +++ b/src/BlitzPaths.swift @@ -19,6 +19,15 @@ enum BlitzPaths { /// Settings file: ~/.blitz/settings.json static var settings: URL { root.appendingPathComponent("settings.json") } + /// User-facing Blitz bin directory: ~/.blitz/bin/ + static var bin: URL { root.appendingPathComponent("bin") } + + /// Shell integration directory: ~/.blitz/shell/ + static var shell: URL { root.appendingPathComponent("shell") } + + /// Shell integration entrypoint: ~/.blitz/shell/init.sh + static var shellInit: URL { shell.appendingPathComponent("init.sh") } + /// MCP helper executable: ~/.blitz/blitz-macos-mcp static var mcpHelper: URL { BlitzMCPTransportPaths.helper } diff --git a/src/services/ASCAuthBridge.swift b/src/services/ASCAuthBridge.swift new file mode 100644 index 0000000..ef56ca6 --- /dev/null +++ b/src/services/ASCAuthBridge.swift @@ -0,0 +1,294 @@ +import Foundation + +struct ASCAuthBridge { + static let managedProfileName = "BlitzKey" + private static let cliSubprocessModeArg = "__ascd_run_cli__" + + let blitzRoot: URL + let fileManager: FileManager + private let bundledASCDPathProvider: () -> String? + + init( + blitzRoot: URL = BlitzPaths.root, + fileManager: FileManager = .default, + bundledASCDPathProvider: @escaping () -> String? = { + ASCAuthBridge.resolveBundledASCDPath( + fileManager: .default, + environment: ProcessInfo.processInfo.environment + ) + } + ) { + self.blitzRoot = blitzRoot + self.fileManager = fileManager + self.bundledASCDPathProvider = bundledASCDPathProvider + } + + var bridgeDirectory: URL { + blitzRoot.appendingPathComponent("asc-agent", isDirectory: true) + } + + var binDirectory: URL { + blitzRoot.appendingPathComponent("bin", isDirectory: true) + } + + var configURL: URL { + bridgeDirectory.appendingPathComponent("config.json") + } + + var privateKeyURL: URL { + bridgeDirectory.appendingPathComponent("AuthKey_\(Self.managedProfileName).p8") + } + + var ascWrapperURL: URL { + binDirectory.appendingPathComponent("asc") + } + + var ascdShimURL: URL { + binDirectory.appendingPathComponent("ascd") + } + + func environmentOverrides(forLaunchPath launchPath: String?) -> [String: String] { + guard shouldInjectEnvironment(forLaunchPath: launchPath) else { + return [:] + } + + prepareEnvironment() + let currentPath = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" + return [ + "PATH": "\(binDirectory.path):\(currentPath)", + ] + } + + func shellExportCommands(forLaunchPath launchPath: String?) -> [String] { + guard shouldInjectEnvironment(forLaunchPath: launchPath) else { + return [] + } + + prepareEnvironment() + return [ + "export PATH=\(shellQuote(binDirectory.path)):\"$PATH\"", + ] + } + + func syncStoredCredentials() throws { + try syncCredentials(ASCCredentials.load()) + } + + func syncCredentials(_ credentials: ASCCredentials?) throws { + guard let credentials else { + cleanup() + return + } + + try ensureBridgeDirectory() + try writePrivateKey(credentials.privateKey) + try writeConfig(credentials: credentials) + } + + func cleanup() { + try? fileManager.removeItem(at: configURL) + try? fileManager.removeItem(at: privateKeyURL) + } + + func installCLIShims() throws { + let bundledASCDPath = bundledASCDPathProvider()? + .trimmingCharacters(in: .whitespacesAndNewlines) + try ensureBinDirectory() + try installASCDShim(from: bundledASCDPath) + try ensureCLIWrapper() + } + + private func prepareEnvironment() { + try? syncStoredCredentials() + try? installCLIShims() + } + + private func ensureBridgeDirectory() throws { + try fileManager.createDirectory( + at: bridgeDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: bridgeDirectory.path) + } + + private func writePrivateKey(_ privateKey: String) throws { + let data = Data(privateKey.trimmingCharacters(in: .whitespacesAndNewlines).utf8) + try data.write(to: privateKeyURL, options: .atomic) + try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: privateKeyURL.path) + } + + private func writeConfig(credentials: ASCCredentials) throws { + let config = ManagedConfig( + keyID: credentials.keyId, + issuerID: credentials.issuerId, + privateKeyPath: privateKeyURL.path, + defaultKeyName: Self.managedProfileName, + keys: [ + ManagedCredential( + name: Self.managedProfileName, + keyID: credentials.keyId, + issuerID: credentials.issuerId, + privateKeyPath: privateKeyURL.path + ) + ] + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(config) + try data.write(to: configURL, options: .atomic) + try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: configURL.path) + } + + private func ensureBinDirectory() throws { + try fileManager.createDirectory( + at: binDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: binDirectory.path) + } + + private func installASCDShim(from bundledASCDPath: String?) throws { + guard let bundledASCDPath, + !bundledASCDPath.isEmpty, + fileManager.isExecutableFile(atPath: bundledASCDPath) else { + guard fileManager.isExecutableFile(atPath: ascdShimURL.path) else { + throw NSError( + domain: "ASCAuthBridge", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Bundled ascd helper is unavailable."] + ) + } + return + } + + let tempURL = binDirectory.appendingPathComponent("ascd.tmp.\(UUID().uuidString)") + try? fileManager.removeItem(at: tempURL) + try fileManager.copyItem(at: URL(fileURLWithPath: bundledASCDPath), to: tempURL) + try? fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tempURL.path) + try? fileManager.removeItem(at: ascdShimURL) + try fileManager.moveItem(at: tempURL, to: ascdShimURL) + try? fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: ascdShimURL.path) + } + + private func ensureCLIWrapper() throws { + let script = wrapperScript() + try script.write(to: ascWrapperURL, atomically: true, encoding: .utf8) + try? fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: ascWrapperURL.path) + } + + private func wrapperScript() -> String { + return """ + #!/bin/sh + set -eu + + SELF_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" + ASCD_PATH="${SELF_DIR}/ascd" + + if [ ! -x "${ASCD_PATH}" ]; then + echo "asc: Blitz helper not found at ${ASCD_PATH}. Start Blitz first." >&2 + exit 1 + fi + + if [ -z "${ASC_CONFIG_PATH:-}" ]; then + export ASC_CONFIG_PATH=\(shellQuote(configURL.path)) + fi + + if [ -z "${ASC_BYPASS_KEYCHAIN:-}" ]; then + export ASC_BYPASS_KEYCHAIN='1' + fi + + exec "${ASCD_PATH}" \(Self.cliSubprocessModeArg) "$@" + """ + } + + private func shouldInjectEnvironment(forLaunchPath launchPath: String?) -> Bool { + guard let launchPath else { return false } + + let normalizedPath = URL(fileURLWithPath: launchPath).standardizedFileURL.path + let projectsRoot = blitzRoot.appendingPathComponent("projects", isDirectory: true).standardizedFileURL.path + let mcpsRoot = blitzRoot.appendingPathComponent("mcps", isDirectory: true).standardizedFileURL.path + + return normalizedPath == projectsRoot + || normalizedPath.hasPrefix(projectsRoot + "/") + || normalizedPath == mcpsRoot + || normalizedPath.hasPrefix(mcpsRoot + "/") + } + + private func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + static func resolveBundledASCDPath( + fileManager: FileManager, + environment: [String: String] + ) -> String? { + var candidates: [String] = [] + var seen = Set() + + func appendCandidate(_ rawValue: String?) { + guard let rawValue else { return } + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + let expanded = NSString(string: trimmed).expandingTildeInPath + let normalized: String + if expanded.hasPrefix("/") { + normalized = URL(fileURLWithPath: expanded).standardizedFileURL.path + } else { + normalized = expanded + } + + guard !normalized.isEmpty, seen.insert(normalized).inserted else { return } + candidates.append(normalized) + } + + appendCandidate(environment["BLITZ_ASCD_PATH"]) + appendCandidate(Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/ascd").path) + appendCandidate( + Bundle.main.executableURL? + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Helpers/ascd").path + ) + appendCandidate( + Bundle.main.privateFrameworksURL? + .deletingLastPathComponent() + .appendingPathComponent("Helpers/ascd").path + ) + + return candidates.first(where: { fileManager.isExecutableFile(atPath: $0) }) + } +} + +private struct ManagedConfig: Codable { + let keyID: String + let issuerID: String + let privateKeyPath: String + let defaultKeyName: String + let keys: [ManagedCredential] + + private enum CodingKeys: String, CodingKey { + case keyID = "key_id" + case issuerID = "issuer_id" + case privateKeyPath = "private_key_path" + case defaultKeyName = "default_key_name" + case keys + } +} + +private struct ManagedCredential: Codable { + let name: String + let keyID: String + let issuerID: String + let privateKeyPath: String + + private enum CodingKeys: String, CodingKey { + case name + case keyID = "key_id" + case issuerID = "issuer_id" + case privateKeyPath = "private_key_path" + } +} diff --git a/src/services/ASCWebSessionStore.swift b/src/services/ASCWebSessionStore.swift new file mode 100644 index 0000000..0a646e7 --- /dev/null +++ b/src/services/ASCWebSessionStore.swift @@ -0,0 +1,179 @@ +import CryptoKit +import Foundation + +struct ASCWebSessionStore: Codable { + static let keychainService = "asc-web-session" + static let keychainAccount = "asc:web-session:store" + static let version = 1 + + struct Session: Codable { + let version: Int + let updatedAt: Date + let userEmail: String? + let cookies: [String: [Cookie]] + + private enum CodingKeys: String, CodingKey { + case version + case updatedAt = "updated_at" + case userEmail = "user_email" + case cookies + } + } + + struct Cookie: Codable { + let name: String + let value: String + let path: String + let domain: String + let expires: Date? + let maxAge: Int? + let secure: Bool + let httpOnly: Bool + let sameSite: Int? + + private enum CodingKeys: String, CodingKey { + case name + case value + case path + case domain + case expires + case maxAge = "max_age" + case secure + case httpOnly = "http_only" + case sameSite = "same_site" + } + } + + let version: Int + var lastKey: String? + var sessions: [String: Session] + + private enum CodingKeys: String, CodingKey { + case version + case lastKey = "last_key" + case sessions + } + + private static let baseURLs = [ + "https://appstoreconnect.apple.com/", + "https://idmsa.apple.com/", + "https://gsa.apple.com/", + ] + + static func mergedData( + storing session: IrisSession, + into existingData: Data?, + now: Date = Date() + ) throws -> Data { + var store = try decode(existingData) ?? ASCWebSessionStore(version: version, lastKey: nil, sessions: [:]) + let key = sessionKey(forEmail: session.email) + store.sessions[key] = Session( + version: version, + updatedAt: now, + userEmail: normalizedEmail(session.email), + cookies: persistedCookies(from: session.cookies) + ) + store.lastKey = key + return try encode(store) + } + + static func removingSession( + email: String?, + from existingData: Data? + ) throws -> Data? { + guard var store = try decode(existingData) else { return nil } + + let key = if let email, !normalizedEmail(email).isEmpty { + sessionKey(forEmail: email) + } else { + store.lastKey ?? "" + } + + guard !key.isEmpty else { return existingData } + + store.sessions.removeValue(forKey: key) + if store.sessions.isEmpty { + return nil + } + + if store.lastKey == key { + store.lastKey = mostRecentSessionKey(in: store.sessions) + } + + return try encode(store) + } + + private static func normalizedEmail(_ email: String?) -> String { + (email ?? "unknown") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } + + private static func sessionKey(forEmail email: String?) -> String { + let digest = SHA256.hash(data: Data(normalizedEmail(email).utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private static func persistedCookies(from cookies: [IrisSession.IrisCookie]) -> [String: [Cookie]] { + var buckets: [String: [Cookie]] = [:] + + for cookie in cookies { + let persistedCookie = Cookie( + name: cookie.name, + value: cookie.value, + path: cookie.path.isEmpty ? "/" : cookie.path, + domain: cookie.domain, + expires: nil, + maxAge: nil, + secure: true, + httpOnly: true, + sameSite: nil + ) + + let matchingBases = baseURLs.filter { baseURL in + guard let host = URL(string: baseURL)?.host else { return false } + return cookieMatches(host: host, domain: cookie.domain) + } + + for baseURL in matchingBases { + buckets[baseURL, default: []].append(persistedCookie) + } + } + + return buckets + } + + private static func cookieMatches(host: String, domain: String) -> Bool { + let normalizedHost = host.lowercased() + let normalizedDomain = domain + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + + guard !normalizedDomain.isEmpty else { return false } + return normalizedHost == normalizedDomain || normalizedHost.hasSuffix("." + normalizedDomain) + } + + private static func mostRecentSessionKey(in sessions: [String: Session]) -> String? { + sessions.max { lhs, rhs in + if lhs.value.updatedAt == rhs.value.updatedAt { + return lhs.key < rhs.key + } + return lhs.value.updatedAt < rhs.value.updatedAt + }?.key + } + + private static func decode(_ data: Data?) throws -> ASCWebSessionStore? { + guard let data, !data.isEmpty else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode(ASCWebSessionStore.self, from: data) + } + + private static func encode(_ store: ASCWebSessionStore) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode(store) + } +} diff --git a/src/services/AutoUpdateService.swift b/src/services/AutoUpdateService.swift index 5a3aa00..fe66d55 100644 --- a/src/services/AutoUpdateService.swift +++ b/src/services/AutoUpdateService.swift @@ -169,27 +169,11 @@ final class AutoUpdateManager { private func installApp(zipPath: URL) async throws { let pid = ProcessInfo.processInfo.processIdentifier - let zip = zipPath.path.replacingOccurrences(of: "'", with: "'\\''") // AppleScript statement 1: unzip, run preinstall, replace app, run postinstall // The PKG postinstall chowns Blitz.app to the current user, so no admin needed. // Pre/postinstall scripts are embedded in the .app at Contents/Resources/pkg-scripts/. - let installScript = """ - do shell script "\ - TMPZIP='\(zip)'; \ - UNZIP_DIR=$(mktemp -d); \ - unzip -qo \\"$TMPZIP\\" -d \\"$UNZIP_DIR\\"; \ - APP_SRC=$(find \\"$UNZIP_DIR\\" -maxdepth 1 -name '*.app' -type d | head -1); \ - if [ -z \\"$APP_SRC\\" ]; then rm -rf \\"$UNZIP_DIR\\"; exit 1; fi; \ - PREINSTALL=\\"$APP_SRC/Contents/Resources/pkg-scripts/preinstall\\"; \ - if [ -x \\"$PREINSTALL\\" ]; then BLITZ_UPDATE_CONTEXT='auto-update' \\"$PREINSTALL\\" '' '' '/' >> /tmp/blitz_install.log 2>&1 || true; fi; \ - rm -rf /Applications/Blitz.app; \ - mv \\"$APP_SRC\\" /Applications/Blitz.app; \ - POSTINSTALL='/Applications/Blitz.app/Contents/Resources/pkg-scripts/postinstall'; \ - if [ -x \\"$POSTINSTALL\\" ]; then BLITZ_UPDATE_CONTEXT='auto-update' \\"$POSTINSTALL\\" '' '' '/' >> /tmp/blitz_install.log 2>&1 || true; fi; \ - rm -rf \\"$UNZIP_DIR\\" \\"$TMPZIP\\"\ - " - """ + let installScript = Self.appUpdateInstallScript(zipPath: zipPath) // AppleScript statement 2: background wait-for-exit + relaunch let relaunchScript = """ @@ -284,4 +268,34 @@ final class AutoUpdateManager { let message: String var errorDescription: String? { message } } + + static func appUpdateInstallScript(zipPath: URL) -> String { + let zip = shellLiteral(zipPath.path) + return """ + do shell script "set -eu; \ + TMPZIP=\(zip); \ + UPDATE_LOG='/tmp/blitz_install.log'; \ + UNZIP_DIR=$(mktemp -d); \ + cleanup() { rm -rf \\"$UNZIP_DIR\\" \\"$TMPZIP\\"; }; \ + trap cleanup EXIT; \ + unzip -qo \\"$TMPZIP\\" -d \\"$UNZIP_DIR\\"; \ + APP_SRC=$(find \\"$UNZIP_DIR\\" -maxdepth 1 -name '*.app' -type d | head -1); \ + if [ -z \\"$APP_SRC\\" ]; then echo 'Update failed: extracted app not found' >> \\"$UPDATE_LOG\\"; exit 1; fi; \ + /usr/bin/codesign --verify --deep --strict \\"$APP_SRC\\" >> \\"$UPDATE_LOG\\" 2>&1; \ + /usr/sbin/spctl --assess --verbose=4 \\"$APP_SRC\\" >> \\"$UPDATE_LOG\\" 2>&1; \ + BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \\"$APP_SRC/Contents/Info.plist\\" 2>> \\"$UPDATE_LOG\\" || true); \ + if [ \\"$BUNDLE_ID\\" != 'com.blitz.macos' ]; then echo 'Update failed: unexpected bundle identifier' >> \\"$UPDATE_LOG\\"; exit 1; fi; \ + if [ ! -x \\"$APP_SRC/Contents/Helpers/ascd\\" ]; then echo 'Update failed: bundled ascd helper missing' >> \\"$UPDATE_LOG\\"; exit 1; fi; \ + PREINSTALL=\\"$APP_SRC/Contents/Resources/pkg-scripts/preinstall\\"; \ + if [ -x \\"$PREINSTALL\\" ]; then BLITZ_UPDATE_CONTEXT='auto-update' \\"$PREINSTALL\\" '' '' '/' >> \\"$UPDATE_LOG\\" 2>&1; fi; \ + rm -rf /Applications/Blitz.app; \ + mv \\"$APP_SRC\\" /Applications/Blitz.app; \ + POSTINSTALL='/Applications/Blitz.app/Contents/Resources/pkg-scripts/postinstall'; \ + if [ -x \\"$POSTINSTALL\\" ]; then BLITZ_UPDATE_CONTEXT='auto-update' \\"$POSTINSTALL\\" '' '' '/' >> \\"$UPDATE_LOG\\" 2>&1; fi" + """ + } + + private static func shellLiteral(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } } diff --git a/src/services/SettingsService.swift b/src/services/SettingsService.swift index 7d48e90..b7e92fc 100644 --- a/src/services/SettingsService.swift +++ b/src/services/SettingsService.swift @@ -33,6 +33,7 @@ final class SettingsService { var sendDefaultPrompt: Bool = true var skipAgentPermissions: Bool = false var whitelistBlitzMCPTools: Bool = true + var enableASCShellIntegration: Bool = false var terminalPosition: String = "bottom" // "bottom" or "right" init() { @@ -55,6 +56,7 @@ final class SettingsService { if let sendPrompt = json["sendDefaultPrompt"] as? Bool { sendDefaultPrompt = sendPrompt } if let skipPerms = json["skipAgentPermissions"] as? Bool { skipAgentPermissions = skipPerms } if let whitelist = json["whitelistBlitzMCPTools"] as? Bool { whitelistBlitzMCPTools = whitelist } + if let shellIntegration = json["enableASCShellIntegration"] as? Bool { enableASCShellIntegration = shellIntegration } if let termPos = json["terminalPosition"] as? String { terminalPosition = termPos } } @@ -69,6 +71,7 @@ final class SettingsService { "sendDefaultPrompt": sendDefaultPrompt, "skipAgentPermissions": skipAgentPermissions, "whitelistBlitzMCPTools": whitelistBlitzMCPTools, + "enableASCShellIntegration": enableASCShellIntegration, "terminalPosition": terminalPosition, ] if let udid = defaultSimulatorUDID { @@ -104,4 +107,18 @@ final class SettingsService { save() return ResolvedTerminalSelection(terminal: resolved, replacedMissingTerminal: configured) } + + func setASCShellIntegrationEnabled(_ enabled: Bool) throws { + let previousValue = enableASCShellIntegration + enableASCShellIntegration = enabled + save() + + do { + try ShellIntegrationService().sync(enabled: enabled) + } catch { + enableASCShellIntegration = previousValue + save() + throw error + } + } } diff --git a/src/services/ShellIntegrationService.swift b/src/services/ShellIntegrationService.swift new file mode 100644 index 0000000..a4fc868 --- /dev/null +++ b/src/services/ShellIntegrationService.swift @@ -0,0 +1,251 @@ +import Darwin +import Foundation + +struct ShellIntegrationService { + enum ShellKind: Equatable { + case zsh + case bash + case unsupported(String?) + + var displayName: String { + switch self { + case .zsh: + return "zsh" + case .bash: + return "bash" + case .unsupported(let path): + let shellName = URL(fileURLWithPath: path ?? "").lastPathComponent + return shellName.isEmpty ? "unknown shell" : shellName + } + } + } + + private static let startMarker = "# >>> Blitz shell integration >>>" + private static let endMarker = "# <<< Blitz shell integration <<<" + + let homeDirectory: URL + let blitzRoot: URL + let fileManager: FileManager + private let authBridge: ASCAuthBridge + private let loginShellPathProvider: () -> String? + + init( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + blitzRoot: URL = BlitzPaths.root, + fileManager: FileManager = .default, + bundledASCDPathProvider: @escaping () -> String? = { + ASCAuthBridge.resolveBundledASCDPath( + fileManager: .default, + environment: ProcessInfo.processInfo.environment + ) + }, + loginShellPathProvider: @escaping () -> String? = { + ShellIntegrationService.defaultLoginShellPath() + } + ) { + self.homeDirectory = homeDirectory + self.blitzRoot = blitzRoot + self.fileManager = fileManager + self.authBridge = ASCAuthBridge( + blitzRoot: blitzRoot, + fileManager: fileManager, + bundledASCDPathProvider: bundledASCDPathProvider + ) + self.loginShellPathProvider = loginShellPathProvider + } + + var shellKind: ShellKind { + Self.detectShellKind(loginShellPathProvider()) + } + + var isSupported: Bool { + targetRCFile != nil + } + + var targetRCFile: URL? { + switch shellKind { + case .zsh: + return homeDirectory.appendingPathComponent(".zshrc") + case .bash: + return homeDirectory.appendingPathComponent(".bashrc") + case .unsupported: + return nil + } + } + + var targetRCFileLabel: String { + guard let targetRCFile else { return "unsupported shell" } + return "~/" + targetRCFile.lastPathComponent + } + + var initScriptURL: URL { + blitzRoot.appendingPathComponent("shell/init.sh") + } + + func sync(enabled: Bool) throws { + if enabled { + try install() + } else { + try uninstall() + } + } + + private func install() throws { + guard let targetRCFile else { + throw ShellIntegrationError.unsupportedShell(shellKind.displayName) + } + + try authBridge.installCLIShims() + try writeInitScript() + try upsertManagedBlock(in: targetRCFile) + + for extraRCFile in managedRCFiles where extraRCFile != targetRCFile { + try removeManagedBlock(from: extraRCFile) + } + } + + private func uninstall() throws { + for rcFile in managedRCFiles { + try removeManagedBlock(from: rcFile) + } + + try? fileManager.removeItem(at: initScriptURL) + + let shellDirectory = initScriptURL.deletingLastPathComponent() + if let contents = try? fileManager.contentsOfDirectory(atPath: shellDirectory.path), contents.isEmpty { + try? fileManager.removeItem(at: shellDirectory) + } + } + + private var managedRCFiles: [URL] { + [ + homeDirectory.appendingPathComponent(".zshrc"), + homeDirectory.appendingPathComponent(".bashrc"), + ] + } + + private func writeInitScript() throws { + let shellDirectory = initScriptURL.deletingLastPathComponent() + try fileManager.createDirectory( + at: shellDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: shellDirectory.path) + + let binDirectory = blitzRoot.appendingPathComponent("bin").path + let script = """ + #!/bin/sh + BLITZ_BIN=\(shellQuote(binDirectory)) + + case ":${PATH}:" in + *":${BLITZ_BIN}:"*) ;; + *) export PATH="${BLITZ_BIN}:$PATH" ;; + esac + """ + + try script.write(to: initScriptURL, atomically: true, encoding: .utf8) + try? fileManager.setAttributes([.posixPermissions: 0o644], ofItemAtPath: initScriptURL.path) + } + + private func upsertManagedBlock(in rcFile: URL) throws { + let existing = (try? String(contentsOf: rcFile, encoding: .utf8)) ?? "" + let stripped = removingManagedBlock(from: existing).trimmingCharacters(in: .whitespacesAndNewlines) + + let block = managedBlock() + let newContents: String + if stripped.isEmpty { + newContents = block + } else { + newContents = stripped + "\n\n" + block + } + + try newContents.write(to: rcFile, atomically: true, encoding: .utf8) + } + + private func removeManagedBlock(from rcFile: URL) throws { + guard fileManager.fileExists(atPath: rcFile.path) else { return } + + let existing = try String(contentsOf: rcFile, encoding: .utf8) + let stripped = removingManagedBlock(from: existing) + guard stripped != existing else { return } + + let trimmed = stripped.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + try "".write(to: rcFile, atomically: true, encoding: .utf8) + } else { + try (trimmed + "\n").write(to: rcFile, atomically: true, encoding: .utf8) + } + } + + private func managedBlock() -> String { + """ + \(Self.startMarker) + if [ -f "$HOME/.blitz/shell/init.sh" ]; then + . "$HOME/.blitz/shell/init.sh" + fi + \(Self.endMarker) + """ + } + + private func removingManagedBlock(from text: String) -> String { + var contents = text + + while let startRange = contents.range(of: Self.startMarker), + let endRange = contents.range(of: Self.endMarker, range: startRange.lowerBound.. contents.startIndex { + let previousIndex = contents.index(before: lowerBound) + if contents[previousIndex] == "\n" { + lowerBound = previousIndex + } + } + + var upperBound = endRange.upperBound + if upperBound < contents.endIndex, contents[upperBound] == "\n" { + upperBound = contents.index(after: upperBound) + } + + contents.removeSubrange(lowerBound.. String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private static func detectShellKind(_ shellPath: String?) -> ShellKind { + let shellName = URL(fileURLWithPath: shellPath ?? "").lastPathComponent + switch shellName { + case "zsh": + return .zsh + case "bash": + return .bash + default: + return .unsupported(shellPath) + } + } + + private static func defaultLoginShellPath() -> String? { + guard let entry = getpwuid(getuid()) else { return nil } + let shellPath = String(cString: entry.pointee.pw_shell) + return shellPath.isEmpty ? nil : shellPath + } +} + +enum ShellIntegrationError: LocalizedError { + case unsupportedShell(String) + + var errorDescription: String? { + switch self { + case .unsupportedShell(let shellName): + return "Automatic shell integration only supports zsh and bash. Detected \(shellName)." + } + } +} diff --git a/src/services/TerminalLauncher.swift b/src/services/TerminalLauncher.swift index 28035d9..d6d2816 100644 --- a/src/services/TerminalLauncher.swift +++ b/src/services/TerminalLauncher.swift @@ -3,30 +3,46 @@ import CoreServices /// Launches the user's configured terminal with an AI agent CLI command. enum TerminalLauncher { - /// Launch the default terminal with the default agent CLI, optionally with a prompt. - /// Returns true if the launch was attempted, false if the terminal couldn't be resolved. - @discardableResult - static func launch( + static func buildAgentCommand( projectPath: String?, agent: AIAgent, - terminal: TerminalApp, prompt: String? = nil, skipPermissions: Bool = false - ) -> Bool { - // Build the shell command: cd to project + agent cli + optional prompt - var shellCommand = "" + ) -> String { + var segments = shellExportCommands(for: projectPath) + if let path = projectPath { - let escaped = path.replacingOccurrences(of: "'", with: "'\\''") - shellCommand = "cd '\(escaped)' && " + segments.append("cd \(shellQuote(path))") } - shellCommand += agent.cliCommand + + var agentCommand = agent.cliCommand if skipPermissions, let flag = agent.skipPermissionsFlag { - shellCommand += " \(flag)" + agentCommand += " \(flag)" } if let prompt, !prompt.isEmpty { - let escapedPrompt = prompt.replacingOccurrences(of: "'", with: "'\\''") - shellCommand += " '\(escapedPrompt)'" + agentCommand += " \(shellQuote(prompt))" } + segments.append(agentCommand) + + return segments.joined(separator: " && ") + } + + /// Launch the default terminal with the default agent CLI, optionally with a prompt. + /// Returns true if the launch was attempted, false if the terminal couldn't be resolved. + @discardableResult + static func launch( + projectPath: String?, + agent: AIAgent, + terminal: TerminalApp, + prompt: String? = nil, + skipPermissions: Bool = false + ) -> Bool { + let shellCommand = buildAgentCommand( + projectPath: projectPath, + agent: agent, + prompt: prompt, + skipPermissions: skipPermissions + ) switch terminal.resolvedFallback { case .builtIn: @@ -157,6 +173,14 @@ enum TerminalLauncher { .replacingOccurrences(of: "\"", with: "\\\"") } + private static func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private static func shellExportCommands(for projectPath: String?) -> [String] { + ASCAuthBridge().shellExportCommands(forLaunchPath: projectPath) + } + private static func runOsascript(_ script: String) -> Bool { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") diff --git a/src/utilities/ProjectStorage.swift b/src/utilities/ProjectStorage.swift index 554e40d..fe8e597 100644 --- a/src/utilities/ProjectStorage.swift +++ b/src/utilities/ProjectStorage.swift @@ -377,8 +377,6 @@ struct ProjectStorage { // into supported local agent skill directories. ensureProjectSkills(projectDir: projectDir) - // 6. ASC CLI — headless install if not already present - ensureASCCLI() } /// Clone or update the app-store-review-agent repo and symlink the agent @@ -733,39 +731,6 @@ struct ProjectStorage { """## } - /// Install the `asc` CLI if not already present on the system. - /// Checks common install locations first; if missing, runs the headless installer. - /// Runs on a background queue so it never blocks the UI. - func ensureASCCLI() { - DispatchQueue.global(qos: .utility).async { - let fm = FileManager.default - let searchPaths = [ - "/opt/homebrew/bin/asc", - "/usr/local/bin/asc", - NSHomeDirectory() + "/.local/bin/asc", - ] - - for path in searchPaths { - if fm.isExecutableFile(atPath: path) { return } - } - - // Not found — install headlessly - let install = Process() - install.executableURL = URL(fileURLWithPath: "/bin/bash") - install.arguments = ["-c", "curl -fsSL https://asccli.sh/install | bash"] - install.standardOutput = FileHandle.nullDevice - install.standardError = FileHandle.nullDevice - try? install.run() - install.waitUntilExit() - - if install.terminationStatus == 0 { - print("[ProjectStorage] ASC CLI installed") - } else { - print("[ProjectStorage] Failed to install ASC CLI") - } - } - } - private static func claudeMdContent(projectType: ProjectType) -> String { guard let templateURL = Bundle.appResources.url(forResource: "CLAUDE.md", withExtension: "template"), var template = try? String(contentsOf: templateURL, encoding: .utf8) else { diff --git a/src/views/ContentView.swift b/src/views/ContentView.swift index aa73a99..0cfccf9 100644 --- a/src/views/ContentView.swift +++ b/src/views/ContentView.swift @@ -53,14 +53,13 @@ struct ContentView: View { // Build and send the agent CLI command let agent = AIAgent(rawValue: settings.defaultAgentCLI) ?? .claudeCode - var command = agent.cliCommand - if settings.skipAgentPermissions, let flag = agent.skipPermissionsFlag { - command += " \(flag)" - } - if settings.sendDefaultPrompt, let prompt = ConnectAIPopover.prompt(for: appState.activeTab) { - let escaped = prompt.replacingOccurrences(of: "'", with: "'\\''") - command += " '\(escaped)'" - } + let prompt = settings.sendDefaultPrompt ? ConnectAIPopover.prompt(for: appState.activeTab) : nil + let command = TerminalLauncher.buildAgentCommand( + projectPath: appState.activeProject?.path, + agent: agent, + prompt: prompt, + skipPermissions: settings.skipAgentPermissions + ) // Small delay so the shell is ready to receive input DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { diff --git a/src/views/build/ConnectAIPopover.swift b/src/views/build/ConnectAIPopover.swift index d752abe..0f169b7 100644 --- a/src/views/build/ConnectAIPopover.swift +++ b/src/views/build/ConnectAIPopover.swift @@ -74,9 +74,10 @@ struct ConnectAIPopover: View { } private var command: String { - let cli = agent.cliCommand - guard let path = projectPath else { return cli } - return "cd \(path) && \(cli)" + TerminalLauncher.buildAgentCommand( + projectPath: projectPath, + agent: agent + ) } private var tabPrompt: String? { diff --git a/src/views/settings/SettingsView.swift b/src/views/settings/SettingsView.swift index c4ce9f8..18c81d4 100644 --- a/src/views/settings/SettingsView.swift +++ b/src/views/settings/SettingsView.swift @@ -10,6 +10,7 @@ struct SettingsView: View { @State private var showSkipPermsDetail = false @State private var showAskAIDetail = false @State private var terminalResetWarning: String? + @State private var shellIntegrationError: String? private let gateableCategories: [(ApprovalRequest.ToolCategory, String)] = [ (.ascFormMutation, "ASC form editing"), @@ -273,6 +274,39 @@ struct SettingsView: View { } } } + + VStack(alignment: .leading, spacing: 4) { + let shellIntegration = ShellIntegrationService() + + Toggle("Enable ASC shell integration", isOn: Binding( + get: { settings.enableASCShellIntegration }, + set: { newValue in + shellIntegrationError = nil + do { + try settings.setASCShellIntegrationEnabled(newValue) + } catch { + shellIntegrationError = error.localizedDescription + } + } + )) + .disabled(!shellIntegration.isSupported && !settings.enableASCShellIntegration) + + if shellIntegration.isSupported { + Text("Adds a managed Blitz block to \(shellIntegration.targetRCFileLabel) so manually opened shells can find `asc` on PATH.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Automatic shell integration only supports zsh and bash. Detected \(shellIntegration.shellKind.displayName).") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let shellIntegrationError { + Text(shellIntegrationError) + .font(.caption) + .foregroundStyle(.red) + } + } } } diff --git a/src/views/shared/asc/ASCCredentialForm.swift b/src/views/shared/asc/ASCCredentialForm.swift index 7e435da..13c773f 100644 --- a/src/views/shared/asc/ASCCredentialForm.swift +++ b/src/views/shared/asc/ASCCredentialForm.swift @@ -160,12 +160,12 @@ struct ASCCredentialForm: View { if terminal.isBuiltIn { appState.showTerminal = true let session = appState.terminalManager.createSession(projectPath: BlitzPaths.mcps.path) - var command = agent.cliCommand - if settings.skipAgentPermissions, let flag = agent.skipPermissionsFlag { - command += " \(flag)" - } - let escaped = prompt.replacingOccurrences(of: "'", with: "'\\''") - command += " '\(escaped)'" + let command = TerminalLauncher.buildAgentCommand( + projectPath: BlitzPaths.mcps.path, + agent: agent, + prompt: prompt, + skipPermissions: settings.skipAgentPermissions + ) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { session.sendCommand(command) } diff --git a/src/views/shared/asc/BundleIDSetupView.swift b/src/views/shared/asc/BundleIDSetupView.swift index 545317f..f4e0057 100644 --- a/src/views/shared/asc/BundleIDSetupView.swift +++ b/src/views/shared/asc/BundleIDSetupView.swift @@ -545,12 +545,12 @@ struct BundleIDSetupView: View { if terminal.isBuiltIn { appState.showTerminal = true let session = appState.terminalManager.createSession(projectPath: projectPath) - var command = agent.cliCommand - if settings.skipAgentPermissions, let flag = agent.skipPermissionsFlag { - command += " \(flag)" - } - let escaped = prompt.replacingOccurrences(of: "'", with: "'\\''") - command += " '\(escaped)'" + let command = TerminalLauncher.buildAgentCommand( + projectPath: projectPath, + agent: agent, + prompt: prompt, + skipPermissions: settings.skipAgentPermissions + ) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { session.sendCommand(command) } From 2e6c2bd94863bc5df2132a2d70592113793ef2e1 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Wed, 25 Mar 2026 13:42:23 -0700 Subject: [PATCH 31/51] dashboard summary store --- src/services/DashboardSummaryStore.swift | 70 ++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/services/DashboardSummaryStore.swift diff --git a/src/services/DashboardSummaryStore.swift b/src/services/DashboardSummaryStore.swift new file mode 100644 index 0000000..b669454 --- /dev/null +++ b/src/services/DashboardSummaryStore.swift @@ -0,0 +1,70 @@ +import Foundation + +@MainActor +@Observable +final class DashboardSummaryStore { + static let shared = DashboardSummaryStore() + + private static let freshness: TimeInterval = 120 + + var summary = ASCDashboardSummary.empty + var projectStatuses: [String: ASCDashboardProjectStatus] = [:] + var hasLoadedSummary = false + var isLoadingSummary = false + + private(set) var cacheKey: String? + private var refreshedAt: Date? + + private init() {} + + func shouldRefresh(for key: String) -> Bool { + guard cacheKey == key, let refreshedAt else { return true } + return Date().timeIntervalSince(refreshedAt) > Self.freshness + } + + func isLoading(for key: String) -> Bool { + isLoadingSummary && cacheKey == key + } + + func beginLoading(for key: String) { + if cacheKey != key { + summary = .empty + projectStatuses = [:] + hasLoadedSummary = false + } + cacheKey = key + isLoadingSummary = true + } + + func store(summary: ASCDashboardSummary, projectStatuses: [String: ASCDashboardProjectStatus], for key: String) { + self.summary = summary + self.projectStatuses = projectStatuses + hasLoadedSummary = true + cacheKey = key + refreshedAt = Date() + isLoadingSummary = false + } + + func markEmpty(for key: String) { + summary = .empty + projectStatuses = [:] + hasLoadedSummary = true + cacheKey = key + refreshedAt = Date() + isLoadingSummary = false + } + + func markUnavailable(for key: String) { + summary = .empty + projectStatuses = [:] + hasLoadedSummary = false + cacheKey = key + refreshedAt = Date() + isLoadingSummary = false + } + + func cancelLoading(for key: String) { + guard cacheKey == key else { return } + isLoadingSummary = false + } +} From 1a3c243c3dac37c75145b8cacbd03f51bb71660d Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Wed, 25 Mar 2026 13:42:37 -0700 Subject: [PATCH 32/51] minor changes and test --- Tests/blitz_tests/ASCDaemonClientTests.swift | 25 ++++++++++++++++++++ package.json | 2 +- src/services/ASCService.swift | 4 ++-- 3 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 Tests/blitz_tests/ASCDaemonClientTests.swift diff --git a/Tests/blitz_tests/ASCDaemonClientTests.swift b/Tests/blitz_tests/ASCDaemonClientTests.swift new file mode 100644 index 0000000..e54b84d --- /dev/null +++ b/Tests/blitz_tests/ASCDaemonClientTests.swift @@ -0,0 +1,25 @@ +import Foundation +import Testing +@testable import Blitz + +@Test func testSequencedPipeBufferReassemblesOutOfOrderChunks() { + var buffer = SequencedPipeBuffer() + + let delayed = buffer.append(Data("\"result\":{\"statusCode\":200}}\n".utf8), sequence: 1) + #expect(delayed.isEmpty) + + let lines = buffer.append(Data("{\"id\":\"ascd-39\",".utf8), sequence: 0) + #expect(lines.count == 1) + #expect(String(data: lines[0], encoding: .utf8) == "{\"id\":\"ascd-39\",\"result\":{\"statusCode\":200}}") +} + +@Test func testSequencedPipeBufferFlushesTrailingChunkAtEOF() { + var buffer = SequencedPipeBuffer() + + let partial = buffer.append(Data("partial stderr".utf8), sequence: 0) + #expect(partial.isEmpty) + + let flushed = buffer.append(Data(), sequence: 1) + #expect(flushed.count == 1) + #expect(String(data: flushed[0], encoding: .utf8) == "partial stderr") +} diff --git a/package.json b/package.json index 5c407f5..829b2a1 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "private": true, "scripts": { - "app": "sudo npm run build:app:debug", + "app": "npm run build:app:debug", "build:app": "killall Blitz 2>/dev/null; BUILD_CONFIG=${BUILD_CONFIG:-release} CODESIGN_TIMESTAMP=${CODESIGN_TIMESTAMP:-auto} bash scripts/bundle.sh ${BUILD_CONFIG:-release} && open .build/Blitz.app", "build:app:debug": "killall Blitz 2>/dev/null; CODESIGN_TIMESTAMP=none bash scripts/bundle.sh debug && open .build/Blitz.app", "build:app:release": "killall Blitz 2>/dev/null; bash scripts/bundle.sh release && open .build/Blitz.app", diff --git a/src/services/ASCService.swift b/src/services/ASCService.swift index 05c9de1..cc71a0d 100644 --- a/src/services/ASCService.swift +++ b/src/services/ASCService.swift @@ -159,9 +159,9 @@ final class AppStoreConnectService { func fetchAppStoreVersions(appId: String) async throws -> [ASCAppStoreVersion] { let resp = try await get("apps/\(appId)/appStoreVersions", queryItems: [ - URLQueryItem(name: "limit", value: "20") + URLQueryItem(name: "limit", value: "50") ], as: ASCListResponse.self) - return resp.data + return ASCReleaseStatus.sortedVersionsByRecency(resp.data) } // MARK: - Localizations From edf2ead5f62739510baca128cf046331b720aaa8 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Wed, 25 Mar 2026 14:17:32 -0700 Subject: [PATCH 33/51] update claude.md and rules --- src/resources/CLAUDE.md.template | 23 ++++++++++++++- src/resources/blitz-rules.md | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/resources/CLAUDE.md.template b/src/resources/CLAUDE.md.template index 83d1954..d2c1974 100644 --- a/src/resources/CLAUDE.md.template +++ b/src/resources/CLAUDE.md.template @@ -2,7 +2,7 @@ ## blitz-macos -This project is opened in **Blitz**, a native macOS iOS development IDE with integrated simulator streaming. The user sees Build, Release, Insights, Testflight tab groups, and can see simulator view in Build>Simulator tab. +This project is opened in **Blitz**, a native macOS iOS/macOS development IDE with integrated simulator streaming. The user sees Build, Release, Insights, Testflight tab groups, and can see simulator view in Build>Simulator tab. ### MCP Servers @@ -10,6 +10,27 @@ Two MCP servers are configured in `.mcp.json`: - **`blitz-macos`** — Controls the Blitz app: project state, tab navigation, App Store Connect forms, build pipeline, settings. - **`blitz-iphone`** — Controls the iOS device/simulator: tap, swipe, type, screenshots, UI hierarchy. See [iPhone MCP docs](https://github.com/blitzdotdev/iPhone-mcp). +- **`asc` CLI** — A bundled Go binary at `~/.blitz/bin/asc` for direct App Store Connect API access. Shares credentials with Blitz MCP tools automatically (no separate login needed). + +### MCP Tools vs `asc` CLI: When to Use Which + +**Default to MCP tools** for common workflows — they are opinionated, safe, and integrate with Blitz's UI (approval prompts, progress tracking, form validation). **Fall back to `asc` CLI** for edge cases, bulk operations, or capabilities MCP tools don't expose. + +**Use MCP tools for:** filling ASC forms (`asc_fill_form`), reading tab/app state (`get_tab_state`), the build pipeline (`app_store_setup_signing` → `app_store_build` → `app_store_upload`), creating IAPs/subscriptions, setting prices, managing screenshots, and submitting for review. + +**Use `asc` CLI for:** listing builds (`asc builds list`), TestFlight beta management (`asc testflight`), certificate/profile inspection (`asc certificates list`, `asc profiles list`), analytics & finance reports (`asc analytics`, `asc finance`), release management (`asc releases`), custom product pages (`asc product-pages`), Xcode Cloud (`asc xcode-cloud`), Game Center, offer codes, bulk localization updates, and any ASC operation without a dedicated MCP tool. + +| Task | MCP (preferred) | `asc` CLI (fallback) | +|---|---|---| +| Set store listing metadata | `asc_fill_form` tab="storeListing" | `asc metadata update --locale en-US ...` | +| Check submission readiness | `get_tab_state` tab="ascOverview" | `asc versions list` + manual checks | +| Create subscription | `asc_create_subscription` (one call) | `asc subscriptions create` + localization + pricing (3 steps) | +| List all builds | N/A | `asc builds list` | +| Add beta testers | N/A | `asc testflight add-tester --email ...` | +| Upload IPA | `app_store_upload` (with polling) | `asc builds upload --path ./app.ipa` | +| Financial reports | N/A | `asc finance download --period 2026-01` | + +Both tools share the same API key via Blitz's auth bridge (`~/.blitz/asc-agent/config.json`). If `asc` is not on PATH: `export PATH="$HOME/.blitz/bin:$PATH"` ### Testing Workflow diff --git a/src/resources/blitz-rules.md b/src/resources/blitz-rules.md index 9d57679..cd9cb7f 100644 --- a/src/resources/blitz-rules.md +++ b/src/resources/blitz-rules.md @@ -8,6 +8,54 @@ Two MCP servers are active in `.mcp.json`: - **`blitz-iphone`** — Controls the iOS simulator/device: tap, swipe, type text, take screenshots, inspect UI hierarchy. See blitz-iphone tool docs for full command list. +Additionally, the **`asc`** CLI (a bundled Go binary for App Store Connect) is available at `~/.blitz/bin/asc`. It shares credentials with Blitz MCP tools automatically via an auth bridge — no separate login required. + +## When to use Blitz MCP tools vs `asc` CLI + +**Default to MCP tools.** They are opinionated, safe, and designed for common workflows with built-in approval prompts for mutating operations. Use `asc` CLI for edge cases, bulk operations, or anything MCP tools don't cover. + +### Use MCP tools when: +- **Filling ASC forms** — `asc_fill_form` handles store listing, app details, monetization, age rating, review contact with validation and auto-navigation +- **Reading app/tab state** — `get_tab_state` returns structured data (form values, submission readiness, builds, versions) without parsing CLI output +- **Build pipeline** — `app_store_setup_signing` → `app_store_build` → `app_store_upload` is the standard flow with progress tracking in Blitz UI +- **Creating IAPs/subscriptions** — `asc_create_iap` and `asc_create_subscription` handle the full creation flow (product + localization + pricing) in one call +- **Setting prices** — `asc_set_app_price` for app pricing, including scheduled price changes +- **Managing screenshots** — `screenshots_add_asset` → `screenshots_set_track` → `screenshots_save` for screenshot upload workflow +- **Submission** — `asc_open_submit_preview` checks readiness and opens the submit modal +- **Anything with a dedicated MCP tool** — the tool exists because it handles the common case safely + +### Use `asc` CLI when: +- **Listing/querying resources** — `asc builds list`, `asc versions list`, `asc apps list` for quick lookups not exposed by MCP +- **Bulk operations** — updating localizations for multiple locales, managing many IAPs at once +- **Certificate/profile management** — `asc certificates list`, `asc profiles list`, `asc devices list` for inspecting signing state beyond what `app_store_setup_signing` manages +- **TestFlight management** — `asc testflight` for beta group management, tester invitations, build distribution beyond what MCP exposes +- **Analytics/finance** — `asc analytics`, `asc finance` for pulling reports +- **Release management** — `asc releases`, `asc versions` for version-level operations (phased release, platform-specific versioning) +- **Custom product pages** — `asc product-pages` for A/B testing store listings +- **Xcode Cloud** — `asc xcode-cloud` for CI/CD workflow management +- **Game Center** — `asc game-center` for leaderboards and achievements +- **Offer codes** — `asc offer-codes` for subscription promotional codes +- **Any operation not covered by an MCP tool** + +### Examples: MCP vs CLI side-by-side + +| Task | MCP tool (preferred) | `asc` CLI (fallback/advanced) | +|---|---|---| +| Set app title & description | `asc_fill_form` tab="storeListing" | `asc metadata update --locale en-US --name "..." --description "..."` | +| Check if ready to submit | `get_tab_state` tab="ascOverview" | `asc versions list` + manual field checks | +| Create a subscription | `asc_create_subscription` (one call) | `asc subscriptions create` + `asc subscriptions add-localization` + `asc pricing set` (three steps) | +| List all builds | Not available via MCP | `asc builds list` | +| Add beta testers | Not available via MCP | `asc testflight add-tester --email user@example.com` | +| Upload IPA | `app_store_upload` (with polling) | `asc builds upload --path ./app.ipa` | +| Get financial reports | Not available via MCP | `asc finance download --period 2026-01` | +| Manage provisioning profiles | `app_store_setup_signing` (automated) | `asc profiles list`, `asc profiles create` (manual control) | + +### Auth bridge + +Both MCP tools and `asc` CLI share the same App Store Connect API key credentials. When you authenticate in Blitz (via `asc_set_credentials` or the Settings UI), the auth bridge automatically syncs credentials to `~/.blitz/asc-agent/config.json`. The `asc` wrapper at `~/.blitz/bin/asc` reads from this config — no separate `asc auth init` needed. + +If `asc` is not on PATH, add it: `export PATH="$HOME/.blitz/bin:$PATH"` + ## Testing workflow After making code changes: From 7c925843847dd4140c9aa0b85bb45207bcce2bf0 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Wed, 25 Mar 2026 16:21:34 -0700 Subject: [PATCH 34/51] Migrate ASC web session to file and extend agent whitelists --- .claude/skills/asc-iap-attach/SKILL.md | 52 ++-- .claude/skills/asc-team-key-create/SKILL.md | 27 +- src/AppState.swift | 13 +- src/BlitzApp.swift | 5 +- src/resources/CLAUDE.md.template | 9 +- src/resources/blitz-rules.md | 15 +- src/services/ASCAuthBridge.swift | 15 ++ src/services/ASCManager.swift | 35 ++- src/services/MCPToolExecutor.swift | 30 ++- src/services/MCPToolRegistry.swift | 2 +- src/services/SettingsService.swift | 3 + src/utilities/ProjectStorage.swift | 271 +++++++++++++++++--- src/views/ContentView.swift | 14 +- src/views/OnboardingView.swift | 25 ++ src/views/projects/ImportProjectSheet.swift | 13 +- src/views/settings/SettingsView.swift | 39 +++ 16 files changed, 457 insertions(+), 111 deletions(-) diff --git a/.claude/skills/asc-iap-attach/SKILL.md b/.claude/skills/asc-iap-attach/SKILL.md index 8501358..a5b7723 100644 --- a/.claude/skills/asc-iap-attach/SKILL.md +++ b/.claude/skills/asc-iap-attach/SKILL.md @@ -22,7 +22,7 @@ This skill uses Apple's internal iris API (`/iris/v1/subscriptionSubmissions`) v ## Preconditions -- Web session cached in macOS Keychain. If no session exists or it has expired (401), call the `asc_web_auth` MCP tool first — this opens the Apple ID login window in Blitz and captures the session automatically. +- Web session file available at `~/.blitz/asc-agent/web-session.json`. If no session exists or it has expired (401), call the `asc_web_auth` MCP tool first — this opens the Apple ID login window in Blitz and captures the session automatically. - Know your app ID. - IAPs and/or subscriptions already exist and are in **Ready to Submit** state. - A build is uploaded and attached to the current app version. @@ -32,7 +32,7 @@ This skill uses Apple's internal iris API (`/iris/v1/subscriptionSubmissions`) v ### 1. Check for an existing web session ```bash -security find-generic-password -s "asc-web-session" -a "asc:web-session:store" -w > /dev/null 2>&1 && echo "SESSION_EXISTS" || echo "NO_SESSION" +test -f ~/.blitz/asc-agent/web-session.json && echo "SESSION_EXISTS" || echo "NO_SESSION" ``` - If `NO_SESSION`: call the `asc_web_auth` MCP tool first. Wait for it to complete before proceeding. @@ -44,20 +44,16 @@ Use the iris API to list subscription groups (with subscriptions) and in-app pur ```bash python3 -c " -import json, subprocess, urllib.request, sys +import json, os, urllib.request, sys APP_ID = 'APP_ID_HERE' -try: - raw = subprocess.check_output([ - 'security', 'find-generic-password', - '-s', 'asc-web-session', - '-a', 'asc:web-session:store', - '-w' - ], stderr=subprocess.DEVNULL).decode() -except subprocess.CalledProcessError: +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): print('ERROR: No web session found. Call asc_web_auth MCP tool first.') sys.exit(1) +with open(session_path) as f: + raw = f.read() store = json.loads(raw) session = store['sessions'][store['last_key']] @@ -118,18 +114,14 @@ Use the following script to attach subscriptions. **Do not print or log the cook ```bash python3 -c " -import json, subprocess, urllib.request, sys - -try: - raw = subprocess.check_output([ - 'security', 'find-generic-password', - '-s', 'asc-web-session', - '-a', 'asc:web-session:store', - '-w' - ], stderr=subprocess.DEVNULL).decode() -except subprocess.CalledProcessError: +import json, os, urllib.request, sys + +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): print('ERROR: No web session found. Call asc_web_auth MCP tool first.') sys.exit(1) +with open(session_path) as f: + raw = f.read() store = json.loads(raw) session = store['sessions'][store['last_key']] @@ -178,18 +170,14 @@ For in-app purchases (non-subscription), change the type and relationship: ```bash python3 -c " -import json, subprocess, urllib.request, sys - -try: - raw = subprocess.check_output([ - 'security', 'find-generic-password', - '-s', 'asc-web-session', - '-a', 'asc:web-session:store', - '-w' - ], stderr=subprocess.DEVNULL).decode() -except subprocess.CalledProcessError: +import json, os, urllib.request, sys + +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): print('ERROR: No web session found. Call asc_web_auth MCP tool first.') sys.exit(1) +with open(session_path) as f: + raw = f.read() store = json.loads(raw) session = store['sessions'][store['last_key']] @@ -243,7 +231,7 @@ After attachment, call `get_tab_state` for `ascOverview` to refresh the submissi The subscription is already attached — this is safe to ignore. HTTP 409 with this message means the item was previously attached. ### 401 Not Authorized (iris API) -The web session has expired. Call the `asc_web_auth` MCP tool to open the Apple ID login window in Blitz — this captures a fresh session and saves it to the keychain automatically. The user will need to complete Apple ID login + 2FA in the popup. After the tool returns success, retry the iris API calls. +The web session has expired. Call the `asc_web_auth` MCP tool to open the Apple ID login window in Blitz — this captures a fresh session and refreshes `~/.blitz/asc-agent/web-session.json` automatically. The user will need to complete Apple ID login + 2FA in the popup. After the tool returns success, retry the iris API calls. ## Agent Behavior diff --git a/.claude/skills/asc-team-key-create/SKILL.md b/.claude/skills/asc-team-key-create/SKILL.md index 3fc2697..1d7daa9 100644 --- a/.claude/skills/asc-team-key-create/SKILL.md +++ b/.claude/skills/asc-team-key-create/SKILL.md @@ -15,17 +15,17 @@ Use this skill to create a new App Store Connect API Key with Admin permissions ## Preconditions -- Web session cached in macOS Keychain. If no session exists or it has expired (401), call the `asc_web_auth` MCP tool first — this opens the Apple ID login window in Blitz and captures the session automatically. +- Web session file available at `~/.blitz/asc-agent/web-session.json`. If no session exists or it has expired (401), call the `asc_web_auth` MCP tool first — this opens the Apple ID login window in Blitz and captures the session automatically. - The authenticated Apple ID must have Account Holder or Admin role. ## Workflow ### 1. Check for an existing web session -Before anything else, check if a web session already exists in the macOS Keychain: +Before anything else, check if a web session file already exists: ```bash -security find-generic-password -s "asc-web-session" -a "asc:web-session:store" -w > /dev/null 2>&1 && echo "SESSION_EXISTS" || echo "NO_SESSION" +test -f ~/.blitz/asc-agent/web-session.json && echo "SESSION_EXISTS" || echo "NO_SESSION" ``` - If `NO_SESSION`: call the `asc_web_auth` MCP tool first to open the Apple ID login window in Blitz. Wait for it to complete before proceeding. @@ -41,22 +41,17 @@ Use the following self-contained script. Replace `KEY_NAME` with the user's chos ```bash python3 -c " -import json, subprocess, urllib.request, base64, os, sys, time +import json, urllib.request, base64, os, sys, time KEY_NAME = 'KEY_NAME_HERE' -# Extract cookies from keychain (silent — never print these) -try: - raw = subprocess.check_output([ - 'security', 'find-generic-password', - '-s', 'asc-web-session', - '-a', 'asc:web-session:store', - '-w' - ], stderr=subprocess.DEVNULL).decode() -except subprocess.CalledProcessError: - print('ERROR: No web session found. User must authenticate first.') - print('Run: asc web auth login --apple-id EMAIL') +# Read web session file (silent — never print these) +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): + print('ERROR: No web session found. Call asc_web_auth MCP tool first.') sys.exit(1) +with open(session_path) as f: + raw = f.read() store = json.loads(raw) session = store['sessions'][store['last_key']] @@ -191,7 +186,7 @@ After the script runs, report: ## Common Errors ### 401 Not Authorized -The web session has expired or doesn't exist. Call the `asc_web_auth` MCP tool — this opens the Apple ID login window in Blitz and captures the session to the macOS Keychain automatically. Then retry the key creation script. +The web session has expired or doesn't exist. Call the `asc_web_auth` MCP tool — this opens the Apple ID login window in Blitz and refreshes `~/.blitz/asc-agent/web-session.json` automatically. Then retry the key creation script. ### 409 Conflict A key with the same name may already exist, or another conflict occurred. Try a different name. diff --git a/src/AppState.swift b/src/AppState.swift index b24ef02..1f5329d 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -393,8 +393,17 @@ final class ProjectSetupManager { // Ensure .mcp.json, CLAUDE.md, .claude/settings.local.json exist // (setup recreates the project dir, so these must be written after) let storage = ProjectStorage() - storage.ensureMCPConfig(projectId: projectId) - storage.ensureClaudeFiles(projectId: projectId, projectType: projectType, whitelistBlitzMCP: SettingsService.shared.whitelistBlitzMCPTools) + storage.ensureMCPConfig( + projectId: projectId, + whitelistBlitzMCP: SettingsService.shared.whitelistBlitzMCPTools, + allowASCCLICalls: SettingsService.shared.allowASCCLICalls + ) + storage.ensureClaudeFiles( + projectId: projectId, + projectType: projectType, + whitelistBlitzMCP: SettingsService.shared.whitelistBlitzMCPTools, + allowASCCLICalls: SettingsService.shared.allowASCCLICalls + ) isSettingUp = false } catch { errorMessage = error.localizedDescription diff --git a/src/BlitzApp.swift b/src/BlitzApp.swift index cddb7a3..8b22921 100644 --- a/src/BlitzApp.swift +++ b/src/BlitzApp.swift @@ -15,7 +15,10 @@ final class MCPBootstrap { installASCEnvironment(settings: appState.settingsStore) installClaudeSkills() updateIphoneMCP() - ProjectStorage().ensureGlobalMCPConfigs(whitelistBlitzMCP: appState.settingsStore.whitelistBlitzMCPTools) + ProjectStorage().ensureGlobalMCPConfigs( + whitelistBlitzMCP: appState.settingsStore.whitelistBlitzMCPTools, + allowASCCLICalls: appState.settingsStore.allowASCCLICalls + ) let server = MCPServerService(appState: appState) self.server = server diff --git a/src/resources/CLAUDE.md.template b/src/resources/CLAUDE.md.template index d2c1974..61e0e00 100644 --- a/src/resources/CLAUDE.md.template +++ b/src/resources/CLAUDE.md.template @@ -12,19 +12,22 @@ Two MCP servers are configured in `.mcp.json`: - **`blitz-iphone`** — Controls the iOS device/simulator: tap, swipe, type, screenshots, UI hierarchy. See [iPhone MCP docs](https://github.com/blitzdotdev/iPhone-mcp). - **`asc` CLI** — A bundled Go binary at `~/.blitz/bin/asc` for direct App Store Connect API access. Shares credentials with Blitz MCP tools automatically (no separate login needed). -### MCP Tools vs `asc` CLI: When to Use Which +### MCP Tools vs `asc` CLI vs Direct API Calls -**Default to MCP tools** for common workflows — they are opinionated, safe, and integrate with Blitz's UI (approval prompts, progress tracking, form validation). **Fall back to `asc` CLI** for edge cases, bulk operations, or capabilities MCP tools don't expose. +**Priority order: MCP tools → `asc` CLI → direct API calls (last resort).** + +**Default to MCP tools** for common workflows — they are opinionated, safe, and integrate with Blitz's UI (approval prompts, progress tracking, form validation). **When MCP tools don't cover an operation, use `asc` CLI** — it has 60+ subcommands covering nearly every ASC API endpoint and handles JWT auth, pagination, and retries. **Direct API calls (python scripts, curl to api.appstoreconnect.apple.com, urllib) should be an absolute last resort** — only when both MCP tools AND `asc` CLI genuinely cannot accomplish the task. Before writing any script, run `asc --help` and `asc --help` to confirm the CLI doesn't already support it. **Use MCP tools for:** filling ASC forms (`asc_fill_form`), reading tab/app state (`get_tab_state`), the build pipeline (`app_store_setup_signing` → `app_store_build` → `app_store_upload`), creating IAPs/subscriptions, setting prices, managing screenshots, and submitting for review. -**Use `asc` CLI for:** listing builds (`asc builds list`), TestFlight beta management (`asc testflight`), certificate/profile inspection (`asc certificates list`, `asc profiles list`), analytics & finance reports (`asc analytics`, `asc finance`), release management (`asc releases`), custom product pages (`asc product-pages`), Xcode Cloud (`asc xcode-cloud`), Game Center, offer codes, bulk localization updates, and any ASC operation without a dedicated MCP tool. +**Use `asc` CLI for:** listing builds (`asc builds list`), TestFlight beta management (`asc testflight`), certificate/profile inspection (`asc certificates list`, `asc profiles list`), analytics & finance reports (`asc analytics`, `asc finance`), release management (`asc releases`), submission management (`asc submit create`, `asc submit cancel`), custom product pages (`asc product-pages`), Xcode Cloud (`asc xcode-cloud`), Game Center, offer codes, bulk localization updates, and any ASC operation without a dedicated MCP tool. | Task | MCP (preferred) | `asc` CLI (fallback) | |---|---|---| | Set store listing metadata | `asc_fill_form` tab="storeListing" | `asc metadata update --locale en-US ...` | | Check submission readiness | `get_tab_state` tab="ascOverview" | `asc versions list` + manual checks | | Create subscription | `asc_create_subscription` (one call) | `asc subscriptions create` + localization + pricing (3 steps) | +| Cancel stuck submission | N/A | `asc submit cancel --id "..."` | | List all builds | N/A | `asc builds list` | | Add beta testers | N/A | `asc testflight add-tester --email ...` | | Upload IPA | `app_store_upload` (with polling) | `asc builds upload --path ./app.ipa` | diff --git a/src/resources/blitz-rules.md b/src/resources/blitz-rules.md index cd9cb7f..48ac3d4 100644 --- a/src/resources/blitz-rules.md +++ b/src/resources/blitz-rules.md @@ -10,9 +10,9 @@ Two MCP servers are active in `.mcp.json`: Additionally, the **`asc`** CLI (a bundled Go binary for App Store Connect) is available at `~/.blitz/bin/asc`. It shares credentials with Blitz MCP tools automatically via an auth bridge — no separate login required. -## When to use Blitz MCP tools vs `asc` CLI +## When to use Blitz MCP tools vs `asc` CLI vs direct API calls -**Default to MCP tools.** They are opinionated, safe, and designed for common workflows with built-in approval prompts for mutating operations. Use `asc` CLI for edge cases, bulk operations, or anything MCP tools don't cover. +**Default to MCP tools.** They are opinionated, safe, and designed for common workflows with built-in approval prompts for mutating operations. **When MCP tools don't cover an operation, use `asc` CLI** — it has 60+ subcommands covering nearly every App Store Connect API endpoint. **Direct API calls (python scripts, curl to api.appstoreconnect.apple.com, urllib, etc.) should be an absolute last resort** — only when both MCP tools AND `asc` CLI genuinely cannot accomplish the task. The `asc` CLI already handles JWT auth, pagination, error handling, and retries; writing raw API scripts bypasses all of that and is fragile. ### Use MCP tools when: - **Filling ASC forms** — `asc_fill_form` handles store listing, app details, monetization, age rating, review contact with validation and auto-navigation @@ -31,11 +31,20 @@ Additionally, the **`asc`** CLI (a bundled Go binary for App Store Connect) is a - **TestFlight management** — `asc testflight` for beta group management, tester invitations, build distribution beyond what MCP exposes - **Analytics/finance** — `asc analytics`, `asc finance` for pulling reports - **Release management** — `asc releases`, `asc versions` for version-level operations (phased release, platform-specific versioning) +- **Submission management** — `asc submit create`, `asc submit cancel` for creating/cancelling review submissions - **Custom product pages** — `asc product-pages` for A/B testing store listings - **Xcode Cloud** — `asc xcode-cloud` for CI/CD workflow management - **Game Center** — `asc game-center` for leaderboards and achievements - **Offer codes** — `asc offer-codes` for subscription promotional codes -- **Any operation not covered by an MCP tool** +- **Any operation not covered by an MCP tool** — check `asc --help` and `asc --help` before resorting to other approaches + +### Direct API calls (last resort only): +Writing raw Python/curl/urllib scripts against `api.appstoreconnect.apple.com` should only happen when you have confirmed that **both** MCP tools and `asc` CLI cannot do what's needed. Before writing a script, always: +1. Check if there's an MCP tool for it +2. Run `asc --help` and `asc --help` to see if `asc` covers it +3. Only then consider a direct API call + +The `asc` CLI covers 60+ command groups — it almost certainly has what you need. Even for uncommon operations like cancelling a stuck review submission or creating API keys, try `asc` first. ### Examples: MCP vs CLI side-by-side diff --git a/src/services/ASCAuthBridge.swift b/src/services/ASCAuthBridge.swift index ef56ca6..f78c105 100644 --- a/src/services/ASCAuthBridge.swift +++ b/src/services/ASCAuthBridge.swift @@ -39,6 +39,10 @@ struct ASCAuthBridge { bridgeDirectory.appendingPathComponent("AuthKey_\(Self.managedProfileName).p8") } + var webSessionURL: URL { + bridgeDirectory.appendingPathComponent("web-session.json") + } + var ascWrapperURL: URL { binDirectory.appendingPathComponent("asc") } @@ -85,6 +89,17 @@ struct ASCAuthBridge { try writeConfig(credentials: credentials) } + /// Write web session data to a file so CLI scripts can read it without Keychain popups. + func syncWebSession(_ data: Data) throws { + try ensureBridgeDirectory() + try data.write(to: webSessionURL, options: .atomic) + try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: webSessionURL.path) + } + + func removeWebSession() { + try? fileManager.removeItem(at: webSessionURL) + } + func cleanup() { try? fileManager.removeItem(at: configURL) try? fileManager.removeItem(at: privateKeyURL) diff --git a/src/services/ASCManager.swift b/src/services/ASCManager.swift index a579c74..0e23cf7 100644 --- a/src/services/ASCManager.swift +++ b/src/services/ASCManager.swift @@ -629,6 +629,7 @@ final class ASCManager { guard credentials == nil || service == nil else { return } let creds = ASCCredentials.load() try? ASCAuthBridge().syncCredentials(creds) + Self.syncWebSessionFileFromKeychain() credentials = creds if let creds { service = AppStoreConnectService(credentials: creds) @@ -680,6 +681,7 @@ final class ASCManager { isLoadingCredentials = true let creds = ASCCredentials.load() try? ASCAuthBridge().syncCredentials(creds) + Self.syncWebSessionFileFromKeychain() credentials = creds isLoadingCredentials = false @@ -773,7 +775,7 @@ final class ASCManager { return } - // Also write the asc-web-session keychain item used by CLI skill scripts. + // Also write the shared web session store (keychain + synced session file). // If that write fails during an MCP-triggered login, keep the native session // but fail the MCP request instead of reporting a false success. do { @@ -816,18 +818,27 @@ final class ASCManager { } } - // MARK: - Unified Web Session Keychain (for CLI skill scripts) + // MARK: - Unified Web Session Store (for CLI skill scripts) private static let webSessionService = ASCWebSessionStore.keychainService private static let webSessionAccount = ASCWebSessionStore.keychainAccount - /// Write session cookies in the format expected by CLI skill scripts - /// (readable via `security find-generic-password -s "asc-web-session" -w`). + /// Write session cookies for CLI skill scripts. + /// Stored in Keychain (for Blitz) and synced to ~/.blitz/asc-agent/web-session.json (for CLI scripts). private static func storeWebSessionToKeychain(_ session: IrisSession) throws { let existingData = readKeychainItem(service: webSessionService, account: webSessionAccount) let data = try ASCWebSessionStore.mergedData(storing: session, into: existingData) removeWebSessionKeychainItem() try writeWebSessionToKeychain(data) + // Also write to file so CLI skill scripts can read without Keychain popups. + try ASCAuthBridge().syncWebSession(data) + } + + private static func syncWebSessionFileFromKeychain() { + guard let data = readKeychainItem(service: webSessionService, account: webSessionAccount) else { + return + } + try? ASCAuthBridge().syncWebSession(data) } private static func writeWebSessionToKeychain(_ data: Data) throws { @@ -871,6 +882,12 @@ final class ASCManager { if status == errSecItemNotFound { try? writeWebSessionToKeychain(updatedData) } + // Keep file in sync with keychain + do { + try ASCAuthBridge().syncWebSession(updatedData) + } catch { + ASCAuthBridge().removeWebSession() + } return } @@ -884,6 +901,7 @@ final class ASCManager { kSecAttrAccount as String: webSessionAccount, ] SecItemDelete(query as CFDictionary) + ASCAuthBridge().removeWebSession() } /// Loads cached feedback from disk for the given rejected version. No auth needed. @@ -1027,6 +1045,14 @@ final class ASCManager { return f1.date(from: iso) ?? f2.date(from: iso) ?? .distantPast } + private func closestVersion(before dateString: String) -> ASCAppStoreVersion? { + let submittedDate = historyDate(dateString) + return appStoreVersions + .filter { historyDate($0.attributes.createdDate) <= submittedDate } + .max { historyDate($0.attributes.createdDate) < historyDate($1.attributes.createdDate) } + ?? ASCReleaseStatus.sortedVersionsByRecency(appStoreVersions).first + } + private func historyEventType(forVersionState state: String) -> ASCSubmissionHistoryEventType? { ASCReleaseStatus.submissionHistoryEventType(forVersionState: state) } @@ -1127,6 +1153,7 @@ final class ASCManager { let versionId = reviewSubmissionItemsBySubmissionId[submission.id]? .compactMap(\.appStoreVersionId) .first + ?? closestVersion(before: submittedAt)?.id let versionString = versionString(for: versionId, versionSnapshots: versionSnapshots) ?? "Unknown" let versionState = versionState(for: versionId, versionSnapshots: versionSnapshots) let eventType = ASCReleaseStatus.reviewSubmissionEventType(forVersionState: versionState) diff --git a/src/services/MCPToolExecutor.swift b/src/services/MCPToolExecutor.swift index 9c5a9ac..c6288bb 100644 --- a/src/services/MCPToolExecutor.swift +++ b/src/services/MCPToolExecutor.swift @@ -450,7 +450,17 @@ actor MCPToolExecutor { lastOpenedAt: Date() ) try storage.writeMetadata(projectId: projectId, metadata: metadata) - storage.ensureMCPConfig(projectId: projectId) + let (whitelistBlitzMCP, allowASCCLICalls) = await MainActor.run { + ( + SettingsService.shared.whitelistBlitzMCPTools, + SettingsService.shared.allowASCCLICalls + ) + } + storage.ensureMCPConfig( + projectId: projectId, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) await appState.projectManager.loadProjects() // Set pending setup so ContentView triggers template scaffolding @@ -500,7 +510,17 @@ actor MCPToolExecutor { let url = URL(fileURLWithPath: path) let storage = ProjectStorage() let projectId = try storage.openProject(at: url) - storage.ensureMCPConfig(projectId: projectId) + let (whitelistBlitzMCP, allowASCCLICalls) = await MainActor.run { + ( + SettingsService.shared.whitelistBlitzMCPTools, + SettingsService.shared.allowASCCLICalls + ) + } + storage.ensureMCPConfig( + projectId: projectId, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) await appState.projectManager.loadProjects() await MainActor.run { appState.activeProjectId = projectId } @@ -986,13 +1006,13 @@ actor MCPToolExecutor { ]) } - // setIrisSession (called by the login sheet callback) already saves to - // both keychain stores (native + asc-web-session), so no extra work needed. + // setIrisSession (called by the login sheet callback) already saves the + // native keychain session and syncs ~/.blitz/asc-agent/web-session.json. let email = session.email ?? "unknown" return mcpJSON([ "success": true, "email": email, - "message": "Web session authenticated and saved to keychain. The asc-iap-attach skill can now use the iris API." + "message": "Web session authenticated and synced to ~/.blitz/asc-agent/web-session.json. The asc-iap-attach skill can now use the iris API." ]) } diff --git a/src/services/MCPToolRegistry.swift b/src/services/MCPToolRegistry.swift index 68590d8..bce5196 100644 --- a/src/services/MCPToolRegistry.swift +++ b/src/services/MCPToolRegistry.swift @@ -276,7 +276,7 @@ enum MCPToolRegistry { tools.append(tool( name: "asc_web_auth", - description: "Open the Apple ID login window in Blitz to authenticate a web session for App Store Connect. Use when the iris API returns 401 (session expired). The login captures cookies and saves them to the macOS Keychain for the asc-iap-attach skill. Requires user interaction (Apple ID + 2FA).", + description: "Open the Apple ID login window in Blitz to authenticate a web session for App Store Connect. Use when the iris API returns 401 (session expired). The login captures cookies and syncs them to ~/.blitz/asc-agent/web-session.json for CLI skills. Requires user interaction (Apple ID + 2FA).", properties: [:], required: [] )) diff --git a/src/services/SettingsService.swift b/src/services/SettingsService.swift index b7e92fc..866c507 100644 --- a/src/services/SettingsService.swift +++ b/src/services/SettingsService.swift @@ -33,6 +33,7 @@ final class SettingsService { var sendDefaultPrompt: Bool = true var skipAgentPermissions: Bool = false var whitelistBlitzMCPTools: Bool = true + var allowASCCLICalls: Bool = false var enableASCShellIntegration: Bool = false var terminalPosition: String = "bottom" // "bottom" or "right" @@ -56,6 +57,7 @@ final class SettingsService { if let sendPrompt = json["sendDefaultPrompt"] as? Bool { sendDefaultPrompt = sendPrompt } if let skipPerms = json["skipAgentPermissions"] as? Bool { skipAgentPermissions = skipPerms } if let whitelist = json["whitelistBlitzMCPTools"] as? Bool { whitelistBlitzMCPTools = whitelist } + if let allowASCCLI = json["allowASCCLICalls"] as? Bool { allowASCCLICalls = allowASCCLI } if let shellIntegration = json["enableASCShellIntegration"] as? Bool { enableASCShellIntegration = shellIntegration } if let termPos = json["terminalPosition"] as? String { terminalPosition = termPos } } @@ -71,6 +73,7 @@ final class SettingsService { "sendDefaultPrompt": sendDefaultPrompt, "skipAgentPermissions": skipAgentPermissions, "whitelistBlitzMCPTools": whitelistBlitzMCPTools, + "allowASCCLICalls": allowASCCLICalls, "enableASCShellIntegration": enableASCShellIntegration, "terminalPosition": terminalPosition, ] diff --git a/src/utilities/ProjectStorage.swift b/src/utilities/ProjectStorage.swift index fe8e597..33630f8 100644 --- a/src/utilities/ProjectStorage.swift +++ b/src/utilities/ProjectStorage.swift @@ -144,14 +144,18 @@ struct ProjectStorage { /// Ensure ~/.blitz/mcps/ has MCP configs, CLAUDE.md, and skills so that /// agent sessions launched outside a project (e.g. onboarding ASC setup) can /// access Blitz MCP tools. Idempotent — safe to call on every launch. - func ensureGlobalMCPConfigs(whitelistBlitzMCP: Bool = true) { + func ensureGlobalMCPConfigs(whitelistBlitzMCP: Bool = true, allowASCCLICalls: Bool = false) { let fm = FileManager.default let mcpsDir = BlitzPaths.mcps try? fm.createDirectory(at: mcpsDir, withIntermediateDirectories: true) - // 1. .mcp.json + .codex/config.toml (reuse project-level logic) - ensureMCPConfig(in: mcpsDir) + // 1. .mcp.json + .codex/config.toml + opencode.json (reuse project-level logic) + ensureMCPConfig( + in: mcpsDir, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) // 2. .claude/settings.local.json let claudeDir = mcpsDir.appendingPathComponent(".claude") @@ -165,6 +169,9 @@ struct ProjectStorage { if whitelistBlitzMCP { allowList = Self.allBlitzMCPToolPermissions() } + if allowASCCLICalls { + Self.ensureAllowPermission("Bash(asc:*)", in: &allowList) + } let settings: [String: Any] = [ "enabledMcpjsonServers": ["blitz-macos", "blitz-iphone"], "permissions": [ @@ -215,13 +222,25 @@ struct ProjectStorage { /// If the file exists, merges into the existing mcpServers key without overwriting other entries. /// If it doesn't exist, creates it. /// Also removes the deprecated blitz-ios entry if present. - func ensureMCPConfig(projectId: String) { + func ensureMCPConfig( + projectId: String, + whitelistBlitzMCP: Bool = true, + allowASCCLICalls: Bool = false + ) { let projectDir = baseDirectory.appendingPathComponent(projectId) - ensureMCPConfig(in: projectDir) + ensureMCPConfig( + in: projectDir, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) } - /// Shared implementation: writes .mcp.json and .codex/config.toml into `directory`. - func ensureMCPConfig(in directory: URL) { + /// Shared implementation: writes .mcp.json, .codex/config.toml, and opencode.json into `directory`. + func ensureMCPConfig( + in directory: URL, + whitelistBlitzMCP: Bool = true, + allowASCCLICalls: Bool = false + ) { let mcpFile = directory.appendingPathComponent(".mcp.json") let helperPath = BlitzPaths.mcpHelper.path @@ -263,14 +282,32 @@ struct ProjectStorage { print("[ProjectStorage] Failed to write .mcp.json: \(error)") } - // Codex config — only blitz_macos (Codex reads .mcp.json for blitz-iphone). - // Uses underscores to avoid Codex hyphenated-name bug. + // Codex config — explicit Blitz MCP servers and tool allowlists. let codexDir = directory.appendingPathComponent(".codex") let codexConfig = codexDir.appendingPathComponent("config.toml") + let codexMacEnabledTools = whitelistBlitzMCP ? Self.blitzMacosToolNames() : Self.minimalBlitzMacosToolNames() + let codexIphoneEnabledTools = whitelistBlitzMCP ? Self.blitzIphoneToolNames() : [] + let codexMacEnabledToolsToml = codexMacEnabledTools + .map { "\"\(Self.escapeTOMLString($0))\"" } + .joined(separator: ", ") + let codexIphoneEnabledToolsToml = codexIphoneEnabledTools + .map { "\"\(Self.escapeTOMLString($0))\"" } + .joined(separator: ", ") + let codexIphonePathEnv = "\(nodeRuntimeBin):/usr/bin:/bin:/usr/sbin:/sbin" let toml = """ [mcp_servers.blitz_macos] - command = "\(helperPath)" - cwd = "\(directory.path)" + command = "\(Self.escapeTOMLString(helperPath))" + cwd = "\(Self.escapeTOMLString(directory.path))" + enabled_tools = [\(codexMacEnabledToolsToml)] + + [mcp_servers."blitz-iphone"] + command = "\(Self.escapeTOMLString(nodeRuntimeBin + "/npx"))" + args = ["-y", "@blitzdev/iphone-mcp"] + cwd = "\(Self.escapeTOMLString(directory.path))" + enabled_tools = [\(codexIphoneEnabledToolsToml)] + + [mcp_servers."blitz-iphone".env] + PATH = "\(Self.escapeTOMLString(codexIphonePathEnv))" """ do { try FileManager.default.createDirectory(at: codexDir, withIntermediateDirectories: true) @@ -278,23 +315,163 @@ struct ProjectStorage { } catch { print("[ProjectStorage] Failed to write .codex/config.toml: \(error)") } + + // Codex ASC bash allowlist — managed as a dedicated Blitz-owned rules file. + let codexRulesDir = codexDir.appendingPathComponent("rules") + let codexBlitzRulesFile = codexRulesDir.appendingPathComponent("blitz.rules") + if allowASCCLICalls { + let ascPath = BlitzPaths.bin.appendingPathComponent("asc").path + let rules = """ + # Managed by Blitz. Allows ASC CLI commands without approval prompts. + prefix_rule(pattern=["asc"], decision="allow") + prefix_rule(pattern=["\(Self.escapeStarlarkString(ascPath))"], decision="allow") + """ + do { + try FileManager.default.createDirectory(at: codexRulesDir, withIntermediateDirectories: true) + try rules.write(to: codexBlitzRulesFile, atomically: true, encoding: .utf8) + } catch { + print("[ProjectStorage] Failed to write .codex/rules/blitz.rules: \(error)") + } + } else if FileManager.default.fileExists(atPath: codexBlitzRulesFile.path) { + try? FileManager.default.removeItem(at: codexBlitzRulesFile) + } + + // OpenCode config + let opencodeConfig = directory.appendingPathComponent("opencode.json") + var opencodeRoot: [String: Any] = [:] + if let data = try? Data(contentsOf: opencodeConfig), + let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + opencodeRoot = existing + } + if opencodeRoot["$schema"] == nil { + opencodeRoot["$schema"] = "https://opencode.ai/config.json" + } + + var opencodeMcp = opencodeRoot["mcp"] as? [String: Any] ?? [:] + opencodeMcp["blitz-macos"] = [ + "type": "local", + "command": [helperPath], + "enabled": true, + ] + opencodeMcp["blitz-iphone"] = [ + "type": "local", + "command": [nodeRuntimeBin + "/npx", "-y", "@blitzdev/iphone-mcp"], + "enabled": true, + "environment": [ + "PATH": "\(nodeRuntimeBin):/usr/bin:/bin:/usr/sbin:/sbin", + ], + ] + opencodeMcp.removeValue(forKey: "blitz-ios") // deprecated + opencodeRoot["mcp"] = opencodeMcp + + var opencodePermission: [String: Any] = [:] + if let existingPermission = opencodeRoot["permission"] as? [String: Any] { + opencodePermission = existingPermission + } else if let existingPermissionString = opencodeRoot["permission"] as? String { + opencodePermission["*"] = existingPermissionString + } + + let opencodeMCPPermissionKeys = Self.allOpenCodeBlitzMCPPermissionKeys() + if whitelistBlitzMCP { + for key in opencodeMCPPermissionKeys { + opencodePermission[key] = "allow" + } + } else { + for key in opencodeMCPPermissionKeys { + opencodePermission[key] = "ask" + } + opencodePermission["blitz-macos_asc_set_credentials"] = "allow" + opencodePermission["blitz-macos_asc_web_auth"] = "allow" + } + + var opencodeBash: [String: Any] = [:] + if let existingBash = opencodePermission["bash"] as? [String: Any] { + opencodeBash = existingBash + } else if let existingBashString = opencodePermission["bash"] as? String { + opencodeBash["*"] = existingBashString + } + let ascPath = BlitzPaths.bin.appendingPathComponent("asc").path + let ascPatterns = [ + "asc", + "asc *", + ascPath, + "\(ascPath) *", + ] + if allowASCCLICalls { + for pattern in ascPatterns { + opencodeBash[pattern] = "allow" + } + } else { + for pattern in ascPatterns { + opencodeBash.removeValue(forKey: pattern) + } + } + if opencodeBash.isEmpty { + opencodePermission.removeValue(forKey: "bash") + } else { + opencodePermission["bash"] = opencodeBash + } + opencodeRoot["permission"] = opencodePermission + + if let data = try? JSONSerialization.data(withJSONObject: opencodeRoot, options: [.prettyPrinted, .sortedKeys]) { + do { + try data.write(to: opencodeConfig) + } catch { + print("[ProjectStorage] Failed to write opencode.json: \(error)") + } + } } /// All Blitz MCP tool permission strings for both blitz-macos and blitz-iphone servers. static func allBlitzMCPToolPermissions() -> [String] { // blitz-macos tools — from MCPToolRegistry - let macTools = MCPToolRegistry.allToolNames().map { "mcp__blitz-macos__\($0)" } + let macTools = blitzMacosToolNames().map { "mcp__blitz-macos__\($0)" } // blitz-iphone tools — from @blitzdev/iphone-mcp - let iphoneTools = [ + let iphoneTools = blitzIphoneToolNames().map { "mcp__blitz-iphone__\($0)" } + return macTools + iphoneTools + } + + private static func blitzMacosToolNames() -> [String] { + MCPToolRegistry.allToolNames() + } + + private static func minimalBlitzMacosToolNames() -> [String] { + ["asc_set_credentials", "asc_web_auth"] + } + + private static func blitzIphoneToolNames() -> [String] { + [ "list_devices", "setup_device", "launch_app", "list_apps", "get_screenshot", "scan_ui", "describe_screen", "device_action", "device_actions", "get_execution_context", - ].map { "mcp__blitz-iphone__\($0)" } + ] + } + + private static func allOpenCodeBlitzMCPPermissionKeys() -> [String] { + let macTools = blitzMacosToolNames().map { "blitz-macos_\($0)" } + let iphoneTools = blitzIphoneToolNames().map { "blitz-iphone_\($0)" } return macTools + iphoneTools } + private static func escapeTOMLString(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + + private static func escapeStarlarkString(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + /// Ensure CLAUDE.md, .claude/settings.local.json, and .claude/rules/ exist for a project. - func ensureClaudeFiles(projectId: String, projectType: ProjectType, whitelistBlitzMCP: Bool = true) { + func ensureClaudeFiles( + projectId: String, + projectType: ProjectType, + whitelistBlitzMCP: Bool = true, + allowASCCLICalls: Bool = false + ) { let fm = FileManager.default let projectDir = baseDirectory.appendingPathComponent(projectId) let claudeDir = projectDir.appendingPathComponent(".claude") @@ -321,6 +498,11 @@ struct ProjectStorage { allow.append(tool) } } + if allowASCCLICalls { + Self.ensureAllowPermission("Bash(asc:*)", in: &allow) + } else { + allow.removeAll { $0 == "Bash(asc:*)" } + } perms["allow"] = allow existing["permissions"] = perms } @@ -339,6 +521,9 @@ struct ProjectStorage { "Bash(xcrun simctl launch:*)", ] } + if allowASCCLICalls { + Self.ensureAllowPermission("Bash(asc:*)", in: &defaultAllow) + } settings = [ "permissions": [ "allow": defaultAllow @@ -443,19 +628,19 @@ struct ProjectStorage { let repoDir = claudeDir.appendingPathComponent("asc-skills") let skillDirectories = projectSkillDirectories(projectDir: projectDir) - DispatchQueue.global(qos: .utility).async { - for skillsDir in skillDirectories { - try? fm.createDirectory(at: skillsDir, withIntermediateDirectories: true) - } + for skillsDir in skillDirectories { + try? fm.createDirectory(at: skillsDir, withIntermediateDirectories: true) + } - if let bundledSkillsDir = Self.bundledProjectSkillsDirectory() { - Self.syncSkillDirectories( - from: bundledSkillsDir, - into: skillDirectories, - using: fm - ) - } + if let bundledSkillsDir = Self.bundledProjectSkillsDirectory() { + Self.syncSkillDirectories( + from: bundledSkillsDir, + into: skillDirectories, + using: fm + ) + } + DispatchQueue.global(qos: .utility).async { let repoURL = BlitzPaths.ascSkillsRepo if fm.fileExists(atPath: repoDir.appendingPathComponent(".git").path) { @@ -485,6 +670,11 @@ struct ProjectStorage { let repoSkillsDir = repoDir.appendingPathComponent("skills") Self.syncSkillDirectories(from: repoSkillsDir, into: skillDirectories, using: fm) + // Ensure bundled Blitz skills always win over upstream repo copies. + if let bundledSkillsDir = Self.bundledProjectSkillsDirectory() { + Self.syncSkillDirectories(from: bundledSkillsDir, into: skillDirectories, using: fm) + } + // Overwrite asc-app-create-ui/SKILL.md with Blitz's pre-cached-session version for skillsDir in skillDirectories { let ascCreateSkillFile = skillsDir @@ -553,8 +743,13 @@ struct ProjectStorage { } } + private static func ensureAllowPermission(_ permission: String, in allowList: inout [String]) { + guard !allowList.contains(permission) else { return } + allowList.append(permission) + } + /// Content for the Blitz-specific asc-app-create-ui skill that uses - /// iris APIs via the web session cached in Keychain. + /// iris APIs via the web session file managed by Blitz. private static func ascAppCreateSkillContent() -> String { return ##""" --- @@ -562,7 +757,7 @@ struct ProjectStorage { description: Create an App Store Connect app via iris API using web session from Blitz --- - Create an App Store Connect app using Apple's iris API. Authentication is handled via a web session stored in the macOS Keychain by Blitz. + Create an App Store Connect app using Apple's iris API. Authentication is handled via a web session file at `~/.blitz/asc-agent/web-session.json` managed by Blitz. Extract from the conversation context: - `bundleId` — the bundle identifier (e.g. `com.blitz.myapp`) @@ -573,7 +768,7 @@ struct ProjectStorage { ### 1. Check for an existing web session ```bash - security find-generic-password -s "asc-web-session" -a "asc:web-session:store" -w > /dev/null 2>&1 && echo "SESSION_EXISTS" || echo "NO_SESSION" + test -f ~/.blitz/asc-agent/web-session.json && echo "SESSION_EXISTS" || echo "NO_SESSION" ``` - If `NO_SESSION`: call the `asc_web_auth` MCP tool first. Wait for it to complete before proceeding. @@ -600,24 +795,20 @@ struct ProjectStorage { ```bash python3 -c " - import json, subprocess, urllib.request, sys + import json, os, urllib.request, sys BUNDLE_ID = 'BUNDLE_ID_HERE' SKU = 'SKU_HERE' APP_NAME = 'APP_NAME_HERE' LOCALE = 'LOCALE_HERE' - # Extract cookies from keychain (silent) - try: - raw = subprocess.check_output([ - 'security', 'find-generic-password', - '-s', 'asc-web-session', - '-a', 'asc:web-session:store', - '-w' - ], stderr=subprocess.DEVNULL).decode() - except subprocess.CalledProcessError: + # Read web session from file (synced by Blitz auth bridge) + session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') + if not os.path.isfile(session_path): print('ERROR: No web session found. Call asc_web_auth MCP tool first.') sys.exit(1) + with open(session_path) as f: + raw = f.read() store = json.loads(raw) session = store['sessions'][store['last_key']] @@ -724,7 +915,7 @@ struct ProjectStorage { ## Agent Behavior - - **Do NOT ask for Apple ID email** — authentication is handled via Keychain session, not email. + - **Do NOT ask for Apple ID email** — authentication is handled via cached web session file, not email. - **NEVER print, log, or echo session cookies.** - Use the self-contained python script — do NOT extract cookies separately. - If iris API returns 401, call `asc_web_auth` MCP tool and retry. diff --git a/src/views/ContentView.swift b/src/views/ContentView.swift index 0cfccf9..5460cae 100644 --- a/src/views/ContentView.swift +++ b/src/views/ContentView.swift @@ -90,14 +90,20 @@ struct ContentView: View { private func refreshProjectFiles(projectId: String, projectType: ProjectType) { let whitelistBlitzMCP = appState.settingsStore.whitelistBlitzMCPTools + let allowASCCLICalls = appState.settingsStore.allowASCCLICalls Task.detached(priority: .utility) { let storage = ProjectStorage() - storage.ensureMCPConfig(projectId: projectId) + storage.ensureMCPConfig( + projectId: projectId, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) storage.ensureTeenybaseBackend(projectId: projectId, projectType: projectType) storage.ensureClaudeFiles( projectId: projectId, projectType: projectType, - whitelistBlitzMCP: whitelistBlitzMCP + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls ) } } @@ -135,6 +141,10 @@ struct ContentView: View { }) .task { await appState.projectManager.loadProjects() + if let projectId = appState.activeProjectId, + let projectType = appState.activeProject?.type { + refreshProjectFiles(projectId: projectId, projectType: projectType) + } // If a project was just created (e.g. from WelcomeWindow), run setup await startPendingSetupIfNeeded() diff --git a/src/views/OnboardingView.swift b/src/views/OnboardingView.swift index b074c71..e08aee5 100644 --- a/src/views/OnboardingView.swift +++ b/src/views/OnboardingView.swift @@ -112,12 +112,14 @@ struct OnboardingView: View { @State private var showExternalTerminals = false @State private var skipAgentPermissions: Bool @State private var whitelistBlitzMCPTools: Bool + @State private var allowASCCLICalls: Bool init(appState: AppState, onComplete: @escaping () -> Void) { self.appState = appState self.onComplete = onComplete _skipAgentPermissions = State(initialValue: appState.settingsStore.skipAgentPermissions) _whitelistBlitzMCPTools = State(initialValue: appState.settingsStore.whitelistBlitzMCPTools) + _allowASCCLICalls = State(initialValue: appState.settingsStore.allowASCCLICalls) } // ASC setup state @@ -296,6 +298,19 @@ struct OnboardingView: View { .toggleStyle(.switch) .controlSize(.small) + Toggle(isOn: $allowASCCLICalls) { + VStack(alignment: .leading, spacing: 1) { + Text("Allow all ASC CLI calls") + .font(.callout) + Text("Whitelists shell commands starting with `asc` for agents.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .toggleStyle(.switch) + .controlSize(.small) + Divider() // Terminal selection @@ -935,9 +950,19 @@ struct OnboardingView: View { settings.defaultAgentCLI = selectedAgent.rawValue settings.skipAgentPermissions = skipAgentPermissions settings.whitelistBlitzMCPTools = whitelistBlitzMCPTools + settings.allowASCCLICalls = allowASCCLICalls settings.hasCompletedOnboarding = true settings.save() + let whitelistBlitzMCP = whitelistBlitzMCPTools + let allowASCCLI = allowASCCLICalls + Task.detached(priority: .utility) { + ProjectStorage().ensureGlobalMCPConfigs( + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLI + ) + } + // Also persist agent selection to AppStorage for ConnectAIPopover UserDefaults.standard.set(selectedAgent.rawValue, forKey: "selectedAIAgent") diff --git a/src/views/projects/ImportProjectSheet.swift b/src/views/projects/ImportProjectSheet.swift index 21d5b4a..6621c8f 100644 --- a/src/views/projects/ImportProjectSheet.swift +++ b/src/views/projects/ImportProjectSheet.swift @@ -166,9 +166,18 @@ struct ImportProjectSheet: View { // This ensures all Blitz files land in the actual project, not a detached directory. try storage.writeMetadataToDirectory(url, metadata: metadata) let projectId = try storage.openProject(at: url) - storage.ensureMCPConfig(projectId: projectId) + storage.ensureMCPConfig( + projectId: projectId, + whitelistBlitzMCP: appState.settingsStore.whitelistBlitzMCPTools, + allowASCCLICalls: appState.settingsStore.allowASCCLICalls + ) storage.ensureTeenybaseBackend(projectId: projectId, projectType: projectType) - storage.ensureClaudeFiles(projectId: projectId, projectType: projectType, whitelistBlitzMCP: appState.settingsStore.whitelistBlitzMCPTools) + storage.ensureClaudeFiles( + projectId: projectId, + projectType: projectType, + whitelistBlitzMCP: appState.settingsStore.whitelistBlitzMCPTools, + allowASCCLICalls: appState.settingsStore.allowASCCLICalls + ) await appState.projectManager.loadProjects() appState.activeProjectId = projectId isPresented = false diff --git a/src/views/settings/SettingsView.swift b/src/views/settings/SettingsView.swift index 18c81d4..aa7c8e3 100644 --- a/src/views/settings/SettingsView.swift +++ b/src/views/settings/SettingsView.swift @@ -240,6 +240,16 @@ struct SettingsView: View { set: { newValue in settings.whitelistBlitzMCPTools = newValue settings.save() + refreshAgentPermissionFiles() + } + )) + + Toggle("Allow all ASC CLI calls", isOn: Binding( + get: { settings.allowASCCLICalls }, + set: { newValue in + settings.allowASCCLICalls = newValue + settings.save() + refreshAgentPermissionFiles() } )) @@ -337,6 +347,35 @@ struct SettingsView: View { } } + private func refreshAgentPermissionFiles() { + let whitelistBlitzMCP = settings.whitelistBlitzMCPTools + let allowASCCLICalls = settings.allowASCCLICalls + let activeProjectId = appState.activeProjectId + let activeProjectType = appState.activeProject?.type + + Task.detached(priority: .utility) { + let storage = ProjectStorage() + storage.ensureGlobalMCPConfigs( + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + + if let activeProjectId, let activeProjectType { + storage.ensureMCPConfig( + projectId: activeProjectId, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + storage.ensureClaudeFiles( + projectId: activeProjectId, + projectType: activeProjectType, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + } + } + } + private func terminalMenuItem(_ terminal: TerminalApp) -> some View { Button { terminalResetWarning = nil From b9af0237f4960d0f05c359861b419dc2edf7cc76 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Wed, 25 Mar 2026 16:56:36 -0700 Subject: [PATCH 35/51] ensure .codex/config.toml gets lines that include .claude/rule/* docs as instructions --- src/BlitzApp.swift | 8 +++++ src/utilities/ProjectStorage.swift | 43 +++++++++++++++++++++++++-- src/views/settings/SettingsView.swift | 4 +++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/BlitzApp.swift b/src/BlitzApp.swift index 8b22921..bdc1ee0 100644 --- a/src/BlitzApp.swift +++ b/src/BlitzApp.swift @@ -19,6 +19,14 @@ final class MCPBootstrap { whitelistBlitzMCP: appState.settingsStore.whitelistBlitzMCPTools, allowASCCLICalls: appState.settingsStore.allowASCCLICalls ) + let whitelistBlitzMCP = appState.settingsStore.whitelistBlitzMCPTools + let allowASCCLICalls = appState.settingsStore.allowASCCLICalls + Task.detached(priority: .utility) { + ProjectStorage().ensureAllProjectMCPConfigs( + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + } let server = MCPServerService(appState: appState) self.server = server diff --git a/src/utilities/ProjectStorage.swift b/src/utilities/ProjectStorage.swift index 33630f8..5775e16 100644 --- a/src/utilities/ProjectStorage.swift +++ b/src/utilities/ProjectStorage.swift @@ -154,7 +154,8 @@ struct ProjectStorage { ensureMCPConfig( in: mcpsDir, whitelistBlitzMCP: whitelistBlitzMCP, - allowASCCLICalls: allowASCCLICalls + allowASCCLICalls: allowASCCLICalls, + includeProjectDocFallback: false ) // 2. .claude/settings.local.json @@ -218,6 +219,26 @@ struct ProjectStorage { } } + /// Ensure agent MCP configs for every existing project under ~/.blitz/projects/. + /// This backfills new config fields for users with older project files. + func ensureAllProjectMCPConfigs(whitelistBlitzMCP: Bool = true, allowASCCLICalls: Bool = false) { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(at: baseDirectory, includingPropertiesForKeys: [.isDirectoryKey]) else { + return + } + + for entry in entries { + var isDir: ObjCBool = false + guard fm.fileExists(atPath: entry.path, isDirectory: &isDir), isDir.boolValue else { continue } + ensureMCPConfig( + in: entry, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls, + includeProjectDocFallback: true + ) + } + } + /// Ensure .mcp.json contains blitz-macos and blitz-iphone MCP server entries. /// If the file exists, merges into the existing mcpServers key without overwriting other entries. /// If it doesn't exist, creates it. @@ -231,7 +252,8 @@ struct ProjectStorage { ensureMCPConfig( in: projectDir, whitelistBlitzMCP: whitelistBlitzMCP, - allowASCCLICalls: allowASCCLICalls + allowASCCLICalls: allowASCCLICalls, + includeProjectDocFallback: true ) } @@ -239,7 +261,8 @@ struct ProjectStorage { func ensureMCPConfig( in directory: URL, whitelistBlitzMCP: Bool = true, - allowASCCLICalls: Bool = false + allowASCCLICalls: Bool = false, + includeProjectDocFallback: Bool = true ) { let mcpFile = directory.appendingPathComponent(".mcp.json") let helperPath = BlitzPaths.mcpHelper.path @@ -294,7 +317,21 @@ struct ProjectStorage { .map { "\"\(Self.escapeTOMLString($0))\"" } .joined(separator: ", ") let codexIphonePathEnv = "\(nodeRuntimeBin):/usr/bin:/bin:/usr/sbin:/sbin" + let codexProjectDocFallbackLine: String + let codexProjectDocMaxBytesLine: String + if includeProjectDocFallback { + codexProjectDocFallbackLine = """ + project_doc_fallback_filenames = [".claude/rules/blitz.md", ".claude/rules/teenybase.md"] + """ + codexProjectDocMaxBytesLine = "project_doc_max_bytes = 65536" + } else { + codexProjectDocFallbackLine = "" + codexProjectDocMaxBytesLine = "" + } let toml = """ + \(codexProjectDocFallbackLine) + \(codexProjectDocMaxBytesLine) + [mcp_servers.blitz_macos] command = "\(Self.escapeTOMLString(helperPath))" cwd = "\(Self.escapeTOMLString(directory.path))" diff --git a/src/views/settings/SettingsView.swift b/src/views/settings/SettingsView.swift index aa7c8e3..d7cc13b 100644 --- a/src/views/settings/SettingsView.swift +++ b/src/views/settings/SettingsView.swift @@ -359,6 +359,10 @@ struct SettingsView: View { whitelistBlitzMCP: whitelistBlitzMCP, allowASCCLICalls: allowASCCLICalls ) + storage.ensureAllProjectMCPConfigs( + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) if let activeProjectId, let activeProjectType { storage.ensureMCPConfig( From b7f67db8178f87bfde0f5527eeeac6424a65dc38 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Wed, 25 Mar 2026 22:52:49 -0700 Subject: [PATCH 36/51] Refactor domain groupings for ASC, MCP, project, database, and simulator --- src/AppState.swift | 557 +--- src/managers/app/DatabaseManager.swift | 150 + src/managers/app/ProjectManager.swift | 87 + src/managers/app/SimulatorManager.swift | 73 + src/managers/app/SimulatorStreamManager.swift | 87 + src/managers/app/TerminalManager.swift | 139 + src/managers/asc/ASCBuildsManager.swift | 24 + src/managers/asc/ASCDetailsManager.swift | 84 + src/managers/asc/ASCIrisManager.swift | 379 +++ src/managers/asc/ASCManager.swift | 153 + src/managers/asc/ASCMonetizationManager.swift | 533 ++++ .../asc/ASCProjectLifecycleManager.swift | 369 +++ src/managers/asc/ASCReleaseManager.swift | 83 + src/managers/asc/ASCReviewManager.swift | 21 + src/managers/asc/ASCScreenshotsManager.swift | 249 ++ src/managers/asc/ASCSessionStoreManager.swift | 101 + src/managers/asc/ASCStoreListingManager.swift | 23 + .../asc/ASCSubmissionHistoryManager.swift | 283 ++ .../asc/ASCSubmissionReadinessManager.swift | 140 + src/managers/asc/ASCTabDataManager.swift | 505 +++ src/models/ASCModels.swift | 178 +- src/models/SimulatorInfo.swift | 23 - .../{ => database}/DatabaseSchema.swift | 0 .../{ => simulator}/SimulatorConfig.swift | 22 + src/services/ASCManager.swift | 2707 ----------------- src/services/BuildPipelineService.swift | 3 +- src/services/MCPToolExecutor.swift | 1938 ------------ .../MacSwiftProjectSetupService.swift | 95 - src/services/ProjectSetupService.swift | 138 - src/services/SwiftProjectSetupService.swift | 120 - src/services/{ => asc}/ASCAuthBridge.swift | 0 src/services/{ => asc}/ASCDaemonClient.swift | 0 src/services/{ => asc}/ASCDaemonLogger.swift | 0 src/services/{ => asc}/ASCError.swift | 0 src/services/{ => asc}/ASCService.swift | 0 .../{ => asc}/ASCWebSessionStore.swift | 0 src/services/{ => asc}/IrisService.swift | 0 .../{ => database}/TeenybaseClient.swift | 0 .../TeenybaseProcessService.swift | 34 +- .../TeenybaseProjectEnvironment.swift | 53 + src/services/mcp/MCPExecutor.swift | 349 +++ src/services/mcp/MCPExecutorASC.swift | 654 ++++ .../mcp/MCPExecutorAppNavigation.swift | 99 + .../mcp/MCPExecutorBuildPipeline.swift | 338 ++ .../mcp/MCPExecutorProjectEnvironment.swift | 194 ++ src/services/mcp/MCPExecutorTabState.swift | 308 ++ .../MCPRegistry.swift} | 2 +- src/services/{ => mcp}/MCPServerService.swift | 6 +- .../project/MacSwiftProjectSetupService.swift | 33 + .../project/ProjectAgentConfigService.swift} | 501 +-- src/services/project/ProjectRepository.swift | 156 + .../project/ProjectSetupService.swift | 53 + src/services/project/ProjectStorage.swift | 114 + .../project/ProjectTeenybaseScaffolder.swift | 129 + .../project/ProjectTemplateScaffolder.swift | 104 + .../project/SwiftProjectSetupService.swift | 56 + .../DeviceInteractionService.swift | 0 .../simulator/IDBClient.swift} | 0 .../{ => simulator}/MetalRenderer.swift | 0 .../simulator}/SimctlClient.swift | 0 .../SimulatorCaptureService.swift | 0 .../{ => simulator}/SimulatorService.swift | 0 .../build/{ => database}/DatabaseView.swift | 0 .../{ => simulator}/DeviceSelectorView.swift | 0 .../{ => simulator}/MetalFrameView.swift | 0 .../build/{ => simulator}/SimulatorView.swift | 0 .../{ => simulator}/TouchOverlayView.swift | 0 67 files changed, 6246 insertions(+), 6201 deletions(-) create mode 100644 src/managers/app/DatabaseManager.swift create mode 100644 src/managers/app/ProjectManager.swift create mode 100644 src/managers/app/SimulatorManager.swift create mode 100644 src/managers/app/SimulatorStreamManager.swift create mode 100644 src/managers/app/TerminalManager.swift create mode 100644 src/managers/asc/ASCBuildsManager.swift create mode 100644 src/managers/asc/ASCDetailsManager.swift create mode 100644 src/managers/asc/ASCIrisManager.swift create mode 100644 src/managers/asc/ASCManager.swift create mode 100644 src/managers/asc/ASCMonetizationManager.swift create mode 100644 src/managers/asc/ASCProjectLifecycleManager.swift create mode 100644 src/managers/asc/ASCReleaseManager.swift create mode 100644 src/managers/asc/ASCReviewManager.swift create mode 100644 src/managers/asc/ASCScreenshotsManager.swift create mode 100644 src/managers/asc/ASCSessionStoreManager.swift create mode 100644 src/managers/asc/ASCStoreListingManager.swift create mode 100644 src/managers/asc/ASCSubmissionHistoryManager.swift create mode 100644 src/managers/asc/ASCSubmissionReadinessManager.swift create mode 100644 src/managers/asc/ASCTabDataManager.swift delete mode 100644 src/models/SimulatorInfo.swift rename src/models/{ => database}/DatabaseSchema.swift (100%) rename src/models/{ => simulator}/SimulatorConfig.swift (90%) delete mode 100644 src/services/ASCManager.swift delete mode 100644 src/services/MCPToolExecutor.swift delete mode 100644 src/services/MacSwiftProjectSetupService.swift delete mode 100644 src/services/ProjectSetupService.swift delete mode 100644 src/services/SwiftProjectSetupService.swift rename src/services/{ => asc}/ASCAuthBridge.swift (100%) rename src/services/{ => asc}/ASCDaemonClient.swift (100%) rename src/services/{ => asc}/ASCDaemonLogger.swift (100%) rename src/services/{ => asc}/ASCError.swift (100%) rename src/services/{ => asc}/ASCService.swift (100%) rename src/services/{ => asc}/ASCWebSessionStore.swift (100%) rename src/services/{ => asc}/IrisService.swift (100%) rename src/services/{ => database}/TeenybaseClient.swift (100%) rename src/services/{ => database}/TeenybaseProcessService.swift (80%) create mode 100644 src/services/database/TeenybaseProjectEnvironment.swift create mode 100644 src/services/mcp/MCPExecutor.swift create mode 100644 src/services/mcp/MCPExecutorASC.swift create mode 100644 src/services/mcp/MCPExecutorAppNavigation.swift create mode 100644 src/services/mcp/MCPExecutorBuildPipeline.swift create mode 100644 src/services/mcp/MCPExecutorProjectEnvironment.swift create mode 100644 src/services/mcp/MCPExecutorTabState.swift rename src/services/{MCPToolRegistry.swift => mcp/MCPRegistry.swift} (99%) rename src/services/{ => mcp}/MCPServerService.swift (98%) create mode 100644 src/services/project/MacSwiftProjectSetupService.swift rename src/{utilities/ProjectStorage.swift => services/project/ProjectAgentConfigService.swift} (62%) create mode 100644 src/services/project/ProjectRepository.swift create mode 100644 src/services/project/ProjectSetupService.swift create mode 100644 src/services/project/ProjectStorage.swift create mode 100644 src/services/project/ProjectTeenybaseScaffolder.swift create mode 100644 src/services/project/ProjectTemplateScaffolder.swift create mode 100644 src/services/project/SwiftProjectSetupService.swift rename src/services/{ => simulator}/DeviceInteractionService.swift (100%) rename src/{IDBProtocol.swift => services/simulator/IDBClient.swift} (100%) rename src/services/{ => simulator}/MetalRenderer.swift (100%) rename src/{ => services/simulator}/SimctlClient.swift (100%) rename src/services/{ => simulator}/SimulatorCaptureService.swift (100%) rename src/services/{ => simulator}/SimulatorService.swift (100%) rename src/views/build/{ => database}/DatabaseView.swift (100%) rename src/views/build/{ => simulator}/DeviceSelectorView.swift (100%) rename src/views/build/{ => simulator}/MetalFrameView.swift (100%) rename src/views/build/{ => simulator}/SimulatorView.swift (100%) rename src/views/build/{ => simulator}/TouchOverlayView.swift (100%) diff --git a/src/AppState.swift b/src/AppState.swift index 1f5329d..6ca76ed 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -154,7 +154,7 @@ var settingsStore = SettingsService.shared // MCP approval flow var pendingApproval: ApprovalRequest? var showApprovalAlert: Bool = false - var toolExecutor: MCPToolExecutor? + var toolExecutor: MCPExecutor? var mcpServer: MCPServerService? init() { @@ -169,560 +169,5 @@ var settingsStore = SettingsService.shared } } -// MARK: - Observable Managers - -@MainActor -@Observable -final class ProjectManager { - var projects: [Project] = [] - var isLoading = false - - func loadProjects() async { - isLoading = true - defer { isLoading = false } - - let storage = ProjectStorage() - projects = await storage.listProjects() - } -} - -@MainActor -@Observable -final class SimulatorManager { - var simulators: [SimulatorInfo] = [] - var bootedDeviceId: String? - var isStreaming = false - var isBooting = false - var bootingDeviceName: String? - - func loadSimulators() async { - let client = SimctlClient() - do { - let devices = try await client.listDevices() - simulators = devices.map { device in - SimulatorInfo( - udid: device.udid, - name: device.name, - state: device.state, - deviceTypeIdentifier: device.deviceTypeIdentifier, - lastBootedAt: device.lastBootedAt - ) - } - // Only auto-select a booted device if it's supported - bootedDeviceId = simulators.first(where: { - $0.isBooted && SimulatorConfigDatabase.isSupported($0.name) - })?.udid - } catch { - print("Failed to load simulators: \(error)") - } - } - - /// Boot a simulator if none is currently running. Called when a project opens. - /// Prefers supported devices (iPhone 16/17); falls back to any iPhone. - func bootIfNeeded() async { - await loadSimulators() - - // If a supported device is already booted, keep it - if let bootedId = bootedDeviceId, - let booted = simulators.first(where: { $0.udid == bootedId }), - SimulatorConfigDatabase.isSupported(booted.name) { return } - - // Otherwise pick a supported device to boot (prefer shutdown ones to avoid conflicts) - guard let target = simulators.first(where: { - SimulatorConfigDatabase.isSupported($0.name) && !$0.isBooted - }) ?? simulators.first(where: { - SimulatorConfigDatabase.isSupported($0.name) - }) else { return } - - isBooting = true - defer { isBooting = false } - - let service = SimulatorService() - do { - try await service.boot(udid: target.udid) - bootedDeviceId = target.udid - await loadSimulators() - } catch { - print("Failed to auto-boot simulator: \(error)") - } - } - - /// Shutdown the booted simulator. Called on app quit. - func shutdownBooted() { - guard let udid = bootedDeviceId else { return } - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") - process.arguments = ["simctl", "shutdown", udid] - try? process.run() - process.waitUntilExit() - } -} - -@MainActor -@Observable -final class SimulatorStreamManager { - let captureService = SimulatorCaptureService() - var renderer: MetalRenderer? - var isCapturing = false - var errorMessage: String? - var statusMessage: String? - /// True when the stream was paused by a tab switch (not manually stopped) - var isPaused = false - - private var rendererInitialized = false - - func ensureRenderer() { - guard !rendererInitialized else { return } - rendererInitialized = true - do { - renderer = try MetalRenderer() - } catch { - errorMessage = "Metal init failed: \(error.localizedDescription)" - } - } - - /// Full start: ensure renderer, open Simulator.app, connect SCStream. - func startStreaming(bootedDeviceId: String?) async { - guard !isCapturing else { return } - guard bootedDeviceId != nil else { - statusMessage = "No simulator booted" - return - } - - errorMessage = nil - isPaused = false - ensureRenderer() - - statusMessage = "Opening Simulator.app..." - let service = SimulatorService() - try? await service.openSimulatorApp() - - statusMessage = "Connecting to simulator..." - do { - try await captureService.startCapture(retryForWindow: true) - } catch { - errorMessage = error.localizedDescription - statusMessage = nil - return - } - - if captureService.isCapturing { - isCapturing = true - statusMessage = nil - } - } - - /// Full stop: stop SCStream, clear state. - func stopStreaming() async { - await captureService.stopCapture() - isCapturing = false - isPaused = false - } - - /// Pause: stop SCStream but keep simulator booted. Lightweight for tab switches. - func pauseStream() async { - guard isCapturing else { return } - isPaused = true - await captureService.stopCapture() - isCapturing = false - } - - /// Resume: restart SCStream after a pause. No window retry needed since sim is already running. - func resumeStream() async { - guard isPaused else { return } - isPaused = false - ensureRenderer() - - do { - try await captureService.startCapture(retryForWindow: false) - if captureService.isCapturing { - isCapturing = true - } - } catch { - errorMessage = error.localizedDescription - } - } -} - - - -@MainActor -@Observable -final class ProjectSetupManager { - var isSettingUp = false - var setupProjectId: String? - var currentStep: ProjectSetupService.SetupStep? - var errorMessage: String? - - /// Set by NewProjectSheet; consumed by ContentView to trigger setup. - var pendingSetupProjectId: String? - - /// Scaffold a project using the appropriate template for its type. - func setup(projectId: String, projectName: String, projectPath: String, projectType: ProjectType = .reactNative, platform: ProjectPlatform = .iOS) async { - isSettingUp = true - setupProjectId = projectId - currentStep = nil - errorMessage = nil - - do { - switch (projectType, platform) { - case (.swift, .macOS): - try await MacSwiftProjectSetupService.setup( - projectId: projectId, - projectName: projectName, - projectPath: projectPath, - onStep: { step in self.currentStep = step } - ) - case (.swift, .iOS): - try await SwiftProjectSetupService.setup( - projectId: projectId, - projectName: projectName, - projectPath: projectPath, - onStep: { step in self.currentStep = step } - ) - case (.reactNative, _): - try await ProjectSetupService.setup( - projectId: projectId, - projectName: projectName, - projectPath: projectPath, - onStep: { step in self.currentStep = step } - ) - case (.flutter, _): - throw ProjectSetupService.SetupError(message: "Flutter projects are not yet supported") - } - // Ensure .mcp.json, CLAUDE.md, .claude/settings.local.json exist - // (setup recreates the project dir, so these must be written after) - let storage = ProjectStorage() - storage.ensureMCPConfig( - projectId: projectId, - whitelistBlitzMCP: SettingsService.shared.whitelistBlitzMCPTools, - allowASCCLICalls: SettingsService.shared.allowASCCLICalls - ) - storage.ensureClaudeFiles( - projectId: projectId, - projectType: projectType, - whitelistBlitzMCP: SettingsService.shared.whitelistBlitzMCPTools, - allowASCCLICalls: SettingsService.shared.allowASCCLICalls - ) - isSettingUp = false - } catch { - errorMessage = error.localizedDescription - isSettingUp = false - } - } - - var stepMessage: String { - if let error = errorMessage { return "Error: \(error)" } - return currentStep?.rawValue ?? "Preparing..." - } -} - -@MainActor -@Observable -final class DatabaseManager { - // Connection & data state - var connectionStatus: ConnectionStatus = .disconnected - var schema: TeenybaseSettingsResponse? - var selectedTable: TeenybaseTable? - var rows: [TableRow] = [] - var totalRows: Int = 0 - var currentPage: Int = 0 - var pageSize: Int = 50 - var sortField: String? - var sortAscending: Bool = true - var searchText: String = "" - var errorMessage: String? - - // Tracks which project we're connected to - private(set) var connectedProjectId: String? - - // Backend process - let backendProcess = TeenybaseProcessService() - let client = TeenybaseClient() - - /// Start the backend server for a project and connect to it. - func startAndConnect(projectId: String, projectPath: String) async { - // Already connected to this project - if connectedProjectId == projectId && connectionStatus == .connected { return } - // Already in progress for this project - if connectedProjectId == projectId && connectionStatus == .connecting { return } - - // Switching projects — tear down old connection - if connectedProjectId != nil && connectedProjectId != projectId { - disconnect() - } - - connectedProjectId = projectId - connectionStatus = .connecting - errorMessage = nil - - // Read admin token from .dev.vars - let token = readDevVar("ADMIN_SERVICE_TOKEN", projectPath: projectPath) - guard let token, !token.isEmpty else { - connectionStatus = .error - errorMessage = "No ADMIN_SERVICE_TOKEN in .dev.vars" - return - } - - // Start the backend process - await backendProcess.start(projectPath: projectPath) - - // Wait for it to be running - guard backendProcess.status == .running else { - connectionStatus = .error - errorMessage = backendProcess.errorMessage ?? "Backend failed to start" - return - } - - // Connect the API client - let baseURL = backendProcess.baseURL - await client.configure(baseURL: baseURL, token: token) - - do { - let settings = try await client.fetchSchema() - self.schema = settings - self.connectionStatus = .connected - self.errorMessage = nil - if self.selectedTable == nil, let first = settings.tables.first { - self.selectedTable = first - } - } catch { - self.connectionStatus = .error - self.errorMessage = "Connected but schema fetch failed: \(error.localizedDescription)" - } - } - - func loadRows() async { - guard let table = selectedTable else { return } - do { - var whereClause: String? = nil - if !searchText.isEmpty { - let textFields = table.fields.filter { ($0.type ?? "text") == "text" || ($0.sqlType ?? "") == "text" } - if !textFields.isEmpty { - let escaped = searchText - .replacingOccurrences(of: "'", with: "''") - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "%", with: "\\%") - .replacingOccurrences(of: "_", with: "\\_") - let clauses = textFields.map { "\($0.name) LIKE '%\(escaped)%'" } - whereClause = clauses.joined(separator: " OR ") - } - } - - let result = try await client.listRecords( - table: table.name, - limit: pageSize, - offset: currentPage * pageSize, - orderBy: sortField, - ascending: sortAscending, - where: whereClause - ) - self.rows = result.items - self.totalRows = result.total - } catch { - self.errorMessage = error.localizedDescription - } - } - - func insertRecord(values: [String: Any]) async { - guard let table = selectedTable else { return } - do { - _ = try await client.insertRecord(table: table.name, values: values) - await loadRows() - } catch { - self.errorMessage = error.localizedDescription - } - } - - func updateRecord(id: String, values: [String: Any]) async { - guard let table = selectedTable else { return } - do { - _ = try await client.updateRecord(table: table.name, id: id, values: values) - await loadRows() - } catch { - self.errorMessage = error.localizedDescription - } - } - - func deleteRecord(id: String) async { - guard let table = selectedTable else { return } - do { - _ = try await client.deleteRecord(table: table.name, id: id) - await loadRows() - } catch { - self.errorMessage = error.localizedDescription - } - } - - func disconnect() { - backendProcess.stop() - connectedProjectId = nil - connectionStatus = .disconnected - schema = nil - selectedTable = nil - rows = [] - totalRows = 0 - currentPage = 0 - errorMessage = nil - } - - private func readDevVar(_ key: String, projectPath: String) -> String? { - let path = projectPath + "/.dev.vars" - guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return nil } - for line in content.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue } - if trimmed.hasPrefix(key + "=") || trimmed.hasPrefix(key + " ") { - let parts = trimmed.split(separator: "=", maxSplits: 1) - if parts.count == 2 { - return String(parts[1]).trimmingCharacters(in: .whitespaces) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) - } - } - } - return nil - } -} - -// MARK: - Terminal - -import SwiftTerm - -/// A single terminal session backed by a pseudo-terminal process. -/// The `terminalView` is created once and reused across show/hide cycles. -@MainActor -final class TerminalSession: Identifiable { - let id = UUID() - var title: String - let terminalView: LocalProcessTerminalView - private(set) var isTerminated = false - - private var delegateProxy: TerminalSessionDelegateProxy? - - init(title: String, projectPath: String?, onTerminated: @escaping (UUID) -> Void, onTitleChanged: @escaping (UUID, String) -> Void) { - self.title = title - - let termView = LocalProcessTerminalView(frame: NSRect(x: 0, y: 0, width: 800, height: 400)) - termView.nativeBackgroundColor = NSColor(red: 0.1, green: 0.1, blue: 0.1, alpha: 1) - termView.nativeForegroundColor = NSColor.white - termView.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) - self.terminalView = termView - - let proxy = TerminalSessionDelegateProxy() - let sessionId = id - proxy.onTerminated = { onTerminated(sessionId) } - proxy.onTitleChanged = { newTitle in onTitleChanged(sessionId, newTitle) } - self.delegateProxy = proxy - termView.processDelegate = proxy - - let cwd: String - if let path = projectPath, FileManager.default.fileExists(atPath: path) { - cwd = path - } else { - cwd = FileManager.default.homeDirectoryForCurrentUser.path - } - - let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" - var env = ProcessInfo.processInfo.environment - env["TERM"] = "xterm-256color" - let authEnvironment = ASCAuthBridge().environmentOverrides(forLaunchPath: projectPath) - for (key, value) in authEnvironment { - env[key] = value - } - let envPairs = env.map { "\($0.key)=\($0.value)" } - - termView.startProcess( - executable: shell, - args: ["-l"], - environment: envPairs, - execName: "-\((shell as NSString).lastPathComponent)", - currentDirectory: cwd - ) - } - - func terminate() { - guard !isTerminated else { return } - isTerminated = true - terminalView.terminate() - } - - func markTerminated() { - isTerminated = true - } - - /// Send a command string to the shell (types it and presses Enter). - func sendCommand(_ command: String) { - guard !isTerminated else { return } - let data = Array((command + "\n").utf8) - terminalView.send(source: terminalView, data: data[...]) - } -} - -/// Bridges SwiftTerm delegate callbacks to closures for TerminalSession. -private class TerminalSessionDelegateProxy: NSObject, LocalProcessTerminalViewDelegate { - var onTerminated: (() -> Void)? - var onTitleChanged: ((String) -> Void)? - - func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {} - - func setTerminalTitle(source: LocalProcessTerminalView, title: String) { - DispatchQueue.main.async { self.onTitleChanged?(title) } - } - - func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} - - func processTerminated(source: TerminalView, exitCode: Int32?) { - DispatchQueue.main.async { self.onTerminated?() } - } -} - -/// Manages terminal session lifecycle. Lives on AppState to persist across all views. -@MainActor -@Observable -final class TerminalManager { - var sessions: [TerminalSession] = [] - var activeSessionId: UUID? - - private var sessionCounter = 0 - - var activeSession: TerminalSession? { - guard let id = activeSessionId else { return nil } - return sessions.first { $0.id == id } - } - - @discardableResult - func createSession(projectPath: String?) -> TerminalSession { - sessionCounter += 1 - let session = TerminalSession( - title: "Terminal \(sessionCounter)", - projectPath: projectPath, - onTerminated: { [weak self] id in - self?.sessions.first { $0.id == id }?.markTerminated() - }, - onTitleChanged: { [weak self] id, newTitle in - self?.sessions.first { $0.id == id }?.title = newTitle - } - ) - sessions.append(session) - activeSessionId = session.id - return session - } - - func closeSession(_ id: UUID) { - sessions.first { $0.id == id }?.terminate() - sessions.removeAll { $0.id == id } - if activeSessionId == id { - activeSessionId = sessions.last?.id - } - } - - func closeAllSessions() { - sessions.forEach { $0.terminate() } - sessions.removeAll() - activeSessionId = nil - sessionCounter = 0 - } -} - // SettingsStore is SettingsService (defined in Services/SettingsService.swift) typealias SettingsStore = SettingsService diff --git a/src/managers/app/DatabaseManager.swift b/src/managers/app/DatabaseManager.swift new file mode 100644 index 0000000..4349986 --- /dev/null +++ b/src/managers/app/DatabaseManager.swift @@ -0,0 +1,150 @@ +import Foundation + +@MainActor +@Observable +final class DatabaseManager { + // Connection & data state + var connectionStatus: ConnectionStatus = .disconnected + var schema: TeenybaseSettingsResponse? + var selectedTable: TeenybaseTable? + var rows: [TableRow] = [] + var totalRows: Int = 0 + var currentPage: Int = 0 + var pageSize: Int = 50 + var sortField: String? + var sortAscending: Bool = true + var searchText: String = "" + var errorMessage: String? + + // Tracks which project we're connected to + private(set) var connectedProjectId: String? + + // Backend process + let backendProcess = TeenybaseProcessService() + let client = TeenybaseClient() + + /// Start the backend server for a project and connect to it. + func startAndConnect(projectId: String, projectPath: String) async { + // Already connected to this project + if connectedProjectId == projectId && connectionStatus == .connected { return } + // Already in progress for this project + if connectedProjectId == projectId && connectionStatus == .connecting { return } + + // Switching projects — tear down old connection + if connectedProjectId != nil && connectedProjectId != projectId { + disconnect() + } + + connectedProjectId = projectId + connectionStatus = .connecting + errorMessage = nil + + let token = TeenybaseProjectEnvironment.adminToken(projectPath: projectPath) + guard let token, !token.isEmpty else { + connectionStatus = .error + errorMessage = "No ADMIN_SERVICE_TOKEN in .dev.vars" + return + } + + // Start the backend process + await backendProcess.start(projectPath: projectPath) + + // Wait for it to be running + guard backendProcess.status == .running else { + connectionStatus = .error + errorMessage = backendProcess.errorMessage ?? "Backend failed to start" + return + } + + // Connect the API client + let baseURL = backendProcess.baseURL + await client.configure(baseURL: baseURL, token: token) + + do { + let settings = try await client.fetchSchema() + self.schema = settings + self.connectionStatus = .connected + self.errorMessage = nil + if self.selectedTable == nil, let first = settings.tables.first { + self.selectedTable = first + } + } catch { + self.connectionStatus = .error + self.errorMessage = "Connected but schema fetch failed: \(error.localizedDescription)" + } + } + + func loadRows() async { + guard let table = selectedTable else { return } + do { + var whereClause: String? = nil + if !searchText.isEmpty { + let textFields = table.fields.filter { ($0.type ?? "text") == "text" || ($0.sqlType ?? "") == "text" } + if !textFields.isEmpty { + let escaped = searchText + .replacingOccurrences(of: "'", with: "''") + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "%", with: "\\%") + .replacingOccurrences(of: "_", with: "\\_") + let clauses = textFields.map { "\($0.name) LIKE '%\(escaped)%'" } + whereClause = clauses.joined(separator: " OR ") + } + } + + let result = try await client.listRecords( + table: table.name, + limit: pageSize, + offset: currentPage * pageSize, + orderBy: sortField, + ascending: sortAscending, + where: whereClause + ) + self.rows = result.items + self.totalRows = result.total + } catch { + self.errorMessage = error.localizedDescription + } + } + + func insertRecord(values: [String: Any]) async { + guard let table = selectedTable else { return } + do { + _ = try await client.insertRecord(table: table.name, values: values) + await loadRows() + } catch { + self.errorMessage = error.localizedDescription + } + } + + func updateRecord(id: String, values: [String: Any]) async { + guard let table = selectedTable else { return } + do { + _ = try await client.updateRecord(table: table.name, id: id, values: values) + await loadRows() + } catch { + self.errorMessage = error.localizedDescription + } + } + + func deleteRecord(id: String) async { + guard let table = selectedTable else { return } + do { + _ = try await client.deleteRecord(table: table.name, id: id) + await loadRows() + } catch { + self.errorMessage = error.localizedDescription + } + } + + func disconnect() { + backendProcess.stop() + connectedProjectId = nil + connectionStatus = .disconnected + schema = nil + selectedTable = nil + rows = [] + totalRows = 0 + currentPage = 0 + errorMessage = nil + } +} diff --git a/src/managers/app/ProjectManager.swift b/src/managers/app/ProjectManager.swift new file mode 100644 index 0000000..56ad2e3 --- /dev/null +++ b/src/managers/app/ProjectManager.swift @@ -0,0 +1,87 @@ +import Foundation + +@MainActor +@Observable +final class ProjectManager { + var projects: [Project] = [] + var isLoading = false + + func loadProjects() async { + isLoading = true + defer { isLoading = false } + + let storage = ProjectStorage() + projects = await storage.listProjects() + } +} + +@MainActor +@Observable +final class ProjectSetupManager { + var isSettingUp = false + var setupProjectId: String? + var currentStep: ProjectSetupService.SetupStep? + var errorMessage: String? + + /// Set by NewProjectSheet; consumed by ContentView to trigger setup. + var pendingSetupProjectId: String? + + /// Scaffold a project using the appropriate template for its type. + func setup(projectId: String, projectName: String, projectPath: String, projectType: ProjectType = .reactNative, platform: ProjectPlatform = .iOS) async { + isSettingUp = true + setupProjectId = projectId + currentStep = nil + errorMessage = nil + + do { + switch (projectType, platform) { + case (.swift, .macOS): + try await MacSwiftProjectSetupService.setup( + projectId: projectId, + projectName: projectName, + projectPath: projectPath, + onStep: { step in self.currentStep = step } + ) + case (.swift, .iOS): + try await SwiftProjectSetupService.setup( + projectId: projectId, + projectName: projectName, + projectPath: projectPath, + onStep: { step in self.currentStep = step } + ) + case (.reactNative, _): + try await ProjectSetupService.setup( + projectId: projectId, + projectName: projectName, + projectPath: projectPath, + onStep: { step in self.currentStep = step } + ) + case (.flutter, _): + throw ProjectSetupService.SetupError(message: "Flutter projects are not yet supported") + } + // Ensure .mcp.json, CLAUDE.md, .claude/settings.local.json exist + // (setup recreates the project dir, so these must be written after) + let storage = ProjectStorage() + storage.ensureMCPConfig( + projectId: projectId, + whitelistBlitzMCP: SettingsService.shared.whitelistBlitzMCPTools, + allowASCCLICalls: SettingsService.shared.allowASCCLICalls + ) + storage.ensureClaudeFiles( + projectId: projectId, + projectType: projectType, + whitelistBlitzMCP: SettingsService.shared.whitelistBlitzMCPTools, + allowASCCLICalls: SettingsService.shared.allowASCCLICalls + ) + isSettingUp = false + } catch { + errorMessage = error.localizedDescription + isSettingUp = false + } + } + + var stepMessage: String { + if let error = errorMessage { return "Error: \(error)" } + return currentStep?.rawValue ?? "Preparing..." + } +} diff --git a/src/managers/app/SimulatorManager.swift b/src/managers/app/SimulatorManager.swift new file mode 100644 index 0000000..e9c6771 --- /dev/null +++ b/src/managers/app/SimulatorManager.swift @@ -0,0 +1,73 @@ +import Foundation + +@MainActor +@Observable +final class SimulatorManager { + var simulators: [SimulatorInfo] = [] + var bootedDeviceId: String? + var isStreaming = false + var isBooting = false + var bootingDeviceName: String? + + func loadSimulators() async { + let client = SimctlClient() + do { + let devices = try await client.listDevices() + simulators = devices.map { device in + SimulatorInfo( + udid: device.udid, + name: device.name, + state: device.state, + deviceTypeIdentifier: device.deviceTypeIdentifier, + lastBootedAt: device.lastBootedAt + ) + } + // Only auto-select a booted device if it's supported + bootedDeviceId = simulators.first(where: { + $0.isBooted && SimulatorConfigDatabase.isSupported($0.name) + })?.udid + } catch { + print("Failed to load simulators: \(error)") + } + } + + /// Boot a simulator if none is currently running. Called when a project opens. + /// Prefers supported devices (iPhone 16/17); falls back to any iPhone. + func bootIfNeeded() async { + await loadSimulators() + + // If a supported device is already booted, keep it + if let bootedId = bootedDeviceId, + let booted = simulators.first(where: { $0.udid == bootedId }), + SimulatorConfigDatabase.isSupported(booted.name) { return } + + // Otherwise pick a supported device to boot (prefer shutdown ones to avoid conflicts) + guard let target = simulators.first(where: { + SimulatorConfigDatabase.isSupported($0.name) && !$0.isBooted + }) ?? simulators.first(where: { + SimulatorConfigDatabase.isSupported($0.name) + }) else { return } + + isBooting = true + defer { isBooting = false } + + let service = SimulatorService() + do { + try await service.boot(udid: target.udid) + bootedDeviceId = target.udid + await loadSimulators() + } catch { + print("Failed to auto-boot simulator: \(error)") + } + } + + /// Shutdown the booted simulator. Called on app quit. + func shutdownBooted() { + guard let udid = bootedDeviceId else { return } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["simctl", "shutdown", udid] + try? process.run() + process.waitUntilExit() + } +} diff --git a/src/managers/app/SimulatorStreamManager.swift b/src/managers/app/SimulatorStreamManager.swift new file mode 100644 index 0000000..044726c --- /dev/null +++ b/src/managers/app/SimulatorStreamManager.swift @@ -0,0 +1,87 @@ +import Foundation + +@MainActor +@Observable +final class SimulatorStreamManager { + let captureService = SimulatorCaptureService() + var renderer: MetalRenderer? + var isCapturing = false + var errorMessage: String? + var statusMessage: String? + /// True when the stream was paused by a tab switch (not manually stopped) + var isPaused = false + + private var rendererInitialized = false + + func ensureRenderer() { + guard !rendererInitialized else { return } + rendererInitialized = true + do { + renderer = try MetalRenderer() + } catch { + errorMessage = "Metal init failed: \(error.localizedDescription)" + } + } + + /// Full start: ensure renderer, open Simulator.app, connect SCStream. + func startStreaming(bootedDeviceId: String?) async { + guard !isCapturing else { return } + guard bootedDeviceId != nil else { + statusMessage = "No simulator booted" + return + } + + errorMessage = nil + isPaused = false + ensureRenderer() + + statusMessage = "Opening Simulator.app..." + let service = SimulatorService() + try? await service.openSimulatorApp() + + statusMessage = "Connecting to simulator..." + do { + try await captureService.startCapture(retryForWindow: true) + } catch { + errorMessage = error.localizedDescription + statusMessage = nil + return + } + + if captureService.isCapturing { + isCapturing = true + statusMessage = nil + } + } + + /// Full stop: stop SCStream, clear state. + func stopStreaming() async { + await captureService.stopCapture() + isCapturing = false + isPaused = false + } + + /// Pause: stop SCStream but keep simulator booted. Lightweight for tab switches. + func pauseStream() async { + guard isCapturing else { return } + isPaused = true + await captureService.stopCapture() + isCapturing = false + } + + /// Resume: restart SCStream after a pause. No window retry needed since sim is already running. + func resumeStream() async { + guard isPaused else { return } + isPaused = false + ensureRenderer() + + do { + try await captureService.startCapture(retryForWindow: false) + if captureService.isCapturing { + isCapturing = true + } + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/src/managers/app/TerminalManager.swift b/src/managers/app/TerminalManager.swift new file mode 100644 index 0000000..adf627b --- /dev/null +++ b/src/managers/app/TerminalManager.swift @@ -0,0 +1,139 @@ +import Foundation +import AppKit +import SwiftTerm + +/// A single terminal session backed by a pseudo-terminal process. +/// The `terminalView` is created once and reused across show/hide cycles. +@MainActor +final class TerminalSession: Identifiable { + let id = UUID() + var title: String + let terminalView: LocalProcessTerminalView + private(set) var isTerminated = false + + private var delegateProxy: TerminalSessionDelegateProxy? + + init(title: String, projectPath: String?, onTerminated: @escaping (UUID) -> Void, onTitleChanged: @escaping (UUID, String) -> Void) { + self.title = title + + let termView = LocalProcessTerminalView(frame: NSRect(x: 0, y: 0, width: 800, height: 400)) + termView.nativeBackgroundColor = NSColor(red: 0.1, green: 0.1, blue: 0.1, alpha: 1) + termView.nativeForegroundColor = NSColor.white + termView.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) + self.terminalView = termView + + let proxy = TerminalSessionDelegateProxy() + let sessionId = id + proxy.onTerminated = { onTerminated(sessionId) } + proxy.onTitleChanged = { newTitle in onTitleChanged(sessionId, newTitle) } + self.delegateProxy = proxy + termView.processDelegate = proxy + + let cwd: String + if let path = projectPath, FileManager.default.fileExists(atPath: path) { + cwd = path + } else { + cwd = FileManager.default.homeDirectoryForCurrentUser.path + } + + let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" + var env = ProcessInfo.processInfo.environment + env["TERM"] = "xterm-256color" + let authEnvironment = ASCAuthBridge().environmentOverrides(forLaunchPath: projectPath) + for (key, value) in authEnvironment { + env[key] = value + } + let envPairs = env.map { "\($0.key)=\($0.value)" } + + termView.startProcess( + executable: shell, + args: ["-l"], + environment: envPairs, + execName: "-\((shell as NSString).lastPathComponent)", + currentDirectory: cwd + ) + } + + func terminate() { + guard !isTerminated else { return } + isTerminated = true + terminalView.terminate() + } + + func markTerminated() { + isTerminated = true + } + + /// Send a command string to the shell (types it and presses Enter). + func sendCommand(_ command: String) { + guard !isTerminated else { return } + let data = Array((command + "\n").utf8) + terminalView.send(source: terminalView, data: data[...]) + } +} + +/// Bridges SwiftTerm delegate callbacks to closures for TerminalSession. +private class TerminalSessionDelegateProxy: NSObject, LocalProcessTerminalViewDelegate { + var onTerminated: (() -> Void)? + var onTitleChanged: ((String) -> Void)? + + func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {} + + func setTerminalTitle(source: LocalProcessTerminalView, title: String) { + DispatchQueue.main.async { self.onTitleChanged?(title) } + } + + func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} + + func processTerminated(source: TerminalView, exitCode: Int32?) { + DispatchQueue.main.async { self.onTerminated?() } + } +} + +/// Manages terminal session lifecycle. Lives on AppState to persist across all views. +@MainActor +@Observable +final class TerminalManager { + var sessions: [TerminalSession] = [] + var activeSessionId: UUID? + + private var sessionCounter = 0 + + var activeSession: TerminalSession? { + guard let id = activeSessionId else { return nil } + return sessions.first { $0.id == id } + } + + @discardableResult + func createSession(projectPath: String?) -> TerminalSession { + sessionCounter += 1 + let session = TerminalSession( + title: "Terminal \(sessionCounter)", + projectPath: projectPath, + onTerminated: { [weak self] id in + self?.sessions.first { $0.id == id }?.markTerminated() + }, + onTitleChanged: { [weak self] id, newTitle in + self?.sessions.first { $0.id == id }?.title = newTitle + } + ) + sessions.append(session) + activeSessionId = session.id + return session + } + + func closeSession(_ id: UUID) { + sessions.first { $0.id == id }?.terminate() + sessions.removeAll { $0.id == id } + if activeSessionId == id { + activeSessionId = sessions.last?.id + } + } + + func closeAllSessions() { + sessions.forEach { $0.terminate() } + sessions.removeAll() + activeSessionId = nil + sessionCounter = 0 + } +} diff --git a/src/managers/asc/ASCBuildsManager.swift b/src/managers/asc/ASCBuildsManager.swift new file mode 100644 index 0000000..7cd952c --- /dev/null +++ b/src/managers/asc/ASCBuildsManager.swift @@ -0,0 +1,24 @@ +import Foundation + +// MARK: - Builds Manager +// Extension containing builds-related functionality for ASCManager + +extension ASCManager { + // MARK: - Beta Feedback + + func refreshBetaFeedback(buildId: String) async { + guard let service else { return } + guard !buildId.isEmpty else { return } + + loadingFeedbackBuildIds.insert(buildId) + defer { loadingFeedbackBuildIds.remove(buildId) } + + do { + betaFeedback[buildId] = try await service.fetchBetaFeedback(buildId: buildId) + } catch { + // Feedback may not be available for all apps; non-fatal. + betaFeedback[buildId] = [] + } + } +} + diff --git a/src/managers/asc/ASCDetailsManager.swift b/src/managers/asc/ASCDetailsManager.swift new file mode 100644 index 0000000..14ab72c --- /dev/null +++ b/src/managers/asc/ASCDetailsManager.swift @@ -0,0 +1,84 @@ +import Foundation + +// MARK: - App Details Manager +// Extension containing app details-related functionality for ASCManager + +extension ASCManager { + // MARK: - App Info Updates + + func updateAppInfoField(_ field: String, value: String) async { + guard let service else { return } + writeError = nil + + // Fields that live on different ASC resources: + // - copyright → appStoreVersions (PATCH /v1/appStoreVersions/{id}) + // - contentRightsDeclaration → apps (PATCH /v1/apps/{id}) + // - primaryCategory, subcategories → appInfos relationships (PATCH /v1/appInfos/{id}) + if field == "copyright" { + guard let versionId = appStoreVersions.first?.id else { return } + do { + try await service.patchVersion(id: versionId, fields: [field: value]) + // Re-fetch versions so submissionReadiness picks up the new copyright + if let appId = app?.id { + appStoreVersions = try await service.fetchAppStoreVersions(appId: appId) + } + } catch { + writeError = error.localizedDescription + } + } else if field == "contentRightsDeclaration" { + guard let appId = app?.id else { return } + do { + try await service.patchApp(id: appId, fields: [field: value]) + // Refetch app to reflect the change + app = try await service.fetchApp(bundleId: app?.bundleId ?? "") + } catch { + writeError = error.localizedDescription + } + } else if let infoId = appInfo?.id { + do { + try await service.patchAppInfo(id: infoId, fields: [field: value]) + appInfo = try? await service.fetchAppInfo(appId: app?.id ?? "") + } catch { + writeError = error.localizedDescription + } + } + } + + func updatePrivacyPolicyUrl(_ url: String) async { + await updateAppInfoLocalizationField("privacyPolicyUrl", value: url) + } + + /// Update a field on appInfoLocalizations (name, subtitle, privacyPolicyUrl) + func updateAppInfoLocalizationField(_ field: String, value: String) async { + guard let service else { return } + guard let locId = appInfoLocalization?.id else { return } + writeError = nil + // Map UI field names to API field names + let apiField = (field == "title") ? "name" : field + do { + try await service.patchAppInfoLocalization(id: locId, fields: [apiField: value]) + if let infoId = appInfo?.id { + appInfoLocalization = try? await service.fetchAppInfoLocalization(appInfoId: infoId) + } + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - Age Rating + + func updateAgeRating(_ attributes: [String: Any]) async { + guard let service else { return } + guard let id = ageRatingDeclaration?.id else { return } + writeError = nil + do { + try await service.patchAgeRating(id: id, attributes: attributes) + if let infoId = appInfo?.id { + ageRatingDeclaration = try? await service.fetchAgeRating(appInfoId: infoId) + } + } catch { + writeError = error.localizedDescription + } + } +} + diff --git a/src/managers/asc/ASCIrisManager.swift b/src/managers/asc/ASCIrisManager.swift new file mode 100644 index 0000000..5be07c2 --- /dev/null +++ b/src/managers/asc/ASCIrisManager.swift @@ -0,0 +1,379 @@ +import Foundation +import Security + +// MARK: - Iris Session (Apple ID cookie-based auth for internal APIs) + +extension ASCManager { + // MARK: - Iris Session (Apple ID auth for rejection feedback) + // TODO - move iris session logic to ASCIrisManager.swift if possible + // TODO - don't do logging in production + private func irisLog(_ msg: String) { + let logPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".blitz/iris-debug.log") + let ts = ISO8601DateFormatter().string(from: Date()) + let line = "[\(ts)] \(msg)\n" + if let data = line.data(using: .utf8) { + if FileManager.default.fileExists(atPath: logPath.path) { + if let handle = try? FileHandle(forWritingTo: logPath) { + handle.seekToEndOfFile() + handle.write(data) + handle.closeFile() + } + } else { + let dir = logPath.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try? data.write(to: logPath) + } + } + } + + func refreshSubmissionFeedbackIfNeeded() { + guard let appId = app?.id else { return } + + let rejectedVersion = appStoreVersions.first(where: { + $0.attributes.appStoreState == "REJECTED" + }) + let pendingVersion = appStoreVersions.first(where: { + let state = $0.attributes.appStoreState ?? "" + return state != "READY_FOR_SALE" && state != "REMOVED_FROM_SALE" + && state != "DEVELOPER_REMOVED_FROM_SALE" && !state.isEmpty + }) + + guard let version = rejectedVersion ?? pendingVersion else { + cachedFeedback = nil + rebuildSubmissionHistory(appId: appId) + return + } + + loadCachedFeedback(appId: appId, versionString: version.attributes.versionString) + loadIrisSession() + if irisSessionState == .valid { + Task { await fetchRejectionFeedback() } + } + } + + /// Loads cached feedback from disk for the given rejected version. No auth needed. + func loadCachedFeedback(appId: String, versionString: String) { + irisLog("ASCManager.loadCachedFeedback: appId=\(appId) version=\(versionString)") + if let cached = IrisFeedbackCache.load(appId: appId, versionString: versionString) { + cachedFeedback = cached + irisLog("ASCManager.loadCachedFeedback: loaded \(cached.reasons.count) reasons, \(cached.messages.count) messages, fetched \(cached.fetchedAt)") + } else { + irisLog("ASCManager.loadCachedFeedback: no cache found") + cachedFeedback = nil + } + rebuildSubmissionHistory(appId: appId) + } + + func fetchRejectionFeedback() async { + irisLog("ASCManager.fetchRejectionFeedback: irisService=\(irisService != nil), appId=\(app?.id ?? "nil")") + guard let irisService, let appId = app?.id else { + irisLog("ASCManager.fetchRejectionFeedback: guard failed, returning") + return + } + + // Determine version string for cache + let rejectedVersion = appStoreVersions.first(where: { + $0.attributes.appStoreState == "REJECTED" + })?.attributes.versionString + + isLoadingIrisFeedback = true + irisFeedbackError = nil + + do { + let threads = try await irisService.fetchResolutionCenterThreads(appId: appId) + irisLog("ASCManager.fetchRejectionFeedback: got \(threads.count) threads") + resolutionCenterThreads = threads + + if let latestThread = threads.first { + irisLog("ASCManager.fetchRejectionFeedback: fetching messages+rejections for thread \(latestThread.id)") + let result = try await irisService.fetchMessagesAndRejections(threadId: latestThread.id) + rejectionMessages = result.messages + rejectionReasons = result.rejections + irisLog("ASCManager.fetchRejectionFeedback: got \(rejectionMessages.count) messages, \(rejectionReasons.count) rejections") + + // Write cache + if let version = rejectedVersion { + let cache = buildFeedbackCache(appId: appId, versionString: version) + do { + try cache.save() + cachedFeedback = cache + irisLog("ASCManager.fetchRejectionFeedback: cache saved for \(version)") + } catch { + irisLog("ASCManager.fetchRejectionFeedback: cache save failed: \(error)") + } + } + } else { + irisLog("ASCManager.fetchRejectionFeedback: no threads found") + rejectionMessages = [] + rejectionReasons = [] + } + } catch let error as IrisError { + irisLog("ASCManager.fetchRejectionFeedback: IrisError: \(error)") + if case .sessionExpired = error { + irisSessionState = .expired + irisSession = nil + self.irisService = nil + } else { + irisFeedbackError = error.localizedDescription + } + } catch { + irisLog("ASCManager.fetchRejectionFeedback: error: \(error)") + irisFeedbackError = error.localizedDescription + } + + isLoadingIrisFeedback = false + rebuildSubmissionHistory(appId: appId) + irisLog("ASCManager.fetchRejectionFeedback: done") + } + + func loadIrisSession() { + irisLog("ASCManager.loadIrisSession: starting") + guard let loaded = IrisSession.load() else { + irisLog("ASCManager.loadIrisSession: no session file found") + irisSessionState = .noSession + irisSession = nil + irisService = nil + return + } + // No time-based expiry — we trust the session until a 401 proves otherwise + irisLog("ASCManager.loadIrisSession: loaded session with \(loaded.cookies.count) cookies, capturedAt=\(loaded.capturedAt)") + do { + try Self.storeWebSessionToKeychain(loaded) + } catch { + irisLog("ASCManager.loadIrisSession: asc-web-session backfill FAILED: \(error)") + } + irisSession = loaded + irisService = IrisService(session: loaded) + irisSessionState = .valid + irisLog("ASCManager.loadIrisSession: session valid, irisService created") + } + + func requestWebAuthForMCP() async -> IrisSession? { + pendingWebAuthContinuation?.resume(returning: nil) + irisFeedbackError = nil + showAppleIDLogin = true + return await withCheckedContinuation { continuation in + pendingWebAuthContinuation = continuation + } + } + + func cancelPendingWebAuth() { + showAppleIDLogin = false + pendingWebAuthContinuation?.resume(returning: nil) + pendingWebAuthContinuation = nil + } + + func setIrisSession(_ session: IrisSession) { + irisLog("ASCManager.setIrisSession: \(session.cookies.count) cookies") + do { + try session.save() + irisLog("ASCManager.setIrisSession: saved to native keychain") + } catch { + irisLog("ASCManager.setIrisSession: save FAILED: \(error)") + irisFeedbackError = "Failed to save session: \(error.localizedDescription)" + showAppleIDLogin = false + pendingWebAuthContinuation?.resume(returning: nil) + pendingWebAuthContinuation = nil + return + } + + // Also write the shared web session store (keychain + synced session file). + // If that write fails during an MCP-triggered login, keep the native session + // but fail the MCP request instead of reporting a false success. + do { + try Self.storeWebSessionToKeychain(session) + } catch { + irisLog("ASCManager.setIrisSession: asc-web-session save FAILED: \(error)") + irisFeedbackError = "Failed to save ASC web session: \(error.localizedDescription)" + if let continuation = pendingWebAuthContinuation { + pendingWebAuthContinuation = nil + continuation.resume(returning: nil) + } + } + + irisSession = session + irisService = IrisService(session: session) + irisSessionState = .valid + irisLog("ASCManager.setIrisSession: state set to .valid") + showAppleIDLogin = false + + // Notify MCP tool if it triggered this login + if let continuation = pendingWebAuthContinuation { + pendingWebAuthContinuation = nil + continuation.resume(returning: session) + } + } + + func clearIrisSession() { + irisLog("ASCManager.clearIrisSession") + let currentSession = irisSession + IrisSession.delete() + Self.deleteWebSessionFromKeychain(email: currentSession?.email) + irisSession = nil + irisService = nil + irisSessionState = .noSession + resolutionCenterThreads = [] + rejectionMessages = [] + rejectionReasons = [] + if let appId = app?.id { + rebuildSubmissionHistory(appId: appId) + } + } +} + +struct IrisSession: Codable, Sendable { + var cookies: [IrisCookie] + var email: String? + var capturedAt: Date + + struct IrisCookie: Codable, Sendable { + let name: String + let value: String + let domain: String + let path: String + } + + private static let keychainService = "dev.blitz.iris-session" + private static let keychainAccount = "iris-cookies" + + static func load() -> IrisSession? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return try? JSONDecoder().decode(IrisSession.self, from: data) + } + + func save() throws { + let data = try JSONEncoder().encode(self) + // Delete any existing item first + Self.delete() + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.keychainService, + kSecAttrAccount as String: Self.keychainAccount, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + ] + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw NSError(domain: "IrisSession", code: Int(status), + userInfo: [NSLocalizedDescriptionKey: "Failed to save session to Keychain (status: \(status))"]) + } + } + + static func delete() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + ] + SecItemDelete(query as CFDictionary) + } +} + +// MARK: - Iris API Response Models + +struct IrisResolutionCenterThread: Decodable, Identifiable { + let id: String + let attributes: Attributes + + struct Attributes: Decodable { + let state: String? + let createdDate: String? + } +} + +struct IrisResolutionCenterMessage: Decodable, Identifiable { + let id: String + let attributes: Attributes + + struct Attributes: Decodable { + let messageBody: String? + let createdDate: String? + } +} + +struct IrisReviewRejection: Decodable, Identifiable { + let id: String + let attributes: Attributes + + struct Attributes: Decodable { + let reasons: [Reason]? + } + + struct Reason: Decodable { + let reasonSection: String? + let reasonDescription: String? + let reasonCode: String? + } +} + +// MARK: - Iris Feedback Cache + +struct IrisFeedbackCache: Codable { + let appId: String + let versionString: String + let fetchedAt: Date + let messages: [CachedMessage] + let reasons: [CachedReason] + + struct CachedMessage: Codable { + let body: String + let date: String? + } + + struct CachedReason: Codable { + let section: String + let description: String + let code: String + } + + // MARK: - Persistence + + func save() throws { + let url = Self.cacheURL(appId: appId, versionString: versionString) + let dir = url.deletingLastPathComponent() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(self) + try data.write(to: url, options: .atomic) + } + + static func load(appId: String, versionString: String) -> IrisFeedbackCache? { + let url = cacheURL(appId: appId, versionString: versionString) + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(IrisFeedbackCache.self, from: data) + } + + static func loadAll(appId: String) -> [IrisFeedbackCache] { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".blitz/iris-cache/\(appId)") + guard let urls = try? FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { return [] } + + return urls + .filter { $0.pathExtension == "json" } + .compactMap { url in + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(IrisFeedbackCache.self, from: data) + } + .sorted { $0.fetchedAt > $1.fetchedAt } + } + + private static func cacheURL(appId: String, versionString: String) -> URL { + let home = FileManager.default.homeDirectoryForCurrentUser + return home.appendingPathComponent(".blitz/iris-cache/\(appId)/\(versionString).json") + } +} diff --git a/src/managers/asc/ASCManager.swift b/src/managers/asc/ASCManager.swift new file mode 100644 index 0000000..af23a63 --- /dev/null +++ b/src/managers/asc/ASCManager.swift @@ -0,0 +1,153 @@ +import Foundation + +@MainActor +@Observable +final class ASCManager { + nonisolated init() {} + + // Credentials & service + var credentials: ASCCredentials? + var service: AppStoreConnectService? + + // App + var app: ASCApp? + + // Loading / error state + var isLoadingCredentials = false + var credentialsError: String? + var isLoadingApp = false + // Bumped after saving credentials so gated ASC tabs rerun their initial load task + // once the credential form disappears and the app lookup has completed. + var credentialActivationRevision = 0 + + // Per-tab data + var appStoreVersions: [ASCAppStoreVersion] = [] + var localizations: [ASCVersionLocalization] = [] + var screenshotSets: [ASCScreenshotSet] = [] + var screenshots: [String: [ASCScreenshot]] = [:] // keyed by screenshotSet.id + var customerReviews: [ASCCustomerReview] = [] + var builds: [ASCBuild] = [] + var betaGroups: [ASCBetaGroup] = [] + var betaLocalizations: [ASCBetaLocalization] = [] + var betaFeedback: [String: [ASCBetaFeedback]] = [:] // keyed by build.id + var selectedBuildId: String? + + // Monetization data + var inAppPurchases: [ASCInAppPurchase] = [] + var subscriptionGroups: [ASCSubscriptionGroup] = [] + var subscriptionsPerGroup: [String: [ASCSubscription]] = [:] // groupId -> subs + var appPricePoints: [ASCPricePoint] = [] // USA price tiers for the app + var currentAppPricePointId: String? + var scheduledAppPricePointId: String? + var scheduledAppPriceEffectiveDate: String? + + // Creation progress (survives tab switches) + var createProgress: Double = 0 + var createProgressMessage: String = "" + var isCreating = false + internal var createTask: Task? + + // New data for submission flow + var appInfo: ASCAppInfo? + var appInfoLocalization: ASCAppInfoLocalization? + var ageRatingDeclaration: ASCAgeRatingDeclaration? + var reviewDetail: ASCReviewDetail? + var pendingCredentialValues: [String: String]? // Pre-fill values for ASC credential form (from MCP) + var pendingFormValues: [String: [String: String]] = [:] // tab -> field -> value (for MCP pre-fill) + var pendingFormVersion: Int = 0 // Incremented when pendingFormValues changes; views watch this + var pendingCreateValues: [String: String]? // Pre-fill values for IAP/subscription create forms (from MCP) + var showSubmitPreview = false + var isSubmitting = false + var submissionError: String? + var writeError: String? // Inline error for write operations (does not replace tab content) + + // Review submission history (for rejection tracking) + var reviewSubmissions: [ASCReviewSubmission] = [] + var reviewSubmissionItemsBySubmissionId: [String: [ASCReviewSubmissionItem]] = [:] + var latestSubmissionItems: [ASCReviewSubmissionItem] = [] + var submissionHistoryEvents: [ASCSubmissionHistoryEvent] = [] + + // Iris (Apple ID session) - rejection feedback from internal API + enum IrisSessionState { case unknown, noSession, valid, expired } + var irisSession: IrisSession? + var irisService: IrisService? + var irisSessionState: IrisSessionState = .unknown + var isLoadingIrisFeedback = false + var irisFeedbackError: String? + var showAppleIDLogin = false + var pendingWebAuthContinuation: CheckedContinuation? + var attachedSubmissionItemIDs: Set = [] // IAP/subscription IDs attached via iris API + var resolutionCenterThreads: [IrisResolutionCenterThread] = [] + var rejectionMessages: [IrisResolutionCenterMessage] = [] + var rejectionReasons: [IrisReviewRejection] = [] + var cachedFeedback: IrisFeedbackCache? // loaded from disk, survives session expiry + + // App icon status (set externally; nil = not checked / missing) + var appIconStatus: String? + + // Monetization status (set after monetization check or setPriceFree success) + var monetizationStatus: String? + + // Build pipeline progress (driven by MCPExecutor) + enum BuildPipelinePhase: String { + case idle + case signingSetup = "Setting up signing…" + case archiving = "Archiving…" + case exporting = "Exporting IPA…" + case uploading = "Uploading to App Store Connect…" + case processing = "Processing build…" + } + var buildPipelinePhase: BuildPipelinePhase = .idle + var buildPipelineMessage: String = "" + + // Screenshot track state per device type + var trackSlots: [String: [TrackSlot?]] = [:] // keyed by ascDisplayType, 10-element arrays + var savedTrackState: [String: [TrackSlot?]] = [:] // snapshot after last load/save + var localScreenshotAssets: [LocalScreenshotAsset] = [] + var isSyncing = false + + // Per-tab loading / error + var isLoadingTab: [AppTab: Bool] = [:] + var tabError: [AppTab: String] = [:] + + // Shared internal state used by feature extensions. + var loadedTabs: Set = [] + var tabLoadedAt: [AppTab: Date] = [:] + var projectSnapshots: [String: ProjectSnapshot] = [:] + var tabHydrationTasks: [AppTab: Task] = [:] + var overviewReadinessLoadingFields: Set = [] + var loadingFeedbackBuildIds: Set = [] + var loadedProjectId: String? + + // Submission readiness labels used by both the view model and background hydration. + static let overviewLocalizationFieldLabels: Set = [ + "App Name", + "Description", + "Keywords", + "Support URL" + ] + static let overviewVersionFieldLabels: Set = ["Copyright"] + static let overviewAppInfoFieldLabels: Set = ["Primary Category"] + static let overviewMetadataFieldLabels: Set = [ + "Privacy Policy URL", + "Age Rating" + ] + static let overviewReviewFieldLabels: Set = [ + "Review Contact First Name", + "Review Contact Last Name", + "Review Contact Email", + "Review Contact Phone", + "Demo Account Name", + "Demo Account Password" + ] + static let overviewBuildFieldLabels: Set = ["Build"] + static let overviewPricingFieldLabels: Set = [ + "Pricing", + "In-App Purchases & Subscriptions" + ] + static let overviewScreenshotFieldLabels: Set = [ + "Mac Screenshots", + "iPhone Screenshots", + "iPad Screenshots" + ] +} diff --git a/src/managers/asc/ASCMonetizationManager.swift b/src/managers/asc/ASCMonetizationManager.swift new file mode 100644 index 0000000..239469e --- /dev/null +++ b/src/managers/asc/ASCMonetizationManager.swift @@ -0,0 +1,533 @@ +import Foundation + +// MARK: - Monetization Manager +// Extension containing monetization-related functionality for ASCManager + +extension ASCManager { + // MARK: - IAP Creation + + func createIAP(name: String, productId: String, type: String, displayName: String, description: String?, price: String, screenshotPath: String? = nil) { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + isCreating = true + createProgress = 0 + createProgressMessage = "Creating in-app purchase…" + + createTask = Task { [weak self] in + guard let self else { return } + do { + createProgress = 0.05 + let iap = try await service.createInAppPurchase( + appId: appId, name: name, productId: productId, inAppPurchaseType: type + ) + + createProgressMessage = "Setting localization…" + createProgress = 0.15 + try await service.localizeInAppPurchase( + iapId: iap.id, locale: "en-US", name: displayName, description: description + ) + + createProgressMessage = "Setting availability…" + createProgress = 0.3 + let territories = try await service.fetchAllTerritories() + try await service.createIAPAvailability(iapId: iap.id, territoryIds: territories) + + createProgress = 0.5 + if !price.isEmpty, let priceVal = Double(price), priceVal > 0 { + createProgressMessage = "Setting price…" + let points = try await service.fetchInAppPurchasePricePoints(iapId: iap.id) + if let match = points.first(where: { + guard let cp = $0.attributes.customerPrice, let cpVal = Double(cp) else { return false } + return abs(cpVal - priceVal) < 0.001 + }) { + try await service.setInAppPurchasePrice(iapId: iap.id, pricePointId: match.id) + } + } + + createProgress = 0.7 + if let path = screenshotPath { + createProgressMessage = "Uploading screenshot…" + try await service.uploadIAPReviewScreenshot(iapId: iap.id, path: path) + } + + createProgressMessage = "Waiting for status update…" + createProgress = 0.9 + try await pollRefreshIAPs(service: service, appId: appId) + createProgress = 1.0 + } catch { + writeError = error.localizedDescription + } + isCreating = false + createProgress = 0 + createProgressMessage = "" + } + } + + // MARK: - IAP Updates + + func updateIAP(id: String, name: String?, reviewNote: String?, displayName: String?, description: String?) async { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + do { + // Patch IAP attributes (name, reviewNote) + var attrs: [String: Any] = [:] + if let name { attrs["name"] = name } + if let reviewNote { attrs["reviewNote"] = reviewNote } + if !attrs.isEmpty { + try await service.patchInAppPurchase(iapId: id, attrs: attrs) + } + // Patch localization (displayName, description) + if displayName != nil || description != nil { + let locs = try await service.fetchIAPLocalizations(iapId: id) + if let loc = locs.first { + var fields: [String: String] = [:] + if let displayName { fields["name"] = displayName } + if let description { fields["description"] = description } + try await service.patchIAPLocalization(locId: loc.id, fields: fields) + } + } + inAppPurchases = try await service.fetchInAppPurchases(appId: appId) + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - IAP Deletion + + func deleteIAP(id: String) async { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + do { + try await service.deleteInAppPurchase(iapId: id) + inAppPurchases = try await service.fetchInAppPurchases(appId: appId) + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - IAP Screenshots + + func uploadIAPScreenshot(iapId: String, path: String) async { + guard let service else { return } + writeError = nil + do { + try await service.uploadIAPReviewScreenshot(iapId: iapId, path: path) + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - IAP Submission + + func submitIAPForReview(id: String) async -> Bool { + guard let service else { return false } + guard let appId = app?.id else { return false } + writeError = nil + do { + try await service.submitIAPForReview(iapId: id) + inAppPurchases = try await service.fetchInAppPurchases(appId: appId) + return true + } catch { + let msg = error.localizedDescription + if msg.contains("FIRST_IAP") || msg.contains("first In-App Purchase") || msg.contains("first in-app purchase") { + writeError = "FIRST_SUBMISSION:" + msg + } else { + writeError = msg + } + return false + } + } + + // MARK: - Subscription Creation + + func createSubscription(groupName: String, name: String, productId: String, displayName: String, description: String?, duration: String, price: String, screenshotPath: String? = nil) { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + isCreating = true + createProgress = 0 + createProgressMessage = "Setting up group…" + + createTask = Task { [weak self] in + guard let self else { return } + do { + createProgress = 0.03 + let group: ASCSubscriptionGroup + if let existing = subscriptionGroups.first(where: { $0.attributes.referenceName == groupName }) { + let groupLocs = try await service.fetchSubscriptionGroupLocalizations(groupId: existing.id) + if groupLocs.isEmpty { + try await service.localizeSubscriptionGroup(groupId: existing.id, locale: "en-US", name: groupName) + } + group = existing + } else { + group = try await service.createSubscriptionGroup(appId: appId, referenceName: groupName) + try await service.localizeSubscriptionGroup(groupId: group.id, locale: "en-US", name: groupName) + } + + createProgressMessage = "Creating subscription…" + createProgress = 0.08 + let sub = try await service.createSubscription( + groupId: group.id, name: name, productId: productId, subscriptionPeriod: duration + ) + + createProgressMessage = "Setting localization…" + createProgress = 0.12 + try await service.localizeSubscription( + subscriptionId: sub.id, locale: "en-US", name: displayName, description: description + ) + + createProgressMessage = "Setting availability…" + createProgress = 0.16 + let territories = try await service.fetchAllTerritories() + try await service.createSubscriptionAvailability(subscriptionId: sub.id, territoryIds: territories) + + createProgress = 0.2 + if !price.isEmpty, let priceVal = Double(price), priceVal > 0 { + let points = try await service.fetchSubscriptionPricePoints(subscriptionId: sub.id) + if let match = points.first(where: { + guard let cp = $0.attributes.customerPrice, let cpVal = Double(cp) else { return false } + return abs(cpVal - priceVal) < 0.001 + }) { + // Pricing loop: 0.2 → 0.8 (bulk of the time) + createProgressMessage = "Setting prices (0/175)…" + try await service.setSubscriptionPrice(subscriptionId: sub.id, pricePointId: match.id) { done, total in + Task { @MainActor [weak self] in + self?.createProgressMessage = "Setting prices (\(done)/\(total))…" + self?.createProgress = 0.2 + 0.6 * (Double(done) / Double(total)) + } + } + } + } + + createProgress = 0.85 + if let path = screenshotPath { + createProgressMessage = "Uploading screenshot…" + try await service.uploadSubscriptionReviewScreenshot(subscriptionId: sub.id, path: path) + } + + createProgressMessage = "Waiting for status update…" + createProgress = 0.9 + try await pollRefreshSubscriptions(service: service, appId: appId) + createProgress = 1.0 + } catch { + writeError = error.localizedDescription + } + isCreating = false + createProgress = 0 + createProgressMessage = "" + } + } + + // MARK: - Subscription Updates + + func updateSubscription(id: String, name: String?, reviewNote: String?, displayName: String?, description: String?) async { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + do { + var attrs: [String: Any] = [:] + if let name { attrs["name"] = name } + if let reviewNote { attrs["reviewNote"] = reviewNote } + if !attrs.isEmpty { + try await service.patchSubscription(subscriptionId: id, attrs: attrs) + } + if displayName != nil || description != nil { + let locs = try await service.fetchSubscriptionLocalizations(subscriptionId: id) + if let loc = locs.first { + var fields: [String: String] = [:] + if let displayName { fields["name"] = displayName } + if let description { fields["description"] = description } + try await service.patchSubscriptionLocalization(locId: loc.id, fields: fields) + } + } + subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) + for g in subscriptionGroups { + subscriptionsPerGroup[g.id] = try await service.fetchSubscriptionsInGroup(groupId: g.id) + } + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - Subscription Deletion + + func deleteSubscription(id: String) async { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + do { + try await service.deleteSubscription(subscriptionId: id) + subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) + for g in subscriptionGroups { + subscriptionsPerGroup[g.id] = try await service.fetchSubscriptionsInGroup(groupId: g.id) + } + } catch { + writeError = error.localizedDescription + } + } + + func deleteSubscriptionGroup(id: String) async { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + do { + try await service.deleteSubscriptionGroup(groupId: id) + subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) + subscriptionsPerGroup.removeValue(forKey: id) + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - Subscription Screenshots + + func uploadSubscriptionScreenshot(subscriptionId: String, path: String) async { + guard let service else { return } + writeError = nil + do { + try await service.uploadSubscriptionReviewScreenshot(subscriptionId: subscriptionId, path: path) + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - Subscription Localization + + func updateSubscriptionGroupLocalization(groupId: String, name: String) async { + guard let service else { return } + writeError = nil + do { + let locs = try await service.fetchSubscriptionGroupLocalizations(groupId: groupId) + if let loc = locs.first { + try await service.patchSubscriptionGroupLocalization(locId: loc.id, name: name) + } else { + try await service.localizeSubscriptionGroup(groupId: groupId, locale: "en-US", name: name) + } + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - Subscription Submission + + func submitSubscriptionForReview(id: String) async -> Bool { + guard let service else { return false } + guard let appId = app?.id else { return false } + writeError = nil + do { + try await service.submitSubscriptionForReview(subscriptionId: id) + subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) + for g in subscriptionGroups { + subscriptionsPerGroup[g.id] = try await service.fetchSubscriptionsInGroup(groupId: g.id) + } + return true + } catch { + let msg = error.localizedDescription + if msg.contains("FIRST_SUBSCRIPTION") || msg.contains("first subscription") { + writeError = "FIRST_SUBMISSION:" + msg + } else { + writeError = msg + } + return false + } + } + + // MARK: - Pricing + + func setAppPrice(pricePointId: String) async { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + do { + try await service.setAppPrice(appId: appId, pricePointId: pricePointId) + try await service.ensureAppAvailability(appId: appId) + currentAppPricePointId = pricePointId + scheduledAppPricePointId = nil + scheduledAppPriceEffectiveDate = nil + monetizationStatus = isFreePricePoint(pricePointId) ? "Free" : "Configured" + } catch { + writeError = error.localizedDescription + } + } + + func setScheduledAppPrice(currentPricePointId: String, futurePricePointId: String, effectiveDate: String) async { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + do { + try await service.setScheduledAppPrice( + appId: appId, + currentPricePointId: currentPricePointId, + futurePricePointId: futurePricePointId, + effectiveDate: effectiveDate + ) + self.currentAppPricePointId = currentPricePointId + scheduledAppPricePointId = futurePricePointId + scheduledAppPriceEffectiveDate = effectiveDate + monetizationStatus = "Configured" + } catch { + writeError = error.localizedDescription + } + } + + func setPriceFree() async { + guard let service else { return } + guard let appId = app?.id else { return } + writeError = nil + do { + try await service.setPriceFree(appId: appId) + try await service.ensureAppAvailability(appId: appId) + currentAppPricePointId = freeAppPricePointId + scheduledAppPricePointId = nil + scheduledAppPriceEffectiveDate = nil + monetizationStatus = "Free" + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - Refresh + + func refreshMonetization() async { + guard let service else { return } + guard let appId = app?.id else { return } + do { + inAppPurchases = try await service.fetchInAppPurchases(appId: appId) + subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) + for group in subscriptionGroups { + subscriptionsPerGroup[group.id] = try await service.fetchSubscriptionsInGroup(groupId: group.id) + } + } catch { + writeError = error.localizedDescription + } + } + + func refreshAttachedSubmissionItemIDs() async { + guard let appId = app?.id else { + attachedSubmissionItemIDs = [] + return + } + guard let cookieHeader = ascWebSessionCookieHeader() else { + attachedSubmissionItemIDs = [] + return + } + + let subscriptionURL = "https://appstoreconnect.apple.com/iris/v1/apps/\(appId)/subscriptionGroups?include=subscriptions&limit=300&fields%5Bsubscriptions%5D=productId,name,state,submitWithNextAppStoreVersion" + let iapURL = "https://appstoreconnect.apple.com/iris/v1/apps/\(appId)/inAppPurchasesV2?limit=300&fields%5BinAppPurchases%5D=productId,name,state,submitWithNextAppStoreVersion" + + let attachedSubscriptions = await fetchAttachedSubmissionItemIDs(urlString: subscriptionURL, cookieHeader: cookieHeader) + let attachedIAPs = await fetchAttachedSubmissionItemIDs(urlString: iapURL, cookieHeader: cookieHeader) + attachedSubmissionItemIDs = attachedSubscriptions.union(attachedIAPs) + } + + // MARK: - Polling + + private func pollRefreshIAPs(service: AppStoreConnectService, appId: String) async throws { + for _ in 0..<5 { + try await Task.sleep(for: .seconds(1)) + inAppPurchases = try await service.fetchInAppPurchases(appId: appId) + let allResolved = inAppPurchases.allSatisfy { $0.attributes.state != "MISSING_METADATA" } + if allResolved { return } + } + } + + private func pollRefreshSubscriptions(service: AppStoreConnectService, appId: String) async throws { + for _ in 0..<5 { + try await Task.sleep(for: .seconds(1)) + subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) + for g in subscriptionGroups { + subscriptionsPerGroup[g.id] = try await service.fetchSubscriptionsInGroup(groupId: g.id) + } + let allResolved = subscriptionsPerGroup.values.joined().allSatisfy { $0.attributes.state != "MISSING_METADATA" } + if allResolved { return } + } + } + + // MARK: - Pricing State + + var freeAppPricePointId: String? { + appPricePoints.first(where: { + let price = $0.attributes.customerPrice ?? "0" + return price == "0" || price == "0.0" || price == "0.00" + })?.id + } + + func applyAppPricingState(_ state: ASCAppPricingState) { + currentAppPricePointId = state.currentPricePointId + scheduledAppPricePointId = state.scheduledPricePointId + scheduledAppPriceEffectiveDate = state.scheduledEffectiveDate + + if let currentPricePointId = currentAppPricePointId { + let isCurrentlyFree = isFreePricePoint(currentPricePointId) + monetizationStatus = (isCurrentlyFree && state.scheduledPricePointId == nil) ? "Free" : "Configured" + } else if state.scheduledPricePointId != nil { + monetizationStatus = "Configured" + } else { + monetizationStatus = nil + } + } + + func isFreePricePoint(_ pricePointId: String) -> Bool { + appPricePoints.contains(where: { + guard $0.id == pricePointId else { return false } + let price = $0.attributes.customerPrice ?? "0" + return price == "0" || price == "0.0" || price == "0.00" + }) + } + + // MARK: - Web Session Helpers (for IAP attachment queries) + + func ascWebSessionCookieHeader() -> String? { + guard let storeData = Self.readKeychainItem(service: "asc-web-session", account: "asc:web-session:store"), + let store = try? JSONSerialization.jsonObject(with: storeData) as? [String: Any], + let lastKey = store["last_key"] as? String, + let sessions = store["sessions"] as? [String: Any], + let sessionDict = sessions[lastKey] as? [String: Any], + let cookies = sessionDict["cookies"] as? [String: [[String: Any]]] else { + return nil + } + + let cookieHeader = cookies.values.flatMap { $0 }.compactMap { cookie -> String? in + guard let name = cookie["name"] as? String, + let value = cookie["value"] as? String, + !name.isEmpty else { return nil } + return name.hasPrefix("DES") ? "\(name)=\"\(value)\"" : "\(name)=\(value)" + }.joined(separator: "; ") + + return cookieHeader.isEmpty ? nil : cookieHeader + } + + func fetchAttachedSubmissionItemIDs(urlString: String, cookieHeader: String) async -> Set { + guard let url = URL(string: urlString) else { return [] } + + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") + request.setValue("https://appstoreconnect.apple.com", forHTTPHeaderField: "Origin") + request.setValue("https://appstoreconnect.apple.com/", forHTTPHeaderField: "Referer") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.timeoutInterval = 10 + + guard let (data, response) = try? await URLSession.shared.data(for: request), + let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return [] + } + + let resources = (json["data"] as? [[String: Any]] ?? []) + + (json["included"] as? [[String: Any]] ?? []) + + return Set(resources.compactMap { item in + guard let attrs = item["attributes"] as? [String: Any], + let id = item["id"] as? String, + let submitWithNext = attrs["submitWithNextAppStoreVersion"] as? Bool, + submitWithNext else { return nil } + return id + }) + } +} + diff --git a/src/managers/asc/ASCProjectLifecycleManager.swift b/src/managers/asc/ASCProjectLifecycleManager.swift new file mode 100644 index 0000000..a516c1e --- /dev/null +++ b/src/managers/asc/ASCProjectLifecycleManager.swift @@ -0,0 +1,369 @@ +import Foundation + +extension ASCManager { + struct ProjectSnapshot { + let projectId: String + let app: ASCApp? + let appStoreVersions: [ASCAppStoreVersion] + let localizations: [ASCVersionLocalization] + let screenshotSets: [ASCScreenshotSet] + let screenshots: [String: [ASCScreenshot]] + let customerReviews: [ASCCustomerReview] + let builds: [ASCBuild] + let betaGroups: [ASCBetaGroup] + let betaLocalizations: [ASCBetaLocalization] + let betaFeedback: [String: [ASCBetaFeedback]] + let selectedBuildId: String? + let inAppPurchases: [ASCInAppPurchase] + let subscriptionGroups: [ASCSubscriptionGroup] + let subscriptionsPerGroup: [String: [ASCSubscription]] + let appPricePoints: [ASCPricePoint] + let currentAppPricePointId: String? + let scheduledAppPricePointId: String? + let scheduledAppPriceEffectiveDate: String? + let appInfo: ASCAppInfo? + let appInfoLocalization: ASCAppInfoLocalization? + let ageRatingDeclaration: ASCAgeRatingDeclaration? + let reviewDetail: ASCReviewDetail? + let reviewSubmissions: [ASCReviewSubmission] + let reviewSubmissionItemsBySubmissionId: [String: [ASCReviewSubmissionItem]] + let latestSubmissionItems: [ASCReviewSubmissionItem] + let submissionHistoryEvents: [ASCSubmissionHistoryEvent] + let attachedSubmissionItemIDs: Set + let resolutionCenterThreads: [IrisResolutionCenterThread] + let rejectionMessages: [IrisResolutionCenterMessage] + let rejectionReasons: [IrisReviewRejection] + let cachedFeedback: IrisFeedbackCache? + let trackSlots: [String: [TrackSlot?]] + let savedTrackState: [String: [TrackSlot?]] + let localScreenshotAssets: [LocalScreenshotAsset] + let appIconStatus: String? + let monetizationStatus: String? + let loadedTabs: Set + let tabLoadedAt: [AppTab: Date] + + @MainActor + init(manager: ASCManager, projectId: String) { + self.projectId = projectId + app = manager.app + appStoreVersions = manager.appStoreVersions + localizations = manager.localizations + screenshotSets = manager.screenshotSets + screenshots = manager.screenshots + customerReviews = manager.customerReviews + builds = manager.builds + betaGroups = manager.betaGroups + betaLocalizations = manager.betaLocalizations + betaFeedback = manager.betaFeedback + selectedBuildId = manager.selectedBuildId + inAppPurchases = manager.inAppPurchases + subscriptionGroups = manager.subscriptionGroups + subscriptionsPerGroup = manager.subscriptionsPerGroup + appPricePoints = manager.appPricePoints + currentAppPricePointId = manager.currentAppPricePointId + scheduledAppPricePointId = manager.scheduledAppPricePointId + scheduledAppPriceEffectiveDate = manager.scheduledAppPriceEffectiveDate + appInfo = manager.appInfo + appInfoLocalization = manager.appInfoLocalization + ageRatingDeclaration = manager.ageRatingDeclaration + reviewDetail = manager.reviewDetail + reviewSubmissions = manager.reviewSubmissions + reviewSubmissionItemsBySubmissionId = manager.reviewSubmissionItemsBySubmissionId + latestSubmissionItems = manager.latestSubmissionItems + submissionHistoryEvents = manager.submissionHistoryEvents + attachedSubmissionItemIDs = manager.attachedSubmissionItemIDs + resolutionCenterThreads = manager.resolutionCenterThreads + rejectionMessages = manager.rejectionMessages + rejectionReasons = manager.rejectionReasons + cachedFeedback = manager.cachedFeedback + trackSlots = manager.trackSlots + savedTrackState = manager.savedTrackState + localScreenshotAssets = manager.localScreenshotAssets + appIconStatus = manager.appIconStatus + monetizationStatus = manager.monetizationStatus + let cachedLoadedTabs = manager.loadedTabs.intersection(Self.cachedProjectTabs) + loadedTabs = cachedLoadedTabs + tabLoadedAt = manager.tabLoadedAt.filter { cachedLoadedTabs.contains($0.key) } + } + + @MainActor + func apply(to manager: ASCManager) { + manager.app = app + manager.appStoreVersions = appStoreVersions + manager.localizations = localizations + manager.screenshotSets = screenshotSets + manager.screenshots = screenshots + manager.customerReviews = customerReviews + manager.builds = builds + manager.betaGroups = betaGroups + manager.betaLocalizations = betaLocalizations + manager.betaFeedback = betaFeedback + manager.selectedBuildId = selectedBuildId + manager.inAppPurchases = inAppPurchases + manager.subscriptionGroups = subscriptionGroups + manager.subscriptionsPerGroup = subscriptionsPerGroup + manager.appPricePoints = appPricePoints + manager.currentAppPricePointId = currentAppPricePointId + manager.scheduledAppPricePointId = scheduledAppPricePointId + manager.scheduledAppPriceEffectiveDate = scheduledAppPriceEffectiveDate + manager.appInfo = appInfo + manager.appInfoLocalization = appInfoLocalization + manager.ageRatingDeclaration = ageRatingDeclaration + manager.reviewDetail = reviewDetail + manager.reviewSubmissions = reviewSubmissions + manager.reviewSubmissionItemsBySubmissionId = reviewSubmissionItemsBySubmissionId + manager.latestSubmissionItems = latestSubmissionItems + manager.submissionHistoryEvents = submissionHistoryEvents + manager.attachedSubmissionItemIDs = attachedSubmissionItemIDs + manager.resolutionCenterThreads = resolutionCenterThreads + manager.rejectionMessages = rejectionMessages + manager.rejectionReasons = rejectionReasons + manager.cachedFeedback = cachedFeedback + manager.trackSlots = trackSlots + manager.savedTrackState = savedTrackState + manager.localScreenshotAssets = localScreenshotAssets + manager.appIconStatus = appIconStatus + manager.monetizationStatus = monetizationStatus + manager.loadedTabs = loadedTabs + manager.tabLoadedAt = tabLoadedAt + manager.loadedProjectId = projectId + manager.tabError = [:] + manager.isLoadingTab = [:] + manager.isLoadingApp = false + manager.isLoadingIrisFeedback = false + manager.loadingFeedbackBuildIds = [] + manager.irisFeedbackError = nil + manager.writeError = nil + manager.submissionError = nil + manager.overviewReadinessLoadingFields = [] + } + + private static let cachedProjectTabs: Set = [ + .app, + .storeListing, + .screenshots, + .appDetails, + .monetization, + .review, + .analytics, + .reviews, + .builds, + .groups, + .betaInfo, + .feedback, + ] + } + + private static let projectCacheFreshness: TimeInterval = 120 + + func checkAppIcon(projectId: String) { + let fm = FileManager.default + let home = fm.homeDirectoryForCurrentUser.path + let iconDir = "\(home)/.blitz/projects/\(projectId)/assets/AppIcon" + let icon1024 = "\(iconDir)/icon_1024.png" + + if fm.fileExists(atPath: icon1024) { + appIconStatus = "1024px" + return + } + + let projectDir = "\(home)/.blitz/projects/\(projectId)" + let xcassetsPattern = ["ios", "macos", "."] + for subdir in xcassetsPattern { + let searchDir = subdir == "." ? projectDir : "\(projectDir)/\(subdir)" + guard let enumerator = fm.enumerator(atPath: searchDir) else { continue } + while let file = enumerator.nextObject() as? String { + guard file.hasSuffix("AppIcon.appiconset/Contents.json") else { continue } + let contentsPath = "\(searchDir)/\(file)" + guard let data = fm.contents(atPath: contentsPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let images = json["images"] as? [[String: Any]] else { + continue + } + if images.contains(where: { $0["filename"] != nil }) { + appIconStatus = "Configured" + return + } + } + } + + appIconStatus = nil + } + + func prepareForProjectSwitch(to projectId: String) { + cacheCurrentProjectSnapshot() + resetProjectData(preserveCredentials: true) + + if let snapshot = projectSnapshots[projectId] { + snapshot.apply(to: self) + } else { + loadedProjectId = projectId + } + } + + func loadStoredCredentialsIfNeeded() { + guard credentials == nil || service == nil else { return } + let creds = ASCCredentials.load() + try? ASCAuthBridge().syncCredentials(creds) + Self.syncWebSessionFileFromKeychain() + credentials = creds + service = creds.map { AppStoreConnectService(credentials: $0) } + } + + func loadCredentials(for projectId: String, bundleId: String?) async { + let needsCredentialReload = credentials == nil || service == nil + let shouldSkip = loadedProjectId == projectId + && !needsCredentialReload + && (bundleId == nil || app != nil) + guard !shouldSkip else { return } + + credentialsError = nil + + if needsCredentialReload { + isLoadingCredentials = true + let creds = ASCCredentials.load() + try? ASCAuthBridge().syncCredentials(creds) + Self.syncWebSessionFileFromKeychain() + credentials = creds + isLoadingCredentials = false + service = creds.map { AppStoreConnectService(credentials: $0) } + } + + loadedProjectId = projectId + refreshAppIconStatusIfNeeded(for: projectId) + + if let bundleId, !bundleId.isEmpty, credentials != nil, app == nil { + await fetchApp(bundleId: bundleId) + } + } + + func clearForProjectSwitch() { + resetProjectData(preserveCredentials: false) + } + + func saveCredentials(_ creds: ASCCredentials, projectId: String, bundleId: String?) async throws { + try creds.save() + credentials = creds + service = AppStoreConnectService(credentials: creds) + credentialsError = nil + cancelBackgroundHydrationTasks() + loadedTabs = [] + tabLoadedAt = [:] + tabError = [:] + isLoadingTab = [:] + loadingFeedbackBuildIds = [] + + if let bundleId, !bundleId.isEmpty { + await fetchApp(bundleId: bundleId) + } + + credentialActivationRevision += 1 + } + + func deleteCredentials() { + ASCCredentials.delete() + let projectId = loadedProjectId + clearForProjectSwitch() + loadedProjectId = projectId + } + + func refreshAppIconStatusIfNeeded(for projectId: String?) { + guard let projectId, !projectId.isEmpty else { return } + checkAppIcon(projectId: projectId) + } + + func cancelBackgroundHydration(for tab: AppTab) { + tabHydrationTasks[tab]?.cancel() + tabHydrationTasks.removeValue(forKey: tab) + } + + func cancelBackgroundHydrationTasks() { + for task in tabHydrationTasks.values { + task.cancel() + } + tabHydrationTasks.removeAll() + } + + func startBackgroundHydration(for tab: AppTab, operation: @escaping @MainActor () async -> Void) { + cancelBackgroundHydration(for: tab) + tabHydrationTasks[tab] = Task { + await operation() + } + } + + func resetProjectData(preserveCredentials: Bool) { + cancelBackgroundHydrationTasks() + overviewReadinessLoadingFields = [] + loadingFeedbackBuildIds = [] + + if !preserveCredentials { + credentials = nil + service = nil + } + + app = nil + isLoadingCredentials = false + credentialsError = nil + isLoadingApp = false + appStoreVersions = [] + localizations = [] + screenshotSets = [] + screenshots = [:] + customerReviews = [] + builds = [] + betaGroups = [] + betaLocalizations = [] + betaFeedback = [:] + selectedBuildId = nil + inAppPurchases = [] + subscriptionGroups = [] + subscriptionsPerGroup = [:] + appPricePoints = [] + currentAppPricePointId = nil + scheduledAppPricePointId = nil + scheduledAppPriceEffectiveDate = nil + appInfo = nil + appInfoLocalization = nil + ageRatingDeclaration = nil + reviewDetail = nil + pendingFormValues = [:] + showSubmitPreview = false + isSubmitting = false + submissionError = nil + writeError = nil + reviewSubmissions = [] + reviewSubmissionItemsBySubmissionId = [:] + latestSubmissionItems = [] + submissionHistoryEvents = [] + appIconStatus = nil + monetizationStatus = nil + attachedSubmissionItemIDs = [] + isLoadingTab = [:] + tabError = [:] + loadedTabs = [] + tabLoadedAt = [:] + if !preserveCredentials { + loadedProjectId = nil + } + + resolutionCenterThreads = [] + rejectionMessages = [] + rejectionReasons = [] + cachedFeedback = nil + isLoadingIrisFeedback = false + irisFeedbackError = nil + cancelPendingWebAuth() + } + + func cacheCurrentProjectSnapshot() { + guard let projectId = loadedProjectId else { return } + guard app != nil || !loadedTabs.isEmpty else { return } + projectSnapshots[projectId] = ProjectSnapshot(manager: self, projectId: projectId) + } + + func shouldRefreshTabCache(_ tab: AppTab) -> Bool { + guard loadedTabs.contains(tab) else { return true } + guard let loadedAt = tabLoadedAt[tab] else { return true } + return Date().timeIntervalSince(loadedAt) > Self.projectCacheFreshness + } +} diff --git a/src/managers/asc/ASCReleaseManager.swift b/src/managers/asc/ASCReleaseManager.swift new file mode 100644 index 0000000..7027e60 --- /dev/null +++ b/src/managers/asc/ASCReleaseManager.swift @@ -0,0 +1,83 @@ +import Foundation + +// MARK: - Release Manager +// Extension containing release-related functionality for ASCManager + +extension ASCManager { + // MARK: - Build Attachment + + func attachBuild(buildId: String) async { + guard let service else { return } + guard let versionId = pendingVersionId else { + writeError = "No app store version found to attach build to." + return + } + writeError = nil + do { + try await service.attachBuild(versionId: versionId, buildId: buildId) + } catch { + writeError = error.localizedDescription + } + } + + // MARK: - Submission + + func submitForReview(attachBuildId: String? = nil) async { + guard let service else { return } + guard let appId = app?.id, let versionId = pendingVersionId else { return } + isSubmitting = true + submissionError = nil + do { + // Attach build if specified + if let buildId = attachBuildId { + try await service.attachBuild(versionId: versionId, buildId: buildId) + } + try await service.submitForReview(appId: appId, versionId: versionId) + isSubmitting = false + await refreshTabData(.app) + } catch { + isSubmitting = false + submissionError = error.localizedDescription + } + } + + // MARK: - Localization Flushing + + func flushPendingLocalizations() async { + guard let service else { return } + let appInfoLocFieldNames: Set = ["name", "title", "subtitle", "privacyPolicyUrl"] + for (tab, fields) in pendingFormValues { + if tab == "storeListing" { + var versionLocFields: [String: String] = [:] + var infoLocFields: [String: String] = [:] + for (field, value) in fields { + if appInfoLocFieldNames.contains(field) { + let apiField = (field == "title") ? "name" : field + infoLocFields[apiField] = value + } else { + versionLocFields[field] = value + } + } + if !versionLocFields.isEmpty, let locId = localizations.first?.id { + try? await service.patchLocalization(id: locId, fields: versionLocFields) + } + if !infoLocFields.isEmpty, let infoLocId = appInfoLocalization?.id { + try? await service.patchAppInfoLocalization(id: infoLocId, fields: infoLocFields) + } + } + } + pendingFormValues = [:] + } + + // MARK: - Computed Properties + + /// The pending version ID (not live / not removed). + var pendingVersionId: String? { + appStoreVersions.first { + let s = $0.attributes.appStoreState ?? "" + return s != "READY_FOR_SALE" && s != "REMOVED_FROM_SALE" + && s != "DEVELOPER_REMOVED_FROM_SALE" && !s.isEmpty + }?.id ?? appStoreVersions.first?.id + } +} + diff --git a/src/managers/asc/ASCReviewManager.swift b/src/managers/asc/ASCReviewManager.swift new file mode 100644 index 0000000..fc6af99 --- /dev/null +++ b/src/managers/asc/ASCReviewManager.swift @@ -0,0 +1,21 @@ +import Foundation + +// MARK: - Review Manager +// Extension containing review-related functionality for ASCManager + +extension ASCManager { + // MARK: - Review Contact Updates + + func updateReviewContact(_ attributes: [String: Any]) async { + guard let service else { return } + guard let versionId = appStoreVersions.first?.id else { return } + writeError = nil + do { + try await service.createOrPatchReviewDetail(versionId: versionId, attributes: attributes) + reviewDetail = try? await service.fetchReviewDetail(versionId: versionId) + } catch { + writeError = error.localizedDescription + } + } +} + diff --git a/src/managers/asc/ASCScreenshotsManager.swift b/src/managers/asc/ASCScreenshotsManager.swift new file mode 100644 index 0000000..b803b6a --- /dev/null +++ b/src/managers/asc/ASCScreenshotsManager.swift @@ -0,0 +1,249 @@ +import Foundation +import AppKit +import ImageIO + +// MARK: - Screenshots Manager +// Extension containing screenshot-related functionality for ASCManager + +extension ASCManager { + // MARK: - Track Synchronization + + func syncTrackToASC(displayType: String, locale: String) async { + guard let service else { writeError = "ASC service not configured"; return } + isSyncing = true + writeError = nil + + // Ensure localizations are loaded + if localizations.isEmpty, let versionId = appStoreVersions.first?.id { + localizations = (try? await service.fetchLocalizations(versionId: versionId)) ?? [] + } + if localizations.isEmpty, let appId = app?.id { + let versions = (try? await service.fetchAppStoreVersions(appId: appId)) ?? [] + appStoreVersions = versions + if let versionId = versions.first?.id { + localizations = (try? await service.fetchLocalizations(versionId: versionId)) ?? [] + } + } + guard let loc = localizations.first(where: { $0.attributes.locale == locale }) + ?? localizations.first else { + writeError = "No localizations found for locale '\(locale)'." + isSyncing = false + return + } + + let current = trackSlots[displayType] ?? Array(repeating: nil, count: 10) + let saved = savedTrackState[displayType] ?? Array(repeating: nil, count: 10) + + do { + // 1. Delete screenshots that were in saved state but not in current track + let savedIds = Set(saved.compactMap { $0?.id }) + let currentIds = Set(current.compactMap { $0?.id }) + let toDelete = savedIds.subtracting(currentIds) + for id in toDelete { + try await service.deleteScreenshot(screenshotId: id) + } + + // 2. Check if existing ASC screenshots need reorder + let currentASCIds = current.compactMap { slot -> String? in + guard let slot, slot.isFromASC else { return nil } + return slot.id + } + let savedASCIds = saved.compactMap { slot -> String? in + guard let slot, slot.isFromASC else { return nil } + return slot.id + } + let remainingASCIds = Set(currentASCIds) + let reorderNeeded = currentASCIds != savedASCIds.filter { remainingASCIds.contains($0) } + + if reorderNeeded { + // Delete remaining ASC screenshots and re-upload in new order + for id in currentASCIds { + if !toDelete.contains(id) { + try await service.deleteScreenshot(screenshotId: id) + } + } + } + + // 3. Upload local assets + re-upload reordered ASC screenshots + for slot in current { + guard let slot else { continue } + if let path = slot.localPath { + try await service.uploadScreenshot(localizationId: loc.id, path: path, displayType: displayType) + } else if reorderNeeded, slot.isFromASC, let ascShot = slot.ascScreenshot { + // For reordered ASC screenshots, we need the original file + // Download from ASC URL and re-upload + if let url = ascShot.imageURL, + let (data, _) = try? await URLSession.shared.data(from: url), + let fileName = ascShot.attributes.fileName { + let tmpPath = FileManager.default.temporaryDirectory.appendingPathComponent(fileName).path + try data.write(to: URL(fileURLWithPath: tmpPath)) + try await service.uploadScreenshot(localizationId: loc.id, path: tmpPath, displayType: displayType) + try? FileManager.default.removeItem(atPath: tmpPath) + } + } + } + + // 4. Reload from ASC + let sets = try await service.fetchScreenshotSets(localizationId: loc.id) + screenshotSets = sets + for set in sets { + screenshots[set.id] = try await service.fetchScreenshots(setId: set.id) + } + loadTrackFromASC(displayType: displayType) + } catch { + writeError = error.localizedDescription + } + + isSyncing = false + } + + // MARK: - Screenshot Deletion + + func deleteScreenshot(screenshotId: String) async throws { + guard let service else { throw ASCError.notFound("ASC service not configured") } + try await service.deleteScreenshot(screenshotId: screenshotId) + } + + // MARK: - Local Assets + + func scanLocalAssets(projectId: String) { + let dir = BlitzPaths.screenshots(projectId: projectId) + let fm = FileManager.default + guard let files = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { + localScreenshotAssets = [] + return + } + let imageExtensions: Set = ["png", "jpg", "jpeg", "webp"] + localScreenshotAssets = files + .filter { imageExtensions.contains($0.pathExtension.lowercased()) } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + .compactMap { url in + // Try NSImage first, fall back to CGImageSource for WebP + var image = NSImage(contentsOf: url) + if image == nil || image!.representations.isEmpty { + if let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) { + image = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) + } + } + guard let image else { return nil } + return LocalScreenshotAsset(id: UUID(), url: url, image: image, fileName: url.lastPathComponent) + } + } + + // MARK: - Track Management + + @discardableResult + func addAssetToTrack(displayType: String, slotIndex: Int, localPath: String) -> String? { + guard slotIndex >= 0 && slotIndex < 10 else { return "Invalid slot index" } + + guard let image = NSImage(contentsOfFile: localPath) else { + return "Could not load image" + } + + // Validate dimensions + var pixelWidth = 0, pixelHeight = 0 + if let rep = image.representations.first, rep.pixelsWide > 0, rep.pixelsHigh > 0 { + pixelWidth = rep.pixelsWide + pixelHeight = rep.pixelsHigh + } else if let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff) { + pixelWidth = bitmap.pixelsWide + pixelHeight = bitmap.pixelsHigh + } + + if let error = Self.validateDimensions(width: pixelWidth, height: pixelHeight, displayType: displayType) { + return error + } + + var slots = trackSlots[displayType] ?? Array(repeating: nil, count: 10) + let slot = TrackSlot( + id: UUID().uuidString, + localPath: localPath, + localImage: image, + ascScreenshot: nil, + isFromASC: false + ) + // If target slot occupied, shift right + if slots[slotIndex] != nil { + slots.insert(slot, at: slotIndex) + slots = Array(slots.prefix(10)) + } else { + slots[slotIndex] = slot + } + // Pad back to 10 + while slots.count < 10 { slots.append(nil) } + trackSlots[displayType] = slots + return nil + } + + func removeFromTrack(displayType: String, slotIndex: Int) { + guard slotIndex >= 0 && slotIndex < 10 else { return } + var slots = trackSlots[displayType] ?? Array(repeating: nil, count: 10) + slots.remove(at: slotIndex) + slots.append(nil) // maintain 10 elements + trackSlots[displayType] = slots + } + + func reorderTrack(displayType: String, fromIndex: Int, toIndex: Int) { + guard fromIndex >= 0 && fromIndex < 10 && toIndex >= 0 && toIndex < 10 else { return } + guard fromIndex != toIndex else { return } + var slots = trackSlots[displayType] ?? Array(repeating: nil, count: 10) + let item = slots.remove(at: fromIndex) + slots.insert(item, at: toIndex) + trackSlots[displayType] = slots + } + + // MARK: - Track Loading + + func loadTrackFromASC(displayType: String) { + let previousSlots = trackSlots[displayType] ?? [] + let set = screenshotSets.first { $0.attributes.screenshotDisplayType == displayType } + var slots: [TrackSlot?] = Array(repeating: nil, count: 10) + if let set, let shots = screenshots[set.id] { + for (i, shot) in shots.prefix(10).enumerated() { + // If ASC hasn't processed the image yet, carry forward the local preview + var localImage: NSImage? = nil + if shot.imageURL == nil, i < previousSlots.count, let prev = previousSlots[i] { + localImage = prev.localImage + } + slots[i] = TrackSlot( + id: shot.id, + localPath: nil, + localImage: localImage, + ascScreenshot: shot, + isFromASC: true + ) + } + } + trackSlots[displayType] = slots + savedTrackState[displayType] = slots + } + + // MARK: - Validation + + func hasUnsavedChanges(displayType: String) -> Bool { + let current = trackSlots[displayType] ?? Array(repeating: nil, count: 10) + let saved = savedTrackState[displayType] ?? Array(repeating: nil, count: 10) + return zip(current, saved).contains { c, s in c?.id != s?.id } + } + + /// Validate pixel dimensions for a display type. Returns nil if valid, or an error string. + static func validateDimensions(width: Int, height: Int, displayType: String) -> String? { + switch displayType { + case "APP_IPHONE_67": + let validSizes: Set = ["1290x2796", "1284x2778", "1242x2688", "1260x2736"] + if validSizes.contains("\(width)x\(height)") { return nil } + return "\(width)×\(height) — need 1290×2796, 1284×2778, 1242×2688, or 1260×2736 for iPhone" + case "APP_IPAD_PRO_3GEN_129": + if width == 2048 && height == 2732 { return nil } + return "\(width)×\(height) — need 2048×2732 for iPad" + case "APP_DESKTOP": + let valid: Set = ["1280x800", "1440x900", "2560x1600", "2880x1800"] + if valid.contains("\(width)x\(height)") { return nil } + return "\(width)×\(height) — need 1280×800, 1440×900, 2560×1600, or 2880×1800 for Mac" + default: + return nil + } + } +} diff --git a/src/managers/asc/ASCSessionStoreManager.swift b/src/managers/asc/ASCSessionStoreManager.swift new file mode 100644 index 0000000..871991f --- /dev/null +++ b/src/managers/asc/ASCSessionStoreManager.swift @@ -0,0 +1,101 @@ +import Foundation +import Security + +extension ASCManager { + private static let webSessionService = ASCWebSessionStore.keychainService + private static let webSessionAccount = ASCWebSessionStore.keychainAccount + + /// Stored in Keychain for Blitz and synced to ~/.blitz/asc-agent/web-session.json + /// so CLI skill scripts can reuse the same session. + static func storeWebSessionToKeychain(_ session: IrisSession) throws { + let existingData = readKeychainItem(service: webSessionService, account: webSessionAccount) + let data = try ASCWebSessionStore.mergedData(storing: session, into: existingData) + removeWebSessionKeychainItem() + try writeWebSessionToKeychain(data) + try ASCAuthBridge().syncWebSession(data) + } + + static func deleteWebSessionFromKeychain(email: String?) { + let existingData = readKeychainItem(service: webSessionService, account: webSessionAccount) + let updatedData: Data? + do { + updatedData = try ASCWebSessionStore.removingSession(email: email, from: existingData) + } catch { + return + } + + guard let updatedData else { + removeWebSessionKeychainItem() + return + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: webSessionService, + kSecAttrAccount as String: webSessionAccount, + ] + let status = SecItemUpdate( + query as CFDictionary, + [kSecValueData as String: updatedData] as CFDictionary + ) + if status == errSecItemNotFound { + try? writeWebSessionToKeychain(updatedData) + } + + do { + try ASCAuthBridge().syncWebSession(updatedData) + } catch { + ASCAuthBridge().removeWebSession() + } + } + + static func readKeychainItem(service: String, account: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { return nil } + return result as? Data + } + + static func syncWebSessionFileFromKeychain() { + guard let data = readKeychainItem(service: webSessionService, account: webSessionAccount) else { + return + } + try? ASCAuthBridge().syncWebSession(data) + } + + private static func writeWebSessionToKeychain(_ data: Data) throws { + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: webSessionService, + kSecAttrAccount as String: webSessionAccount, + kSecAttrLabel as String: "ASC Web Session Store", + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, + ] + let status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { + throw NSError( + domain: "ASCWebSessionStore", + code: Int(status), + userInfo: [NSLocalizedDescriptionKey: "Keychain write failed (status: \(status))"] + ) + } + } + + private static func removeWebSessionKeychainItem() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: webSessionService, + kSecAttrAccount as String: webSessionAccount, + ] + SecItemDelete(query as CFDictionary) + ASCAuthBridge().removeWebSession() + } +} diff --git a/src/managers/asc/ASCStoreListingManager.swift b/src/managers/asc/ASCStoreListingManager.swift new file mode 100644 index 0000000..d396b21 --- /dev/null +++ b/src/managers/asc/ASCStoreListingManager.swift @@ -0,0 +1,23 @@ +import Foundation + +// MARK: - Store Listing Manager +// Extension containing store listing-related functionality for ASCManager + +extension ASCManager { + // MARK: - Localization Updates + + func updateLocalizationField(_ field: String, value: String, locId: String) async { + guard let service else { return } + writeError = nil + do { + try await service.patchLocalization(id: locId, fields: [field: value]) + if let latestId = appStoreVersions.first?.id { + localizations = try await service.fetchLocalizations(versionId: latestId) + } + } catch { + writeError = error.localizedDescription + } + } + +} + diff --git a/src/managers/asc/ASCSubmissionHistoryManager.swift b/src/managers/asc/ASCSubmissionHistoryManager.swift new file mode 100644 index 0000000..53f12d4 --- /dev/null +++ b/src/managers/asc/ASCSubmissionHistoryManager.swift @@ -0,0 +1,283 @@ +import Foundation + +extension ASCManager { + func buildFeedbackCache(appId: String, versionString: String) -> IrisFeedbackCache { + let messages = rejectionMessages.map { message in + IrisFeedbackCache.CachedMessage( + body: message.attributes.messageBody.map { htmlToPlainText($0) } ?? "", + date: message.attributes.createdDate + ) + } + let reasons = rejectionReasons.flatMap { rejection in + (rejection.attributes.reasons ?? []).map { reason in + IrisFeedbackCache.CachedReason( + section: reason.reasonSection ?? "", + description: reason.reasonDescription ?? "", + code: reason.reasonCode ?? "" + ) + } + } + + return IrisFeedbackCache( + appId: appId, + versionString: versionString, + fetchedAt: Date(), + messages: messages, + reasons: reasons + ) + } + + func rebuildSubmissionHistory(appId: String) { + let cache = refreshSubmissionHistoryCache(appId: appId) + let versionSnapshots = cache.versionSnapshots + + let submissionEvents = reviewSubmissions.compactMap { submission -> ASCSubmissionHistoryEvent? in + guard let submittedAt = submission.attributes.submittedDate else { return nil } + let versionId = reviewSubmissionItemsBySubmissionId[submission.id]? + .compactMap(\.appStoreVersionId) + .first + ?? closestVersion(before: submittedAt)?.id + let resolvedVersionString = versionString(for: versionId, versionSnapshots: versionSnapshots) ?? "Unknown" + let resolvedVersionState = versionState(for: versionId, versionSnapshots: versionSnapshots) + let eventType = ASCReleaseStatus.reviewSubmissionEventType(forVersionState: resolvedVersionState) + return ASCSubmissionHistoryEvent( + id: "submission:\(submission.id)", + versionId: versionId, + versionString: resolvedVersionString, + eventType: eventType, + appleState: resolvedVersionState ?? "WAITING_FOR_REVIEW", + occurredAt: submittedAt, + source: .reviewSubmission, + accuracy: .exact, + submissionId: submission.id, + note: nil + ) + } + + var rejectionEventsByVersion: [String: ASCSubmissionHistoryEvent] = [:] + for cacheEntry in IrisFeedbackCache.loadAll(appId: appId) { + let rejectionAt = cacheEntry.messages + .compactMap(\.date) + .sorted(by: { historyDate($0) < historyDate($1) }) + .first + ?? ISO8601DateFormatter().string(from: cacheEntry.fetchedAt) + + rejectionEventsByVersion[cacheEntry.versionString] = ASCSubmissionHistoryEvent( + id: "iris:\(cacheEntry.versionString):\(rejectionAt)", + versionId: versionId(for: cacheEntry.versionString, versionSnapshots: versionSnapshots), + versionString: cacheEntry.versionString, + eventType: .rejected, + appleState: "REJECTED", + occurredAt: rejectionAt, + source: .irisFeedback, + accuracy: .derived, + submissionId: nil, + note: cacheEntry.reasons.first?.section + ) + } + + if let rejectedVersion = appStoreVersions.first(where: { $0.attributes.appStoreState == "REJECTED" }) { + let rejectionAt = resolutionCenterThreads.first?.attributes.createdDate + ?? rejectionMessages.compactMap(\.attributes.createdDate) + .sorted(by: { historyDate($0) < historyDate($1) }) + .first + if let rejectionAt { + rejectionEventsByVersion[rejectedVersion.attributes.versionString] = ASCSubmissionHistoryEvent( + id: "iris-live:\(rejectedVersion.id):\(rejectionAt)", + versionId: rejectedVersion.id, + versionString: rejectedVersion.attributes.versionString, + eventType: .rejected, + appleState: "REJECTED", + occurredAt: rejectionAt, + source: .irisFeedback, + accuracy: .derived, + submissionId: nil, + note: rejectionReasons.first?.attributes.reasons?.first?.reasonSection + ) + } + } + + let durableEvents = submissionEvents + + Array(rejectionEventsByVersion.values) + + cache.transitionEvents + + let coveredEventKeys = Set( + durableEvents.map { + historyCoverageKey(versionId: $0.versionId, versionString: $0.versionString, eventType: $0.eventType) + } + ) + + let fallbackEvents = appStoreVersions.compactMap { version -> ASCSubmissionHistoryEvent? in + let state = version.attributes.appStoreState ?? "" + guard let eventType = historyEventType(forVersionState: state) else { return nil } + + let coverageKey = historyCoverageKey( + versionId: version.id, + versionString: version.attributes.versionString, + eventType: eventType + ) + guard !coveredEventKeys.contains(coverageKey) else { return nil } + + let occurredAt = version.attributes.createdDate + ?? cache.versionSnapshots[version.id]?.lastSeenAt + ?? historyNowString() + + return ASCSubmissionHistoryEvent( + id: "version:\(version.id):\(state)", + versionId: version.id, + versionString: version.attributes.versionString, + eventType: eventType, + appleState: state, + occurredAt: occurredAt, + source: .currentVersion, + accuracy: .derived, + submissionId: nil, + note: nil + ) + } + + submissionHistoryEvents = (durableEvents + fallbackEvents) + .sorted { lhs, rhs in + historyDate(lhs.occurredAt) > historyDate(rhs.occurredAt) + } + } + + func refreshReviewSubmissionData(appId: String, service: AppStoreConnectService) async { + let submissions = (try? await service.fetchReviewSubmissions(appId: appId)) ?? [] + reviewSubmissions = submissions + + guard !submissions.isEmpty else { + reviewSubmissionItemsBySubmissionId = [:] + latestSubmissionItems = [] + return + } + + var itemsBySubmissionId: [String: [ASCReviewSubmissionItem]] = [:] + await withTaskGroup(of: (String, [ASCReviewSubmissionItem]).self) { group in + for submission in submissions { + group.addTask { + let items = (try? await service.fetchReviewSubmissionItems(submissionId: submission.id)) ?? [] + return (submission.id, items) + } + } + + for await (submissionId, items) in group { + itemsBySubmissionId[submissionId] = items + } + } + + reviewSubmissionItemsBySubmissionId = itemsBySubmissionId + latestSubmissionItems = itemsBySubmissionId[submissions.first?.id ?? ""] ?? [] + } + + private func historyNowString() -> String { + ISO8601DateFormatter().string(from: Date()) + } + + private func historyDate(_ iso: String?) -> Date { + guard let iso else { return .distantPast } + let formatterWithFractionalSeconds = ISO8601DateFormatter() + formatterWithFractionalSeconds.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let formatter = ISO8601DateFormatter() + return formatterWithFractionalSeconds.date(from: iso) ?? formatter.date(from: iso) ?? .distantPast + } + + private func closestVersion(before dateString: String) -> ASCAppStoreVersion? { + let submittedDate = historyDate(dateString) + return appStoreVersions + .filter { historyDate($0.attributes.createdDate) <= submittedDate } + .max { historyDate($0.attributes.createdDate) < historyDate($1.attributes.createdDate) } + ?? ASCReleaseStatus.sortedVersionsByRecency(appStoreVersions).first + } + + private func historyEventType(forVersionState state: String) -> ASCSubmissionHistoryEventType? { + ASCReleaseStatus.submissionHistoryEventType(forVersionState: state) + } + + private func historyCoverageKey( + versionId: String?, + versionString: String, + eventType: ASCSubmissionHistoryEventType + ) -> String { + "\(versionId ?? "version:\(versionString)")::\(eventType.rawValue)" + } + + private func versionString( + for versionId: String?, + versionSnapshots: [String: ASCSubmissionHistoryCache.VersionSnapshot] + ) -> String? { + guard let versionId else { return nil } + if let version = appStoreVersions.first(where: { $0.id == versionId }) { + return version.attributes.versionString + } + return versionSnapshots[versionId]?.versionString + } + + private func versionId( + for versionString: String, + versionSnapshots: [String: ASCSubmissionHistoryCache.VersionSnapshot] + ) -> String? { + if let version = appStoreVersions.first(where: { $0.attributes.versionString == versionString }) { + return version.id + } + return versionSnapshots.values.first(where: { $0.versionString == versionString })?.versionId + } + + private func versionState( + for versionId: String?, + versionSnapshots: [String: ASCSubmissionHistoryCache.VersionSnapshot] + ) -> String? { + guard let versionId else { return nil } + if let version = appStoreVersions.first(where: { $0.id == versionId }) { + return version.attributes.appStoreState + } + return versionSnapshots[versionId]?.lastKnownState + } + + private func refreshSubmissionHistoryCache(appId: String) -> ASCSubmissionHistoryCache { + var cache = ASCSubmissionHistoryCache.load(appId: appId) + let now = historyNowString() + + for version in appStoreVersions { + let state = version.attributes.appStoreState ?? "" + guard !state.isEmpty else { continue } + + if var snapshot = cache.versionSnapshots[version.id] { + snapshot.versionString = version.attributes.versionString + if snapshot.lastKnownState != state, + let eventType = historyEventType(forVersionState: state) { + cache.transitionEvents.append( + ASCSubmissionHistoryEvent( + id: "ledger:\(version.id):\(state):\(now)", + versionId: version.id, + versionString: version.attributes.versionString, + eventType: eventType, + appleState: state, + occurredAt: now, + source: .transitionLedger, + accuracy: .firstSeen, + submissionId: nil, + note: nil + ) + ) + snapshot.lastKnownState = state + snapshot.lastSeenAt = now + } else { + snapshot.lastSeenAt = now + } + cache.versionSnapshots[version.id] = snapshot + } else { + cache.versionSnapshots[version.id] = .init( + versionId: version.id, + versionString: version.attributes.versionString, + lastKnownState: state, + lastSeenAt: now + ) + } + } + + cache.transitionEvents.sort { historyDate($0.occurredAt) > historyDate($1.occurredAt) } + try? cache.save() + return cache + } +} diff --git a/src/managers/asc/ASCSubmissionReadinessManager.swift b/src/managers/asc/ASCSubmissionReadinessManager.swift new file mode 100644 index 0000000..8fdc727 --- /dev/null +++ b/src/managers/asc/ASCSubmissionReadinessManager.swift @@ -0,0 +1,140 @@ +import Foundation + +extension ASCManager { + /// ASC returns an age rating declaration object with nil fields by default. + /// Submitting with nil fields later causes a 409. + private var ageRatingIsConfigured: Bool { + guard let attributes = ageRatingDeclaration?.attributes else { return false } + return attributes.alcoholTobaccoOrDrugUseOrReferences != nil + && attributes.violenceCartoonOrFantasy != nil + && attributes.violenceRealistic != nil + && attributes.sexualContentOrNudity != nil + && attributes.sexualContentGraphicAndNudity != nil + && attributes.profanityOrCrudeHumor != nil + && attributes.gamblingSimulated != nil + } + + var submissionReadiness: SubmissionReadiness { + let localization = localizations.first + let appInfoLocalization = appInfoLocalization + let review = reviewDetail + let demoRequired = review?.attributes.demoAccountRequired == true + let version = appStoreVersions.first + + let macScreenshots = screenshotSets.first { $0.attributes.screenshotDisplayType == "APP_DESKTOP" } + let isMacApp = macScreenshots != nil + let iphoneScreenshots = screenshotSets.first { $0.attributes.screenshotDisplayType == "APP_IPHONE_67" } + let ipadScreenshots = screenshotSets.first { $0.attributes.screenshotDisplayType == "APP_IPAD_PRO_3GEN_129" } + + let privacyUrl: String? = app.map { + "https://appstoreconnect.apple.com/apps/\($0.id)/distribution/privacy" + } + + func readinessField( + label: String, + value: String?, + required: Bool = true, + actionUrl: String? = nil, + hint: String? = nil + ) -> SubmissionReadiness.FieldStatus { + SubmissionReadiness.FieldStatus( + label: label, + value: value, + isLoading: overviewReadinessLoadingFields.contains(label) && (value == nil || value?.isEmpty == true), + required: required, + actionUrl: actionUrl, + hint: hint + ) + } + + var fields: [SubmissionReadiness.FieldStatus] = [ + readinessField(label: "App Name", value: appInfoLocalization?.attributes.name ?? localization?.attributes.title), + readinessField(label: "Description", value: localization?.attributes.description), + readinessField(label: "Keywords", value: localization?.attributes.keywords), + readinessField(label: "Support URL", value: localization?.attributes.supportUrl), + readinessField(label: "Privacy Policy URL", value: appInfoLocalization?.attributes.privacyPolicyUrl), + readinessField(label: "Copyright", value: version?.attributes.copyright), + readinessField(label: "Content Rights", value: app?.contentRightsDeclaration), + readinessField(label: "Primary Category", value: appInfo?.primaryCategoryId), + readinessField(label: "Age Rating", value: ageRatingIsConfigured ? "Configured" : nil), + readinessField(label: "Pricing", value: monetizationStatus), + readinessField(label: "Review Contact First Name", value: review?.attributes.contactFirstName), + readinessField(label: "Review Contact Last Name", value: review?.attributes.contactLastName), + readinessField(label: "Review Contact Email", value: review?.attributes.contactEmail), + readinessField(label: "Review Contact Phone", value: review?.attributes.contactPhone), + ] + + if demoRequired { + fields.append(readinessField(label: "Demo Account Name", value: review?.attributes.demoAccountName)) + fields.append(readinessField(label: "Demo Account Password", value: review?.attributes.demoAccountPassword)) + } + + fields.append(readinessField(label: "App Icon", value: appIconStatus)) + + func validCount(for set: ASCScreenshotSet?) -> Int { + guard let set else { return 0 } + if let screenshots = screenshots[set.id] { + return screenshots.filter { !$0.hasError }.count + } + return set.attributes.screenshotCount ?? 0 + } + + if isMacApp { + let macCount = validCount(for: macScreenshots) + fields.append(readinessField(label: "Mac Screenshots", value: macCount > 0 ? "\(macCount) screenshot(s)" : nil)) + } else { + let iphoneCount = validCount(for: iphoneScreenshots) + let ipadCount = validCount(for: ipadScreenshots) + fields.append(readinessField(label: "iPhone Screenshots", value: iphoneCount > 0 ? "\(iphoneCount) screenshot(s)" : nil)) + fields.append(readinessField(label: "iPad Screenshots", value: ipadCount > 0 ? "\(ipadCount) screenshot(s)" : nil)) + } + + fields.append(contentsOf: [ + readinessField(label: "Privacy Nutrition Labels", value: nil, required: false, actionUrl: privacyUrl), + readinessField(label: "Build", value: builds.first?.attributes.version), + ]) + + let approvedStates: Set = [ + "READY_FOR_SALE", + "REMOVED_FROM_SALE", + "DEVELOPER_REMOVED_FROM_SALE", + "REPLACED_WITH_NEW_VERSION", + "PROCESSING_FOR_APP_STORE" + ] + let hasApprovedVersion = appStoreVersions.contains { + approvedStates.contains($0.attributes.appStoreState ?? "") + } + let isFirstVersion = !hasApprovedVersion + if isFirstVersion { + let readyIAPs = inAppPurchases.filter { + $0.attributes.state == "READY_TO_SUBMIT" && !attachedSubmissionItemIDs.contains($0.id) + } + let readySubscriptions = subscriptionsPerGroup.values.flatMap { $0 } + .filter { + $0.attributes.state == "READY_TO_SUBMIT" && !attachedSubmissionItemIDs.contains($0.id) + } + let readyCount = readyIAPs.count + readySubscriptions.count + if readyCount > 0 { + let names = (readyIAPs.map { $0.attributes.name ?? $0.attributes.productId ?? $0.id } + + readySubscriptions.map { $0.attributes.name ?? $0.attributes.productId ?? $0.id }) + .joined(separator: ", ") + let iapUrl: String? = app.map { + "https://appstoreconnect.apple.com/apps/\($0.id)/distribution/ios/version/inflight" + } + fields.append(readinessField( + label: "In-App Purchases & Subscriptions", + value: nil, + required: true, + actionUrl: iapUrl, + hint: "\(readyCount) item(s) in Ready to Submit state (\(names)) must be attached to this version before submission. " + + "Use the asc-iap-attach skill to attach them via the iris API (asc web session). " + + "The public API does not support first-time IAP/subscription attachment - " + + "run: asc web auth login, then POST to /iris/v1/subscriptionSubmissions or /iris/v1/inAppPurchaseSubmissions " + + "with submitWithNextAppStoreVersion:true for each item." + )) + } + } + + return SubmissionReadiness(fields: fields) + } +} diff --git a/src/managers/asc/ASCTabDataManager.swift b/src/managers/asc/ASCTabDataManager.swift new file mode 100644 index 0000000..b0ce546 --- /dev/null +++ b/src/managers/asc/ASCTabDataManager.swift @@ -0,0 +1,505 @@ +import Foundation + +extension ASCManager { + func ensureTabData(_ tab: AppTab) async { + guard credentials != nil else { return } + + if loadedTabs.contains(tab) { + if shouldRefreshTabCache(tab) { + await refreshTabData(tab) + } + return + } + + await fetchTabData(tab) + } + + func hasLoadedTabData(_ tab: AppTab) -> Bool { + loadedTabs.contains(tab) + } + + func isTabLoading(_ tab: AppTab) -> Bool { + isLoadingTab[tab] == true || isLoadingApp + } + + func isFeedbackLoading(for buildId: String?) -> Bool { + guard let buildId, !buildId.isEmpty else { + return isLoadingTab[.feedback] == true + } + return loadingFeedbackBuildIds.contains(buildId) + } + + @discardableResult + func fetchApp(bundleId: String, exactName: String? = nil) async -> Bool { + guard let service else { return false } + isLoadingApp = true + do { + let fetched = try await service.fetchApp(bundleId: bundleId, exactName: exactName) + app = fetched + credentialsError = nil + isLoadingApp = false + return true + } catch { + app = nil + credentialsError = error.localizedDescription + isLoadingApp = false + return false + } + } + + func fetchTabData(_ tab: AppTab) async { + guard let service else { return } + guard credentials != nil else { return } + guard !loadedTabs.contains(tab) else { return } + guard isLoadingTab[tab] != true else { return } + + cancelBackgroundHydration(for: tab) + isLoadingTab[tab] = true + tabError.removeValue(forKey: tab) + + do { + try await loadData(for: tab, service: service) + isLoadingTab[tab] = false + loadedTabs.insert(tab) + tabLoadedAt[tab] = Date() + } catch { + isLoadingTab[tab] = false + tabError[tab] = error.localizedDescription + } + } + + /// Called after bundle ID setup completes and the app is confirmed in ASC. + /// Clears all tab errors and forces data to be re-fetched. + func resetTabState() { + cancelBackgroundHydrationTasks() + tabError.removeAll() + loadedTabs.removeAll() + tabLoadedAt.removeAll() + loadingFeedbackBuildIds = [] + } + + func refreshTabData(_ tab: AppTab) async { + guard let service else { return } + guard credentials != nil else { return } + + let hadLoadedData = loadedTabs.contains(tab) + cancelBackgroundHydration(for: tab) + isLoadingTab[tab] = true + tabError.removeValue(forKey: tab) + + do { + try await loadData(for: tab, service: service) + isLoadingTab[tab] = false + loadedTabs.insert(tab) + tabLoadedAt[tab] = Date() + } catch { + isLoadingTab[tab] = false + if !hadLoadedData { + loadedTabs.remove(tab) + tabLoadedAt.removeValue(forKey: tab) + } + tabError[tab] = error.localizedDescription + } + } + + func refreshSubmissionReadinessData() async { + await refreshMonetization() + await refreshAttachedSubmissionItemIDs() + } + + func startOverviewReadinessLoading(_ fields: Set) { + overviewReadinessLoadingFields = fields + } + + func finishOverviewReadinessLoading(_ fields: Set) { + overviewReadinessLoadingFields.subtract(fields) + } + + func isCurrentProject(_ projectId: String?) -> Bool { + guard let projectId else { return false } + return loadedProjectId == projectId + } + + private func hydrateOverviewSecondaryData( + projectId: String?, + appId: String, + firstLocalizationId: String?, + appInfoId: String?, + service: AppStoreConnectService + ) async { + if let firstLocalizationId { + do { + let fetchedSets = try await service.fetchScreenshotSets(localizationId: firstLocalizationId) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + screenshotSets = fetchedSets + + let fetchedScreenshots = try await withThrowingTaskGroup(of: (String, [ASCScreenshot]).self) { group in + for set in fetchedSets { + group.addTask { + let screenshots = try await service.fetchScreenshots(setId: set.id) + return (set.id, screenshots) + } + } + + var pairs: [(String, [ASCScreenshot])] = [] + for try await pair in group { + pairs.append(pair) + } + return pairs + } + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + screenshots = Dictionary(uniqueKeysWithValues: fetchedScreenshots) + finishOverviewReadinessLoading(Self.overviewScreenshotFieldLabels) + } catch { + print("Failed to hydrate overview screenshots: \(error)") + finishOverviewReadinessLoading(Self.overviewScreenshotFieldLabels) + } + } else { + finishOverviewReadinessLoading(Self.overviewScreenshotFieldLabels) + } + + if let appInfoId { + async let ageRatingTask: ASCAgeRatingDeclaration? = try? service.fetchAgeRating(appInfoId: appInfoId) + async let appInfoLocalizationTask: ASCAppInfoLocalization? = try? service.fetchAppInfoLocalization(appInfoId: appInfoId) + + let fetchedAgeRating = await ageRatingTask + let fetchedAppInfoLocalization = await appInfoLocalizationTask + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + ageRatingDeclaration = fetchedAgeRating + appInfoLocalization = fetchedAppInfoLocalization + finishOverviewReadinessLoading(Self.overviewMetadataFieldLabels) + } else { + finishOverviewReadinessLoading(Self.overviewMetadataFieldLabels) + } + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + await refreshReviewSubmissionData(appId: appId, service: service) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + rebuildSubmissionHistory(appId: appId) + refreshSubmissionFeedbackIfNeeded() + + if monetizationStatus == nil { + let hasPricing = await service.fetchPricingConfigured(appId: appId) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + monetizationStatus = hasPricing ? "Configured" : nil + } + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + await refreshSubmissionReadinessData() + finishOverviewReadinessLoading(Self.overviewPricingFieldLabels) + } + + private func hydrateScreenshotsSecondaryData( + projectId: String?, + localizationId: String, + service: AppStoreConnectService + ) async { + do { + let fetchedSets = try await service.fetchScreenshotSets(localizationId: localizationId) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + screenshotSets = fetchedSets + + let fetchedScreenshots = try await withThrowingTaskGroup(of: (String, [ASCScreenshot]).self) { group in + for set in fetchedSets { + group.addTask { + let screenshots = try await service.fetchScreenshots(setId: set.id) + return (set.id, screenshots) + } + } + + var pairs: [(String, [ASCScreenshot])] = [] + for try await pair in group { + pairs.append(pair) + } + return pairs + } + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + screenshots = Dictionary(uniqueKeysWithValues: fetchedScreenshots) + } catch { + print("Failed to hydrate screenshots: \(error)") + } + } + + private func hydrateReviewSecondaryData( + projectId: String?, + appId: String, + appInfoId: String?, + service: AppStoreConnectService + ) async { + if let appInfoId { + let fetchedAgeRating = try? await service.fetchAgeRating(appInfoId: appInfoId) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + ageRatingDeclaration = fetchedAgeRating + } else { + ageRatingDeclaration = nil + } + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + await refreshReviewSubmissionData(appId: appId, service: service) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + rebuildSubmissionHistory(appId: appId) + refreshSubmissionFeedbackIfNeeded() + } + + private func hydrateMonetizationSecondaryData( + projectId: String?, + appId: String, + groups: [ASCSubscriptionGroup], + service: AppStoreConnectService + ) async { + do { + let fetchedSubscriptions = try await withThrowingTaskGroup(of: (String, [ASCSubscription]).self) { taskGroup in + for subscriptionGroup in groups { + taskGroup.addTask { + let subscriptions = try await service.fetchSubscriptionsInGroup(groupId: subscriptionGroup.id) + return (subscriptionGroup.id, subscriptions) + } + } + + var pairs: [(String, [ASCSubscription])] = [] + for try await pair in taskGroup { + pairs.append(pair) + } + return pairs + } + + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + subscriptionsPerGroup = Dictionary(uniqueKeysWithValues: fetchedSubscriptions) + } catch { + print("Failed to hydrate monetization subscriptions: \(error)") + } + + if currentAppPricePointId == nil && scheduledAppPricePointId == nil && monetizationStatus == nil { + let hasPricing = await service.fetchPricingConfigured(appId: appId) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + monetizationStatus = hasPricing ? "Configured" : nil + } + } + + private func hydrateFeedbackSecondaryData( + projectId: String?, + buildId: String, + service: AppStoreConnectService + ) async { + guard isCurrentProject(projectId) else { return } + guard !Task.isCancelled else { return } + loadingFeedbackBuildIds.insert(buildId) + defer { loadingFeedbackBuildIds.remove(buildId) } + + do { + let items = try await service.fetchBetaFeedback(buildId: buildId) + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + betaFeedback[buildId] = items + } catch { + guard !Task.isCancelled, isCurrentProject(projectId) else { return } + betaFeedback[buildId] = [] + } + } + + private func loadData(for tab: AppTab, service: AppStoreConnectService) async throws { + guard let appId = app?.id else { + throw ASCError.notFound("App - check your bundle ID in project settings") + } + + switch tab { + case .app: + refreshAppIconStatusIfNeeded(for: loadedProjectId) + startOverviewReadinessLoading( + Self.overviewLocalizationFieldLabels + .union(Self.overviewVersionFieldLabels) + .union(Self.overviewAppInfoFieldLabels) + .union(Self.overviewMetadataFieldLabels) + .union(Self.overviewReviewFieldLabels) + .union(Self.overviewBuildFieldLabels) + .union(Self.overviewPricingFieldLabels) + .union(Self.overviewScreenshotFieldLabels) + ) + async let versionsTask = service.fetchAppStoreVersions(appId: appId) + async let appInfoTask: ASCAppInfo? = try? service.fetchAppInfo(appId: appId) + async let buildsTask = service.fetchBuilds(appId: appId) + + let versions = try await versionsTask + appStoreVersions = versions + finishOverviewReadinessLoading(Self.overviewVersionFieldLabels) + appInfo = await appInfoTask + finishOverviewReadinessLoading(Self.overviewAppInfoFieldLabels) + builds = try await buildsTask + finishOverviewReadinessLoading(Self.overviewBuildFieldLabels) + + var firstLocalizationId: String? + if let latestId = versions.first?.id { + async let localizationsTask = service.fetchLocalizations(versionId: latestId) + async let reviewDetailTask: ASCReviewDetail? = try? service.fetchReviewDetail(versionId: latestId) + + let fetchedLocalizations = try await localizationsTask + localizations = fetchedLocalizations + firstLocalizationId = fetchedLocalizations.first?.id + finishOverviewReadinessLoading(Self.overviewLocalizationFieldLabels) + reviewDetail = await reviewDetailTask + finishOverviewReadinessLoading(Self.overviewReviewFieldLabels) + } else { + finishOverviewReadinessLoading( + Self.overviewLocalizationFieldLabels + .union(Self.overviewReviewFieldLabels) + ) + } + + refreshSubmissionFeedbackIfNeeded() + + let projectId = loadedProjectId + let currentAppInfoId = appInfo?.id + startBackgroundHydration(for: .app) { + await self.hydrateOverviewSecondaryData( + projectId: projectId, + appId: appId, + firstLocalizationId: firstLocalizationId, + appInfoId: currentAppInfoId, + service: service + ) + } + + case .storeListing: + async let versionsTask = service.fetchAppStoreVersions(appId: appId) + async let appInfoTask: ASCAppInfo? = try? await service.fetchAppInfo(appId: appId) + + let versions = try await versionsTask + appStoreVersions = versions + if let latestId = versions.first?.id { + localizations = try await service.fetchLocalizations(versionId: latestId) + } else { + localizations = [] + } + appInfo = await appInfoTask + if let infoId = appInfo?.id { + appInfoLocalization = try? await service.fetchAppInfoLocalization(appInfoId: infoId) + } else { + appInfoLocalization = nil + } + + case .screenshots: + let versions = try await service.fetchAppStoreVersions(appId: appId) + appStoreVersions = versions + if let latestId = versions.first?.id { + let localizations = try await service.fetchLocalizations(versionId: latestId) + self.localizations = localizations + if let firstLocalizationId = localizations.first?.id { + let projectId = loadedProjectId + startBackgroundHydration(for: .screenshots) { + await self.hydrateScreenshotsSecondaryData( + projectId: projectId, + localizationId: firstLocalizationId, + service: service + ) + } + } else { + screenshotSets = [] + screenshots = [:] + } + } else { + localizations = [] + screenshotSets = [] + screenshots = [:] + } + + case .appDetails: + async let versionsTask = service.fetchAppStoreVersions(appId: appId) + async let appInfoTask: ASCAppInfo? = try? await service.fetchAppInfo(appId: appId) + + appStoreVersions = try await versionsTask + appInfo = await appInfoTask + + case .review: + async let versionsTask = service.fetchAppStoreVersions(appId: appId) + async let appInfoTask: ASCAppInfo? = try? await service.fetchAppInfo(appId: appId) + async let buildsTask = service.fetchBuilds(appId: appId) + + let versions = try await versionsTask + appStoreVersions = versions + if let latestId = versions.first?.id { + reviewDetail = try? await service.fetchReviewDetail(versionId: latestId) + } else { + reviewDetail = nil + } + appInfo = await appInfoTask + builds = try await buildsTask + let projectId = loadedProjectId + let currentAppInfoId = appInfo?.id + startBackgroundHydration(for: .review) { + await self.hydrateReviewSecondaryData( + projectId: projectId, + appId: appId, + appInfoId: currentAppInfoId, + service: service + ) + } + + case .monetization: + async let pricePointsTask = service.fetchAppPricePoints(appId: appId) + async let pricingStateTask = (try? await service.fetchAppPricingState(appId: appId)) + ?? ASCAppPricingState(currentPricePointId: nil, scheduledPricePointId: nil, scheduledEffectiveDate: nil) + async let iapTask = service.fetchInAppPurchases(appId: appId) + async let groupsTask = service.fetchSubscriptionGroups(appId: appId) + + appPricePoints = try await pricePointsTask + applyAppPricingState(await pricingStateTask) + inAppPurchases = try await iapTask + let groups = try await groupsTask + subscriptionGroups = groups + + let projectId = loadedProjectId + startBackgroundHydration(for: .monetization) { + await self.hydrateMonetizationSecondaryData( + projectId: projectId, + appId: appId, + groups: groups, + service: service + ) + } + + case .analytics: + break + + case .reviews: + customerReviews = try await service.fetchCustomerReviews(appId: appId) + + case .builds: + builds = try await service.fetchBuilds(appId: appId) + + case .groups: + betaGroups = try await service.fetchBetaGroups(appId: appId) + + case .betaInfo: + betaLocalizations = try await service.fetchBetaLocalizations(appId: appId) + + case .feedback: + let fetchedBuilds = try await service.fetchBuilds(appId: appId) + builds = fetchedBuilds + let resolvedBuildId: String? + if let currentSelectedBuildId = selectedBuildId, + fetchedBuilds.contains(where: { $0.id == currentSelectedBuildId }) { + resolvedBuildId = currentSelectedBuildId + } else { + resolvedBuildId = fetchedBuilds.first?.id + } + selectedBuildId = resolvedBuildId + if let resolvedBuildId { + let projectId = loadedProjectId + startBackgroundHydration(for: .feedback) { + await self.hydrateFeedbackSecondaryData( + projectId: projectId, + buildId: resolvedBuildId, + service: service + ) + } + } else { + betaFeedback = [:] + } + + default: + break + } + } +} diff --git a/src/models/ASCModels.swift b/src/models/ASCModels.swift index 3548357..246d95d 100644 --- a/src/models/ASCModels.swift +++ b/src/models/ASCModels.swift @@ -1,5 +1,6 @@ import Foundation import Security +import AppKit // MARK: - Credentials @@ -265,6 +266,25 @@ struct ASCScreenshot: Decodable, Identifiable { } } +struct TrackSlot: Identifiable, Equatable { + let id: String // UUID for local, ASC id for uploaded + var localPath: String? // file path for local assets + var localImage: NSImage? // loaded thumbnail + var ascScreenshot: ASCScreenshot? // present if from ASC + var isFromASC: Bool // true if this slot was loaded from ASC + + static func == (lhs: TrackSlot, rhs: TrackSlot) -> Bool { + lhs.id == rhs.id + } +} + +struct LocalScreenshotAsset: Identifiable { + let id: UUID + let url: URL + let image: NSImage + let fileName: String +} + // MARK: - CustomerReview struct ASCCustomerReview: Decodable, Identifiable { @@ -637,161 +657,3 @@ struct ASCProfile: Decodable, Identifiable { } let attributes: Attributes } - -// MARK: - Iris Session (Apple ID cookie-based auth for internal APIs) - -struct IrisSession: Codable, Sendable { - var cookies: [IrisCookie] - var email: String? - var capturedAt: Date - - struct IrisCookie: Codable, Sendable { - let name: String - let value: String - let domain: String - let path: String - } - - private static let keychainService = "dev.blitz.iris-session" - private static let keychainAccount = "iris-cookies" - - static func load() -> IrisSession? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: keychainAccount, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let data = result as? Data else { return nil } - return try? JSONDecoder().decode(IrisSession.self, from: data) - } - - func save() throws { - let data = try JSONEncoder().encode(self) - // Delete any existing item first - Self.delete() - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: Self.keychainService, - kSecAttrAccount as String: Self.keychainAccount, - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - ] - let status = SecItemAdd(query as CFDictionary, nil) - guard status == errSecSuccess else { - throw NSError(domain: "IrisSession", code: Int(status), - userInfo: [NSLocalizedDescriptionKey: "Failed to save session to Keychain (status: \(status))"]) - } - } - - static func delete() { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: keychainAccount, - ] - SecItemDelete(query as CFDictionary) - } -} - -// MARK: - Iris API Response Models - -struct IrisResolutionCenterThread: Decodable, Identifiable { - let id: String - let attributes: Attributes - - struct Attributes: Decodable { - let state: String? - let createdDate: String? - } -} - -struct IrisResolutionCenterMessage: Decodable, Identifiable { - let id: String - let attributes: Attributes - - struct Attributes: Decodable { - let messageBody: String? - let createdDate: String? - } -} - -struct IrisReviewRejection: Decodable, Identifiable { - let id: String - let attributes: Attributes - - struct Attributes: Decodable { - let reasons: [Reason]? - } - - struct Reason: Decodable { - let reasonSection: String? - let reasonDescription: String? - let reasonCode: String? - } -} - -// MARK: - Iris Feedback Cache - -struct IrisFeedbackCache: Codable { - let appId: String - let versionString: String - let fetchedAt: Date - let messages: [CachedMessage] - let reasons: [CachedReason] - - struct CachedMessage: Codable { - let body: String - let date: String? - } - - struct CachedReason: Codable { - let section: String - let description: String - let code: String - } - - // MARK: - Persistence - - func save() throws { - let url = Self.cacheURL(appId: appId, versionString: versionString) - let dir = url.deletingLastPathComponent() - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(self) - try data.write(to: url, options: .atomic) - } - - static func load(appId: String, versionString: String) -> IrisFeedbackCache? { - let url = cacheURL(appId: appId, versionString: versionString) - guard let data = try? Data(contentsOf: url) else { return nil } - return try? JSONDecoder().decode(IrisFeedbackCache.self, from: data) - } - - static func loadAll(appId: String) -> [IrisFeedbackCache] { - let dir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".blitz/iris-cache/\(appId)") - guard let urls = try? FileManager.default.contentsOfDirectory( - at: dir, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) else { return [] } - - return urls - .filter { $0.pathExtension == "json" } - .compactMap { url in - guard let data = try? Data(contentsOf: url) else { return nil } - return try? JSONDecoder().decode(IrisFeedbackCache.self, from: data) - } - .sorted { $0.fetchedAt > $1.fetchedAt } - } - - private static func cacheURL(appId: String, versionString: String) -> URL { - let home = FileManager.default.homeDirectoryForCurrentUser - return home.appendingPathComponent(".blitz/iris-cache/\(appId)/\(versionString).json") - } -} diff --git a/src/models/SimulatorInfo.swift b/src/models/SimulatorInfo.swift deleted file mode 100644 index 0dbbd7e..0000000 --- a/src/models/SimulatorInfo.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -struct SimulatorInfo: Identifiable, Hashable { - let udid: String - let name: String - let state: String - let deviceTypeIdentifier: String? - let lastBootedAt: String? - - var id: String { udid } - var isBooted: Bool { state == "Booted" } - - var displayName: String { - // Extract device type from identifier like "com.apple.CoreSimulator.SimDeviceType.iPhone-16" - if let typeId = deviceTypeIdentifier { - let components = typeId.split(separator: ".") - if let last = components.last { - return String(last).replacingOccurrences(of: "-", with: " ") - } - } - return name - } -} diff --git a/src/models/DatabaseSchema.swift b/src/models/database/DatabaseSchema.swift similarity index 100% rename from src/models/DatabaseSchema.swift rename to src/models/database/DatabaseSchema.swift diff --git a/src/models/SimulatorConfig.swift b/src/models/simulator/SimulatorConfig.swift similarity index 90% rename from src/models/SimulatorConfig.swift rename to src/models/simulator/SimulatorConfig.swift index 4b7e9bc..a0c8b5f 100644 --- a/src/models/SimulatorConfig.swift +++ b/src/models/simulator/SimulatorConfig.swift @@ -156,3 +156,25 @@ struct SimulatorConfigDatabase { return (x: vx * viewWidth, y: vy * viewHeight) } } + +struct SimulatorInfo: Identifiable, Hashable { + let udid: String + let name: String + let state: String + let deviceTypeIdentifier: String? + let lastBootedAt: String? + + var id: String { udid } + var isBooted: Bool { state == "Booted" } + + var displayName: String { + // Extract device type from identifier like "com.apple.CoreSimulator.SimDeviceType.iPhone-16" + if let typeId = deviceTypeIdentifier { + let components = typeId.split(separator: ".") + if let last = components.last { + return String(last).replacingOccurrences(of: "-", with: " ") + } + } + return name + } +} diff --git a/src/services/ASCManager.swift b/src/services/ASCManager.swift deleted file mode 100644 index 0e23cf7..0000000 --- a/src/services/ASCManager.swift +++ /dev/null @@ -1,2707 +0,0 @@ -import Foundation -import AppKit -import ImageIO -import Security - -// MARK: - Screenshot Track Models - -struct TrackSlot: Identifiable, Equatable { - let id: String // UUID for local, ASC id for uploaded - var localPath: String? // file path for local assets - var localImage: NSImage? // loaded thumbnail - var ascScreenshot: ASCScreenshot? // present if from ASC - var isFromASC: Bool // true if this slot was loaded from ASC - - static func == (lhs: TrackSlot, rhs: TrackSlot) -> Bool { - lhs.id == rhs.id - } -} - -struct LocalScreenshotAsset: Identifiable { - let id: UUID - let url: URL - let image: NSImage - let fileName: String -} - -@MainActor -@Observable -final class ASCManager { - private struct ProjectSnapshot { - let projectId: String - let app: ASCApp? - let appStoreVersions: [ASCAppStoreVersion] - let localizations: [ASCVersionLocalization] - let screenshotSets: [ASCScreenshotSet] - let screenshots: [String: [ASCScreenshot]] - let customerReviews: [ASCCustomerReview] - let builds: [ASCBuild] - let betaGroups: [ASCBetaGroup] - let betaLocalizations: [ASCBetaLocalization] - let betaFeedback: [String: [ASCBetaFeedback]] - let selectedBuildId: String? - let inAppPurchases: [ASCInAppPurchase] - let subscriptionGroups: [ASCSubscriptionGroup] - let subscriptionsPerGroup: [String: [ASCSubscription]] - let appPricePoints: [ASCPricePoint] - let currentAppPricePointId: String? - let scheduledAppPricePointId: String? - let scheduledAppPriceEffectiveDate: String? - let appInfo: ASCAppInfo? - let appInfoLocalization: ASCAppInfoLocalization? - let ageRatingDeclaration: ASCAgeRatingDeclaration? - let reviewDetail: ASCReviewDetail? - let reviewSubmissions: [ASCReviewSubmission] - let reviewSubmissionItemsBySubmissionId: [String: [ASCReviewSubmissionItem]] - let latestSubmissionItems: [ASCReviewSubmissionItem] - let submissionHistoryEvents: [ASCSubmissionHistoryEvent] - let attachedSubmissionItemIDs: Set - let resolutionCenterThreads: [IrisResolutionCenterThread] - let rejectionMessages: [IrisResolutionCenterMessage] - let rejectionReasons: [IrisReviewRejection] - let cachedFeedback: IrisFeedbackCache? - let trackSlots: [String: [TrackSlot?]] - let savedTrackState: [String: [TrackSlot?]] - let localScreenshotAssets: [LocalScreenshotAsset] - let appIconStatus: String? - let monetizationStatus: String? - let loadedTabs: Set - let tabLoadedAt: [AppTab: Date] - - @MainActor - init(manager: ASCManager, projectId: String) { - self.projectId = projectId - app = manager.app - appStoreVersions = manager.appStoreVersions - localizations = manager.localizations - screenshotSets = manager.screenshotSets - screenshots = manager.screenshots - customerReviews = manager.customerReviews - builds = manager.builds - betaGroups = manager.betaGroups - betaLocalizations = manager.betaLocalizations - betaFeedback = manager.betaFeedback - selectedBuildId = manager.selectedBuildId - inAppPurchases = manager.inAppPurchases - subscriptionGroups = manager.subscriptionGroups - subscriptionsPerGroup = manager.subscriptionsPerGroup - appPricePoints = manager.appPricePoints - currentAppPricePointId = manager.currentAppPricePointId - scheduledAppPricePointId = manager.scheduledAppPricePointId - scheduledAppPriceEffectiveDate = manager.scheduledAppPriceEffectiveDate - appInfo = manager.appInfo - appInfoLocalization = manager.appInfoLocalization - ageRatingDeclaration = manager.ageRatingDeclaration - reviewDetail = manager.reviewDetail - reviewSubmissions = manager.reviewSubmissions - reviewSubmissionItemsBySubmissionId = manager.reviewSubmissionItemsBySubmissionId - latestSubmissionItems = manager.latestSubmissionItems - submissionHistoryEvents = manager.submissionHistoryEvents - attachedSubmissionItemIDs = manager.attachedSubmissionItemIDs - resolutionCenterThreads = manager.resolutionCenterThreads - rejectionMessages = manager.rejectionMessages - rejectionReasons = manager.rejectionReasons - cachedFeedback = manager.cachedFeedback - trackSlots = manager.trackSlots - savedTrackState = manager.savedTrackState - localScreenshotAssets = manager.localScreenshotAssets - appIconStatus = manager.appIconStatus - monetizationStatus = manager.monetizationStatus - let cachedLoadedTabs = manager.loadedTabs.intersection(ASCManager.cachedProjectTabs) - loadedTabs = cachedLoadedTabs - tabLoadedAt = manager.tabLoadedAt.filter { cachedLoadedTabs.contains($0.key) } - } - - @MainActor - func apply(to manager: ASCManager) { - manager.app = app - manager.appStoreVersions = appStoreVersions - manager.localizations = localizations - manager.screenshotSets = screenshotSets - manager.screenshots = screenshots - manager.customerReviews = customerReviews - manager.builds = builds - manager.betaGroups = betaGroups - manager.betaLocalizations = betaLocalizations - manager.betaFeedback = betaFeedback - manager.selectedBuildId = selectedBuildId - manager.inAppPurchases = inAppPurchases - manager.subscriptionGroups = subscriptionGroups - manager.subscriptionsPerGroup = subscriptionsPerGroup - manager.appPricePoints = appPricePoints - manager.currentAppPricePointId = currentAppPricePointId - manager.scheduledAppPricePointId = scheduledAppPricePointId - manager.scheduledAppPriceEffectiveDate = scheduledAppPriceEffectiveDate - manager.appInfo = appInfo - manager.appInfoLocalization = appInfoLocalization - manager.ageRatingDeclaration = ageRatingDeclaration - manager.reviewDetail = reviewDetail - manager.reviewSubmissions = reviewSubmissions - manager.reviewSubmissionItemsBySubmissionId = reviewSubmissionItemsBySubmissionId - manager.latestSubmissionItems = latestSubmissionItems - manager.submissionHistoryEvents = submissionHistoryEvents - manager.attachedSubmissionItemIDs = attachedSubmissionItemIDs - manager.resolutionCenterThreads = resolutionCenterThreads - manager.rejectionMessages = rejectionMessages - manager.rejectionReasons = rejectionReasons - manager.cachedFeedback = cachedFeedback - manager.trackSlots = trackSlots - manager.savedTrackState = savedTrackState - manager.localScreenshotAssets = localScreenshotAssets - manager.appIconStatus = appIconStatus - manager.monetizationStatus = monetizationStatus - manager.loadedTabs = loadedTabs - manager.tabLoadedAt = tabLoadedAt - manager.loadedProjectId = projectId - manager.tabError = [:] - manager.isLoadingTab = [:] - manager.isLoadingApp = false - manager.isLoadingIrisFeedback = false - manager.loadingFeedbackBuildIds = [] - manager.irisFeedbackError = nil - manager.writeError = nil - manager.submissionError = nil - manager.overviewReadinessLoadingFields = [] - } - } - - private static let projectCacheFreshness: TimeInterval = 120 - private static let cachedProjectTabs: Set = [ - .app, - .storeListing, - .screenshots, - .appDetails, - .monetization, - .review, - .analytics, - .reviews, - .builds, - .groups, - .betaInfo, - .feedback, - ] - private static let overviewLocalizationFieldLabels: Set = [ - "App Name", - "Description", - "Keywords", - "Support URL" - ] - private static let overviewVersionFieldLabels: Set = ["Copyright"] - private static let overviewAppInfoFieldLabels: Set = ["Primary Category"] - private static let overviewMetadataFieldLabels: Set = [ - "Privacy Policy URL", - "Age Rating" - ] - private static let overviewReviewFieldLabels: Set = [ - "Review Contact First Name", - "Review Contact Last Name", - "Review Contact Email", - "Review Contact Phone", - "Demo Account Name", - "Demo Account Password" - ] - private static let overviewBuildFieldLabels: Set = ["Build"] - private static let overviewPricingFieldLabels: Set = [ - "Pricing", - "In-App Purchases & Subscriptions" - ] - private static let overviewScreenshotFieldLabels: Set = [ - "Mac Screenshots", - "iPhone Screenshots", - "iPad Screenshots" - ] - - nonisolated init() {} - - // Credentials & service - var credentials: ASCCredentials? - private(set) var service: AppStoreConnectService? - - // App - var app: ASCApp? - - // Loading / error state - var isLoadingCredentials = false - var credentialsError: String? - var isLoadingApp = false - // Bumped after saving credentials so gated ASC tabs rerun their initial load task - // once the credential form disappears and the app lookup has completed. - var credentialActivationRevision = 0 - - // Per-tab data - var appStoreVersions: [ASCAppStoreVersion] = [] - var localizations: [ASCVersionLocalization] = [] - var screenshotSets: [ASCScreenshotSet] = [] - var screenshots: [String: [ASCScreenshot]] = [:] // keyed by screenshotSet.id - var customerReviews: [ASCCustomerReview] = [] - var builds: [ASCBuild] = [] - var betaGroups: [ASCBetaGroup] = [] - var betaLocalizations: [ASCBetaLocalization] = [] - var betaFeedback: [String: [ASCBetaFeedback]] = [:] // keyed by build.id - var selectedBuildId: String? - - // Monetization data - var inAppPurchases: [ASCInAppPurchase] = [] - var subscriptionGroups: [ASCSubscriptionGroup] = [] - var subscriptionsPerGroup: [String: [ASCSubscription]] = [:] // groupId → subs - var appPricePoints: [ASCPricePoint] = [] // USA price tiers for the app - var currentAppPricePointId: String? - var scheduledAppPricePointId: String? - var scheduledAppPriceEffectiveDate: String? - - // Creation progress (survives tab switches) - var createProgress: Double = 0 - var createProgressMessage: String = "" - var isCreating = false - private var createTask: Task? - - // New data for submission flow - var appInfo: ASCAppInfo? - var appInfoLocalization: ASCAppInfoLocalization? - var ageRatingDeclaration: ASCAgeRatingDeclaration? - var reviewDetail: ASCReviewDetail? - var pendingCredentialValues: [String: String]? // Pre-fill values for ASC credential form (from MCP) - var pendingFormValues: [String: [String: String]] = [:] // tab → field → value (for MCP pre-fill) - var pendingFormVersion: Int = 0 // Incremented when pendingFormValues changes; views watch this - var pendingCreateValues: [String: String]? // Pre-fill values for IAP/subscription create forms (from MCP) - var showSubmitPreview = false - var isSubmitting = false - var submissionError: String? - var writeError: String? // Inline error for write operations (does not replace tab content) - - // Review submission history (for rejection tracking) - var reviewSubmissions: [ASCReviewSubmission] = [] - var reviewSubmissionItemsBySubmissionId: [String: [ASCReviewSubmissionItem]] = [:] - var latestSubmissionItems: [ASCReviewSubmissionItem] = [] - var submissionHistoryEvents: [ASCSubmissionHistoryEvent] = [] - - // Iris (Apple ID session) — rejection feedback from internal API - enum IrisSessionState { case unknown, noSession, valid, expired } - var irisSession: IrisSession? - private(set) var irisService: IrisService? - var irisSessionState: IrisSessionState = .unknown - var isLoadingIrisFeedback = false - var irisFeedbackError: String? - var showAppleIDLogin = false - private var pendingWebAuthContinuation: CheckedContinuation? - var attachedSubmissionItemIDs: Set = [] // IAP/subscription IDs attached via iris API - var resolutionCenterThreads: [IrisResolutionCenterThread] = [] - var rejectionMessages: [IrisResolutionCenterMessage] = [] - var rejectionReasons: [IrisReviewRejection] = [] - var cachedFeedback: IrisFeedbackCache? // loaded from disk, survives session expiry - - // App icon status (set externally; nil = not checked / missing) - var appIconStatus: String? - - // monetization status (set after monetization check or setPriceFree success) - var monetizationStatus: String? - - // Build pipeline progress (driven by MCPToolExecutor) - enum BuildPipelinePhase: String { - case idle - case signingSetup = "Setting up signing…" - case archiving = "Archiving…" - case exporting = "Exporting IPA…" - case uploading = "Uploading to App Store Connect…" - case processing = "Processing build…" - } - var buildPipelinePhase: BuildPipelinePhase = .idle - var buildPipelineMessage: String = "" // Latest progress line from the build - - // Screenshot track state per device type - var trackSlots: [String: [TrackSlot?]] = [:] // keyed by ascDisplayType, 10-element arrays - var savedTrackState: [String: [TrackSlot?]] = [:] // snapshot after last load/save - var localScreenshotAssets: [LocalScreenshotAsset] = [] - var isSyncing = false - - /// Age rating is "configured" only if the enum fields have actual values (not nil). - /// ASC returns the declaration object with nil fields by default — submitting with - /// nil fields causes a 409. - private var ageRatingIsConfigured: Bool { - guard let ar = ageRatingDeclaration?.attributes else { return false } - // Check that at least the required enum fields are non-nil - return ar.alcoholTobaccoOrDrugUseOrReferences != nil - && ar.violenceCartoonOrFantasy != nil - && ar.violenceRealistic != nil - && ar.sexualContentOrNudity != nil - && ar.sexualContentGraphicAndNudity != nil - && ar.profanityOrCrudeHumor != nil - && ar.gamblingSimulated != nil - } - - var submissionReadiness: SubmissionReadiness { - let loc = localizations.first - let info = appInfoLocalization - let review = reviewDetail - let demoRequired = review?.attributes.demoAccountRequired == true - let version = appStoreVersions.first - - // Screenshot checks per display type — detect platform from available sets - let macScreenshots = screenshotSets.first { $0.attributes.screenshotDisplayType == "APP_DESKTOP" } - let isMacApp = macScreenshots != nil - let iphoneScreenshots = screenshotSets.first { $0.attributes.screenshotDisplayType == "APP_IPHONE_67" } - let ipadScreenshots = screenshotSets.first { $0.attributes.screenshotDisplayType == "APP_IPAD_PRO_3GEN_129" } - - // Privacy nutrition labels URL (manual action required) - let privacyUrl: String? = app.map { - "https://appstoreconnect.apple.com/apps/\($0.id)/distribution/privacy" - } - - func readinessField( - label: String, - value: String?, - required: Bool = true, - actionUrl: String? = nil, - hint: String? = nil - ) -> SubmissionReadiness.FieldStatus { - SubmissionReadiness.FieldStatus( - label: label, - value: value, - isLoading: overviewReadinessLoadingFields.contains(label) && (value == nil || value?.isEmpty == true), - required: required, - actionUrl: actionUrl, - hint: hint - ) - } - - var fields: [SubmissionReadiness.FieldStatus] = [ - readinessField(label: "App Name", value: info?.attributes.name ?? loc?.attributes.title), - readinessField(label: "Description", value: loc?.attributes.description), - readinessField(label: "Keywords", value: loc?.attributes.keywords), - readinessField(label: "Support URL", value: loc?.attributes.supportUrl), - readinessField(label: "Privacy Policy URL", value: info?.attributes.privacyPolicyUrl), - readinessField(label: "Copyright", value: version?.attributes.copyright), - readinessField(label: "Content Rights", value: app?.contentRightsDeclaration), - readinessField(label: "Primary Category", value: appInfo?.primaryCategoryId), - readinessField(label: "Age Rating", value: ageRatingIsConfigured ? "Configured" : nil), - readinessField(label: "Pricing", value: monetizationStatus), - readinessField(label: "Review Contact First Name", value: review?.attributes.contactFirstName), - readinessField(label: "Review Contact Last Name", value: review?.attributes.contactLastName), - readinessField(label: "Review Contact Email", value: review?.attributes.contactEmail), - readinessField(label: "Review Contact Phone", value: review?.attributes.contactPhone), - ] - - // Conditional: demo credentials required when demoAccountRequired is set - if demoRequired { - fields.append(readinessField(label: "Demo Account Name", value: review?.attributes.demoAccountName)) - fields.append(readinessField(label: "Demo Account Password", value: review?.attributes.demoAccountPassword)) - } - - fields.append(readinessField(label: "App Icon", value: appIconStatus)) - - // Count only non-failed screenshots for readiness - func validCount(for set: ASCScreenshotSet?) -> Int { - guard let set else { return 0 } - if let shots = screenshots[set.id] { - return shots.filter { !$0.hasError }.count - } - return set.attributes.screenshotCount ?? 0 - } - - if isMacApp { - let macCount = validCount(for: macScreenshots) - fields.append(readinessField(label: "Mac Screenshots", value: macCount > 0 ? "\(macCount) screenshot(s)" : nil)) - } else { - let iphoneCount = validCount(for: iphoneScreenshots) - let ipadCount = validCount(for: ipadScreenshots) - fields.append(readinessField(label: "iPhone Screenshots", value: iphoneCount > 0 ? "\(iphoneCount) screenshot(s)" : nil)) - fields.append(readinessField(label: "iPad Screenshots", value: ipadCount > 0 ? "\(ipadCount) screenshot(s)" : nil)) - } - - fields.append(contentsOf: [ - readinessField(label: "Privacy Nutrition Labels", value: nil, required: false, actionUrl: privacyUrl), - readinessField(label: "Build", value: builds.first?.attributes.version), - ]) - - // Conditional: first-time IAP/subscription attachment - // Only shown when (a) IAPs or subscriptions exist in READY_TO_SUBMIT state - // AND (b) no version has ever been approved (first-time submission) - let approvedStates: Set = ["READY_FOR_SALE", "REMOVED_FROM_SALE", - "DEVELOPER_REMOVED_FROM_SALE", "REPLACED_WITH_NEW_VERSION", "PROCESSING_FOR_APP_STORE"] - let hasApprovedVersion = appStoreVersions.contains { - approvedStates.contains($0.attributes.appStoreState ?? "") - } - let isFirstVersion = !hasApprovedVersion - if isFirstVersion { - let readyIAPs = inAppPurchases.filter { $0.attributes.state == "READY_TO_SUBMIT" && !attachedSubmissionItemIDs.contains($0.id) } - let readySubs = subscriptionsPerGroup.values.flatMap { $0 } - .filter { $0.attributes.state == "READY_TO_SUBMIT" && !attachedSubmissionItemIDs.contains($0.id) } - let readyCount = readyIAPs.count + readySubs.count - if readyCount > 0 { - let names = (readyIAPs.map { $0.attributes.name ?? $0.attributes.productId ?? $0.id } - + readySubs.map { $0.attributes.name ?? $0.attributes.productId ?? $0.id }) - .joined(separator: ", ") - let iapUrl: String? = app.map { - "https://appstoreconnect.apple.com/apps/\($0.id)/distribution/ios/version/inflight" - } - fields.append(readinessField( - label: "In-App Purchases & Subscriptions", - value: nil, - required: true, - actionUrl: iapUrl, - hint: "\(readyCount) item(s) in Ready to Submit state (\(names)) must be attached to this version before submission. " - + "Use the asc-iap-attach skill to attach them via the iris API (asc web session). " - + "The public API does not support first-time IAP/subscription attachment — " - + "run: asc web auth login, then POST to /iris/v1/subscriptionSubmissions or /iris/v1/inAppPurchaseSubmissions " - + "with submitWithNextAppStoreVersion:true for each item." - )) - } - } - - return SubmissionReadiness(fields: fields) - } - - // Per-tab loading / error - var isLoadingTab: [AppTab: Bool] = [:] - var tabError: [AppTab: String] = [:] - private var loadedTabs: Set = [] - private var tabLoadedAt: [AppTab: Date] = [:] - private var projectSnapshots: [String: ProjectSnapshot] = [:] - private var tabHydrationTasks: [AppTab: Task] = [:] - private var overviewReadinessLoadingFields: Set = [] - private var loadingFeedbackBuildIds: Set = [] - - var loadedProjectId: String? - - // MARK: - App Icon Check - - /// Check whether the project has app icon assets at ~/.blitz/projects/{projectId}/assets/AppIcon/ - func checkAppIcon(projectId: String) { - let fm = FileManager.default - let home = fm.homeDirectoryForCurrentUser.path - let iconDir = "\(home)/.blitz/projects/\(projectId)/assets/AppIcon" - let icon1024 = "\(iconDir)/icon_1024.png" - - if fm.fileExists(atPath: icon1024) { - appIconStatus = "1024px" - } else { - // Also check the Xcode project's xcassets as fallback - let projectDir = "\(home)/.blitz/projects/\(projectId)" - let xcassetsPattern = ["ios", "macos", "."] - for subdir in xcassetsPattern { - let searchDir = subdir == "." ? projectDir : "\(projectDir)/\(subdir)" - if let enumerator = fm.enumerator(atPath: searchDir) { - while let file = enumerator.nextObject() as? String { - if file.hasSuffix("AppIcon.appiconset/Contents.json") { - let contentsPath = "\(searchDir)/\(file)" - if let data = fm.contents(atPath: contentsPath), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let images = json["images"] as? [[String: Any]] { - let hasFilename = images.contains { $0["filename"] != nil } - if hasFilename { - appIconStatus = "Configured" - return - } - } - } - } - } - } - appIconStatus = nil - } - } - - private func refreshAppIconStatusIfNeeded(for projectId: String?) { - guard let projectId, !projectId.isEmpty else { return } - checkAppIcon(projectId: projectId) - } - - private func cancelBackgroundHydration(for tab: AppTab) { - tabHydrationTasks[tab]?.cancel() - tabHydrationTasks.removeValue(forKey: tab) - } - - private func cancelBackgroundHydrationTasks() { - for task in tabHydrationTasks.values { - task.cancel() - } - tabHydrationTasks.removeAll() - } - - private func startBackgroundHydration(for tab: AppTab, operation: @escaping @MainActor () async -> Void) { - cancelBackgroundHydration(for: tab) - tabHydrationTasks[tab] = Task { - await operation() - } - } - - private func resetProjectData(preserveCredentials: Bool) { - cancelBackgroundHydrationTasks() - overviewReadinessLoadingFields = [] - loadingFeedbackBuildIds = [] - - if !preserveCredentials { - credentials = nil - service = nil - } - - app = nil - isLoadingCredentials = false - credentialsError = nil - isLoadingApp = false - appStoreVersions = [] - localizations = [] - screenshotSets = [] - screenshots = [:] - customerReviews = [] - builds = [] - betaGroups = [] - betaLocalizations = [] - betaFeedback = [:] - selectedBuildId = nil - inAppPurchases = [] - subscriptionGroups = [] - subscriptionsPerGroup = [:] - appPricePoints = [] - currentAppPricePointId = nil - scheduledAppPricePointId = nil - scheduledAppPriceEffectiveDate = nil - appInfo = nil - appInfoLocalization = nil - ageRatingDeclaration = nil - reviewDetail = nil - pendingFormValues = [:] - showSubmitPreview = false - isSubmitting = false - submissionError = nil - writeError = nil - reviewSubmissions = [] - reviewSubmissionItemsBySubmissionId = [:] - latestSubmissionItems = [] - submissionHistoryEvents = [] - appIconStatus = nil - monetizationStatus = nil - attachedSubmissionItemIDs = [] - isLoadingTab = [:] - tabError = [:] - loadedTabs = [] - tabLoadedAt = [:] - if !preserveCredentials { - loadedProjectId = nil - } - // Clear iris data but keep session (it's account-wide, not project-specific) - resolutionCenterThreads = [] - rejectionMessages = [] - rejectionReasons = [] - cachedFeedback = nil - isLoadingIrisFeedback = false - irisFeedbackError = nil - cancelPendingWebAuth() - } - - private func cacheCurrentProjectSnapshot() { - guard let projectId = loadedProjectId else { return } - guard app != nil || !loadedTabs.isEmpty else { return } - projectSnapshots[projectId] = ProjectSnapshot(manager: self, projectId: projectId) - } - - private func startOverviewReadinessLoading(_ fields: Set) { - overviewReadinessLoadingFields = fields - } - - private func finishOverviewReadinessLoading(_ fields: Set) { - overviewReadinessLoadingFields.subtract(fields) - } - - private func shouldRefreshTabCache(_ tab: AppTab) -> Bool { - guard loadedTabs.contains(tab) else { return true } - guard let loadedAt = tabLoadedAt[tab] else { return true } - return Date().timeIntervalSince(loadedAt) > Self.projectCacheFreshness - } - - private func isCurrentProject(_ projectId: String?) -> Bool { - guard let projectId else { return false } - return loadedProjectId == projectId - } - - func prepareForProjectSwitch(to projectId: String) { - cacheCurrentProjectSnapshot() - resetProjectData(preserveCredentials: true) - - if let snapshot = projectSnapshots[projectId] { - snapshot.apply(to: self) - } else { - loadedProjectId = projectId - } - } - - func loadStoredCredentialsIfNeeded() { - guard credentials == nil || service == nil else { return } - let creds = ASCCredentials.load() - try? ASCAuthBridge().syncCredentials(creds) - Self.syncWebSessionFileFromKeychain() - credentials = creds - if let creds { - service = AppStoreConnectService(credentials: creds) - } else { - service = nil - } - } - - func ensureTabData(_ tab: AppTab) async { - guard credentials != nil else { return } - - if loadedTabs.contains(tab) { - if shouldRefreshTabCache(tab) { - await refreshTabData(tab) - } - return - } - - await fetchTabData(tab) - } - - func hasLoadedTabData(_ tab: AppTab) -> Bool { - loadedTabs.contains(tab) - } - - func isTabLoading(_ tab: AppTab) -> Bool { - isLoadingTab[tab] == true || isLoadingApp - } - - func isFeedbackLoading(for buildId: String?) -> Bool { - guard let buildId, !buildId.isEmpty else { - return isLoadingTab[.feedback] == true - } - return loadingFeedbackBuildIds.contains(buildId) - } - - // MARK: - Project Lifecycle - - func loadCredentials(for projectId: String, bundleId: String?) async { - let needsCredentialReload = credentials == nil || service == nil - let shouldSkip = loadedProjectId == projectId - && !needsCredentialReload - && (bundleId == nil || app != nil) - guard !shouldSkip else { return } - - credentialsError = nil - - if needsCredentialReload { - isLoadingCredentials = true - let creds = ASCCredentials.load() - try? ASCAuthBridge().syncCredentials(creds) - Self.syncWebSessionFileFromKeychain() - credentials = creds - isLoadingCredentials = false - - if let creds { - service = AppStoreConnectService(credentials: creds) - } - } - - loadedProjectId = projectId - refreshAppIconStatusIfNeeded(for: projectId) - - if let bundleId, !bundleId.isEmpty, credentials != nil, app == nil { - await fetchApp(bundleId: bundleId) - } - } - - func clearForProjectSwitch() { - resetProjectData(preserveCredentials: false) - } - - // MARK: - Iris Session (Apple ID auth for rejection feedback) - - private func irisLog(_ msg: String) { - let logPath = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".blitz/iris-debug.log") - let ts = ISO8601DateFormatter().string(from: Date()) - let line = "[\(ts)] \(msg)\n" - if let data = line.data(using: .utf8) { - if FileManager.default.fileExists(atPath: logPath.path) { - if let handle = try? FileHandle(forWritingTo: logPath) { - handle.seekToEndOfFile() - handle.write(data) - handle.closeFile() - } - } else { - let dir = logPath.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - try? data.write(to: logPath) - } - } - } - - func loadIrisSession() { - irisLog("ASCManager.loadIrisSession: starting") - guard let loaded = IrisSession.load() else { - irisLog("ASCManager.loadIrisSession: no session file found") - irisSessionState = .noSession - irisSession = nil - irisService = nil - return - } - // No time-based expiry — we trust the session until a 401 proves otherwise - irisLog("ASCManager.loadIrisSession: loaded session with \(loaded.cookies.count) cookies, capturedAt=\(loaded.capturedAt)") - do { - try Self.storeWebSessionToKeychain(loaded) - } catch { - irisLog("ASCManager.loadIrisSession: asc-web-session backfill FAILED: \(error)") - } - irisSession = loaded - irisService = IrisService(session: loaded) - irisSessionState = .valid - irisLog("ASCManager.loadIrisSession: session valid, irisService created") - } - - func requestWebAuthForMCP() async -> IrisSession? { - pendingWebAuthContinuation?.resume(returning: nil) - irisFeedbackError = nil - showAppleIDLogin = true - return await withCheckedContinuation { continuation in - pendingWebAuthContinuation = continuation - } - } - - func cancelPendingWebAuth() { - showAppleIDLogin = false - pendingWebAuthContinuation?.resume(returning: nil) - pendingWebAuthContinuation = nil - } - - func setIrisSession(_ session: IrisSession) { - irisLog("ASCManager.setIrisSession: \(session.cookies.count) cookies") - do { - try session.save() - irisLog("ASCManager.setIrisSession: saved to native keychain") - } catch { - irisLog("ASCManager.setIrisSession: save FAILED: \(error)") - irisFeedbackError = "Failed to save session: \(error.localizedDescription)" - showAppleIDLogin = false - pendingWebAuthContinuation?.resume(returning: nil) - pendingWebAuthContinuation = nil - return - } - - // Also write the shared web session store (keychain + synced session file). - // If that write fails during an MCP-triggered login, keep the native session - // but fail the MCP request instead of reporting a false success. - do { - try Self.storeWebSessionToKeychain(session) - } catch { - irisLog("ASCManager.setIrisSession: asc-web-session save FAILED: \(error)") - irisFeedbackError = "Failed to save ASC web session: \(error.localizedDescription)" - if let continuation = pendingWebAuthContinuation { - pendingWebAuthContinuation = nil - continuation.resume(returning: nil) - } - } - - irisSession = session - irisService = IrisService(session: session) - irisSessionState = .valid - irisLog("ASCManager.setIrisSession: state set to .valid") - showAppleIDLogin = false - - // Notify MCP tool if it triggered this login - if let continuation = pendingWebAuthContinuation { - pendingWebAuthContinuation = nil - continuation.resume(returning: session) - } - } - - func clearIrisSession() { - irisLog("ASCManager.clearIrisSession") - let currentSession = irisSession - IrisSession.delete() - Self.deleteWebSessionFromKeychain(email: currentSession?.email) - irisSession = nil - irisService = nil - irisSessionState = .noSession - resolutionCenterThreads = [] - rejectionMessages = [] - rejectionReasons = [] - if let appId = app?.id { - rebuildSubmissionHistory(appId: appId) - } - } - - // MARK: - Unified Web Session Store (for CLI skill scripts) - - private static let webSessionService = ASCWebSessionStore.keychainService - private static let webSessionAccount = ASCWebSessionStore.keychainAccount - - /// Write session cookies for CLI skill scripts. - /// Stored in Keychain (for Blitz) and synced to ~/.blitz/asc-agent/web-session.json (for CLI scripts). - private static func storeWebSessionToKeychain(_ session: IrisSession) throws { - let existingData = readKeychainItem(service: webSessionService, account: webSessionAccount) - let data = try ASCWebSessionStore.mergedData(storing: session, into: existingData) - removeWebSessionKeychainItem() - try writeWebSessionToKeychain(data) - // Also write to file so CLI skill scripts can read without Keychain popups. - try ASCAuthBridge().syncWebSession(data) - } - - private static func syncWebSessionFileFromKeychain() { - guard let data = readKeychainItem(service: webSessionService, account: webSessionAccount) else { - return - } - try? ASCAuthBridge().syncWebSession(data) - } - - private static func writeWebSessionToKeychain(_ data: Data) throws { - let addQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: webSessionService, - kSecAttrAccount as String: webSessionAccount, - kSecAttrLabel as String: "ASC Web Session Store", - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, - ] - let status = SecItemAdd(addQuery as CFDictionary, nil) - guard status == errSecSuccess else { - throw NSError( - domain: "ASCWebSessionStore", - code: Int(status), - userInfo: [NSLocalizedDescriptionKey: "Keychain write failed (status: \(status))"] - ) - } - } - - private static func deleteWebSessionFromKeychain(email: String?) { - let existingData = readKeychainItem(service: webSessionService, account: webSessionAccount) - let updatedData: Data? - do { - updatedData = try ASCWebSessionStore.removingSession(email: email, from: existingData) - } catch { - return - } - - if let updatedData = updatedData { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: webSessionService, - kSecAttrAccount as String: webSessionAccount, - ] - let status = SecItemUpdate( - query as CFDictionary, - [kSecValueData as String: updatedData] as CFDictionary - ) - if status == errSecItemNotFound { - try? writeWebSessionToKeychain(updatedData) - } - // Keep file in sync with keychain - do { - try ASCAuthBridge().syncWebSession(updatedData) - } catch { - ASCAuthBridge().removeWebSession() - } - return - } - - removeWebSessionKeychainItem() - } - - private static func removeWebSessionKeychainItem() { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: webSessionService, - kSecAttrAccount as String: webSessionAccount, - ] - SecItemDelete(query as CFDictionary) - ASCAuthBridge().removeWebSession() - } - - /// Loads cached feedback from disk for the given rejected version. No auth needed. - func loadCachedFeedback(appId: String, versionString: String) { - irisLog("ASCManager.loadCachedFeedback: appId=\(appId) version=\(versionString)") - if let cached = IrisFeedbackCache.load(appId: appId, versionString: versionString) { - cachedFeedback = cached - irisLog("ASCManager.loadCachedFeedback: loaded \(cached.reasons.count) reasons, \(cached.messages.count) messages, fetched \(cached.fetchedAt)") - } else { - irisLog("ASCManager.loadCachedFeedback: no cache found") - cachedFeedback = nil - } - rebuildSubmissionHistory(appId: appId) - } - - func fetchRejectionFeedback() async { - irisLog("ASCManager.fetchRejectionFeedback: irisService=\(irisService != nil), appId=\(app?.id ?? "nil")") - guard let irisService, let appId = app?.id else { - irisLog("ASCManager.fetchRejectionFeedback: guard failed, returning") - return - } - - // Determine version string for cache - let rejectedVersion = appStoreVersions.first(where: { - $0.attributes.appStoreState == "REJECTED" - })?.attributes.versionString - - isLoadingIrisFeedback = true - irisFeedbackError = nil - - do { - let threads = try await irisService.fetchResolutionCenterThreads(appId: appId) - irisLog("ASCManager.fetchRejectionFeedback: got \(threads.count) threads") - resolutionCenterThreads = threads - - if let latestThread = threads.first { - irisLog("ASCManager.fetchRejectionFeedback: fetching messages+rejections for thread \(latestThread.id)") - let result = try await irisService.fetchMessagesAndRejections(threadId: latestThread.id) - rejectionMessages = result.messages - rejectionReasons = result.rejections - irisLog("ASCManager.fetchRejectionFeedback: got \(rejectionMessages.count) messages, \(rejectionReasons.count) rejections") - - // Write cache - if let version = rejectedVersion { - let cache = buildFeedbackCache(appId: appId, versionString: version) - do { - try cache.save() - cachedFeedback = cache - irisLog("ASCManager.fetchRejectionFeedback: cache saved for \(version)") - } catch { - irisLog("ASCManager.fetchRejectionFeedback: cache save failed: \(error)") - } - } - } else { - irisLog("ASCManager.fetchRejectionFeedback: no threads found") - rejectionMessages = [] - rejectionReasons = [] - } - } catch let error as IrisError { - irisLog("ASCManager.fetchRejectionFeedback: IrisError: \(error)") - if case .sessionExpired = error { - irisSessionState = .expired - irisSession = nil - self.irisService = nil - } else { - irisFeedbackError = error.localizedDescription - } - } catch { - irisLog("ASCManager.fetchRejectionFeedback: error: \(error)") - irisFeedbackError = error.localizedDescription - } - - isLoadingIrisFeedback = false - rebuildSubmissionHistory(appId: appId) - irisLog("ASCManager.fetchRejectionFeedback: done") - } - - /// Builds a cache object from current in-memory rejection data. - private func buildFeedbackCache(appId: String, versionString: String) -> IrisFeedbackCache { - let msgs = rejectionMessages.map { msg in - IrisFeedbackCache.CachedMessage( - body: msg.attributes.messageBody.map { htmlToPlainText($0) } ?? "", - date: msg.attributes.createdDate - ) - } - let reasons = rejectionReasons.flatMap { rejection in - (rejection.attributes.reasons ?? []).map { r in - IrisFeedbackCache.CachedReason( - section: r.reasonSection ?? "", - description: r.reasonDescription ?? "", - code: r.reasonCode ?? "" - ) - } - } - return IrisFeedbackCache( - appId: appId, - versionString: versionString, - fetchedAt: Date(), - messages: msgs, - reasons: reasons - ) - } - - private func refreshReviewSubmissionData(appId: String, service: AppStoreConnectService) async { - let submissions = (try? await service.fetchReviewSubmissions(appId: appId)) ?? [] - reviewSubmissions = submissions - - guard !submissions.isEmpty else { - reviewSubmissionItemsBySubmissionId = [:] - latestSubmissionItems = [] - return - } - - var itemsBySubmissionId: [String: [ASCReviewSubmissionItem]] = [:] - await withTaskGroup(of: (String, [ASCReviewSubmissionItem]).self) { group in - for submission in submissions { - group.addTask { - let items = (try? await service.fetchReviewSubmissionItems(submissionId: submission.id)) ?? [] - return (submission.id, items) - } - } - - for await (submissionId, items) in group { - itemsBySubmissionId[submissionId] = items - } - } - - reviewSubmissionItemsBySubmissionId = itemsBySubmissionId - latestSubmissionItems = itemsBySubmissionId[submissions.first?.id ?? ""] ?? [] - } - - private func historyNowString() -> String { - ISO8601DateFormatter().string(from: Date()) - } - - private func historyDate(_ iso: String?) -> Date { - guard let iso else { return .distantPast } - let f1 = ISO8601DateFormatter() - f1.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let f2 = ISO8601DateFormatter() - return f1.date(from: iso) ?? f2.date(from: iso) ?? .distantPast - } - - private func closestVersion(before dateString: String) -> ASCAppStoreVersion? { - let submittedDate = historyDate(dateString) - return appStoreVersions - .filter { historyDate($0.attributes.createdDate) <= submittedDate } - .max { historyDate($0.attributes.createdDate) < historyDate($1.attributes.createdDate) } - ?? ASCReleaseStatus.sortedVersionsByRecency(appStoreVersions).first - } - - private func historyEventType(forVersionState state: String) -> ASCSubmissionHistoryEventType? { - ASCReleaseStatus.submissionHistoryEventType(forVersionState: state) - } - - private func historyCoverageKey( - versionId: String?, - versionString: String, - eventType: ASCSubmissionHistoryEventType - ) -> String { - "\(versionId ?? "version:\(versionString)")::\(eventType.rawValue)" - } - - private func versionString( - for versionId: String?, - versionSnapshots: [String: ASCSubmissionHistoryCache.VersionSnapshot] - ) -> String? { - guard let versionId else { return nil } - if let version = appStoreVersions.first(where: { $0.id == versionId }) { - return version.attributes.versionString - } - return versionSnapshots[versionId]?.versionString - } - - private func versionId( - for versionString: String, - versionSnapshots: [String: ASCSubmissionHistoryCache.VersionSnapshot] - ) -> String? { - if let version = appStoreVersions.first(where: { $0.attributes.versionString == versionString }) { - return version.id - } - return versionSnapshots.values.first(where: { $0.versionString == versionString })?.versionId - } - - private func versionState( - for versionId: String?, - versionSnapshots: [String: ASCSubmissionHistoryCache.VersionSnapshot] - ) -> String? { - guard let versionId else { return nil } - if let version = appStoreVersions.first(where: { $0.id == versionId }) { - return version.attributes.appStoreState - } - return versionSnapshots[versionId]?.lastKnownState - } - - private func refreshSubmissionHistoryCache(appId: String) -> ASCSubmissionHistoryCache { - var cache = ASCSubmissionHistoryCache.load(appId: appId) - let now = historyNowString() - - for version in appStoreVersions { - let state = version.attributes.appStoreState ?? "" - guard !state.isEmpty else { continue } - - if var snapshot = cache.versionSnapshots[version.id] { - snapshot.versionString = version.attributes.versionString - if snapshot.lastKnownState != state, - let eventType = historyEventType(forVersionState: state) { - cache.transitionEvents.append( - ASCSubmissionHistoryEvent( - id: "ledger:\(version.id):\(state):\(now)", - versionId: version.id, - versionString: version.attributes.versionString, - eventType: eventType, - appleState: state, - occurredAt: now, - source: .transitionLedger, - accuracy: .firstSeen, - submissionId: nil, - note: nil - ) - ) - snapshot.lastKnownState = state - snapshot.lastSeenAt = now - } else { - snapshot.lastSeenAt = now - } - cache.versionSnapshots[version.id] = snapshot - } else { - cache.versionSnapshots[version.id] = .init( - versionId: version.id, - versionString: version.attributes.versionString, - lastKnownState: state, - lastSeenAt: now - ) - } - } - - cache.transitionEvents.sort { historyDate($0.occurredAt) > historyDate($1.occurredAt) } - try? cache.save() - return cache - } - - private func rebuildSubmissionHistory(appId: String) { - let cache = refreshSubmissionHistoryCache(appId: appId) - let versionSnapshots = cache.versionSnapshots - - let submissionEvents = reviewSubmissions.compactMap { submission -> ASCSubmissionHistoryEvent? in - guard let submittedAt = submission.attributes.submittedDate else { return nil } - let versionId = reviewSubmissionItemsBySubmissionId[submission.id]? - .compactMap(\.appStoreVersionId) - .first - ?? closestVersion(before: submittedAt)?.id - let versionString = versionString(for: versionId, versionSnapshots: versionSnapshots) ?? "Unknown" - let versionState = versionState(for: versionId, versionSnapshots: versionSnapshots) - let eventType = ASCReleaseStatus.reviewSubmissionEventType(forVersionState: versionState) - return ASCSubmissionHistoryEvent( - id: "submission:\(submission.id)", - versionId: versionId, - versionString: versionString, - eventType: eventType, - appleState: versionState ?? "WAITING_FOR_REVIEW", - occurredAt: submittedAt, - source: .reviewSubmission, - accuracy: .exact, - submissionId: submission.id, - note: nil - ) - } - - var rejectionEventsByVersion: [String: ASCSubmissionHistoryEvent] = [:] - for cacheEntry in IrisFeedbackCache.loadAll(appId: appId) { - let rejectionAt = cacheEntry.messages - .compactMap(\.date) - .sorted(by: { historyDate($0) < historyDate($1) }) - .first - ?? ISO8601DateFormatter().string(from: cacheEntry.fetchedAt) - - rejectionEventsByVersion[cacheEntry.versionString] = ASCSubmissionHistoryEvent( - id: "iris:\(cacheEntry.versionString):\(rejectionAt)", - versionId: versionId(for: cacheEntry.versionString, versionSnapshots: versionSnapshots), - versionString: cacheEntry.versionString, - eventType: .rejected, - appleState: "REJECTED", - occurredAt: rejectionAt, - source: .irisFeedback, - accuracy: .derived, - submissionId: nil, - note: cacheEntry.reasons.first?.section - ) - } - - if let rejectedVersion = appStoreVersions.first(where: { $0.attributes.appStoreState == "REJECTED" }) { - let rejectionAt = resolutionCenterThreads.first?.attributes.createdDate - ?? rejectionMessages.compactMap(\.attributes.createdDate) - .sorted(by: { historyDate($0) < historyDate($1) }) - .first - if let rejectionAt { - rejectionEventsByVersion[rejectedVersion.attributes.versionString] = ASCSubmissionHistoryEvent( - id: "iris-live:\(rejectedVersion.id):\(rejectionAt)", - versionId: rejectedVersion.id, - versionString: rejectedVersion.attributes.versionString, - eventType: .rejected, - appleState: "REJECTED", - occurredAt: rejectionAt, - source: .irisFeedback, - accuracy: .derived, - submissionId: nil, - note: rejectionReasons.first?.attributes.reasons?.first?.reasonSection - ) - } - } - - let durableEvents = submissionEvents - + Array(rejectionEventsByVersion.values) - + cache.transitionEvents - - let coveredEventKeys = Set( - durableEvents.map { - historyCoverageKey(versionId: $0.versionId, versionString: $0.versionString, eventType: $0.eventType) - } - ) - - let fallbackEvents = appStoreVersions.compactMap { version -> ASCSubmissionHistoryEvent? in - let state = version.attributes.appStoreState ?? "" - guard let eventType = historyEventType(forVersionState: state) else { return nil } - - let coverageKey = historyCoverageKey( - versionId: version.id, - versionString: version.attributes.versionString, - eventType: eventType - ) - guard !coveredEventKeys.contains(coverageKey) else { return nil } - - let occurredAt = version.attributes.createdDate - ?? cache.versionSnapshots[version.id]?.lastSeenAt - ?? historyNowString() - - return ASCSubmissionHistoryEvent( - id: "version:\(version.id):\(state)", - versionId: version.id, - versionString: version.attributes.versionString, - eventType: eventType, - appleState: state, - occurredAt: occurredAt, - source: .currentVersion, - accuracy: .derived, - submissionId: nil, - note: nil - ) - } - - submissionHistoryEvents = (durableEvents + fallbackEvents) - .sorted { lhs, rhs in - historyDate(lhs.occurredAt) > historyDate(rhs.occurredAt) - } - } - - func refreshSubmissionFeedbackIfNeeded() { - guard let appId = app?.id else { return } - - let rejectedVersion = appStoreVersions.first(where: { - $0.attributes.appStoreState == "REJECTED" - }) - let pendingVersion = appStoreVersions.first(where: { - let state = $0.attributes.appStoreState ?? "" - return state != "READY_FOR_SALE" && state != "REMOVED_FROM_SALE" - && state != "DEVELOPER_REMOVED_FROM_SALE" && !state.isEmpty - }) - - guard let version = rejectedVersion ?? pendingVersion else { - cachedFeedback = nil - rebuildSubmissionHistory(appId: appId) - return - } - - loadCachedFeedback(appId: appId, versionString: version.attributes.versionString) - loadIrisSession() - if irisSessionState == .valid { - Task { await fetchRejectionFeedback() } - } - } - - func saveCredentials(_ creds: ASCCredentials, projectId: String, bundleId: String?) async throws { - try creds.save() - credentials = creds - service = AppStoreConnectService(credentials: creds) - credentialsError = nil - cancelBackgroundHydrationTasks() - loadedTabs = [] // force re-fetch after new credentials - tabLoadedAt = [:] - tabError = [:] - isLoadingTab = [:] - loadingFeedbackBuildIds = [] - - if let bundleId, !bundleId.isEmpty { - await fetchApp(bundleId: bundleId) - } - - credentialActivationRevision += 1 - } - - func deleteCredentials() { - ASCCredentials.delete() - let pid = loadedProjectId - clearForProjectSwitch() - loadedProjectId = pid // keep project id so gate re-checks correctly - } - - // MARK: - App Fetch - - @discardableResult - func fetchApp(bundleId: String, exactName: String? = nil) async -> Bool { - guard let service else { return false } - isLoadingApp = true - do { - let fetched = try await service.fetchApp(bundleId: bundleId, exactName: exactName) - app = fetched - credentialsError = nil - isLoadingApp = false - return true - } catch { - app = nil - credentialsError = error.localizedDescription - isLoadingApp = false - return false - } - } - - // MARK: - Tab Data - - func fetchTabData(_ tab: AppTab) async { - guard let service else { return } - guard credentials != nil else { return } - guard !loadedTabs.contains(tab) else { return } - guard isLoadingTab[tab] != true else { return } - - cancelBackgroundHydration(for: tab) - isLoadingTab[tab] = true - tabError.removeValue(forKey: tab) - - do { - try await loadData(for: tab, service: service) - isLoadingTab[tab] = false - loadedTabs.insert(tab) - tabLoadedAt[tab] = Date() - } catch { - isLoadingTab[tab] = false - tabError[tab] = error.localizedDescription - } - } - - /// Called after bundle ID setup completes and the app is confirmed in ASC. - /// Clears all tab errors and forces data to be re-fetched. - func resetTabState() { - cancelBackgroundHydrationTasks() - tabError.removeAll() - loadedTabs.removeAll() - tabLoadedAt.removeAll() - loadingFeedbackBuildIds = [] - } - - func refreshTabData(_ tab: AppTab) async { - guard let service else { return } - guard credentials != nil else { return } - - let hadLoadedData = loadedTabs.contains(tab) - cancelBackgroundHydration(for: tab) - isLoadingTab[tab] = true - tabError.removeValue(forKey: tab) - - do { - try await loadData(for: tab, service: service) - isLoadingTab[tab] = false - loadedTabs.insert(tab) - tabLoadedAt[tab] = Date() - } catch { - isLoadingTab[tab] = false - if !hadLoadedData { - loadedTabs.remove(tab) - tabLoadedAt.removeValue(forKey: tab) - } - tabError[tab] = error.localizedDescription - } - } - - func refreshSubmissionReadinessData() async { - await refreshMonetization() - await refreshAttachedSubmissionItemIDs() - } - - func refreshBetaFeedback(buildId: String) async { - guard let service else { return } - guard !buildId.isEmpty else { return } - - loadingFeedbackBuildIds.insert(buildId) - defer { loadingFeedbackBuildIds.remove(buildId) } - - do { - betaFeedback[buildId] = try await service.fetchBetaFeedback(buildId: buildId) - } catch { - // Feedback may not be available for all apps; non-fatal. - betaFeedback[buildId] = [] - } - } - - private func hydrateOverviewSecondaryData( - projectId: String?, - appId: String, - firstLocalizationId: String?, - appInfoId: String?, - service: AppStoreConnectService - ) async { - if let firstLocalizationId { - do { - let fetchedSets = try await service.fetchScreenshotSets(localizationId: firstLocalizationId) - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - screenshotSets = fetchedSets - - let fetchedScreenshots = try await withThrowingTaskGroup(of: (String, [ASCScreenshot]).self) { group in - for set in fetchedSets { - group.addTask { - let screenshots = try await service.fetchScreenshots(setId: set.id) - return (set.id, screenshots) - } - } - - var pairs: [(String, [ASCScreenshot])] = [] - for try await pair in group { - pairs.append(pair) - } - return pairs - } - - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - screenshots = Dictionary(uniqueKeysWithValues: fetchedScreenshots) - finishOverviewReadinessLoading(Self.overviewScreenshotFieldLabels) - } catch { - print("Failed to hydrate overview screenshots: \(error)") - finishOverviewReadinessLoading(Self.overviewScreenshotFieldLabels) - } - } else { - finishOverviewReadinessLoading(Self.overviewScreenshotFieldLabels) - } - - if let appInfoId { - async let ageRatingTask: ASCAgeRatingDeclaration? = try? service.fetchAgeRating(appInfoId: appInfoId) - async let appInfoLocalizationTask: ASCAppInfoLocalization? = try? service.fetchAppInfoLocalization(appInfoId: appInfoId) - - let fetchedAgeRating = await ageRatingTask - let fetchedAppInfoLocalization = await appInfoLocalizationTask - - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - ageRatingDeclaration = fetchedAgeRating - appInfoLocalization = fetchedAppInfoLocalization - finishOverviewReadinessLoading(Self.overviewMetadataFieldLabels) - } else { - finishOverviewReadinessLoading(Self.overviewMetadataFieldLabels) - } - - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - await refreshReviewSubmissionData(appId: appId, service: service) - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - rebuildSubmissionHistory(appId: appId) - refreshSubmissionFeedbackIfNeeded() - - if monetizationStatus == nil { - let hasPricing = await service.fetchPricingConfigured(appId: appId) - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - monetizationStatus = hasPricing ? "Configured" : nil - } - - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - await refreshSubmissionReadinessData() - finishOverviewReadinessLoading(Self.overviewPricingFieldLabels) - } - - private func hydrateScreenshotsSecondaryData( - projectId: String?, - localizationId: String, - service: AppStoreConnectService - ) async { - do { - let fetchedSets = try await service.fetchScreenshotSets(localizationId: localizationId) - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - screenshotSets = fetchedSets - - let fetchedScreenshots = try await withThrowingTaskGroup(of: (String, [ASCScreenshot]).self) { group in - for set in fetchedSets { - group.addTask { - let screenshots = try await service.fetchScreenshots(setId: set.id) - return (set.id, screenshots) - } - } - - var pairs: [(String, [ASCScreenshot])] = [] - for try await pair in group { - pairs.append(pair) - } - return pairs - } - - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - screenshots = Dictionary(uniqueKeysWithValues: fetchedScreenshots) - } catch { - print("Failed to hydrate screenshots: \(error)") - } - } - - private func hydrateReviewSecondaryData( - projectId: String?, - appId: String, - appInfoId: String?, - service: AppStoreConnectService - ) async { - if let appInfoId { - let fetchedAgeRating = try? await service.fetchAgeRating(appInfoId: appInfoId) - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - ageRatingDeclaration = fetchedAgeRating - } else { - ageRatingDeclaration = nil - } - - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - await refreshReviewSubmissionData(appId: appId, service: service) - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - rebuildSubmissionHistory(appId: appId) - refreshSubmissionFeedbackIfNeeded() - } - - private func hydrateMonetizationSecondaryData( - projectId: String?, - appId: String, - groups: [ASCSubscriptionGroup], - service: AppStoreConnectService - ) async { - do { - let fetchedSubscriptions = try await withThrowingTaskGroup(of: (String, [ASCSubscription]).self) { taskGroup in - for subscriptionGroup in groups { - taskGroup.addTask { - let subscriptions = try await service.fetchSubscriptionsInGroup(groupId: subscriptionGroup.id) - return (subscriptionGroup.id, subscriptions) - } - } - - var pairs: [(String, [ASCSubscription])] = [] - for try await pair in taskGroup { - pairs.append(pair) - } - return pairs - } - - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - subscriptionsPerGroup = Dictionary(uniqueKeysWithValues: fetchedSubscriptions) - } catch { - print("Failed to hydrate monetization subscriptions: \(error)") - } - - if currentAppPricePointId == nil && scheduledAppPricePointId == nil && monetizationStatus == nil { - let hasPricing = await service.fetchPricingConfigured(appId: appId) - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - monetizationStatus = hasPricing ? "Configured" : nil - } - } - - private func hydrateFeedbackSecondaryData( - projectId: String?, - buildId: String, - service: AppStoreConnectService - ) async { - guard isCurrentProject(projectId) else { return } - guard !Task.isCancelled else { return } - loadingFeedbackBuildIds.insert(buildId) - defer { loadingFeedbackBuildIds.remove(buildId) } - - do { - let items = try await service.fetchBetaFeedback(buildId: buildId) - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - betaFeedback[buildId] = items - } catch { - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - betaFeedback[buildId] = [] - } - } - - private func loadData(for tab: AppTab, service: AppStoreConnectService) async throws { - guard let appId = app?.id else { - throw ASCError.notFound("App — check your bundle ID in project settings") - } - - switch tab { - case .app: - refreshAppIconStatusIfNeeded(for: loadedProjectId) - startOverviewReadinessLoading( - Self.overviewLocalizationFieldLabels - .union(Self.overviewVersionFieldLabels) - .union(Self.overviewAppInfoFieldLabels) - .union(Self.overviewMetadataFieldLabels) - .union(Self.overviewReviewFieldLabels) - .union(Self.overviewBuildFieldLabels) - .union(Self.overviewPricingFieldLabels) - .union(Self.overviewScreenshotFieldLabels) - ) - async let versionsTask = service.fetchAppStoreVersions(appId: appId) - async let appInfoTask: ASCAppInfo? = try? service.fetchAppInfo(appId: appId) - async let buildsTask = service.fetchBuilds(appId: appId) - - let versions = try await versionsTask - appStoreVersions = versions - finishOverviewReadinessLoading(Self.overviewVersionFieldLabels) - appInfo = await appInfoTask - finishOverviewReadinessLoading(Self.overviewAppInfoFieldLabels) - builds = try await buildsTask - finishOverviewReadinessLoading(Self.overviewBuildFieldLabels) - - var firstLocalizationId: String? - if let latestId = versions.first?.id { - async let localizationsTask = service.fetchLocalizations(versionId: latestId) - async let reviewDetailTask: ASCReviewDetail? = try? service.fetchReviewDetail(versionId: latestId) - - let fetchedLocalizations = try await localizationsTask - localizations = fetchedLocalizations - firstLocalizationId = fetchedLocalizations.first?.id - finishOverviewReadinessLoading(Self.overviewLocalizationFieldLabels) - reviewDetail = await reviewDetailTask - finishOverviewReadinessLoading(Self.overviewReviewFieldLabels) - } else { - finishOverviewReadinessLoading( - Self.overviewLocalizationFieldLabels - .union(Self.overviewReviewFieldLabels) - ) - } - - refreshSubmissionFeedbackIfNeeded() - - let projectId = loadedProjectId - let currentAppInfoId = appInfo?.id - startBackgroundHydration(for: .app) { - await self.hydrateOverviewSecondaryData( - projectId: projectId, - appId: appId, - firstLocalizationId: firstLocalizationId, - appInfoId: currentAppInfoId, - service: service - ) - } - - case .storeListing: - async let versionsTask = service.fetchAppStoreVersions(appId: appId) - async let appInfoTask: ASCAppInfo? = try? await service.fetchAppInfo(appId: appId) - - let versions = try await versionsTask - appStoreVersions = versions - if let latestId = versions.first?.id { - localizations = try await service.fetchLocalizations(versionId: latestId) - } else { - localizations = [] - } - appInfo = await appInfoTask - if let infoId = appInfo?.id { - appInfoLocalization = try? await service.fetchAppInfoLocalization(appInfoId: infoId) - } else { - appInfoLocalization = nil - } - - case .screenshots: - let versions = try await service.fetchAppStoreVersions(appId: appId) - appStoreVersions = versions - if let latestId = versions.first?.id { - let locs = try await service.fetchLocalizations(versionId: latestId) - localizations = locs - if let firstLocId = locs.first?.id { - let projectId = loadedProjectId - startBackgroundHydration(for: .screenshots) { - await self.hydrateScreenshotsSecondaryData( - projectId: projectId, - localizationId: firstLocId, - service: service - ) - } - } else { - screenshotSets = [] - screenshots = [:] - } - } else { - localizations = [] - screenshotSets = [] - screenshots = [:] - } - - case .appDetails: - async let versionsTask = service.fetchAppStoreVersions(appId: appId) - async let appInfoTask: ASCAppInfo? = try? await service.fetchAppInfo(appId: appId) - - appStoreVersions = try await versionsTask - appInfo = await appInfoTask - - case .review: - async let versionsTask = service.fetchAppStoreVersions(appId: appId) - async let appInfoTask: ASCAppInfo? = try? await service.fetchAppInfo(appId: appId) - async let buildsTask = service.fetchBuilds(appId: appId) - - let versions = try await versionsTask - appStoreVersions = versions - if let latestId = versions.first?.id { - reviewDetail = try? await service.fetchReviewDetail(versionId: latestId) - } else { - reviewDetail = nil - } - appInfo = await appInfoTask - builds = try await buildsTask - let projectId = loadedProjectId - let currentAppInfoId = appInfo?.id - startBackgroundHydration(for: .review) { - await self.hydrateReviewSecondaryData( - projectId: projectId, - appId: appId, - appInfoId: currentAppInfoId, - service: service - ) - } - - case .monetization: - async let pricePointsTask = service.fetchAppPricePoints(appId: appId) - async let pricingStateTask = (try? await service.fetchAppPricingState(appId: appId)) - ?? ASCAppPricingState(currentPricePointId: nil, scheduledPricePointId: nil, scheduledEffectiveDate: nil) - async let iapTask = service.fetchInAppPurchases(appId: appId) - async let groupsTask = service.fetchSubscriptionGroups(appId: appId) - - appPricePoints = try await pricePointsTask - applyAppPricingState(await pricingStateTask) - inAppPurchases = try await iapTask - let groups = try await groupsTask - subscriptionGroups = groups - let projectId = loadedProjectId - startBackgroundHydration(for: .monetization) { - await self.hydrateMonetizationSecondaryData( - projectId: projectId, - appId: appId, - groups: groups, - service: service - ) - } - - case .analytics: - break // Sales reports use a separate reports API; handled in view - - case .reviews: - customerReviews = try await service.fetchCustomerReviews(appId: appId) - - case .builds: - builds = try await service.fetchBuilds(appId: appId) - - case .groups: - betaGroups = try await service.fetchBetaGroups(appId: appId) - - case .betaInfo: - betaLocalizations = try await service.fetchBetaLocalizations(appId: appId) - - case .feedback: - let fetched = try await service.fetchBuilds(appId: appId) - builds = fetched - let resolvedBuildId: String? - if let currentSelectedBuildId = selectedBuildId, - fetched.contains(where: { $0.id == currentSelectedBuildId }) { - resolvedBuildId = currentSelectedBuildId - } else { - resolvedBuildId = fetched.first?.id - } - selectedBuildId = resolvedBuildId - if let resolvedBuildId { - let projectId = loadedProjectId - startBackgroundHydration(for: .feedback) { - await self.hydrateFeedbackSecondaryData( - projectId: projectId, - buildId: resolvedBuildId, - service: service - ) - } - } else { - betaFeedback = [:] - } - - default: - break - } - } - - // MARK: - Write Methods - - func updateLocalizationField(_ field: String, value: String, locId: String) async { - guard let service else { return } - writeError = nil - do { - try await service.patchLocalization(id: locId, fields: [field: value]) - if let latestId = appStoreVersions.first?.id { - localizations = try await service.fetchLocalizations(versionId: latestId) - } - } catch { - writeError = error.localizedDescription - } - } - - func updatePrivacyPolicyUrl(_ url: String) async { - await updateAppInfoLocalizationField("privacyPolicyUrl", value: url) - } - - /// Update a field on appInfoLocalizations (name, subtitle, privacyPolicyUrl) - func updateAppInfoLocalizationField(_ field: String, value: String) async { - guard let service else { return } - guard let locId = appInfoLocalization?.id else { return } - writeError = nil - // Map UI field names to API field names - let apiField = (field == "title") ? "name" : field - do { - try await service.patchAppInfoLocalization(id: locId, fields: [apiField: value]) - if let infoId = appInfo?.id { - appInfoLocalization = try? await service.fetchAppInfoLocalization(appInfoId: infoId) - } - } catch { - writeError = error.localizedDescription - } - } - - func updateAppInfoField(_ field: String, value: String) async { - guard let service else { return } - writeError = nil - - // Fields that live on different ASC resources: - // - copyright → appStoreVersions (PATCH /v1/appStoreVersions/{id}) - // - contentRightsDeclaration → apps (PATCH /v1/apps/{id}) - // - primaryCategory, subcategories → appInfos relationships (PATCH /v1/appInfos/{id}) - if field == "copyright" { - guard let versionId = appStoreVersions.first?.id else { return } - do { - try await service.patchVersion(id: versionId, fields: [field: value]) - // Re-fetch versions so submissionReadiness picks up the new copyright - if let appId = app?.id { - appStoreVersions = try await service.fetchAppStoreVersions(appId: appId) - } - } catch { - writeError = error.localizedDescription - } - } else if field == "contentRightsDeclaration" { - guard let appId = app?.id else { return } - do { - try await service.patchApp(id: appId, fields: [field: value]) - // Refetch app to reflect the change - app = try await service.fetchApp(bundleId: app?.bundleId ?? "") - } catch { - writeError = error.localizedDescription - } - } else if let infoId = appInfo?.id { - do { - try await service.patchAppInfo(id: infoId, fields: [field: value]) - appInfo = try? await service.fetchAppInfo(appId: app?.id ?? "") - } catch { - writeError = error.localizedDescription - } - } - } - - func updateAgeRating(_ attributes: [String: Any]) async { - guard let service else { return } - guard let id = ageRatingDeclaration?.id else { return } - writeError = nil - do { - try await service.patchAgeRating(id: id, attributes: attributes) - if let infoId = appInfo?.id { - ageRatingDeclaration = try? await service.fetchAgeRating(appInfoId: infoId) - } - } catch { - writeError = error.localizedDescription - } - } - - func updateReviewContact(_ attributes: [String: Any]) async { - guard let service else { return } - guard let versionId = appStoreVersions.first?.id else { return } - writeError = nil - do { - try await service.createOrPatchReviewDetail(versionId: versionId, attributes: attributes) - reviewDetail = try? await service.fetchReviewDetail(versionId: versionId) - } catch { - writeError = error.localizedDescription - } - } - - func setAppPrice(pricePointId: String) async { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - do { - try await service.setAppPrice(appId: appId, pricePointId: pricePointId) - try await service.ensureAppAvailability(appId: appId) - currentAppPricePointId = pricePointId - scheduledAppPricePointId = nil - scheduledAppPriceEffectiveDate = nil - monetizationStatus = isFreePricePoint(pricePointId) ? "Free" : "Configured" - } catch { - writeError = error.localizedDescription - } - } - - func setScheduledAppPrice(currentPricePointId: String, futurePricePointId: String, effectiveDate: String) async { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - do { - try await service.setScheduledAppPrice( - appId: appId, - currentPricePointId: currentPricePointId, - futurePricePointId: futurePricePointId, - effectiveDate: effectiveDate - ) - self.currentAppPricePointId = currentPricePointId - scheduledAppPricePointId = futurePricePointId - scheduledAppPriceEffectiveDate = effectiveDate - monetizationStatus = "Configured" - } catch { - writeError = error.localizedDescription - } - } - - func createIAP(name: String, productId: String, type: String, displayName: String, description: String?, price: String, screenshotPath: String? = nil) { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - isCreating = true - createProgress = 0 - createProgressMessage = "Creating in-app purchase…" - - createTask = Task { [weak self] in - guard let self else { return } - do { - createProgress = 0.05 - let iap = try await service.createInAppPurchase( - appId: appId, name: name, productId: productId, inAppPurchaseType: type - ) - - createProgressMessage = "Setting localization…" - createProgress = 0.15 - try await service.localizeInAppPurchase( - iapId: iap.id, locale: "en-US", name: displayName, description: description - ) - - createProgressMessage = "Setting availability…" - createProgress = 0.3 - let territories = try await service.fetchAllTerritories() - try await service.createIAPAvailability(iapId: iap.id, territoryIds: territories) - - createProgress = 0.5 - if !price.isEmpty, let priceVal = Double(price), priceVal > 0 { - createProgressMessage = "Setting price…" - let points = try await service.fetchInAppPurchasePricePoints(iapId: iap.id) - if let match = points.first(where: { - guard let cp = $0.attributes.customerPrice, let cpVal = Double(cp) else { return false } - return abs(cpVal - priceVal) < 0.001 - }) { - try await service.setInAppPurchasePrice(iapId: iap.id, pricePointId: match.id) - } - } - - createProgress = 0.7 - if let path = screenshotPath { - createProgressMessage = "Uploading screenshot…" - try await service.uploadIAPReviewScreenshot(iapId: iap.id, path: path) - } - - createProgressMessage = "Waiting for status update…" - createProgress = 0.9 - try await pollRefreshIAPs(service: service, appId: appId) - createProgress = 1.0 - } catch { - writeError = error.localizedDescription - } - isCreating = false - createProgress = 0 - createProgressMessage = "" - } - } - - func updateIAP(id: String, name: String?, reviewNote: String?, displayName: String?, description: String?) async { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - do { - // Patch IAP attributes (name, reviewNote) - var attrs: [String: Any] = [:] - if let name { attrs["name"] = name } - if let reviewNote { attrs["reviewNote"] = reviewNote } - if !attrs.isEmpty { - try await service.patchInAppPurchase(iapId: id, attrs: attrs) - } - // Patch localization (displayName, description) - if displayName != nil || description != nil { - let locs = try await service.fetchIAPLocalizations(iapId: id) - if let loc = locs.first { - var fields: [String: String] = [:] - if let displayName { fields["name"] = displayName } - if let description { fields["description"] = description } - try await service.patchIAPLocalization(locId: loc.id, fields: fields) - } - } - inAppPurchases = try await service.fetchInAppPurchases(appId: appId) - } catch { - writeError = error.localizedDescription - } - } - - func uploadIAPScreenshot(iapId: String, path: String) async { - guard let service else { return } - writeError = nil - do { - try await service.uploadIAPReviewScreenshot(iapId: iapId, path: path) - } catch { - writeError = error.localizedDescription - } - } - - func deleteIAP(id: String) async { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - do { - try await service.deleteInAppPurchase(iapId: id) - inAppPurchases = try await service.fetchInAppPurchases(appId: appId) - } catch { - writeError = error.localizedDescription - } - } - - func createSubscription(groupName: String, name: String, productId: String, displayName: String, description: String?, duration: String, price: String, screenshotPath: String? = nil) { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - isCreating = true - createProgress = 0 - createProgressMessage = "Setting up group…" - - createTask = Task { [weak self] in - guard let self else { return } - do { - createProgress = 0.03 - let group: ASCSubscriptionGroup - if let existing = subscriptionGroups.first(where: { $0.attributes.referenceName == groupName }) { - let groupLocs = try await service.fetchSubscriptionGroupLocalizations(groupId: existing.id) - if groupLocs.isEmpty { - try await service.localizeSubscriptionGroup(groupId: existing.id, locale: "en-US", name: groupName) - } - group = existing - } else { - group = try await service.createSubscriptionGroup(appId: appId, referenceName: groupName) - try await service.localizeSubscriptionGroup(groupId: group.id, locale: "en-US", name: groupName) - } - - createProgressMessage = "Creating subscription…" - createProgress = 0.08 - let sub = try await service.createSubscription( - groupId: group.id, name: name, productId: productId, subscriptionPeriod: duration - ) - - createProgressMessage = "Setting localization…" - createProgress = 0.12 - try await service.localizeSubscription( - subscriptionId: sub.id, locale: "en-US", name: displayName, description: description - ) - - createProgressMessage = "Setting availability…" - createProgress = 0.16 - let territories = try await service.fetchAllTerritories() - try await service.createSubscriptionAvailability(subscriptionId: sub.id, territoryIds: territories) - - createProgress = 0.2 - if !price.isEmpty, let priceVal = Double(price), priceVal > 0 { - let points = try await service.fetchSubscriptionPricePoints(subscriptionId: sub.id) - if let match = points.first(where: { - guard let cp = $0.attributes.customerPrice, let cpVal = Double(cp) else { return false } - return abs(cpVal - priceVal) < 0.001 - }) { - // Pricing loop: 0.2 → 0.8 (bulk of the time) - createProgressMessage = "Setting prices (0/175)…" - try await service.setSubscriptionPrice(subscriptionId: sub.id, pricePointId: match.id) { done, total in - Task { @MainActor [weak self] in - self?.createProgressMessage = "Setting prices (\(done)/\(total))…" - self?.createProgress = 0.2 + 0.6 * (Double(done) / Double(total)) - } - } - } - } - - createProgress = 0.85 - if let path = screenshotPath { - createProgressMessage = "Uploading screenshot…" - try await service.uploadSubscriptionReviewScreenshot(subscriptionId: sub.id, path: path) - } - - createProgressMessage = "Waiting for status update…" - createProgress = 0.9 - try await pollRefreshSubscriptions(service: service, appId: appId) - createProgress = 1.0 - } catch { - writeError = error.localizedDescription - } - isCreating = false - createProgress = 0 - createProgressMessage = "" - } - } - - func updateSubscription(id: String, name: String?, reviewNote: String?, displayName: String?, description: String?) async { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - do { - var attrs: [String: Any] = [:] - if let name { attrs["name"] = name } - if let reviewNote { attrs["reviewNote"] = reviewNote } - if !attrs.isEmpty { - try await service.patchSubscription(subscriptionId: id, attrs: attrs) - } - if displayName != nil || description != nil { - let locs = try await service.fetchSubscriptionLocalizations(subscriptionId: id) - if let loc = locs.first { - var fields: [String: String] = [:] - if let displayName { fields["name"] = displayName } - if let description { fields["description"] = description } - try await service.patchSubscriptionLocalization(locId: loc.id, fields: fields) - } - } - subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) - for g in subscriptionGroups { - subscriptionsPerGroup[g.id] = try await service.fetchSubscriptionsInGroup(groupId: g.id) - } - } catch { - writeError = error.localizedDescription - } - } - - func uploadSubscriptionScreenshot(subscriptionId: String, path: String) async { - guard let service else { return } - writeError = nil - do { - try await service.uploadSubscriptionReviewScreenshot(subscriptionId: subscriptionId, path: path) - } catch { - writeError = error.localizedDescription - } - } - - func updateSubscriptionGroupLocalization(groupId: String, name: String) async { - guard let service else { return } - writeError = nil - do { - let locs = try await service.fetchSubscriptionGroupLocalizations(groupId: groupId) - if let loc = locs.first { - try await service.patchSubscriptionGroupLocalization(locId: loc.id, name: name) - } else { - try await service.localizeSubscriptionGroup(groupId: groupId, locale: "en-US", name: name) - } - } catch { - writeError = error.localizedDescription - } - } - - func deleteSubscription(id: String) async { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - do { - try await service.deleteSubscription(subscriptionId: id) - subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) - for g in subscriptionGroups { - subscriptionsPerGroup[g.id] = try await service.fetchSubscriptionsInGroup(groupId: g.id) - } - } catch { - writeError = error.localizedDescription - } - } - - func deleteSubscriptionGroup(id: String) async { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - do { - try await service.deleteSubscriptionGroup(groupId: id) - subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) - subscriptionsPerGroup.removeValue(forKey: id) - } catch { - writeError = error.localizedDescription - } - } - - // MARK: - Post-Create Polling - - private func pollRefreshIAPs(service: AppStoreConnectService, appId: String) async throws { - for _ in 0..<5 { - try await Task.sleep(for: .seconds(1)) - inAppPurchases = try await service.fetchInAppPurchases(appId: appId) - let allResolved = inAppPurchases.allSatisfy { $0.attributes.state != "MISSING_METADATA" } - if allResolved { return } - } - } - - private func pollRefreshSubscriptions(service: AppStoreConnectService, appId: String) async throws { - for _ in 0..<5 { - try await Task.sleep(for: .seconds(1)) - subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) - for g in subscriptionGroups { - subscriptionsPerGroup[g.id] = try await service.fetchSubscriptionsInGroup(groupId: g.id) - } - let allResolved = subscriptionsPerGroup.values.joined().allSatisfy { $0.attributes.state != "MISSING_METADATA" } - if allResolved { return } - } - } - - // MARK: - Review Submissions - - /// Returns true on success, false on failure (writeError set). - /// Sets writeError to a message starting with "FIRST_SUBMISSION:" if the first-time restriction applies. - func submitIAPForReview(id: String) async -> Bool { - guard let service else { return false } - guard let appId = app?.id else { return false } - writeError = nil - do { - try await service.submitIAPForReview(iapId: id) - inAppPurchases = try await service.fetchInAppPurchases(appId: appId) - return true - } catch { - let msg = error.localizedDescription - if msg.contains("FIRST_IAP") || msg.contains("first In-App Purchase") || msg.contains("first in-app purchase") { - writeError = "FIRST_SUBMISSION:" + msg - } else { - writeError = msg - } - return false - } - } - - /// Returns true on success, false on failure (writeError set). - /// Sets writeError to a message starting with "FIRST_SUBMISSION:" if the first-time restriction applies. - func submitSubscriptionForReview(id: String) async -> Bool { - guard let service else { return false } - guard let appId = app?.id else { return false } - writeError = nil - do { - try await service.submitSubscriptionForReview(subscriptionId: id) - subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) - for g in subscriptionGroups { - subscriptionsPerGroup[g.id] = try await service.fetchSubscriptionsInGroup(groupId: g.id) - } - return true - } catch { - let msg = error.localizedDescription - if msg.contains("FIRST_SUBSCRIPTION") || msg.contains("first subscription") { - writeError = "FIRST_SUBMISSION:" + msg - } else { - writeError = msg - } - return false - } - } - - func refreshMonetization() async { - guard let service else { return } - guard let appId = app?.id else { return } - do { - inAppPurchases = try await service.fetchInAppPurchases(appId: appId) - subscriptionGroups = try await service.fetchSubscriptionGroups(appId: appId) - for group in subscriptionGroups { - subscriptionsPerGroup[group.id] = try await service.fetchSubscriptionsInGroup(groupId: group.id) - } - } catch { - writeError = error.localizedDescription - } - } - - func refreshAttachedSubmissionItemIDs() async { - guard let appId = app?.id else { - attachedSubmissionItemIDs = [] - return - } - guard let cookieHeader = ascWebSessionCookieHeader() else { - attachedSubmissionItemIDs = [] - return - } - - let subscriptionURL = "https://appstoreconnect.apple.com/iris/v1/apps/\(appId)/subscriptionGroups?include=subscriptions&limit=300&fields%5Bsubscriptions%5D=productId,name,state,submitWithNextAppStoreVersion" - let iapURL = "https://appstoreconnect.apple.com/iris/v1/apps/\(appId)/inAppPurchasesV2?limit=300&fields%5BinAppPurchases%5D=productId,name,state,submitWithNextAppStoreVersion" - - let attachedSubscriptions = await fetchAttachedSubmissionItemIDs(urlString: subscriptionURL, cookieHeader: cookieHeader) - let attachedIAPs = await fetchAttachedSubmissionItemIDs(urlString: iapURL, cookieHeader: cookieHeader) - attachedSubmissionItemIDs = attachedSubscriptions.union(attachedIAPs) - } - - private func fetchAttachedSubmissionItemIDs(urlString: String, cookieHeader: String) async -> Set { - guard let url = URL(string: urlString) else { return [] } - - var request = URLRequest(url: url) - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") - request.setValue("https://appstoreconnect.apple.com", forHTTPHeaderField: "Origin") - request.setValue("https://appstoreconnect.apple.com/", forHTTPHeaderField: "Referer") - request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - request.timeoutInterval = 10 - - guard let (data, response) = try? await URLSession.shared.data(for: request), - let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200, - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return [] - } - - let resources = (json["data"] as? [[String: Any]] ?? []) - + (json["included"] as? [[String: Any]] ?? []) - - return Set(resources.compactMap { item in - guard let attrs = item["attributes"] as? [String: Any], - let id = item["id"] as? String, - let submitWithNext = attrs["submitWithNextAppStoreVersion"] as? Bool, - submitWithNext else { return nil } - return id - }) - } - - private func ascWebSessionCookieHeader() -> String? { - guard let storeData = Self.readKeychainItem(service: "asc-web-session", account: "asc:web-session:store"), - let store = try? JSONSerialization.jsonObject(with: storeData) as? [String: Any], - let lastKey = store["last_key"] as? String, - let sessions = store["sessions"] as? [String: Any], - let sessionDict = sessions[lastKey] as? [String: Any], - let cookies = sessionDict["cookies"] as? [String: [[String: Any]]] else { - return nil - } - - let cookieHeader = cookies.values.flatMap { $0 }.compactMap { cookie -> String? in - guard let name = cookie["name"] as? String, - let value = cookie["value"] as? String, - !name.isEmpty else { return nil } - return name.hasPrefix("DES") ? "\(name)=\"\(value)\"" : "\(name)=\(value)" - }.joined(separator: "; ") - - return cookieHeader.isEmpty ? nil : cookieHeader - } - - private static func readKeychainItem(service: String, account: String) -> Data? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess else { return nil } - return result as? Data - } - - func setPriceFree() async { - guard let service else { return } - guard let appId = app?.id else { return } - writeError = nil - do { - try await service.setPriceFree(appId: appId) - try await service.ensureAppAvailability(appId: appId) - currentAppPricePointId = freeAppPricePointId - scheduledAppPricePointId = nil - scheduledAppPriceEffectiveDate = nil - monetizationStatus = "Free" - } catch { - writeError = error.localizedDescription - } - } - - var freeAppPricePointId: String? { - appPricePoints.first(where: { - let price = $0.attributes.customerPrice ?? "0" - return price == "0" || price == "0.0" || price == "0.00" - })?.id - } - - func applyAppPricingState(_ state: ASCAppPricingState) { - currentAppPricePointId = state.currentPricePointId - scheduledAppPricePointId = state.scheduledPricePointId - scheduledAppPriceEffectiveDate = state.scheduledEffectiveDate - - if let currentPricePointId = currentAppPricePointId { - let isCurrentlyFree = isFreePricePoint(currentPricePointId) - monetizationStatus = (isCurrentlyFree && state.scheduledPricePointId == nil) ? "Free" : "Configured" - } else if state.scheduledPricePointId != nil { - monetizationStatus = "Configured" - } else { - monetizationStatus = nil - } - } - - func isFreePricePoint(_ pricePointId: String) -> Bool { - appPricePoints.contains(where: { - guard $0.id == pricePointId else { return false } - let price = $0.attributes.customerPrice ?? "0" - return price == "0" || price == "0.0" || price == "0.00" - }) - } - - // MARK: - Screenshot Track - - func hasUnsavedChanges(displayType: String) -> Bool { - let current = trackSlots[displayType] ?? Array(repeating: nil, count: 10) - let saved = savedTrackState[displayType] ?? Array(repeating: nil, count: 10) - return zip(current, saved).contains { c, s in c?.id != s?.id } - } - - func loadTrackFromASC(displayType: String) { - let previousSlots = trackSlots[displayType] ?? [] - let set = screenshotSets.first { $0.attributes.screenshotDisplayType == displayType } - var slots: [TrackSlot?] = Array(repeating: nil, count: 10) - if let set, let shots = screenshots[set.id] { - for (i, shot) in shots.prefix(10).enumerated() { - // If ASC hasn't processed the image yet, carry forward the local preview - var localImage: NSImage? = nil - if shot.imageURL == nil, i < previousSlots.count, let prev = previousSlots[i] { - localImage = prev.localImage - } - slots[i] = TrackSlot( - id: shot.id, - localPath: nil, - localImage: localImage, - ascScreenshot: shot, - isFromASC: true - ) - } - } - trackSlots[displayType] = slots - savedTrackState[displayType] = slots - } - - func syncTrackToASC(displayType: String, locale: String) async { - guard let service else { writeError = "ASC service not configured"; return } - isSyncing = true - writeError = nil - - // Ensure localizations are loaded - if localizations.isEmpty, let versionId = appStoreVersions.first?.id { - localizations = (try? await service.fetchLocalizations(versionId: versionId)) ?? [] - } - if localizations.isEmpty, let appId = app?.id { - let versions = (try? await service.fetchAppStoreVersions(appId: appId)) ?? [] - appStoreVersions = versions - if let versionId = versions.first?.id { - localizations = (try? await service.fetchLocalizations(versionId: versionId)) ?? [] - } - } - guard let loc = localizations.first(where: { $0.attributes.locale == locale }) - ?? localizations.first else { - writeError = "No localizations found for locale '\(locale)'." - isSyncing = false - return - } - - let current = trackSlots[displayType] ?? Array(repeating: nil, count: 10) - let saved = savedTrackState[displayType] ?? Array(repeating: nil, count: 10) - - do { - // 1. Delete screenshots that were in saved state but not in current track - let savedIds = Set(saved.compactMap { $0?.id }) - let currentIds = Set(current.compactMap { $0?.id }) - let toDelete = savedIds.subtracting(currentIds) - for id in toDelete { - try await service.deleteScreenshot(screenshotId: id) - } - - // 2. Check if existing ASC screenshots need reorder - let currentASCIds = current.compactMap { slot -> String? in - guard let slot, slot.isFromASC else { return nil } - return slot.id - } - let savedASCIds = saved.compactMap { slot -> String? in - guard let slot, slot.isFromASC else { return nil } - return slot.id - } - let remainingASCIds = Set(currentASCIds) - let reorderNeeded = currentASCIds != savedASCIds.filter { remainingASCIds.contains($0) } - - if reorderNeeded { - // Delete remaining ASC screenshots and re-upload in new order - for id in currentASCIds { - if !toDelete.contains(id) { - try await service.deleteScreenshot(screenshotId: id) - } - } - } - - // 3. Upload local assets + re-upload reordered ASC screenshots - for slot in current { - guard let slot else { continue } - if let path = slot.localPath { - try await service.uploadScreenshot(localizationId: loc.id, path: path, displayType: displayType) - } else if reorderNeeded, slot.isFromASC, let ascShot = slot.ascScreenshot { - // For reordered ASC screenshots, we need the original file - // Download from ASC URL and re-upload - if let url = ascShot.imageURL, - let (data, _) = try? await URLSession.shared.data(from: url), - let fileName = ascShot.attributes.fileName { - let tmpPath = FileManager.default.temporaryDirectory.appendingPathComponent(fileName).path - try data.write(to: URL(fileURLWithPath: tmpPath)) - try await service.uploadScreenshot(localizationId: loc.id, path: tmpPath, displayType: displayType) - try? FileManager.default.removeItem(atPath: tmpPath) - } - } - } - - // 4. Reload from ASC - let sets = try await service.fetchScreenshotSets(localizationId: loc.id) - screenshotSets = sets - for set in sets { - screenshots[set.id] = try await service.fetchScreenshots(setId: set.id) - } - loadTrackFromASC(displayType: displayType) - } catch { - writeError = error.localizedDescription - } - - isSyncing = false - } - - func deleteScreenshot(screenshotId: String) async throws { - guard let service else { throw ASCError.notFound("ASC service not configured") } - try await service.deleteScreenshot(screenshotId: screenshotId) - } - - func scanLocalAssets(projectId: String) { - let dir = BlitzPaths.screenshots(projectId: projectId) - let fm = FileManager.default - guard let files = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { - localScreenshotAssets = [] - return - } - let imageExtensions: Set = ["png", "jpg", "jpeg", "webp"] - localScreenshotAssets = files - .filter { imageExtensions.contains($0.pathExtension.lowercased()) } - .sorted { $0.lastPathComponent < $1.lastPathComponent } - .compactMap { url in - // Try NSImage first, fall back to CGImageSource for WebP - var image = NSImage(contentsOf: url) - if image == nil || image!.representations.isEmpty { - if let source = CGImageSourceCreateWithURL(url as CFURL, nil), - let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) { - image = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) - } - } - guard let image else { return nil } - return LocalScreenshotAsset(id: UUID(), url: url, image: image, fileName: url.lastPathComponent) - } - } - - /// Validate pixel dimensions for a display type. Returns nil if valid, or an error string. - static func validateDimensions(width: Int, height: Int, displayType: String) -> String? { - switch displayType { - case "APP_IPHONE_67": - let validSizes: Set = ["1290x2796", "1284x2778", "1242x2688", "1260x2736"] - if validSizes.contains("\(width)x\(height)") { return nil } - return "\(width)\u{00d7}\(height) — need 1290\u{00d7}2796, 1284\u{00d7}2778, 1242\u{00d7}2688, or 1260\u{00d7}2736 for iPhone" - case "APP_IPAD_PRO_3GEN_129": - if width == 2048 && height == 2732 { return nil } - return "\(width)\u{00d7}\(height) — need 2048\u{00d7}2732 for iPad" - case "APP_DESKTOP": - let valid: Set = ["1280x800", "1440x900", "2560x1600", "2880x1800"] - if valid.contains("\(width)x\(height)") { return nil } - return "\(width)\u{00d7}\(height) — need 1280\u{00d7}800, 1440\u{00d7}900, 2560\u{00d7}1600, or 2880\u{00d7}1800 for Mac" - default: - return nil - } - } - - /// Add asset to track slot. Returns nil on success, or an error string on dimension mismatch. - @discardableResult - func addAssetToTrack(displayType: String, slotIndex: Int, localPath: String) -> String? { - guard slotIndex >= 0 && slotIndex < 10 else { return "Invalid slot index" } - - guard let image = NSImage(contentsOfFile: localPath) else { - return "Could not load image" - } - - // Validate dimensions - var pixelWidth = 0, pixelHeight = 0 - if let rep = image.representations.first, rep.pixelsWide > 0, rep.pixelsHigh > 0 { - pixelWidth = rep.pixelsWide - pixelHeight = rep.pixelsHigh - } else if let tiff = image.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiff) { - pixelWidth = bitmap.pixelsWide - pixelHeight = bitmap.pixelsHigh - } - - if let error = Self.validateDimensions(width: pixelWidth, height: pixelHeight, displayType: displayType) { - return error - } - - var slots = trackSlots[displayType] ?? Array(repeating: nil, count: 10) - let slot = TrackSlot( - id: UUID().uuidString, - localPath: localPath, - localImage: image, - ascScreenshot: nil, - isFromASC: false - ) - // If target slot occupied, shift right - if slots[slotIndex] != nil { - slots.insert(slot, at: slotIndex) - slots = Array(slots.prefix(10)) - } else { - slots[slotIndex] = slot - } - // Pad back to 10 - while slots.count < 10 { slots.append(nil) } - trackSlots[displayType] = slots - return nil - } - - func removeFromTrack(displayType: String, slotIndex: Int) { - guard slotIndex >= 0 && slotIndex < 10 else { return } - var slots = trackSlots[displayType] ?? Array(repeating: nil, count: 10) - slots.remove(at: slotIndex) - slots.append(nil) // maintain 10 elements - trackSlots[displayType] = slots - } - - func reorderTrack(displayType: String, fromIndex: Int, toIndex: Int) { - guard fromIndex >= 0 && fromIndex < 10 && toIndex >= 0 && toIndex < 10 else { return } - guard fromIndex != toIndex else { return } - var slots = trackSlots[displayType] ?? Array(repeating: nil, count: 10) - let item = slots.remove(at: fromIndex) - slots.insert(item, at: toIndex) - trackSlots[displayType] = slots - } - - /// The pending version ID (not live / not removed). - var pendingVersionId: String? { - appStoreVersions.first { - let s = $0.attributes.appStoreState ?? "" - return s != "READY_FOR_SALE" && s != "REMOVED_FROM_SALE" - && s != "DEVELOPER_REMOVED_FROM_SALE" && !s.isEmpty - }?.id ?? appStoreVersions.first?.id - } - - func attachBuild(buildId: String) async { - guard let service else { return } - guard let versionId = pendingVersionId else { - writeError = "No app store version found to attach build to." - return - } - writeError = nil - do { - try await service.attachBuild(versionId: versionId, buildId: buildId) - } catch { - writeError = error.localizedDescription - } - } - - func submitForReview(attachBuildId: String? = nil) async { - guard let service else { return } - guard let appId = app?.id, let versionId = pendingVersionId else { return } - isSubmitting = true - submissionError = nil - do { - // Attach build if specified - if let buildId = attachBuildId { - try await service.attachBuild(versionId: versionId, buildId: buildId) - } - try await service.submitForReview(appId: appId, versionId: versionId) - isSubmitting = false - await refreshTabData(.app) - } catch { - isSubmitting = false - submissionError = error.localizedDescription - } - } - - func flushPendingLocalizations() async { - guard let service else { return } - let appInfoLocFieldNames: Set = ["name", "title", "subtitle", "privacyPolicyUrl"] - for (tab, fields) in pendingFormValues { - if tab == "storeListing" { - var versionLocFields: [String: String] = [:] - var infoLocFields: [String: String] = [:] - for (field, value) in fields { - if appInfoLocFieldNames.contains(field) { - let apiField = (field == "title") ? "name" : field - infoLocFields[apiField] = value - } else { - versionLocFields[field] = value - } - } - if !versionLocFields.isEmpty, let locId = localizations.first?.id { - try? await service.patchLocalization(id: locId, fields: versionLocFields) - } - if !infoLocFields.isEmpty, let infoLocId = appInfoLocalization?.id { - try? await service.patchAppInfoLocalization(id: infoLocId, fields: infoLocFields) - } - } - } - pendingFormValues = [:] - } -} diff --git a/src/services/BuildPipelineService.swift b/src/services/BuildPipelineService.swift index b3f7d08..82364ea 100644 --- a/src/services/BuildPipelineService.swift +++ b/src/services/BuildPipelineService.swift @@ -360,6 +360,7 @@ actor BuildPipelineService { "-keypbe", "PBE-SHA1-3DES", "-macalg", "SHA1" ], timeout: 30) + try fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: p12Path) // Import to keychain @@ -398,7 +399,7 @@ actor BuildPipelineService { } /// Update only PRODUCT_BUNDLE_IDENTIFIER in the project's pbxproj. - /// Public so it can be called from MCPToolExecutor when the user changes bundle ID. + /// Public so it can be called from MCPExecutor when the user changes bundle ID. func updateBundleIdInPbxproj(projectPath: String, bundleId: String) { let projectURL = URL(fileURLWithPath: projectPath).resolvingSymlinksInPath() var searchDirs = [projectURL] diff --git a/src/services/MCPToolExecutor.swift b/src/services/MCPToolExecutor.swift deleted file mode 100644 index c6288bb..0000000 --- a/src/services/MCPToolExecutor.swift +++ /dev/null @@ -1,1938 +0,0 @@ -import Foundation -import AppKit -import Security - -/// Runs an async operation with a timeout. Throws CancellationError if the deadline is exceeded. -private func withThrowingTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { try await operation() } - group.addTask { - try await Task.sleep(for: .seconds(seconds)) - throw CancellationError() - } - guard let result = try await group.next() else { - throw CancellationError() - } - group.cancelAll() - return result - } -} - -/// Executes MCP tool calls against AppState. -/// Holds pending approval continuations for destructive operations. -actor MCPToolExecutor { - private let appState: AppState - private var pendingContinuations: [String: CheckedContinuation] = [:] - - init(appState: AppState) { - self.appState = appState - } - - /// Execute a tool call, requesting approval if needed - func execute(name: String, arguments: [String: Any]) async throws -> [String: Any] { - let category = MCPToolRegistry.category(for: name) - - // Pre-navigate for ASC form tools so the user sees the target tab before approving - var previousTab: AppTab? - if name == "asc_fill_form" || name == "asc_open_submit_preview" - || name == "asc_create_iap" || name == "asc_create_subscription" || name == "asc_set_app_price" - || name == "screenshots_add_asset" || name == "screenshots_set_track" || name == "screenshots_save" { - previousTab = await preNavigateASCTool(name: name, arguments: arguments) - } - - let request = ApprovalRequest( - id: UUID().uuidString, - toolName: name, - description: "Execute '\(name)'", - parameters: arguments.mapValues { "\($0)" }, - category: category - ) - - if request.requiresApproval(permissionToggles: await SettingsService.shared.permissionToggles) { - let approved = await requestApproval(request) - guard approved else { - // Navigate back if denied - if let prev = previousTab { - await MainActor.run { appState.activeTab = prev } - _ = await MainActor.run { appState.ascManager.pendingFormValues.removeAll() } - } - return mcpText("Tool '\(name)' was denied by the user.") - } - } - - return try await executeTool(name: name, arguments: arguments) - } - - /// Navigate to the appropriate tab before approval, and set pending form values. - /// Returns the previous tab so we can navigate back if denied. - private func preNavigateASCTool(name: String, arguments: [String: Any]) async -> AppTab? { - let previousTab = await MainActor.run { appState.activeTab } - - let targetTab: AppTab? - if name == "asc_fill_form" { - let tab = arguments["tab"] as? String ?? "" - switch tab { - case "storeListing": targetTab = .storeListing - case "appDetails": targetTab = .appDetails - case "monetization": targetTab = .monetization - case "review.ageRating", "review.contact": targetTab = .review - case "settings.bundleId": targetTab = .settings - default: targetTab = nil - } - } else if name == "asc_open_submit_preview" { - targetTab = .app - } else if name == "screenshots_add_asset" - || name == "screenshots_set_track" || name == "screenshots_save" { - targetTab = .screenshots - } else if name == "asc_set_app_price" { - targetTab = .monetization - } else if name == "asc_create_iap" || name == "asc_create_subscription" { - targetTab = .monetization - } else { - targetTab = nil - } - - if let targetTab { - await MainActor.run { appState.activeTab = targetTab } - // Ensure tab data is loaded - if targetTab.isASCTab { - await appState.ascManager.fetchTabData(targetTab) - } - } - - // For asc_fill_form, pre-populate pending values so the form shows intended changes - if name == "asc_fill_form", - let tab = arguments["tab"] as? String { - var fieldMap: [String: String] = [:] - if let fieldsArray = arguments["fields"] as? [[String: Any]] { - for item in fieldsArray { - if let field = item["field"] as? String, let value = item["value"] as? String { - fieldMap[field] = value - } - } - } else if let fieldsDict = arguments["fields"] as? [String: Any] { - for (key, value) in fieldsDict { - fieldMap[key] = "\(value)" - } - } else if let fieldsString = arguments["fields"] as? String, - let data = fieldsString.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data) { - if let dict = parsed as? [String: Any] { - for (key, value) in dict { - fieldMap[key] = "\(value)" - } - } else if let array = parsed as? [[String: Any]] { - for item in array { - if let field = item["field"] as? String, let value = item["value"] as? String { - fieldMap[field] = value - } - } - } - } - if !fieldMap.isEmpty { - let fieldMapCopy = fieldMap - await MainActor.run { - appState.ascManager.pendingFormValues[tab] = fieldMapCopy - appState.ascManager.pendingFormVersion += 1 - } - } - } - - return previousTab - } - - /// Resume a pending approval - nonisolated func resolveApproval(id: String, approved: Bool) { - Task { await _resolveApproval(id: id, approved: approved) } - } - - private func _resolveApproval(id: String, approved: Bool) { - guard let continuation = pendingContinuations.removeValue(forKey: id) else { return } - continuation.resume(returning: approved) - } - - // MARK: - Approval Flow - - private func requestApproval(_ request: ApprovalRequest) async -> Bool { - // Show alert on main thread and bring Blitz to front so user sees it - await MainActor.run { - appState.pendingApproval = request - appState.showApprovalAlert = true - NSApp.activate(ignoringOtherApps: true) - } - - // Suspend until user approves/denies or timeout - let approved = await withCheckedContinuation { (continuation: CheckedContinuation) in - pendingContinuations[request.id] = continuation - - // 5-minute auto-deny timeout - Task { - try? await Task.sleep(for: .seconds(300)) - if pendingContinuations[request.id] != nil { - _resolveApproval(id: request.id, approved: false) - } - } - } - - // Clear alert - await MainActor.run { - appState.pendingApproval = nil - appState.showApprovalAlert = false - } - - return approved - } - - // MARK: - Tool Execution - - private func executeTool(name: String, arguments: [String: Any]) async throws -> [String: Any] { - switch name { - // -- App State -- - case "app_get_state": - return try await executeAppGetState() - - // -- Navigation -- - case "nav_switch_tab": - return try await executeNavSwitchTab(arguments) - case "nav_list_tabs": - return await executeNavListTabs() - - // -- Projects -- - case "project_list": - return await executeProjectList() - case "project_get_active": - return await executeProjectGetActive() - case "project_open": - return try await executeProjectOpen(arguments) - case "project_create": - return try await executeProjectCreate(arguments) - case "project_import": - return try await executeProjectImport(arguments) - case "project_close": - return await executeProjectClose() - - // -- Simulator -- - case "simulator_list_devices": - return await executeSimulatorListDevices() - case "simulator_select_device": - return try await executeSimulatorSelectDevice(arguments) - - // -- Settings -- - case "settings_get": - return await executeSettingsGet() - case "settings_update": - return await executeSettingsUpdate(arguments) - case "settings_save": - return await executeSettingsSave() - - - - // -- Rejection Feedback -- - case "get_rejection_feedback": - return try await executeGetRejectionFeedback(arguments) - - // -- Tab State -- - case "get_tab_state": - return try await executeGetTabState(arguments) - - // -- ASC Credentials -- - case "asc_set_credentials": - return await executeASCSetCredentials(arguments) - - // -- ASC Form Tools -- - case "asc_fill_form": - return try await executeASCFillForm(arguments) - case "screenshots_add_asset": - return try await executeScreenshotsAddAsset(arguments) - case "screenshots_set_track": - return try await executeScreenshotsSetTrack(arguments) - case "screenshots_save": - return try await executeScreenshotsSave(arguments) - case "asc_open_submit_preview": - return await executeASCOpenSubmitPreview() - case "asc_create_iap": - return try await executeASCCreateIAP(arguments) - case "asc_create_subscription": - return try await executeASCCreateSubscription(arguments) - case "asc_set_app_price": - return try await executeASCSetAppPrice(arguments) - case "asc_web_auth": - return await executeASCWebAuth() - - // -- Build Pipeline -- - case "app_store_setup_signing": - return try await executeSetupSigning(arguments) - case "app_store_build": - return try await executeBuildIPA(arguments) - case "app_store_upload": - return try await executeUploadToTestFlight(arguments) - - case "get_blitz_screenshot": - let path = "/tmp/blitz-app-screenshot-\(Int(Date().timeIntervalSince1970)).png" - let saved = await MainActor.run { () -> Bool in - guard let window = NSApp.windows.first(where: { $0.title != "Welcome to Blitz" && $0.canBecomeMain && $0.isVisible }) ?? NSApp.mainWindow else { - return false - } - let windowId = CGWindowID(window.windowNumber) - guard let cgImage = CGWindowListCreateImage( - .null, - .optionIncludingWindow, - windowId, - [.boundsIgnoreFraming, .bestResolution] - ) else { - return false - } - let bitmap = NSBitmapImageRep(cgImage: cgImage) - guard let png = bitmap.representation(using: .png, properties: [:]) else { - return false - } - return ((try? png.write(to: URL(fileURLWithPath: path))) != nil) - } - if saved { - return mcpText(path) - } else { - return mcpText("Error: could not capture Blitz window screenshot") - } - - default: - throw MCPServerService.MCPError.unknownTool(name) - } - } - - // MARK: - App State Tools - - private func executeAppGetState() async throws -> [String: Any] { - let state = await MainActor.run { () -> [String: Any] in - var result: [String: Any] = [ - "activeTab": appState.activeTab.rawValue, - "activeAppSubTab": appState.activeAppSubTab.rawValue, - "isStreaming": appState.simulatorStream.isCapturing - ] - if let project = appState.activeProject { - result["activeProject"] = [ - "id": project.id, - "name": project.name, - "path": project.path, - "type": project.type.rawValue - ] - } - if let udid = appState.simulatorManager.bootedDeviceId { - result["bootedSimulator"] = udid - } - // Expose Teenybase DB URL so AI agents can curl it directly - let db = appState.databaseManager - if db.connectionStatus == .connected || db.backendProcess.isRunning { - result["database"] = [ - "url": db.backendProcess.baseURL, - "status": db.connectionStatus == .connected ? "connected" : "running" - ] - } - return result - } - return mcpJSON(state) - } - - // MARK: - Navigation Tools - - private func executeNavSwitchTab(_ args: [String: Any]) async throws -> [String: Any] { - guard let tabStr = args["tab"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - - // Map legacy tab names to new App sub-tabs - let legacySubTabMap: [String: AppSubTab] = [ - "simulator": .simulator, - "database": .database, - "tests": .tests, - "assets": .icon, - "icon": .icon, - "ascOverview": .overview, - "overview": .overview, - ] - - if let subTab = legacySubTabMap[tabStr] { - await MainActor.run { - appState.activeTab = .app - appState.activeAppSubTab = subTab - } - - // Auto-connect database when switching to database sub-tab - if subTab == .database { - let status = await MainActor.run { appState.databaseManager.connectionStatus } - if status != .connected, let project = await MainActor.run(body: { appState.activeProject }) { - await appState.databaseManager.startAndConnect(projectId: project.id, projectPath: project.path) - } - } - - return mcpText("Switched to App > \(subTab.label)") - } - - guard let tab = AppTab(rawValue: tabStr) else { - throw MCPServerService.MCPError.invalidToolArgs - } - await MainActor.run { appState.activeTab = tab } - - return mcpText("Switched to tab: \(tab.label)") - } - - private func executeNavListTabs() async -> [String: Any] { - // Top-level standalone tabs - let topLevel: [[String: Any]] = [ - ["name": "dashboard", "label": "Dashboard", "icon": "square.grid.2x2"], - ["name": "app", "label": "App", "icon": "app", - "subTabs": AppSubTab.allCases.map { ["name": $0.rawValue, "label": $0.label, "icon": $0.systemImage] as [String: Any] }], - ] - var groups: [[String: Any]] = [["group": "Top", "tabs": topLevel]] - for group in AppTab.Group.allCases { - let tabs = group.tabs.map { ["name": $0.rawValue, "label": $0.label, "icon": $0.icon] as [String: Any] } - groups.append(["group": group.rawValue, "tabs": tabs]) - } - groups.append(["group": "Other", "tabs": [["name": "settings", "label": "Settings", "icon": "gear"]]]) - return mcpJSON(["groups": groups]) - } - - // MARK: - Project Tools - - private func executeProjectList() async -> [String: Any] { - await appState.projectManager.loadProjects() - let projects = await MainActor.run { - appState.projectManager.projects.map { p -> [String: Any] in - ["id": p.id, "name": p.name, "path": p.path, "type": p.type.rawValue] - } - } - return mcpJSON(["projects": projects]) - } - - private func executeProjectGetActive() async -> [String: Any] { - let result = await MainActor.run { () -> [String: Any]? in - guard let project = appState.activeProject else { return nil } - return ["id": project.id, "name": project.name, "path": project.path, "type": project.type.rawValue] - } - if let result { - return mcpJSON(result) - } - return mcpText("No active project") - } - - private func executeProjectOpen(_ args: [String: Any]) async throws -> [String: Any] { - guard let projectId = args["projectId"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - let storage = ProjectStorage() - storage.updateLastOpened(projectId: projectId) - await MainActor.run { appState.activeProjectId = projectId } - await appState.projectManager.loadProjects() - return mcpText("Opened project: \(projectId)") - } - - private func executeProjectCreate(_ args: [String: Any]) async throws -> [String: Any] { - guard let name = args["name"] as? String, - let typeStr = args["type"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - - let storage = ProjectStorage() - let projectId = name.lowercased() - .replacingOccurrences(of: " ", with: "-") - .filter { $0.isLetter || $0.isNumber || $0 == "-" } - let projectDir = storage.baseDirectory.appendingPathComponent(projectId) - - try FileManager.default.createDirectory(at: projectDir, withIntermediateDirectories: true) - - let projectType = ProjectType(rawValue: typeStr) ?? .reactNative - let platformStr = args["platform"] as? String - let platform = ProjectPlatform(rawValue: platformStr ?? "iOS") ?? .iOS - let metadata = BlitzProjectMetadata( - name: name, - type: projectType, - platform: platform, - createdAt: Date(), - lastOpenedAt: Date() - ) - try storage.writeMetadata(projectId: projectId, metadata: metadata) - let (whitelistBlitzMCP, allowASCCLICalls) = await MainActor.run { - ( - SettingsService.shared.whitelistBlitzMCPTools, - SettingsService.shared.allowASCCLICalls - ) - } - storage.ensureMCPConfig( - projectId: projectId, - whitelistBlitzMCP: whitelistBlitzMCP, - allowASCCLICalls: allowASCCLICalls - ) - await appState.projectManager.loadProjects() - - // Set pending setup so ContentView triggers template scaffolding - await MainActor.run { - appState.projectSetup.pendingSetupProjectId = projectId - appState.activeProjectId = projectId - } - - // Wait for setup to complete (ContentView picks up pendingSetupProjectId). - // If the main window isn't open (WelcomeWindow's onChange should open it), - // fall back to running setup directly. - try? await Task.sleep(for: .seconds(2)) - let setupStarted = await MainActor.run { appState.projectSetup.isSettingUp } - if !setupStarted { - // ContentView didn't pick it up — run setup directly - guard let project = await MainActor.run(body: { appState.activeProject }) else { - return mcpText("Created project '\(name)' but could not start setup (project not found)") - } - await appState.projectSetup.setup( - projectId: project.id, - projectName: project.name, - projectPath: project.path, - projectType: project.type, - platform: project.platform - ) - } else { - // Wait for setup to finish (up to 3 min) - for _ in 0..<180 { - let done = await MainActor.run { !appState.projectSetup.isSettingUp } - if done { break } - try? await Task.sleep(for: .seconds(1)) - } - } - - let errorMsg = await MainActor.run { appState.projectSetup.errorMessage } - if let errorMsg { - return mcpText("Created project '\(name)' but setup failed: \(errorMsg)") - } - return mcpText("Created project '\(name)' (type: \(typeStr), id: \(projectId)) — setup complete") - } - - private func executeProjectImport(_ args: [String: Any]) async throws -> [String: Any] { - guard let path = args["path"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - - let url = URL(fileURLWithPath: path) - let storage = ProjectStorage() - let projectId = try storage.openProject(at: url) - let (whitelistBlitzMCP, allowASCCLICalls) = await MainActor.run { - ( - SettingsService.shared.whitelistBlitzMCPTools, - SettingsService.shared.allowASCCLICalls - ) - } - storage.ensureMCPConfig( - projectId: projectId, - whitelistBlitzMCP: whitelistBlitzMCP, - allowASCCLICalls: allowASCCLICalls - ) - await appState.projectManager.loadProjects() - await MainActor.run { appState.activeProjectId = projectId } - - return mcpText("Imported project from '\(path)' (id: \(projectId))") - } - - private func executeProjectClose() async -> [String: Any] { - await MainActor.run { appState.activeProjectId = nil } - return mcpText("Project closed") - } - - // MARK: - Simulator Tools - - private func executeSimulatorListDevices() async -> [String: Any] { - await appState.simulatorManager.loadSimulators() - let devices = await MainActor.run { - appState.simulatorManager.simulators.map { sim -> [String: Any] in - [ - "udid": sim.udid, - "name": sim.name, - "state": sim.state, - "isBooted": sim.isBooted - ] - } - } - return mcpJSON(["devices": devices]) - } - - private func executeSimulatorSelectDevice(_ args: [String: Any]) async throws -> [String: Any] { - guard let udid = args["udid"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - - let service = SimulatorService() - try await service.boot(udid: udid) - await MainActor.run { appState.simulatorManager.bootedDeviceId = udid } - await appState.simulatorManager.loadSimulators() - - return mcpText("Booted simulator: \(udid)") - } - - - // MARK: - Settings Tools - - private func executeSettingsGet() async -> [String: Any] { - let settings = await MainActor.run { () -> [String: Any] in - [ - "showCursor": appState.settingsStore.showCursor, - "cursorSize": appState.settingsStore.cursorSize, - "defaultSimulatorUDID": appState.settingsStore.defaultSimulatorUDID ?? "" - ] - } - return mcpJSON(settings) - } - - private func executeSettingsUpdate(_ args: [String: Any]) async -> [String: Any] { - await MainActor.run { - if let cursor = args["showCursor"] as? Bool { appState.settingsStore.showCursor = cursor } - if let size = args["cursorSize"] as? Double { appState.settingsStore.cursorSize = size } - } - return mcpText("Settings updated") - } - - private func executeSettingsSave() async -> [String: Any] { - await MainActor.run { appState.settingsStore.save() } - return mcpText("Settings saved to disk") - } - - // MARK: - ASC Form Tools - - // Valid field names per tab — rejects unknown fields before API roundtrip - private static let validFieldsByTab: [String: Set] = [ - "storeListing": ["title", "name", "subtitle", "description", "keywords", "promotionalText", - "marketingUrl", "supportUrl", "whatsNew", "privacyPolicyUrl"], - "appDetails": ["copyright", "primaryCategory", "contentRightsDeclaration"], - "monetization": ["isFree"], - "review.ageRating": ["gambling", "messagingAndChat", "unrestrictedWebAccess", - "userGeneratedContent", "advertising", "lootBox", - "healthOrWellnessTopics", "parentalControls", "ageAssurance", - "alcoholTobaccoOrDrugUseOrReferences", "contests", "gamblingSimulated", - "gunsOrOtherWeapons", "horrorOrFearThemes", "matureOrSuggestiveThemes", - "medicalOrTreatmentInformation", "profanityOrCrudeHumor", - "sexualContentGraphicAndNudity", "sexualContentOrNudity", - "violenceCartoonOrFantasy", "violenceRealistic", - "violenceRealisticProlongedGraphicOrSadistic"], - "review.contact": ["contactFirstName", "contactLastName", "contactEmail", "contactPhone", - "notes", "demoAccountRequired", "demoAccountName", "demoAccountPassword"], - "settings.bundleId": ["bundleId"], - ] - - // Common aliases: user-friendly field names → API field names (per tab) - private static let fieldAliases: [String: String] = [ - "firstName": "contactFirstName", - "lastName": "contactLastName", - "email": "contactEmail", - "phone": "contactPhone", - ] - - private func executeASCSetCredentials(_ args: [String: Any]) async -> [String: Any] { - guard let issuerId = args["issuerId"] as? String, - let keyId = args["keyId"] as? String, - let rawPath = args["privateKeyPath"] as? String else { - return mcpText("Error: issuerId, keyId, and privateKeyPath are required.") - } - - let path = NSString(string: rawPath).expandingTildeInPath - guard FileManager.default.fileExists(atPath: path), - let privateKey = try? String(contentsOfFile: path, encoding: .utf8), - !privateKey.isEmpty else { - return mcpText("Error: could not read private key file at \(rawPath)") - } - - await MainActor.run { - appState.ascManager.pendingCredentialValues = [ - "issuerId": issuerId, - "keyId": keyId, - "privateKey": privateKey, - "privateKeyFileName": URL(fileURLWithPath: path).lastPathComponent - ] - } - return mcpText("Credentials pre-filled. The user can verify and click 'Save Credentials'.") - } - - private func executeASCFillForm(_ args: [String: Any]) async throws -> [String: Any] { - guard let tab = args["tab"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - - // Build field map with alias resolution — accept multiple formats: - // 1. Array of {field, value} objects: [{"field":"k","value":"v"}, ...] - // 2. Flat dict: {"key": "value", ...} - // 3. JSON string containing either format above - var fieldMap: [String: String] = [:] - if let fieldsArray = args["fields"] as? [[String: Any]] { - for item in fieldsArray { - if let field = item["field"] as? String, let value = item["value"] as? String { - let resolved = Self.fieldAliases[field] ?? field - fieldMap[resolved] = value - } - } - } else if let fieldsDict = args["fields"] as? [String: Any] { - for (key, value) in fieldsDict { - let resolved = Self.fieldAliases[key] ?? key - fieldMap[resolved] = "\(value)" - } - } else if let fieldsString = args["fields"] as? String, - let data = fieldsString.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data) { - if let dict = parsed as? [String: Any] { - for (key, value) in dict { - let resolved = Self.fieldAliases[key] ?? key - fieldMap[resolved] = "\(value)" - } - } else if let array = parsed as? [[String: Any]] { - for item in array { - if let field = item["field"] as? String, let value = item["value"] as? String { - let resolved = Self.fieldAliases[field] ?? field - fieldMap[resolved] = value - } - } - } - } - - guard !fieldMap.isEmpty else { - throw MCPServerService.MCPError.invalidToolArgs - } - - // Validate field names against allowed set for this tab - if let validFields = Self.validFieldsByTab[tab] { - let invalid = fieldMap.keys.filter { !validFields.contains($0) } - if !invalid.isEmpty { - // Check if the field belongs to a different tab - var hints: [String] = [] - for field in invalid { - for (otherTab, otherFields) in Self.validFieldsByTab where otherTab != tab { - if otherFields.contains(field) { - hints.append("'\(field)' belongs on tab '\(otherTab)'") - } - } - } - let hintStr = hints.isEmpty ? "" : " Hint: \(hints.joined(separator: "; "))." - return mcpText("Error: invalid field(s) for tab '\(tab)': \(invalid.sorted().joined(separator: ", ")). Valid fields: \(validFields.sorted().joined(separator: ", ")).\(hintStr)") - } - } - - // Navigation + pending values already set by preNavigateASCTool in execute() - - // Execute the write based on tab - switch tab { - case "storeListing": - // Fields are split across two ASC resources: - // - appInfoLocalizations: name (title), subtitle, privacyPolicyUrl - // - appStoreVersionLocalizations: description, keywords, whatsNew, marketingUrl, supportUrl, promotionalText - let appInfoLocFields: Set = ["name", "title", "subtitle", "privacyPolicyUrl"] - var versionLocFields: [String: String] = [:] - var infoLocFields: [String: String] = [:] - - for (field, value) in fieldMap { - if appInfoLocFields.contains(field) { - // Map "title" to "name" for the API - let apiField = (field == "title") ? "name" : field - infoLocFields[apiField] = value - } else { - versionLocFields[field] = value - } - } - - // Save appInfoLocalization fields (name, subtitle, privacyPolicyUrl) - if !infoLocFields.isEmpty { - for (field, value) in infoLocFields { - await appState.ascManager.updateAppInfoLocalizationField(field, value: value) - } - if let err = await checkASCWriteError(tab: tab) { return err } - } - - // Save version localization fields (description, keywords, etc.) - if !versionLocFields.isEmpty { - guard let locId = await MainActor.run(body: { appState.ascManager.localizations.first?.id }) else { - return mcpText("Error: no version localizations found.") - } - do { - guard let service = await MainActor.run(body: { appState.ascManager.service }) else { - return mcpText("Error: ASC service not configured") - } - try await service.patchLocalization(id: locId, fields: versionLocFields) - if let versionId = await MainActor.run(body: { appState.ascManager.appStoreVersions.first?.id }) { - let locs = try await service.fetchLocalizations(versionId: versionId) - await MainActor.run { appState.ascManager.localizations = locs } - } - } catch { - _ = await MainActor.run { appState.ascManager.pendingFormValues.removeValue(forKey: tab) } - return mcpText("Error: \(error.localizedDescription)") - } - } - - case "appDetails": - for (field, value) in fieldMap { - await appState.ascManager.updateAppInfoField(field, value: value) - } - if let err = await checkASCWriteError(tab: tab) { return err } - - case "monetization": - guard let isFree = fieldMap["isFree"] else { - return mcpText("Error: monetization tab requires the 'isFree' field (value: \"true\" or \"false\").") - } - if isFree == "true" { - await appState.ascManager.setPriceFree() - } else { - // Paid pricing — use asc_set_app_price tool instead - return mcpText("To set a paid price, use the asc_set_app_price tool with a price parameter (e.g. price=\"0.99\").") - } - if let err = await checkASCWriteError(tab: tab) { return err } - - case "review.ageRating": - var attrs: [String: Any] = [:] - let boolFields = Set(["gambling", "messagingAndChat", "unrestrictedWebAccess", - "userGeneratedContent", "advertising", "lootBox", - "healthOrWellnessTopics", "parentalControls", "ageAssurance"]) - for (field, value) in fieldMap { - if boolFields.contains(field) { - attrs[field] = value == "true" - } else { - attrs[field] = value - } - } - await appState.ascManager.updateAgeRating(attrs) - if let err = await checkASCWriteError(tab: tab) { return err } - - case "review.contact": - var attrs: [String: Any] = [:] - for (field, value) in fieldMap { - if field == "demoAccountRequired" { - attrs[field] = value == "true" - } else if field == "contactPhone" { - // ASC requires phone as + — strip dashes, spaces, parens - let stripped = value.hasPrefix("+") - ? "+" + value.dropFirst().filter(\.isNumber) - : value.filter(\.isNumber) - attrs[field] = stripped - } else { - attrs[field] = value - } - } - await appState.ascManager.updateReviewContact(attrs) - if let err = await checkASCWriteError(tab: tab) { return err } - - case "settings.bundleId": - if let bundleId = fieldMap["bundleId"] { - let projectPath = await MainActor.run { appState.activeProject?.path } - await MainActor.run { - guard let projectId = appState.activeProjectId else { return } - let storage = ProjectStorage() - guard var metadata = storage.readMetadata(projectId: projectId) else { return } - metadata.bundleIdentifier = bundleId - try? storage.writeMetadata(projectId: projectId, metadata: metadata) - } - // Also update PRODUCT_BUNDLE_IDENTIFIER in pbxproj - if let projectPath { - let pipeline = BuildPipelineService() - await pipeline.updateBundleIdInPbxproj(projectPath: projectPath, bundleId: bundleId) - } - await appState.projectManager.loadProjects() - let hasCreds = await MainActor.run { appState.ascManager.credentials != nil } - if hasCreds { - await appState.ascManager.fetchApp(bundleId: bundleId) - } - } - - default: - return mcpText("Unknown tab: \(tab)") - } - - // Clear pending values - _ = await MainActor.run { appState.ascManager.pendingFormValues.removeValue(forKey: tab) } - - return mcpJSON(["success": true, "tab": tab, "fieldsUpdated": fieldMap.count]) - } - - private func executeScreenshotsAddAsset(_ args: [String: Any]) async throws -> [String: Any] { - guard let sourcePath = args["sourcePath"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - let expanded = (sourcePath as NSString).expandingTildeInPath - guard FileManager.default.fileExists(atPath: expanded) else { - return mcpText("Error: file not found at \(expanded)") - } - - guard let projectId = await MainActor.run(body: { appState.activeProjectId }) else { - return mcpText("Error: no active project") - } - - let destDir = BlitzPaths.screenshots(projectId: projectId) - let fm = FileManager.default - try? fm.createDirectory(at: destDir, withIntermediateDirectories: true) - - let fileName = args["fileName"] as? String ?? (expanded as NSString).lastPathComponent - let dest = destDir.appendingPathComponent(fileName) - - do { - if fm.fileExists(atPath: dest.path) { - try fm.removeItem(at: dest) - } - try fm.copyItem(atPath: expanded, toPath: dest.path) - } catch { - return mcpText("Error copying file: \(error.localizedDescription)") - } - - await MainActor.run { appState.ascManager.scanLocalAssets(projectId: projectId) } - return mcpJSON(["success": true, "fileName": fileName]) - } - - private func executeScreenshotsSetTrack(_ args: [String: Any]) async throws -> [String: Any] { - guard let assetFileName = args["assetFileName"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - guard let slotRaw = args["slotIndex"] as? Int ?? (args["slotIndex"] as? Double).map({ Int($0) }), - slotRaw >= 1 && slotRaw <= 10 else { - return mcpText("Error: slotIndex must be between 1 and 10") - } - let slotIndex = slotRaw - 1 // Convert to 0-based - let displayType = args["displayType"] as? String ?? "APP_IPHONE_67" - - guard let projectId = await MainActor.run(body: { appState.activeProjectId }) else { - return mcpText("Error: no active project") - } - - let dir = BlitzPaths.screenshots(projectId: projectId) - let filePath = dir.appendingPathComponent(assetFileName).path - - guard FileManager.default.fileExists(atPath: filePath) else { - return mcpText("Error: asset '\(assetFileName)' not found in local screenshots library") - } - - let error = await MainActor.run { - appState.ascManager.addAssetToTrack(displayType: displayType, slotIndex: slotIndex, localPath: filePath) - } - if let error { - return mcpText("Error: \(error)") - } - return mcpJSON(["success": true, "slot": slotRaw]) - } - - private func executeScreenshotsSave(_ args: [String: Any]) async throws -> [String: Any] { - let displayType = args["displayType"] as? String ?? "APP_IPHONE_67" - let locale = args["locale"] as? String ?? "en-US" - - let hasChanges = await MainActor.run { appState.ascManager.hasUnsavedChanges(displayType: displayType) } - guard hasChanges else { - return mcpJSON(["success": true, "message": "No changes to save"]) - } - - await appState.ascManager.syncTrackToASC(displayType: displayType, locale: locale) - - if let err = await checkASCWriteError(tab: "screenshots") { return err } - - let slotCount = await MainActor.run { - (appState.ascManager.trackSlots[displayType] ?? []).compactMap { $0 }.count - } - return mcpJSON(["success": true, "synced": slotCount]) - } - - private func executeASCOpenSubmitPreview() async -> [String: Any] { - // Navigation already done by preNavigateASCTool - - // Refresh IAP/subscription data so readiness reflects latest App Store Connect and iris API state - await appState.ascManager.refreshSubmissionReadinessData() - - var readiness = await MainActor.run { appState.ascManager.submissionReadiness } - - // If Build is the only (or one of the) missing fields, try to auto-attach - let buildMissing = readiness.missingRequired.contains { $0.label == "Build" } - if buildMissing { - // Refresh builds list from ASC - let service = await MainActor.run { appState.ascManager.service } - let appId = await MainActor.run { appState.ascManager.app?.id } - if let service, let appId { - // Fetch latest builds - if let latestBuild = try? await service.fetchLatestBuild(appId: appId), - latestBuild.attributes.processingState == "VALID" { - // Find the pending version to attach to - let versionId = await MainActor.run { () -> String? in - appState.ascManager.appStoreVersions.first { - let s = $0.attributes.appStoreState ?? "" - return s != "READY_FOR_SALE" && s != "REMOVED_FROM_SALE" - && s != "DEVELOPER_REMOVED_FROM_SALE" && !s.isEmpty - }?.id ?? appState.ascManager.appStoreVersions.first?.id - } - if let versionId { - do { - try await service.attachBuild(versionId: versionId, buildId: latestBuild.id) - // Refresh data so readiness reflects the attached build - await appState.ascManager.refreshTabData(.app) - readiness = await MainActor.run { appState.ascManager.submissionReadiness } - } catch { - // Non-fatal: report in missing fields - } - } - } - } - } - - if !readiness.isComplete { - let missing = readiness.missingRequired.map { $0.label } - return mcpJSON(["ready": false, "missing": missing]) - } - - await MainActor.run { - appState.ascManager.showSubmitPreview = true - } - - return mcpJSON(["ready": true, "opened": true]) - } - - // MARK: - ASC IAP / Subscriptions / Pricing Tools - - /// Fuzzy price match: "0.99" matches "0.990", "0.99", etc. - private static func priceMatches(_ customerPrice: String?, target: String) -> Bool { - guard let customerPrice else { return false } - guard let a = Double(customerPrice), let b = Double(target) else { - return customerPrice == target - } - return abs(a - b) < 0.001 - } - - private func executeASCWebAuth() async -> [String: Any] { - await MainActor.run { - NSApp.activate(ignoringOtherApps: true) - } - - guard let session = await appState.ascManager.requestWebAuthForMCP() else { - let authError = await MainActor.run { appState.ascManager.irisFeedbackError } - if let authError, !authError.isEmpty { - return mcpJSON([ - "success": false, - "cancelled": false, - "message": authError - ]) - } - return mcpJSON([ - "success": false, - "cancelled": true, - "message": "Web authentication was cancelled before a session was captured." - ]) - } - - // setIrisSession (called by the login sheet callback) already saves the - // native keychain session and syncs ~/.blitz/asc-agent/web-session.json. - let email = session.email ?? "unknown" - return mcpJSON([ - "success": true, - "email": email, - "message": "Web session authenticated and synced to ~/.blitz/asc-agent/web-session.json. The asc-iap-attach skill can now use the iris API." - ]) - } - - private func executeASCSetAppPrice(_ args: [String: Any]) async throws -> [String: Any] { - guard let priceStr = args["price"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - let effectiveDate = args["effectiveDate"] as? String // optional: ISO date like "2026-06-01" - - guard let service = await MainActor.run(body: { appState.ascManager.service }) else { - return mcpText("Error: ASC service not configured") - } - guard let appId = await MainActor.run(body: { appState.ascManager.app?.id }) else { - return mcpText("Error: no ASC app loaded. Open a project with a bundle ID first.") - } - - // If price is "0" or "0.00", use the existing setPriceFree method - if let priceVal = Double(priceStr), priceVal < 0.001 { - try await service.setPriceFree(appId: appId) - try await service.ensureAppAvailability(appId: appId) - await MainActor.run { - appState.ascManager.currentAppPricePointId = appState.ascManager.freeAppPricePointId - appState.ascManager.scheduledAppPricePointId = nil - appState.ascManager.scheduledAppPriceEffectiveDate = nil - appState.ascManager.monetizationStatus = "Free" - } - await appState.ascManager.refreshTabData(.monetization) - return mcpJSON(["success": true, "price": "0.00", "message": "App set to free with territory availability configured"]) - } - - // Fetch price points and find matching one - let pricePoints = try await service.fetchAppPricePoints(appId: appId) - guard let match = pricePoints.first(where: { Self.priceMatches($0.attributes.customerPrice, target: priceStr) }) else { - let sorted = pricePoints.compactMap { $0.attributes.customerPrice } - .compactMap { Double($0) } - .filter { $0 > 0 } - .sorted() - let samples = sorted.count <= 30 ? sorted : { - // Show a spread: lowest 5, some mid-range, highest 5 - let lo = Array(sorted.prefix(5)) - let hi = Array(sorted.suffix(5)) - let step = max(1, (sorted.count - 10) / 10) - let mid = stride(from: 5, to: sorted.count - 5, by: step).map { sorted[$0] } - return lo + mid + hi - }() - let formatted = samples.map { String(format: "%.2f", $0) } - return mcpText("Error: no price point matching $\(priceStr). \(sorted.count) tiers available, samples: \(formatted.joined(separator: ", "))") - } - - if let effectiveDate { - // Scheduled price change: keep current price until effectiveDate, then switch - let freePoint = pricePoints.first(where: { - let p = $0.attributes.customerPrice ?? "0" - return p == "0" || p == "0.0" || p == "0.00" - }) - // Use free point as default current price - let currentId = freePoint?.id ?? match.id - try await service.setScheduledAppPrice( - appId: appId, - currentPricePointId: currentId, - futurePricePointId: match.id, - effectiveDate: effectiveDate - ) - try await service.ensureAppAvailability(appId: appId) - await MainActor.run { - appState.ascManager.currentAppPricePointId = currentId - appState.ascManager.scheduledAppPricePointId = match.id - appState.ascManager.scheduledAppPriceEffectiveDate = effectiveDate - appState.ascManager.monetizationStatus = "Configured" - } - await appState.ascManager.refreshTabData(.monetization) - return mcpJSON(["success": true, "price": priceStr, "effectiveDate": effectiveDate, "message": "Scheduled price change for \(effectiveDate) with territory availability configured"]) - } - - try await service.setAppPrice(appId: appId, pricePointId: match.id) - try await service.ensureAppAvailability(appId: appId) - await MainActor.run { - appState.ascManager.currentAppPricePointId = match.id - appState.ascManager.scheduledAppPricePointId = nil - appState.ascManager.scheduledAppPriceEffectiveDate = nil - appState.ascManager.monetizationStatus = "Configured" - } - await appState.ascManager.refreshTabData(.monetization) - return mcpJSON(["success": true, "price": priceStr, "pricePointId": match.id]) - } - - private func executeASCCreateIAP(_ args: [String: Any]) async throws -> [String: Any] { - guard let productId = args["productId"] as? String, - let name = args["name"] as? String, - let type = args["type"] as? String, - let displayName = args["displayName"] as? String, - let priceStr = args["price"] as? String, - let screenshotPath = args["screenshotPath"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - let description = args["description"] as? String - - // Validate type - let validTypes = ["CONSUMABLE", "NON_CONSUMABLE", "NON_RENEWING_SUBSCRIPTION"] - guard validTypes.contains(type) else { - return mcpText("Error: invalid type '\(type)'. Must be one of: \(validTypes.joined(separator: ", "))") - } - - // Pre-fill form values so the UI shows them - await MainActor.run { - var values: [String: String] = [ - "kind": "iap", - "name": name, "productId": productId, "type": type, - "displayName": displayName, "price": priceStr - ] - if let description { values["description"] = description } - appState.ascManager.pendingCreateValues = values - } - - // Delegate to ASCManager (same flow as the SwiftUI form) - await MainActor.run { - appState.ascManager.createIAP( - name: name, productId: productId, type: type, - displayName: displayName, description: description, - price: priceStr, screenshotPath: screenshotPath - ) - } - - // Poll until creation completes - let result = await pollASCCreation() - - if let error = result { - return mcpText("Error creating IAP: \(error)") - } - - return mcpJSON([ - "success": true, - "productId": productId, - "type": type, - "displayName": displayName, - "price": priceStr - ] as [String: Any]) - } - - private func executeASCCreateSubscription(_ args: [String: Any]) async throws -> [String: Any] { - guard let groupName = args["groupName"] as? String, - let productId = args["productId"] as? String, - let name = args["name"] as? String, - let displayName = args["displayName"] as? String, - let duration = args["duration"] as? String, - let priceStr = args["price"] as? String, - let screenshotPath = args["screenshotPath"] as? String else { - throw MCPServerService.MCPError.invalidToolArgs - } - let description = args["description"] as? String - - // Validate duration - let validDurations = ["ONE_WEEK", "ONE_MONTH", "TWO_MONTHS", "THREE_MONTHS", "SIX_MONTHS", "ONE_YEAR"] - guard validDurations.contains(duration) else { - return mcpText("Error: invalid duration '\(duration)'. Must be one of: \(validDurations.joined(separator: ", "))") - } - - // Pre-fill form values so the UI shows them - await MainActor.run { - var values: [String: String] = [ - "kind": "subscription", - "groupName": groupName, "name": name, "productId": productId, - "displayName": displayName, "duration": duration, "price": priceStr - ] - if let description { values["description"] = description } - appState.ascManager.pendingCreateValues = values - } - - // Delegate to ASCManager (same flow as the SwiftUI form) - await MainActor.run { - appState.ascManager.createSubscription( - groupName: groupName, name: name, productId: productId, - displayName: displayName, description: description, - duration: duration, price: priceStr, screenshotPath: screenshotPath - ) - } - - // Poll until creation completes - let result = await pollASCCreation() - - if let error = result { - return mcpText("Error creating subscription: \(error)") - } - - return mcpJSON([ - "success": true, - "groupName": groupName, - "productId": productId, - "displayName": displayName, - "duration": duration, - "price": priceStr - ] as [String: Any]) - } - - /// Poll ASCManager.isCreating until it finishes. Returns the error string if failed, nil on success. - private func pollASCCreation() async -> String? { - // Wait for isCreating to become true (task starts) - for _ in 0..<10 { - let creating = await MainActor.run { appState.ascManager.isCreating } - if creating { break } - try? await Task.sleep(for: .milliseconds(100)) - } - // Wait for isCreating to become false (task completes) - while await MainActor.run(body: { appState.ascManager.isCreating }) { - try? await Task.sleep(for: .milliseconds(500)) - } - return await MainActor.run { appState.ascManager.writeError } - } - - // MARK: - Tab State Tool - - private func executeGetRejectionFeedback(_ args: [String: Any]) async throws -> [String: Any] { - let raw = await MainActor.run { () -> [String: Any] in - let asc = appState.ascManager - guard let appId = asc.app?.id else { - return ["error": "No app connected. Set up ASC credentials first."] - } - - let requestedVersion = args["version"] as? String - let version: String - if let v = requestedVersion { - version = v - } else if let rejected = asc.appStoreVersions.first(where: { $0.attributes.appStoreState == "REJECTED" }) { - version = rejected.attributes.versionString - } else { - return ["error": "No rejected version found.", "appId": appId] as [String: Any] - } - - if let cached = IrisFeedbackCache.load(appId: appId, versionString: version) { - let reasons = cached.reasons.map { r in - ["section": r.section, "description": r.description, "code": r.code] - } - let messages = cached.messages.map { m -> [String: String] in - var msg = ["body": m.body] - if let d = m.date { msg["date"] = d } - return msg - } - return [ - "appId": appId, - "version": version, - "fetchedAt": ISO8601DateFormatter().string(from: cached.fetchedAt), - "reasons": reasons, - "messages": messages, - "source": "cache" - ] as [String: Any] - } - - return [ - "error": "No rejection feedback cached for version \(version). The user needs to sign in with their Apple ID in the ASC Overview tab to fetch feedback.", - "appId": appId, - "version": version - ] as [String: Any] - } - return mcpJSON(raw) - } - - private func executeGetTabState(_ args: [String: Any]) async throws -> [String: Any] { - let tabStr = args["tab"] as? String - let tab: AppTab - if let tabStr { - // Map legacy "ascOverview" to ".app" - if tabStr == "ascOverview" || tabStr == "overview" { - tab = .app - } else if let parsed = AppTab(rawValue: tabStr) { - tab = parsed - } else { - tab = await MainActor.run { appState.activeTab } - } - } else { - tab = await MainActor.run { appState.activeTab } - } - - // Build base result on main actor - var result = await MainActor.run { () -> [String: Any] in - let asc = appState.ascManager - var r: [String: Any] = [ - "tab": tab.rawValue, - "isLoading": asc.isLoadingTab[tab] ?? false, - ] - if let error = asc.tabError[tab] { r["error"] = error } - if let writeErr = asc.writeError { r["writeError"] = writeErr } - if tab.isASCTab, let app = asc.app { - r["app"] = ["id": app.id, "name": app.name, "bundleId": app.bundleId] as [String: Any] - } - return r - } - - // Refresh IAP/subscription data for overview so readiness reflects latest state - if tab == .app { - await appState.ascManager.refreshSubmissionReadinessData() - } - - // Build tab-specific data - let tabData = await MainActor.run { () -> [String: Any] in - let projectId = appState.activeProjectId - return tabStateData(for: tab, asc: appState.ascManager, projectId: projectId) - } - for (key, value) in tabData { - result[key] = value - } - - return mcpJSON(result) - } - - /// Extract tab-specific state data. Must be called on MainActor. - @MainActor - private func tabStateData(for tab: AppTab, asc: ASCManager, projectId: String?) -> [String: Any] { - switch tab { - case .app: - if let pid = projectId { - asc.checkAppIcon(projectId: pid) - } - return tabStateASCOverview(asc) - case .storeListing: - return tabStateStoreListing(asc) - case .appDetails: - return tabStateAppDetails(asc) - case .review: - return tabStateReview(asc) - case .screenshots: - return tabStateScreenshots(asc) - case .reviews: - return tabStateReviews(asc) - case .builds: - return tabStateBuilds(asc) - case .groups: - return tabStateGroups(asc) - case .betaInfo: - return tabStateBetaInfo(asc) - case .feedback: - return tabStateFeedback(asc) - default: - return ["note": "No structured state available for this tab"] - } - } - - @MainActor - private func tabStateASCOverview(_ asc: ASCManager) -> [String: Any] { - let readiness = asc.submissionReadiness - var fields: [[String: Any]] = [] - for f in readiness.fields { - let filled = f.value != nil && !f.value!.isEmpty - var entry: [String: Any] = ["label": f.label, "value": f.value as Any, "required": f.required, "filled": filled] - if let hint = f.hint { - entry["hint"] = hint - } - fields.append(entry) - } - var r: [String: Any] = [ - "submissionReadiness": [ - "isComplete": readiness.isComplete, - "fields": fields, - "missingRequired": readiness.missingRequired.map { $0.label } - ] as [String: Any], - "totalVersions": asc.appStoreVersions.count, - "isSubmitting": asc.isSubmitting - ] - if let v = asc.appStoreVersions.first { - r["latestVersion"] = ["id": v.id, "versionString": v.attributes.versionString, "state": v.attributes.appStoreState ?? "unknown"] as [String: Any] - } - if let error = asc.submissionError { - r["submissionError"] = error - } - // Include rejection feedback hint if available - if let cached = asc.cachedFeedback { - r["rejectionFeedback"] = [ - "version": cached.versionString, - "reasonCount": cached.reasons.count, - "messageCount": cached.messages.count, - "hint": "Use get_rejection_feedback tool for full details" - ] as [String: Any] - } - return r - } - - @MainActor - private func tabStateStoreListing(_ asc: ASCManager) -> [String: Any] { - let loc = asc.localizations.first - let infoLoc = asc.appInfoLocalization - return [ - "localization": [ - "locale": loc?.attributes.locale ?? "", - "name": infoLoc?.attributes.name ?? loc?.attributes.title ?? "", - "subtitle": infoLoc?.attributes.subtitle ?? loc?.attributes.subtitle ?? "", - "description": loc?.attributes.description ?? "", - "keywords": loc?.attributes.keywords ?? "", - "promotionalText": loc?.attributes.promotionalText ?? "", - "marketingUrl": loc?.attributes.marketingUrl ?? "", - "supportUrl": loc?.attributes.supportUrl ?? "", - "whatsNew": loc?.attributes.whatsNew ?? "" - ] as [String: Any], - "privacyPolicyUrl": infoLoc?.attributes.privacyPolicyUrl ?? "", - "localeCount": asc.localizations.count - ] - } - - @MainActor - private func tabStateAppDetails(_ asc: ASCManager) -> [String: Any] { - var r: [String: Any] = [ - "appInfo": [ - "primaryCategory": asc.appInfo?.primaryCategoryId ?? "", - "contentRightsDeclaration": asc.app?.contentRightsDeclaration ?? "" - ] as [String: Any], - "versionCount": asc.appStoreVersions.count - ] - if let v = asc.appStoreVersions.first { - r["latestVersion"] = ["versionString": v.attributes.versionString, "state": v.attributes.appStoreState ?? "unknown"] as [String: Any] - } - return r - } - - @MainActor - private func tabStateReview(_ asc: ASCManager) -> [String: Any] { - var r: [String: Any] = [:] - - if let ar = asc.ageRatingDeclaration { - let a = ar.attributes - var arDict: [String: Any] = ["id": ar.id] - arDict["gambling"] = a.gambling ?? false - arDict["messagingAndChat"] = a.messagingAndChat ?? false - arDict["unrestrictedWebAccess"] = a.unrestrictedWebAccess ?? false - arDict["userGeneratedContent"] = a.userGeneratedContent ?? false - arDict["advertising"] = a.advertising ?? false - arDict["lootBox"] = a.lootBox ?? false - arDict["healthOrWellnessTopics"] = a.healthOrWellnessTopics ?? false - arDict["parentalControls"] = a.parentalControls ?? false - arDict["ageAssurance"] = a.ageAssurance ?? false - arDict["alcoholTobaccoOrDrugUseOrReferences"] = a.alcoholTobaccoOrDrugUseOrReferences ?? "NONE" - arDict["contests"] = a.contests ?? "NONE" - arDict["gamblingSimulated"] = a.gamblingSimulated ?? "NONE" - arDict["gunsOrOtherWeapons"] = a.gunsOrOtherWeapons ?? "NONE" - arDict["horrorOrFearThemes"] = a.horrorOrFearThemes ?? "NONE" - arDict["matureOrSuggestiveThemes"] = a.matureOrSuggestiveThemes ?? "NONE" - arDict["medicalOrTreatmentInformation"] = a.medicalOrTreatmentInformation ?? "NONE" - arDict["profanityOrCrudeHumor"] = a.profanityOrCrudeHumor ?? "NONE" - arDict["sexualContentGraphicAndNudity"] = a.sexualContentGraphicAndNudity ?? "NONE" - arDict["sexualContentOrNudity"] = a.sexualContentOrNudity ?? "NONE" - arDict["violenceCartoonOrFantasy"] = a.violenceCartoonOrFantasy ?? "NONE" - arDict["violenceRealistic"] = a.violenceRealistic ?? "NONE" - arDict["violenceRealisticProlongedGraphicOrSadistic"] = a.violenceRealisticProlongedGraphicOrSadistic ?? "NONE" - r["ageRating"] = arDict - } - - if let rd = asc.reviewDetail { - let a = rd.attributes - r["reviewContact"] = [ - "contactFirstName": a.contactFirstName ?? "", - "contactLastName": a.contactLastName ?? "", - "contactEmail": a.contactEmail ?? "", - "contactPhone": a.contactPhone ?? "", - "notes": a.notes ?? "", - "demoAccountRequired": a.demoAccountRequired ?? false, - "demoAccountName": a.demoAccountName ?? "", - "demoAccountPassword": a.demoAccountPassword ?? "" - ] as [String: Any] - } - - r["builds"] = asc.builds.prefix(10).map { b -> [String: Any] in - ["id": b.id, "version": b.attributes.version, "processingState": b.attributes.processingState ?? "unknown", "uploadedDate": b.attributes.uploadedDate ?? ""] - } - return r - } - - @MainActor - private func tabStateScreenshots(_ asc: ASCManager) -> [String: Any] { - let sets = asc.screenshotSets.map { s -> [String: Any] in - var set: [String: Any] = ["id": s.id, "displayType": s.attributes.screenshotDisplayType] - if let shots = asc.screenshots[s.id] { - set["screenshotCount"] = shots.count - set["screenshots"] = shots.map { ["id": $0.id, "fileName": $0.attributes.fileName ?? ""] as [String: Any] } - } - return set - } - return ["screenshotSets": sets, "localeCount": asc.localizations.count] - } - - @MainActor - private func tabStateReviews(_ asc: ASCManager) -> [String: Any] { - let reviews = asc.customerReviews.prefix(20).map { r -> [String: Any] in - ["id": r.id, "title": r.attributes.title ?? "", "body": r.attributes.body ?? "", "rating": r.attributes.rating, "reviewerNickname": r.attributes.reviewerNickname ?? ""] - } - return ["reviews": reviews, "totalReviews": asc.customerReviews.count] - } - - @MainActor - private func tabStateBuilds(_ asc: ASCManager) -> [String: Any] { - let builds = asc.builds.prefix(20).map { b -> [String: Any] in - ["id": b.id, "version": b.attributes.version, "processingState": b.attributes.processingState ?? "unknown", "uploadedDate": b.attributes.uploadedDate ?? ""] - } - return ["builds": builds] - } - - @MainActor - private func tabStateGroups(_ asc: ASCManager) -> [String: Any] { - let groups = asc.betaGroups.map { g -> [String: Any] in - ["id": g.id, "name": g.attributes.name, "isInternalGroup": g.attributes.isInternalGroup ?? false] - } - return ["betaGroups": groups] - } - - @MainActor - private func tabStateBetaInfo(_ asc: ASCManager) -> [String: Any] { - let locs = asc.betaLocalizations.map { l -> [String: Any] in - ["id": l.id, "locale": l.attributes.locale, "description": l.attributes.description ?? ""] - } - return ["betaLocalizations": locs] - } - - @MainActor - private func tabStateFeedback(_ asc: ASCManager) -> [String: Any] { - var items: [[String: Any]] = [] - for (buildId, feedbackItems) in asc.betaFeedback { - for item in feedbackItems { - items.append(["buildId": buildId, "id": item.id, "comment": item.attributes.comment ?? "", "timestamp": item.attributes.timestamp ?? ""]) - } - } - return ["feedback": items, "selectedBuildId": asc.selectedBuildId ?? ""] - } - - // MARK: - Build Pipeline Tools - - private func executeSetupSigning(_ args: [String: Any]) async throws -> [String: Any] { - let (optCtx, err) = await requireBuildContext() - guard let ctx = optCtx else { return err! } - let project = ctx.project - let bundleId = ctx.bundleId - let service = ctx.service - let teamId = args["teamId"] as? String ?? (ctx.teamId.isEmpty ? nil : ctx.teamId) - - await MainActor.run { - appState.ascManager.buildPipelinePhase = .signingSetup - appState.ascManager.buildPipelineMessage = "Setting up signing…" - } - - let pipeline = BuildPipelineService() - let appStateRef = appState - do { - // Run with 5-minute overall timeout to prevent silent hangs - let projectPlatform = await MainActor.run { project.platform } - let result = try await withThrowingTimeout(seconds: 300) { - try await pipeline.setupSigning( - projectPath: project.path, - bundleId: bundleId, - teamId: teamId, - ascService: service, - platform: projectPlatform, - onProgress: { msg in - Task { @MainActor in - appStateRef.ascManager.buildPipelineMessage = msg - } - } - ) - } - - // Persist teamId to project metadata on success - if !result.teamId.isEmpty { - await MainActor.run { - let storage = ProjectStorage() - guard var metadata = storage.readMetadata(projectId: project.id) else { return } - metadata.teamId = result.teamId - try? storage.writeMetadata(projectId: project.id, metadata: metadata) - } - } - - await MainActor.run { - appState.ascManager.buildPipelinePhase = .idle - appState.ascManager.buildPipelineMessage = "" - } - - var resultDict: [String: Any] = [ - "success": true, - "bundleIdResourceId": result.bundleIdResourceId, - "certificateId": result.certificateId, - "profileUUID": result.profileUUID, - "teamId": result.teamId, - "log": result.log - ] - if let installerCertId = result.installerCertificateId { - resultDict["installerCertificateId"] = installerCertId - } - return mcpJSON(resultDict) - } catch { - await MainActor.run { - appState.ascManager.buildPipelinePhase = .idle - appState.ascManager.buildPipelineMessage = "" - } - return mcpText("Error in signing setup: \(error.localizedDescription)") - } - } - - private func executeBuildIPA(_ args: [String: Any]) async throws -> [String: Any] { - let (optCtx, err) = await requireBuildContext(needsTeamId: true) - guard let ctx = optCtx else { return err! } - let project = ctx.project - let bundleId = ctx.bundleId - let teamId = ctx.teamId - - let scheme = args["scheme"] as? String - let configuration = args["configuration"] as? String - - await MainActor.run { - appState.ascManager.buildPipelinePhase = .archiving - appState.ascManager.buildPipelineMessage = "Starting build…" - } - - let pipeline = BuildPipelineService() - let appStateRef = appState - do { - let buildPlatform = await MainActor.run { project.platform } - let result = try await pipeline.buildIPA( - projectPath: project.path, - bundleId: bundleId, - teamId: teamId, - scheme: scheme, - configuration: configuration, - platform: buildPlatform, - onProgress: { msg in - Task { @MainActor in - // Detect phase transitions from build output - if msg.contains("ARCHIVE SUCCEEDED") || msg.contains("-exportArchive") { - appStateRef.ascManager.buildPipelinePhase = .exporting - } - appStateRef.ascManager.buildPipelineMessage = String(msg.prefix(120)) - } - } - ) - - await MainActor.run { - appState.ascManager.buildPipelinePhase = .idle - appState.ascManager.buildPipelineMessage = "" - } - - return mcpJSON([ - "success": true, - "ipaPath": result.ipaPath, - "archivePath": result.archivePath, - "log": result.log - ] as [String: Any]) - } catch { - await MainActor.run { - appState.ascManager.buildPipelinePhase = .idle - appState.ascManager.buildPipelineMessage = "" - } - return mcpText("Error building IPA: \(error.localizedDescription)") - } - } - - private func executeUploadToTestFlight(_ args: [String: Any]) async throws -> [String: Any] { - guard let credentials = await MainActor.run(body: { appState.ascManager.credentials }) else { - return mcpText("Error: ASC credentials not configured.") - } - guard await MainActor.run(body: { appState.activeProject }) != nil else { - return mcpText("Error: no active project.") - } - - // Resolve IPA path - let ipaPath: String - if let path = args["ipaPath"] as? String { - ipaPath = (path as NSString).expandingTildeInPath - } else { - // Try to find most recent IPA in /tmp - let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory()) - let tmpContents = try FileManager.default.contentsOfDirectory(at: tmpURL, includingPropertiesForKeys: [.contentModificationDateKey]) - let exportDirs = tmpContents.filter { $0.lastPathComponent.hasPrefix("BlitzExport-") } - .sorted { a, b in - let aDate = (try? a.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - let bDate = (try? b.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - return aDate > bDate - } - // Search for .ipa (iOS) or .pkg (macOS) - let searchExts: Set = ["ipa", "pkg"] - var foundArtifact: String? - for dir in exportDirs { - let files = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) - if let match = files.first(where: { searchExts.contains($0.pathExtension) }) { - foundArtifact = match.path - break - } - } - guard let found = foundArtifact else { - return mcpText("Error: no IPA/PKG path provided and no recent build found. Run app_store_build first.") - } - ipaPath = found - } - - guard FileManager.default.fileExists(atPath: ipaPath) else { - return mcpText("Error: IPA not found at \(ipaPath)") - } - - let skipPolling = args["skipPolling"] as? Bool ?? false - - // Get app ID for polling - let appId = await MainActor.run { appState.ascManager.app?.id } - let service = await MainActor.run { appState.ascManager.service } - - // --- Pre-upload validation: build version & encryption key (IPA only, skip for PKG) --- - let isIPA = ipaPath.hasSuffix(".ipa") - var existingVersions: Set = [] - do { - guard isIPA else { throw NSError(domain: "skip", code: 0) } - // Extract IPA plist fields in one pass - let plistXML = try await ProcessRunner.run( - "/bin/bash", - arguments: ["-c", "unzip -p '\(ipaPath)' 'Payload/*.app/Info.plist' | plutil -convert xml1 -o - -"] - ) - - // Check CFBundleVersion - let ipaVersion: String? = { - guard let range = plistXML.range(of: "CFBundleVersion"), - let valueStart = plistXML.range(of: "", range: range.upperBound..", range: valueStart.upperBound..ITSAppUsesNonExemptEncryption directly to Info.plist." - ) - } - - // Validate build version against existing builds - if let ipaVersion, !ipaVersion.isEmpty, let appId, let service { - let builds = try await service.fetchBuilds(appId: appId) - existingVersions = Set(builds.map(\.attributes.version)) - if existingVersions.contains(ipaVersion) { - let maxVersion = existingVersions.compactMap { Int($0) }.max() ?? 0 - return mcpText( - "Error: build version \(ipaVersion) already exists in App Store Connect. " - + "Existing build versions: \(existingVersions.sorted().joined(separator: ", ")). " - + "The next valid build version is \(maxVersion + 1). " - + "Update CFBundleVersion in Info.plist (or CURRENT_PROJECT_VERSION in the Xcode build settings) and rebuild." - ) - } - } - } catch { - // Non-fatal — proceed with upload and let altool catch any issues - } - - // If we didn't capture existing versions above, fetch them now for polling comparison - if existingVersions.isEmpty, let appId, let service { - existingVersions = Set((try? await service.fetchBuilds(appId: appId))?.map(\.attributes.version) ?? []) - } - - // --- Upload --- - await MainActor.run { - appState.ascManager.buildPipelinePhase = .uploading - appState.ascManager.buildPipelineMessage = "Uploading IPA…" - } - - let pipeline = BuildPipelineService() - let appStateRef = appState - do { - // Always skip BuildPipelineService's built-in polling — we poll ourselves below - let uploadPlatform = await MainActor.run { appState.activeProject?.platform ?? .iOS } - let result = try await pipeline.uploadToTestFlight( - ipaPath: ipaPath, - keyId: credentials.keyId, - issuerId: credentials.issuerId, - privateKeyPEM: credentials.privateKey, - appId: appId, - ascService: service, - skipPolling: true, - platform: uploadPlatform, - onProgress: { msg in - Task { @MainActor in - appStateRef.ascManager.buildPipelineMessage = String(msg.prefix(120)) - } - } - ) - - var allLog = result.log - var finalState = result.processingState - var finalVersion = result.buildVersion - - // --- Poll for new build to appear (every 10s, up to 300s) --- - if !skipPolling, let appId, let service { - await MainActor.run { - appStateRef.ascManager.buildPipelinePhase = .processing - appStateRef.ascManager.buildPipelineMessage = "Waiting for new build to appear…" - } - - let pollInterval: TimeInterval = 10 - let maxAttempts = 30 // 300 seconds total - - for attempt in 1...maxAttempts { - try? await Task.sleep(for: .seconds(pollInterval)) - - guard let builds = try? await service.fetchBuilds(appId: appId) else { continue } - - if let newBuild = builds.first(where: { !existingVersions.contains($0.attributes.version) }) { - let state = newBuild.attributes.processingState ?? "UNKNOWN" - let version = newBuild.attributes.version - let msg = "Poll \(attempt): build \(version) — \(state)" - allLog.append(msg) - await MainActor.run { - appStateRef.ascManager.buildPipelineMessage = msg - appStateRef.ascManager.builds = builds - } - - finalVersion = version - finalState = state - - if state == "VALID" { - allLog.append("Build processing complete!") - // Auto-set encryption exemption via API as backup - try? await service.patchBuildEncryption( - buildId: newBuild.id, - usesNonExemptEncryption: false - ) - // Auto-attach to pending version - let versionId = await MainActor.run(body: { - appStateRef.ascManager.pendingVersionId - }) - if let versionId { - do { - try await service.attachBuild(versionId: versionId, buildId: newBuild.id) - allLog.append("Build \(version) attached to app store version.") - } catch { - allLog.append("Warning: could not auto-attach build \u{2014} \(error.localizedDescription)") - } - } - break - } else if state == "INVALID" { - allLog.append("Build processing failed with INVALID state.") - break - } - // Still processing — keep polling - } else { - let msg = "Poll \(attempt): new build not yet visible…" - allLog.append(msg) - await MainActor.run { - appStateRef.ascManager.buildPipelineMessage = msg - } - } - } - } - - // --- Finalize: reset UI and refresh tab data --- - await MainActor.run { - appState.ascManager.buildPipelinePhase = .idle - appState.ascManager.buildPipelineMessage = "" - } - await appState.ascManager.refreshTabData(.builds) - - var response: [String: Any] = [ - "success": true, - "processingState": finalState ?? "UNKNOWN", - "log": allLog - ] - if let version = finalVersion { - response["buildVersion"] = version - } - return mcpJSON(response) - } catch { - await MainActor.run { - appState.ascManager.buildPipelinePhase = .idle - appState.ascManager.buildPipelineMessage = "" - } - return mcpText("Error uploading to TestFlight: \(error.localizedDescription)") - } - } - - // MARK: - Helpers - - private func mcpText(_ text: String) -> [String: Any] { - ["content": [["type": "text", "text": text]]] - } - - private func mcpJSON(_ value: Any) -> [String: Any] { - if let data = try? JSONSerialization.data(withJSONObject: value), - let str = String(data: data, encoding: .utf8) { - return mcpText(str) - } - return mcpText("{}") - } - - /// Check for ASC write error and return it, clearing pending form values. - private func checkASCWriteError(tab: String) async -> [String: Any]? { - guard let error = await MainActor.run(body: { appState.ascManager.writeError }) else { return nil } - _ = await MainActor.run { appState.ascManager.pendingFormValues.removeValue(forKey: tab) } - return mcpText("Error: \(error)") - } - - private struct BuildContext { - let project: Project - let bundleId: String - let teamId: String - let service: AppStoreConnectService - } - - /// Resolve and validate bundle ID + ASC service for build pipeline tools. - /// Returns (context, nil) on success or (nil, errorResponse) on failure. - private func requireBuildContext(needsTeamId: Bool = false) async -> (BuildContext?, [String: Any]?) { - guard let project = await MainActor.run(body: { appState.activeProject }) else { - return (nil, mcpText("Error: no active project.")) - } - guard let service = await MainActor.run(body: { appState.ascManager.service }) else { - return (nil, mcpText("Error: ASC credentials not configured.")) - } - let bundleId = await MainActor.run { () -> String? in - ProjectStorage().readMetadata(projectId: project.id)?.bundleIdentifier - } - guard let bundleId, !bundleId.isEmpty else { - return (nil, mcpText("Error: no bundle identifier set. Use asc_fill_form tab=settings.bundleId to set it first.")) - } - let ascBundleId = await MainActor.run { appState.ascManager.app?.bundleId } - if let ascBundleId, !ascBundleId.isEmpty, ascBundleId != bundleId { - return (nil, mcpText("Error: bundle ID mismatch. Project has '\(bundleId)' but ASC app uses '\(ascBundleId)'.")) - } - let teamId = await MainActor.run { () -> String? in - ProjectStorage().readMetadata(projectId: project.id)?.teamId - } - if needsTeamId, (teamId == nil || teamId!.isEmpty) { - return (nil, mcpText("Error: no team ID set. Run app_store_setup_signing first.")) - } - return (BuildContext(project: project, bundleId: bundleId, teamId: teamId ?? "", service: service), nil) - } -} diff --git a/src/services/MacSwiftProjectSetupService.swift b/src/services/MacSwiftProjectSetupService.swift deleted file mode 100644 index 43f7cf1..0000000 --- a/src/services/MacSwiftProjectSetupService.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation - -/// Scaffolds a new macOS Swift/SwiftUI project from the bundled template. -/// Sandboxed by default for Mac App Store submission. -struct MacSwiftProjectSetupService { - - /// Set up a new macOS Swift project from the bundled template. - static func setup( - projectId: String, - projectName: String, - projectPath: String, - onStep: @MainActor (ProjectSetupService.SetupStep) -> Void - ) async throws { - - let fm = FileManager.default - let appName = SwiftProjectSetupService.toSwiftAppName(projectId) - let bundleId = SwiftProjectSetupService.toBundleId(appName) - - // --- Step 1: Copy & patch template --- - await onStep(.copying) - print("[mac-swift-setup] Scaffolding: appName=\(appName) bundleId=\(bundleId)") - - guard let templateURL = Bundle.appResources.url(forResource: "swift-mac-template", withExtension: nil, subdirectory: "templates") else { - throw ProjectSetupService.SetupError(message: "Bundled macOS Swift template not found") - } - - // Back up project metadata before overwriting dir - let metadataPath = projectPath + "/.blitz/project.json" - let metadataData = try? Data(contentsOf: URL(fileURLWithPath: metadataPath)) - - // Remove existing (near-empty) project dir - if fm.fileExists(atPath: projectPath) { - try fm.removeItem(atPath: projectPath) - } - try fm.createDirectory(atPath: projectPath, withIntermediateDirectories: true) - - // Recursively copy template, replacing placeholders in names & contents - try copyTemplateDir( - src: templateURL.path, - dest: projectPath, - appName: appName, - bundleId: bundleId - ) - - // Restore project metadata - let blitzDir = projectPath + "/.blitz" - if !fm.fileExists(atPath: blitzDir) { - try fm.createDirectory(atPath: blitzDir, withIntermediateDirectories: true) - } - if let data = metadataData { - try data.write(to: URL(fileURLWithPath: metadataPath)) - } - - print("[mac-swift-setup] Template copied and patched") - - // No npm install needed for Swift projects — go straight to ready - await onStep(.ready) - print("[mac-swift-setup] Project setup complete!") - } - - // MARK: - Helpers - - private static let appNamePlaceholder = "__APP_NAME__" - private static let bundleIdPlaceholder = "__BUNDLE_ID__" - - private static func copyTemplateDir( - src: String, - dest: String, - appName: String, - bundleId: String - ) throws { - let fm = FileManager.default - try fm.createDirectory(atPath: dest, withIntermediateDirectories: true) - - let entries = try fm.contentsOfDirectory(atPath: src) - for entry in entries { - let resolvedName = entry.replacingOccurrences(of: appNamePlaceholder, with: appName) - let srcPath = src + "/" + entry - let destPath = dest + "/" + resolvedName - - var isDir: ObjCBool = false - fm.fileExists(atPath: srcPath, isDirectory: &isDir) - - if isDir.boolValue { - try copyTemplateDir(src: srcPath, dest: destPath, appName: appName, bundleId: bundleId) - } else { - var content = try String(contentsOfFile: srcPath, encoding: .utf8) - content = content - .replacingOccurrences(of: appNamePlaceholder, with: appName) - .replacingOccurrences(of: bundleIdPlaceholder, with: bundleId) - try content.write(toFile: destPath, atomically: true, encoding: .utf8) - } - } - } -} diff --git a/src/services/ProjectSetupService.swift b/src/services/ProjectSetupService.swift deleted file mode 100644 index 81c630a..0000000 --- a/src/services/ProjectSetupService.swift +++ /dev/null @@ -1,138 +0,0 @@ -import Foundation - -/// Scaffolds a new React Native / Blitz project from the bundled template. -/// Handles the full lifecycle: copy template → patch placeholders → write .dev.vars -/// The AI agent handles npm install, pod install, metro, and builds. -struct ProjectSetupService { - - enum SetupStep: String { - case copying = "Copying template..." - case ready = "Ready" - } - - struct SetupError: LocalizedError { - let message: String - var errorDescription: String? { message } - } - - private static let sampleDevVars = """ - JWT_SECRET_MAIN=this_is_the_main_secret_used_for_all_tables_and_admin - JWT_SECRET_USERS=secret_used_for_users_table_appended_to_the_main_secret - ADMIN_SERVICE_TOKEN=password_for_accessing_the_backend_as_admin - ADMIN_JWT_SECRET=this_will_be_used_for_jwt_token_for_admin_operations - POCKET_UI_VIEWER_PASSWORD=admin_db_password_for_readonly_mode - POCKET_UI_EDITOR_PASSWORD=admin_db_password_for_readwrite_mode - MAILGUN_API_KEY=api-key-from-mailgun - API_ROUTE=NA - """ - - private static let projectNamePlaceholder = "__PROJECT_NAME__" - - /// Set up a new project from the bundled RN template. - /// Calls `onStep` on the main actor as each phase begins. - static func setup( - projectId: String, - projectName: String, - projectPath: String, - onStep: @MainActor (SetupStep) -> Void - ) async throws { - - let fm = FileManager.default - - // --- Step 1: Copy bundled template --- - await onStep(.copying) - print("[setup] Step 1: Copying bundled RN template") - - guard let templateURL = Bundle.appResources.url(forResource: "rn-notes-template", withExtension: nil, subdirectory: "templates") else { - throw SetupError(message: "Bundled RN template not found") - } - print("[setup] Template source: \(templateURL.path)") - print("[setup] Project path: \(projectPath)") - - // Back up project metadata before overwriting dir - let metadataBackup = projectPath + "/.blitz/project.json" - let metadataData = try? Data(contentsOf: URL(fileURLWithPath: metadataBackup)) - print("[setup] Metadata backed up: \(metadataData != nil)") - - // Remove existing (near-empty) project dir - if fm.fileExists(atPath: projectPath) { - try fm.removeItem(atPath: projectPath) - print("[setup] Removed existing project dir") - } - - // Recursively copy template, replacing placeholders in names & contents - try copyTemplateDir( - src: templateURL.path, - dest: projectPath, - projectName: projectName - ) - print("[setup] Template copied and patched") - - // Remove any stale local database state from the template copy - let localPersist = projectPath + "/.local-persist" - if fm.fileExists(atPath: localPersist) { - try? fm.removeItem(atPath: localPersist) - print("[setup] Removed stale .local-persist") - } - - // Restore project metadata - let blitzDir = projectPath + "/.blitz" - if !fm.fileExists(atPath: blitzDir) { - try fm.createDirectory(atPath: blitzDir, withIntermediateDirectories: true) - } - if let data = metadataData { - try data.write(to: URL(fileURLWithPath: metadataBackup)) - print("[setup] Metadata restored") - } - - // Ensure .dev.vars exists - let devVarsPath = projectPath + "/.dev.vars" - if !fm.fileExists(atPath: devVarsPath) { - let sampleVarsPath = projectPath + "/sample.vars" - if fm.fileExists(atPath: sampleVarsPath) { - try fm.copyItem(atPath: sampleVarsPath, toPath: devVarsPath) - print("[setup] .dev.vars copied from sample.vars") - } else { - try sampleDevVars.write(toFile: devVarsPath, atomically: true, encoding: .utf8) - print("[setup] .dev.vars written from default") - } - } else { - print("[setup] .dev.vars already exists") - } - - // --- Done --- - await onStep(.ready) - print("[setup] Project setup complete!") - } - - // MARK: - Helpers - - /// Recursively copy a template directory, replacing placeholders in - /// filenames/directory names and file contents. - private static func copyTemplateDir( - src: String, - dest: String, - projectName: String - ) throws { - let fm = FileManager.default - try fm.createDirectory(atPath: dest, withIntermediateDirectories: true) - - let entries = try fm.contentsOfDirectory(atPath: src) - for entry in entries { - let srcPath = (src as NSString).appendingPathComponent(entry) - let patchedName = entry.replacingOccurrences(of: projectNamePlaceholder, with: projectName) - let destPath = (dest as NSString).appendingPathComponent(patchedName) - - var isDir: ObjCBool = false - fm.fileExists(atPath: srcPath, isDirectory: &isDir) - - if isDir.boolValue { - try copyTemplateDir(src: srcPath, dest: destPath, projectName: projectName) - } else { - var content = try String(contentsOfFile: srcPath, encoding: .utf8) - content = content.replacingOccurrences(of: projectNamePlaceholder, with: projectName) - try content.write(toFile: destPath, atomically: true, encoding: .utf8) - } - } - } -} diff --git a/src/services/SwiftProjectSetupService.swift b/src/services/SwiftProjectSetupService.swift deleted file mode 100644 index 9c4096e..0000000 --- a/src/services/SwiftProjectSetupService.swift +++ /dev/null @@ -1,120 +0,0 @@ -import Foundation - -/// Scaffolds a new Swift/SwiftUI project from the bundled template. -/// Mirrors the logic in blitz-cn's create-swift-project.ts. -struct SwiftProjectSetupService { - - /// Convert a project ID like "my-cool-app" → "MyCoolApp". - static func toSwiftAppName(_ projectId: String) -> String { - let parts = projectId.components(separatedBy: CharacterSet.alphanumerics.inverted) - let camel = parts - .filter { !$0.isEmpty } - .map { $0.prefix(1).uppercased() + $0.dropFirst() } - .joined() - - // Ensure starts with a letter - var result = camel - while let first = result.first, !first.isLetter { - result = String(result.dropFirst()) - } - return result.isEmpty ? "App" : result - } - - /// Derive a bundle ID: "MyCoolApp" → "dev.blitz.MyCoolApp". - static func toBundleId(_ appName: String) -> String { - let safe = appName.filter { $0.isLetter || $0.isNumber } - return "dev.blitz.\(safe.isEmpty ? "App" : safe)" - } - - /// Set up a new Swift project from the bundled template. - /// Calls `onStep` on the main actor as each phase begins. - static func setup( - projectId: String, - projectName: String, - projectPath: String, - onStep: @MainActor (ProjectSetupService.SetupStep) -> Void - ) async throws { - - let fm = FileManager.default - let appName = toSwiftAppName(projectId) - let bundleId = toBundleId(appName) - - // --- Step 1: Copy & patch template --- - await onStep(.copying) - print("[swift-setup] Scaffolding: appName=\(appName) bundleId=\(bundleId)") - - guard let templateURL = Bundle.appResources.url(forResource: "swift-hello-template", withExtension: nil, subdirectory: "templates") else { - throw ProjectSetupService.SetupError(message: "Bundled Swift template not found") - } - - // Back up project metadata before overwriting dir - let metadataPath = projectPath + "/.blitz/project.json" - let metadataData = try? Data(contentsOf: URL(fileURLWithPath: metadataPath)) - - // Remove existing (near-empty) project dir - if fm.fileExists(atPath: projectPath) { - try fm.removeItem(atPath: projectPath) - } - try fm.createDirectory(atPath: projectPath, withIntermediateDirectories: true) - - // Recursively copy template, replacing placeholders in names & contents - try copyTemplateDir( - src: templateURL.path, - dest: projectPath, - appName: appName, - bundleId: bundleId - ) - - // Restore project metadata - let blitzDir = projectPath + "/.blitz" - if !fm.fileExists(atPath: blitzDir) { - try fm.createDirectory(atPath: blitzDir, withIntermediateDirectories: true) - } - if let data = metadataData { - try data.write(to: URL(fileURLWithPath: metadataPath)) - } - - print("[swift-setup] Template copied and patched") - - // No npm install needed for Swift projects — go straight to ready - await onStep(.ready) - print("[swift-setup] Project setup complete!") - } - - // MARK: - Helpers - - private static let appNamePlaceholder = "__APP_NAME__" - private static let bundleIdPlaceholder = "__BUNDLE_ID__" - - /// Recursively copy a template directory, replacing placeholders in - /// filenames/directory names and file contents. - private static func copyTemplateDir( - src: String, - dest: String, - appName: String, - bundleId: String - ) throws { - let fm = FileManager.default - try fm.createDirectory(atPath: dest, withIntermediateDirectories: true) - - let entries = try fm.contentsOfDirectory(atPath: src) - for entry in entries { - let resolvedName = entry.replacingOccurrences(of: appNamePlaceholder, with: appName) - let srcPath = src + "/" + entry - let destPath = dest + "/" + resolvedName - - var isDir: ObjCBool = false - fm.fileExists(atPath: srcPath, isDirectory: &isDir) - - if isDir.boolValue { - try copyTemplateDir(src: srcPath, dest: destPath, appName: appName, bundleId: bundleId) - } else { - var content = try String(contentsOfFile: srcPath, encoding: .utf8) - content = content - .replacingOccurrences(of: appNamePlaceholder, with: appName) - .replacingOccurrences(of: bundleIdPlaceholder, with: bundleId) - try content.write(toFile: destPath, atomically: true, encoding: .utf8) - } - } - } -} diff --git a/src/services/ASCAuthBridge.swift b/src/services/asc/ASCAuthBridge.swift similarity index 100% rename from src/services/ASCAuthBridge.swift rename to src/services/asc/ASCAuthBridge.swift diff --git a/src/services/ASCDaemonClient.swift b/src/services/asc/ASCDaemonClient.swift similarity index 100% rename from src/services/ASCDaemonClient.swift rename to src/services/asc/ASCDaemonClient.swift diff --git a/src/services/ASCDaemonLogger.swift b/src/services/asc/ASCDaemonLogger.swift similarity index 100% rename from src/services/ASCDaemonLogger.swift rename to src/services/asc/ASCDaemonLogger.swift diff --git a/src/services/ASCError.swift b/src/services/asc/ASCError.swift similarity index 100% rename from src/services/ASCError.swift rename to src/services/asc/ASCError.swift diff --git a/src/services/ASCService.swift b/src/services/asc/ASCService.swift similarity index 100% rename from src/services/ASCService.swift rename to src/services/asc/ASCService.swift diff --git a/src/services/ASCWebSessionStore.swift b/src/services/asc/ASCWebSessionStore.swift similarity index 100% rename from src/services/ASCWebSessionStore.swift rename to src/services/asc/ASCWebSessionStore.swift diff --git a/src/services/IrisService.swift b/src/services/asc/IrisService.swift similarity index 100% rename from src/services/IrisService.swift rename to src/services/asc/IrisService.swift diff --git a/src/services/TeenybaseClient.swift b/src/services/database/TeenybaseClient.swift similarity index 100% rename from src/services/TeenybaseClient.swift rename to src/services/database/TeenybaseClient.swift diff --git a/src/services/TeenybaseProcessService.swift b/src/services/database/TeenybaseProcessService.swift similarity index 80% rename from src/services/TeenybaseProcessService.swift rename to src/services/database/TeenybaseProcessService.swift index 734538d..c8ba00c 100644 --- a/src/services/TeenybaseProcessService.swift +++ b/src/services/database/TeenybaseProcessService.swift @@ -44,7 +44,7 @@ final class TeenybaseProcessService { return } - let env = buildEnvironment(projectPath: projectPath) + let env = TeenybaseProjectEnvironment.environment(projectPath: projectPath, port: port) // Kill anything already on the port await killPort(port) @@ -119,38 +119,6 @@ final class TeenybaseProcessService { ) } - private func buildEnvironment(projectPath: String) -> [String: String] { - var env = ProcessInfo.processInfo.environment - // Ensure project's local binaries are first in PATH - let localBin = projectPath + "/node_modules/.bin" - if let existing = env["PATH"] { - env["PATH"] = localBin + ":" + existing - } else { - env["PATH"] = localBin - } - // Standard overrides - env["TEENY_DEV_PORT"] = String(port) - env["WRANGLER_SEND_METRICS"] = "false" - // Load .dev.vars into environment - loadDevVars(projectPath: projectPath, into: &env) - return env - } - - private func loadDevVars(projectPath: String, into env: inout [String: String]) { - let path = projectPath + "/.dev.vars" - guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return } - for line in content.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue } - let parts = trimmed.split(separator: "=", maxSplits: 1) - guard parts.count == 2 else { continue } - let key = String(parts[0]).trimmingCharacters(in: .whitespaces) - let value = String(parts[1]).trimmingCharacters(in: .whitespaces) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) - env[key] = value - } - } - private func waitForHealth(port: Int, timeout: Int) async -> Bool { let url = URL(string: "http://localhost:\(port)/api/v1/health")! for _ in 0..<(timeout * 2) { diff --git a/src/services/database/TeenybaseProjectEnvironment.swift b/src/services/database/TeenybaseProjectEnvironment.swift new file mode 100644 index 0000000..37e1d52 --- /dev/null +++ b/src/services/database/TeenybaseProjectEnvironment.swift @@ -0,0 +1,53 @@ +import Foundation + +enum TeenybaseProjectEnvironment { + static func adminToken(projectPath: String) -> String? { + readDevVar("ADMIN_SERVICE_TOKEN", projectPath: projectPath) + } + + static func environment( + projectPath: String, + port: Int, + base: [String: String] = ProcessInfo.processInfo.environment + ) -> [String: String] { + var env = base + let localBin = projectPath + "/node_modules/.bin" + if let existingPath = env["PATH"] { + env["PATH"] = localBin + ":" + existingPath + } else { + env["PATH"] = localBin + } + + env["TEENY_DEV_PORT"] = String(port) + env["WRANGLER_SEND_METRICS"] = "false" + + for (key, value) in loadDevVars(projectPath: projectPath) { + env[key] = value + } + return env + } + + static func readDevVar(_ key: String, projectPath: String) -> String? { + loadDevVars(projectPath: projectPath)[key] + } + + static func loadDevVars(projectPath: String) -> [String: String] { + let path = projectPath + "/.dev.vars" + guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return [:] } + + var values: [String: String] = [:] + for line in content.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue } + + let parts = trimmed.split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { continue } + + let key = String(parts[0]).trimmingCharacters(in: .whitespaces) + let value = String(parts[1]).trimmingCharacters(in: .whitespaces) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + values[key] = value + } + return values + } +} diff --git a/src/services/mcp/MCPExecutor.swift b/src/services/mcp/MCPExecutor.swift new file mode 100644 index 0000000..798d70e --- /dev/null +++ b/src/services/mcp/MCPExecutor.swift @@ -0,0 +1,349 @@ +import AppKit +import Foundation +import Security + +/// Runs an async operation with a timeout. Throws CancellationError if the deadline is exceeded. +func withThrowingTimeout( + seconds: TimeInterval, + operation: @escaping @Sendable () async throws -> T +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(for: .seconds(seconds)) + throw CancellationError() + } + guard let result = try await group.next() else { + throw CancellationError() + } + group.cancelAll() + return result + } +} + +/// Executes MCP tool calls against AppState. +/// Holds pending approval continuations for destructive operations. +actor MCPExecutor { + let appState: AppState + private var pendingContinuations: [String: CheckedContinuation] = [:] + + init(appState: AppState) { + self.appState = appState + } + + /// Execute a tool call, requesting approval if needed. + func execute(name: String, arguments: [String: Any]) async throws -> [String: Any] { + let category = MCPRegistry.category(for: name) + + // Pre-navigate for ASC form tools so the user sees the target tab before approving. + var previousTab: AppTab? + if name == "asc_fill_form" || name == "asc_open_submit_preview" + || name == "asc_create_iap" || name == "asc_create_subscription" || name == "asc_set_app_price" + || name == "screenshots_add_asset" || name == "screenshots_set_track" || name == "screenshots_save" { + previousTab = await preNavigateASCTool(name: name, arguments: arguments) + } + + let request = ApprovalRequest( + id: UUID().uuidString, + toolName: name, + description: "Execute '\(name)'", + parameters: arguments.mapValues { "\($0)" }, + category: category + ) + + if request.requiresApproval(permissionToggles: await SettingsService.shared.permissionToggles) { + let approved = await requestApproval(request) + guard approved else { + if let prev = previousTab { + await MainActor.run { appState.activeTab = prev } + _ = await MainActor.run { appState.ascManager.pendingFormValues.removeAll() } + } + return mcpText("Tool '\(name)' was denied by the user.") + } + } + + return try await executeTool(name: name, arguments: arguments) + } + + /// Navigate to the appropriate tab before approval, and set pending form values. + /// Returns the previous tab so we can navigate back if denied. + private func preNavigateASCTool(name: String, arguments: [String: Any]) async -> AppTab? { + let previousTab = await MainActor.run { appState.activeTab } + + let targetTab: AppTab? + if name == "asc_fill_form" { + let tab = arguments["tab"] as? String ?? "" + switch tab { + case "storeListing": + targetTab = .storeListing + case "appDetails": + targetTab = .appDetails + case "monetization": + targetTab = .monetization + case "review.ageRating", "review.contact": + targetTab = .review + case "settings.bundleId": + targetTab = .settings + default: + targetTab = nil + } + } else if name == "asc_open_submit_preview" { + targetTab = .app + } else if name == "screenshots_add_asset" + || name == "screenshots_set_track" || name == "screenshots_save" { + targetTab = .screenshots + } else if name == "asc_set_app_price" { + targetTab = .monetization + } else if name == "asc_create_iap" || name == "asc_create_subscription" { + targetTab = .monetization + } else { + targetTab = nil + } + + if let targetTab { + await MainActor.run { appState.activeTab = targetTab } + if targetTab.isASCTab { + await appState.ascManager.fetchTabData(targetTab) + } + } + + if name == "asc_fill_form", + let tab = arguments["tab"] as? String { + var fieldMap: [String: String] = [:] + if let fieldsArray = arguments["fields"] as? [[String: Any]] { + for item in fieldsArray { + if let field = item["field"] as? String, let value = item["value"] as? String { + fieldMap[field] = value + } + } + } else if let fieldsDict = arguments["fields"] as? [String: Any] { + for (key, value) in fieldsDict { + fieldMap[key] = "\(value)" + } + } else if let fieldsString = arguments["fields"] as? String, + let data = fieldsString.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) { + if let dict = parsed as? [String: Any] { + for (key, value) in dict { + fieldMap[key] = "\(value)" + } + } else if let array = parsed as? [[String: Any]] { + for item in array { + if let field = item["field"] as? String, let value = item["value"] as? String { + fieldMap[field] = value + } + } + } + } + + if !fieldMap.isEmpty { + let fieldMapCopy = fieldMap + await MainActor.run { + appState.ascManager.pendingFormValues[tab] = fieldMapCopy + appState.ascManager.pendingFormVersion += 1 + } + } + } + + return previousTab + } + + /// Resume a pending approval. + nonisolated func resolveApproval(id: String, approved: Bool) { + Task { await _resolveApproval(id: id, approved: approved) } + } + + private func _resolveApproval(id: String, approved: Bool) { + guard let continuation = pendingContinuations.removeValue(forKey: id) else { return } + continuation.resume(returning: approved) + } + + // MARK: - Approval Flow + + private func requestApproval(_ request: ApprovalRequest) async -> Bool { + await MainActor.run { + appState.pendingApproval = request + appState.showApprovalAlert = true + NSApp.activate(ignoringOtherApps: true) + } + + let approved = await withCheckedContinuation { (continuation: CheckedContinuation) in + pendingContinuations[request.id] = continuation + + Task { + try? await Task.sleep(for: .seconds(300)) + if pendingContinuations[request.id] != nil { + _resolveApproval(id: request.id, approved: false) + } + } + } + + await MainActor.run { + appState.pendingApproval = nil + appState.showApprovalAlert = false + } + + return approved + } + + // MARK: - Tool Dispatch + + func executeTool(name: String, arguments: [String: Any]) async throws -> [String: Any] { + switch name { + case "app_get_state": + return try await executeAppGetState() + + case "nav_switch_tab": + return try await executeNavSwitchTab(arguments) + case "nav_list_tabs": + return await executeNavListTabs() + + case "project_list": + return await executeProjectList() + case "project_get_active": + return await executeProjectGetActive() + case "project_open": + return try await executeProjectOpen(arguments) + case "project_create": + return try await executeProjectCreate(arguments) + case "project_import": + return try await executeProjectImport(arguments) + case "project_close": + return await executeProjectClose() + + case "simulator_list_devices": + return await executeSimulatorListDevices() + case "simulator_select_device": + return try await executeSimulatorSelectDevice(arguments) + + case "settings_get": + return await executeSettingsGet() + case "settings_update": + return await executeSettingsUpdate(arguments) + case "settings_save": + return await executeSettingsSave() + + case "get_rejection_feedback": + return try await executeGetRejectionFeedback(arguments) + case "get_tab_state": + return try await executeGetTabState(arguments) + + case "asc_set_credentials": + return await executeASCSetCredentials(arguments) + case "asc_fill_form": + return try await executeASCFillForm(arguments) + case "screenshots_add_asset": + return try await executeScreenshotsAddAsset(arguments) + case "screenshots_set_track": + return try await executeScreenshotsSetTrack(arguments) + case "screenshots_save": + return try await executeScreenshotsSave(arguments) + case "asc_open_submit_preview": + return await executeASCOpenSubmitPreview() + case "asc_create_iap": + return try await executeASCCreateIAP(arguments) + case "asc_create_subscription": + return try await executeASCCreateSubscription(arguments) + case "asc_set_app_price": + return try await executeASCSetAppPrice(arguments) + case "asc_web_auth": + return await executeASCWebAuth() + + case "app_store_setup_signing": + return try await executeSetupSigning(arguments) + case "app_store_build": + return try await executeBuildIPA(arguments) + case "app_store_upload": + return try await executeUploadToTestFlight(arguments) + + case "get_blitz_screenshot": + let path = "/tmp/blitz-app-screenshot-\(Int(Date().timeIntervalSince1970)).png" + let saved = await MainActor.run { () -> Bool in + guard let window = NSApp.windows.first(where: { + $0.title != "Welcome to Blitz" && $0.canBecomeMain && $0.isVisible + }) ?? NSApp.mainWindow else { + return false + } + let windowId = CGWindowID(window.windowNumber) + guard let cgImage = CGWindowListCreateImage( + .null, + .optionIncludingWindow, + windowId, + [.boundsIgnoreFraming, .bestResolution] + ) else { + return false + } + let bitmap = NSBitmapImageRep(cgImage: cgImage) + guard let png = bitmap.representation(using: .png, properties: [:]) else { + return false + } + return ((try? png.write(to: URL(fileURLWithPath: path))) != nil) + } + return saved ? mcpText(path) : mcpText("Error: could not capture Blitz window screenshot") + + default: + throw MCPServerService.MCPError.unknownTool(name) + } + } + + // MARK: - Shared Helpers + + func mcpText(_ text: String) -> [String: Any] { + ["content": [["type": "text", "text": text]]] + } + + func mcpJSON(_ value: Any) -> [String: Any] { + if let data = try? JSONSerialization.data(withJSONObject: value), + let str = String(data: data, encoding: .utf8) { + return mcpText(str) + } + return mcpText("{}") + } + + /// Check for ASC write error and return it, clearing pending form values. + func checkASCWriteError(tab: String) async -> [String: Any]? { + guard let error = await MainActor.run(body: { appState.ascManager.writeError }) else { return nil } + _ = await MainActor.run { appState.ascManager.pendingFormValues.removeValue(forKey: tab) } + return mcpText("Error: \(error)") + } + + struct BuildContext { + let project: Project + let bundleId: String + let teamId: String + let service: AppStoreConnectService + } + + /// Resolve and validate bundle ID + ASC service for build pipeline tools. + /// Returns `(context, nil)` on success or `(nil, errorResponse)` on failure. + func requireBuildContext(needsTeamId: Bool = false) async -> (BuildContext?, [String: Any]?) { + guard let project = await MainActor.run(body: { appState.activeProject }) else { + return (nil, mcpText("Error: no active project.")) + } + guard let service = await MainActor.run(body: { appState.ascManager.service }) else { + return (nil, mcpText("Error: ASC credentials not configured.")) + } + let bundleId = await MainActor.run { () -> String? in + ProjectStorage().readMetadata(projectId: project.id)?.bundleIdentifier + } + guard let bundleId, !bundleId.isEmpty else { + return (nil, mcpText( + "Error: no bundle identifier set. Use asc_fill_form tab=settings.bundleId to set it first." + )) + } + let ascBundleId = await MainActor.run { appState.ascManager.app?.bundleId } + if let ascBundleId, !ascBundleId.isEmpty, ascBundleId != bundleId { + return ( + nil, + mcpText("Error: bundle ID mismatch. Project has '\(bundleId)' but ASC app uses '\(ascBundleId)'.") + ) + } + let teamId = await MainActor.run { () -> String? in + ProjectStorage().readMetadata(projectId: project.id)?.teamId + } + if needsTeamId, (teamId == nil || teamId?.isEmpty == true) { + return (nil, mcpText("Error: no team ID set. Run app_store_setup_signing first.")) + } + return (BuildContext(project: project, bundleId: bundleId, teamId: teamId ?? "", service: service), nil) + } +} diff --git a/src/services/mcp/MCPExecutorASC.swift b/src/services/mcp/MCPExecutorASC.swift new file mode 100644 index 0000000..ed10571 --- /dev/null +++ b/src/services/mcp/MCPExecutorASC.swift @@ -0,0 +1,654 @@ +import AppKit +import Foundation + +extension MCPExecutor { + // MARK: - ASC Form Tools + + static let validFieldsByTab: [String: Set] = [ + "storeListing": ["title", "name", "subtitle", "description", "keywords", "promotionalText", + "marketingUrl", "supportUrl", "whatsNew", "privacyPolicyUrl"], + "appDetails": ["copyright", "primaryCategory", "contentRightsDeclaration"], + "monetization": ["isFree"], + "review.ageRating": ["gambling", "messagingAndChat", "unrestrictedWebAccess", + "userGeneratedContent", "advertising", "lootBox", + "healthOrWellnessTopics", "parentalControls", "ageAssurance", + "alcoholTobaccoOrDrugUseOrReferences", "contests", "gamblingSimulated", + "gunsOrOtherWeapons", "horrorOrFearThemes", "matureOrSuggestiveThemes", + "medicalOrTreatmentInformation", "profanityOrCrudeHumor", + "sexualContentGraphicAndNudity", "sexualContentOrNudity", + "violenceCartoonOrFantasy", "violenceRealistic", + "violenceRealisticProlongedGraphicOrSadistic"], + "review.contact": ["contactFirstName", "contactLastName", "contactEmail", "contactPhone", + "notes", "demoAccountRequired", "demoAccountName", "demoAccountPassword"], + "settings.bundleId": ["bundleId"], + ] + + static let fieldAliases: [String: String] = [ + "firstName": "contactFirstName", + "lastName": "contactLastName", + "email": "contactEmail", + "phone": "contactPhone", + ] + + func executeASCSetCredentials(_ args: [String: Any]) async -> [String: Any] { + guard let issuerId = args["issuerId"] as? String, + let keyId = args["keyId"] as? String, + let rawPath = args["privateKeyPath"] as? String else { + return mcpText("Error: issuerId, keyId, and privateKeyPath are required.") + } + + let path = NSString(string: rawPath).expandingTildeInPath + guard FileManager.default.fileExists(atPath: path), + let privateKey = try? String(contentsOfFile: path, encoding: .utf8), + !privateKey.isEmpty else { + return mcpText("Error: could not read private key file at \(rawPath)") + } + + await MainActor.run { + appState.ascManager.pendingCredentialValues = [ + "issuerId": issuerId, + "keyId": keyId, + "privateKey": privateKey, + "privateKeyFileName": URL(fileURLWithPath: path).lastPathComponent + ] + } + return mcpText("Credentials pre-filled. The user can verify and click 'Save Credentials'.") + } + + func executeASCFillForm(_ args: [String: Any]) async throws -> [String: Any] { + guard let tab = args["tab"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + + var fieldMap: [String: String] = [:] + if let fieldsArray = args["fields"] as? [[String: Any]] { + for item in fieldsArray { + if let field = item["field"] as? String, let value = item["value"] as? String { + fieldMap[Self.fieldAliases[field] ?? field] = value + } + } + } else if let fieldsDict = args["fields"] as? [String: Any] { + for (key, value) in fieldsDict { + fieldMap[Self.fieldAliases[key] ?? key] = "\(value)" + } + } else if let fieldsString = args["fields"] as? String, + let data = fieldsString.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) { + if let dict = parsed as? [String: Any] { + for (key, value) in dict { + fieldMap[Self.fieldAliases[key] ?? key] = "\(value)" + } + } else if let array = parsed as? [[String: Any]] { + for item in array { + if let field = item["field"] as? String, let value = item["value"] as? String { + fieldMap[Self.fieldAliases[field] ?? field] = value + } + } + } + } + + guard !fieldMap.isEmpty else { + throw MCPServerService.MCPError.invalidToolArgs + } + + if let validFields = Self.validFieldsByTab[tab] { + let invalid = fieldMap.keys.filter { !validFields.contains($0) } + if !invalid.isEmpty { + var hints: [String] = [] + for field in invalid { + for (otherTab, otherFields) in Self.validFieldsByTab where otherTab != tab { + if otherFields.contains(field) { + hints.append("'\(field)' belongs on tab '\(otherTab)'") + } + } + } + let hintStr = hints.isEmpty ? "" : " Hint: \(hints.joined(separator: "; "))." + return mcpText( + "Error: invalid field(s) for tab '\(tab)': \(invalid.sorted().joined(separator: ", ")). " + + "Valid fields: \(validFields.sorted().joined(separator: ", ")).\(hintStr)" + ) + } + } + + switch tab { + case "storeListing": + let appInfoLocFields: Set = ["name", "title", "subtitle", "privacyPolicyUrl"] + var versionLocFields: [String: String] = [:] + var infoLocFields: [String: String] = [:] + + for (field, value) in fieldMap { + if appInfoLocFields.contains(field) { + let apiField = (field == "title") ? "name" : field + infoLocFields[apiField] = value + } else { + versionLocFields[field] = value + } + } + + if !infoLocFields.isEmpty { + for (field, value) in infoLocFields { + await appState.ascManager.updateAppInfoLocalizationField(field, value: value) + } + if let err = await checkASCWriteError(tab: tab) { return err } + } + + if !versionLocFields.isEmpty { + guard let locId = await MainActor.run(body: { appState.ascManager.localizations.first?.id }) else { + return mcpText("Error: no version localizations found.") + } + do { + guard let service = await MainActor.run(body: { appState.ascManager.service }) else { + return mcpText("Error: ASC service not configured") + } + try await service.patchLocalization(id: locId, fields: versionLocFields) + if let versionId = await MainActor.run(body: { appState.ascManager.appStoreVersions.first?.id }) { + let localizations = try await service.fetchLocalizations(versionId: versionId) + await MainActor.run { appState.ascManager.localizations = localizations } + } + } catch { + _ = await MainActor.run { appState.ascManager.pendingFormValues.removeValue(forKey: tab) } + return mcpText("Error: \(error.localizedDescription)") + } + } + + case "appDetails": + for (field, value) in fieldMap { + await appState.ascManager.updateAppInfoField(field, value: value) + } + if let err = await checkASCWriteError(tab: tab) { return err } + + case "monetization": + guard let isFree = fieldMap["isFree"] else { + return mcpText( + "Error: monetization tab requires the 'isFree' field (value: \"true\" or \"false\")." + ) + } + if isFree == "true" { + await appState.ascManager.setPriceFree() + } else { + return mcpText( + "To set a paid price, use the asc_set_app_price tool with a price parameter (e.g. price=\"0.99\")." + ) + } + if let err = await checkASCWriteError(tab: tab) { return err } + + case "review.ageRating": + var attrs: [String: Any] = [:] + let boolFields = Set(["gambling", "messagingAndChat", "unrestrictedWebAccess", + "userGeneratedContent", "advertising", "lootBox", + "healthOrWellnessTopics", "parentalControls", "ageAssurance"]) + for (field, value) in fieldMap { + attrs[field] = boolFields.contains(field) ? (value == "true") : value + } + await appState.ascManager.updateAgeRating(attrs) + if let err = await checkASCWriteError(tab: tab) { return err } + + case "review.contact": + var attrs: [String: Any] = [:] + for (field, value) in fieldMap { + if field == "demoAccountRequired" { + attrs[field] = value == "true" + } else if field == "contactPhone" { + let stripped = value.hasPrefix("+") + ? "+" + value.dropFirst().filter(\.isNumber) + : value.filter(\.isNumber) + attrs[field] = stripped + } else { + attrs[field] = value + } + } + await appState.ascManager.updateReviewContact(attrs) + if let err = await checkASCWriteError(tab: tab) { return err } + + case "settings.bundleId": + if let bundleId = fieldMap["bundleId"] { + let projectPath = await MainActor.run { appState.activeProject?.path } + await MainActor.run { + guard let projectId = appState.activeProjectId else { return } + let storage = ProjectStorage() + guard var metadata = storage.readMetadata(projectId: projectId) else { return } + metadata.bundleIdentifier = bundleId + try? storage.writeMetadata(projectId: projectId, metadata: metadata) + } + if let projectPath { + let pipeline = BuildPipelineService() + await pipeline.updateBundleIdInPbxproj(projectPath: projectPath, bundleId: bundleId) + } + await appState.projectManager.loadProjects() + let hasCreds = await MainActor.run { appState.ascManager.credentials != nil } + if hasCreds { + await appState.ascManager.fetchApp(bundleId: bundleId) + } + } + + default: + return mcpText("Unknown tab: \(tab)") + } + + _ = await MainActor.run { appState.ascManager.pendingFormValues.removeValue(forKey: tab) } + return mcpJSON(["success": true, "tab": tab, "fieldsUpdated": fieldMap.count]) + } + + func executeScreenshotsAddAsset(_ args: [String: Any]) async throws -> [String: Any] { + guard let sourcePath = args["sourcePath"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + let expanded = (sourcePath as NSString).expandingTildeInPath + guard FileManager.default.fileExists(atPath: expanded) else { + return mcpText("Error: file not found at \(expanded)") + } + + guard let projectId = await MainActor.run(body: { appState.activeProjectId }) else { + return mcpText("Error: no active project") + } + + let destDir = BlitzPaths.screenshots(projectId: projectId) + let fm = FileManager.default + try? fm.createDirectory(at: destDir, withIntermediateDirectories: true) + + let fileName = args["fileName"] as? String ?? (expanded as NSString).lastPathComponent + let dest = destDir.appendingPathComponent(fileName) + + do { + if fm.fileExists(atPath: dest.path) { + try fm.removeItem(at: dest) + } + try fm.copyItem(atPath: expanded, toPath: dest.path) + } catch { + return mcpText("Error copying file: \(error.localizedDescription)") + } + + await MainActor.run { appState.ascManager.scanLocalAssets(projectId: projectId) } + return mcpJSON(["success": true, "fileName": fileName]) + } + + func executeScreenshotsSetTrack(_ args: [String: Any]) async throws -> [String: Any] { + guard let assetFileName = args["assetFileName"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + guard let slotRaw = args["slotIndex"] as? Int ?? (args["slotIndex"] as? Double).map({ Int($0) }), + slotRaw >= 1 && slotRaw <= 10 else { + return mcpText("Error: slotIndex must be between 1 and 10") + } + let slotIndex = slotRaw - 1 + let displayType = args["displayType"] as? String ?? "APP_IPHONE_67" + + guard let projectId = await MainActor.run(body: { appState.activeProjectId }) else { + return mcpText("Error: no active project") + } + + let dir = BlitzPaths.screenshots(projectId: projectId) + let filePath = dir.appendingPathComponent(assetFileName).path + + guard FileManager.default.fileExists(atPath: filePath) else { + return mcpText("Error: asset '\(assetFileName)' not found in local screenshots library") + } + + let error = await MainActor.run { + appState.ascManager.addAssetToTrack(displayType: displayType, slotIndex: slotIndex, localPath: filePath) + } + if let error { + return mcpText("Error: \(error)") + } + return mcpJSON(["success": true, "slot": slotRaw]) + } + + func executeScreenshotsSave(_ args: [String: Any]) async throws -> [String: Any] { + let displayType = args["displayType"] as? String ?? "APP_IPHONE_67" + let locale = args["locale"] as? String ?? "en-US" + + let hasChanges = await MainActor.run { appState.ascManager.hasUnsavedChanges(displayType: displayType) } + guard hasChanges else { + return mcpJSON(["success": true, "message": "No changes to save"]) + } + + await appState.ascManager.syncTrackToASC(displayType: displayType, locale: locale) + + if let err = await checkASCWriteError(tab: "screenshots") { return err } + + let slotCount = await MainActor.run { + (appState.ascManager.trackSlots[displayType] ?? []).compactMap { $0 }.count + } + return mcpJSON(["success": true, "synced": slotCount]) + } + + func executeASCOpenSubmitPreview() async -> [String: Any] { + await appState.ascManager.refreshSubmissionReadinessData() + + var readiness = await MainActor.run { appState.ascManager.submissionReadiness } + let buildMissing = readiness.missingRequired.contains { $0.label == "Build" } + if buildMissing { + let service = await MainActor.run { appState.ascManager.service } + let appId = await MainActor.run { appState.ascManager.app?.id } + if let service, let appId, + let latestBuild = try? await service.fetchLatestBuild(appId: appId), + latestBuild.attributes.processingState == "VALID" { + let versionId = await MainActor.run { appState.ascManager.pendingVersionId } + if let versionId { + do { + try await service.attachBuild(versionId: versionId, buildId: latestBuild.id) + await appState.ascManager.refreshTabData(.app) + readiness = await MainActor.run { appState.ascManager.submissionReadiness } + } catch { + // Non-fatal: readiness will still surface the missing build. + } + } + } + } + + if !readiness.isComplete { + let missing = readiness.missingRequired.map { $0.label } + return mcpJSON(["ready": false, "missing": missing]) + } + + await MainActor.run { + appState.ascManager.showSubmitPreview = true + } + + return mcpJSON(["ready": true, "opened": true]) + } + + // MARK: - ASC IAP / Subscriptions / Pricing Tools + + static func priceMatches(_ customerPrice: String?, target: String) -> Bool { + guard let customerPrice else { return false } + guard let a = Double(customerPrice), let b = Double(target) else { + return customerPrice == target + } + return abs(a - b) < 0.001 + } + + func executeASCWebAuth() async -> [String: Any] { + await MainActor.run { + NSApp.activate(ignoringOtherApps: true) + } + + guard let session = await appState.ascManager.requestWebAuthForMCP() else { + let authError = await MainActor.run { appState.ascManager.irisFeedbackError } + if let authError, !authError.isEmpty { + return mcpJSON([ + "success": false, + "cancelled": false, + "message": authError + ]) + } + return mcpJSON([ + "success": false, + "cancelled": true, + "message": "Web authentication was cancelled before a session was captured." + ]) + } + + let email = session.email ?? "unknown" + return mcpJSON([ + "success": true, + "email": email, + "message": "Web session authenticated and synced to ~/.blitz/asc-agent/web-session.json. The asc-iap-attach skill can now use the iris API." + ]) + } + + func executeASCSetAppPrice(_ args: [String: Any]) async throws -> [String: Any] { + guard let priceStr = args["price"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + let effectiveDate = args["effectiveDate"] as? String + + guard let service = await MainActor.run(body: { appState.ascManager.service }) else { + return mcpText("Error: ASC service not configured") + } + guard let appId = await MainActor.run(body: { appState.ascManager.app?.id }) else { + return mcpText("Error: no ASC app loaded. Open a project with a bundle ID first.") + } + + if let priceVal = Double(priceStr), priceVal < 0.001 { + try await service.setPriceFree(appId: appId) + try await service.ensureAppAvailability(appId: appId) + await MainActor.run { + appState.ascManager.currentAppPricePointId = appState.ascManager.freeAppPricePointId + appState.ascManager.scheduledAppPricePointId = nil + appState.ascManager.scheduledAppPriceEffectiveDate = nil + appState.ascManager.monetizationStatus = "Free" + } + await appState.ascManager.refreshTabData(.monetization) + return mcpJSON([ + "success": true, + "price": "0.00", + "message": "App set to free with territory availability configured" + ]) + } + + let pricePoints = try await service.fetchAppPricePoints(appId: appId) + guard let match = pricePoints.first(where: { + Self.priceMatches($0.attributes.customerPrice, target: priceStr) + }) else { + let sorted = pricePoints.compactMap { $0.attributes.customerPrice } + .compactMap { Double($0) } + .filter { $0 > 0 } + .sorted() + let samples = sorted.count <= 30 ? sorted : { + let lo = Array(sorted.prefix(5)) + let hi = Array(sorted.suffix(5)) + let step = max(1, (sorted.count - 10) / 10) + let mid = stride(from: 5, to: sorted.count - 5, by: step).map { sorted[$0] } + return lo + mid + hi + }() + let formatted = samples.map { String(format: "%.2f", $0) } + return mcpText( + "Error: no price point matching $\(priceStr). \(sorted.count) tiers available, " + + "samples: \(formatted.joined(separator: ", "))" + ) + } + + if let effectiveDate { + let freePoint = pricePoints.first(where: { + let p = $0.attributes.customerPrice ?? "0" + return p == "0" || p == "0.0" || p == "0.00" + }) + let currentId = freePoint?.id ?? match.id + try await service.setScheduledAppPrice( + appId: appId, + currentPricePointId: currentId, + futurePricePointId: match.id, + effectiveDate: effectiveDate + ) + try await service.ensureAppAvailability(appId: appId) + await MainActor.run { + appState.ascManager.currentAppPricePointId = currentId + appState.ascManager.scheduledAppPricePointId = match.id + appState.ascManager.scheduledAppPriceEffectiveDate = effectiveDate + appState.ascManager.monetizationStatus = "Configured" + } + await appState.ascManager.refreshTabData(.monetization) + return mcpJSON([ + "success": true, + "price": priceStr, + "effectiveDate": effectiveDate, + "message": "Scheduled price change for \(effectiveDate) with territory availability configured" + ]) + } + + try await service.setAppPrice(appId: appId, pricePointId: match.id) + try await service.ensureAppAvailability(appId: appId) + await MainActor.run { + appState.ascManager.currentAppPricePointId = match.id + appState.ascManager.scheduledAppPricePointId = nil + appState.ascManager.scheduledAppPriceEffectiveDate = nil + appState.ascManager.monetizationStatus = "Configured" + } + await appState.ascManager.refreshTabData(.monetization) + return mcpJSON(["success": true, "price": priceStr, "pricePointId": match.id]) + } + + func executeASCCreateIAP(_ args: [String: Any]) async throws -> [String: Any] { + guard let productId = args["productId"] as? String, + let name = args["name"] as? String, + let type = args["type"] as? String, + let displayName = args["displayName"] as? String, + let priceStr = args["price"] as? String, + let screenshotPath = args["screenshotPath"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + let description = args["description"] as? String + + let validTypes = ["CONSUMABLE", "NON_CONSUMABLE", "NON_RENEWING_SUBSCRIPTION"] + guard validTypes.contains(type) else { + return mcpText("Error: invalid type '\(type)'. Must be one of: \(validTypes.joined(separator: ", "))") + } + + await MainActor.run { + var values: [String: String] = [ + "kind": "iap", + "name": name, + "productId": productId, + "type": type, + "displayName": displayName, + "price": priceStr + ] + if let description { values["description"] = description } + appState.ascManager.pendingCreateValues = values + } + + await MainActor.run { + appState.ascManager.createIAP( + name: name, + productId: productId, + type: type, + displayName: displayName, + description: description, + price: priceStr, + screenshotPath: screenshotPath + ) + } + + if let error = await pollASCCreation() { + return mcpText("Error creating IAP: \(error)") + } + + return mcpJSON([ + "success": true, + "productId": productId, + "type": type, + "displayName": displayName, + "price": priceStr + ]) + } + + func executeASCCreateSubscription(_ args: [String: Any]) async throws -> [String: Any] { + guard let groupName = args["groupName"] as? String, + let productId = args["productId"] as? String, + let name = args["name"] as? String, + let displayName = args["displayName"] as? String, + let duration = args["duration"] as? String, + let priceStr = args["price"] as? String, + let screenshotPath = args["screenshotPath"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + let description = args["description"] as? String + + let validDurations = ["ONE_WEEK", "ONE_MONTH", "TWO_MONTHS", "THREE_MONTHS", "SIX_MONTHS", "ONE_YEAR"] + guard validDurations.contains(duration) else { + return mcpText( + "Error: invalid duration '\(duration)'. Must be one of: \(validDurations.joined(separator: ", "))" + ) + } + + await MainActor.run { + var values: [String: String] = [ + "kind": "subscription", + "groupName": groupName, + "name": name, + "productId": productId, + "displayName": displayName, + "duration": duration, + "price": priceStr + ] + if let description { values["description"] = description } + appState.ascManager.pendingCreateValues = values + } + + await MainActor.run { + appState.ascManager.createSubscription( + groupName: groupName, + name: name, + productId: productId, + displayName: displayName, + description: description, + duration: duration, + price: priceStr, + screenshotPath: screenshotPath + ) + } + + if let error = await pollASCCreation() { + return mcpText("Error creating subscription: \(error)") + } + + return mcpJSON([ + "success": true, + "groupName": groupName, + "productId": productId, + "displayName": displayName, + "duration": duration, + "price": priceStr + ]) + } + + func pollASCCreation() async -> String? { + for _ in 0..<10 { + let creating = await MainActor.run { appState.ascManager.isCreating } + if creating { break } + try? await Task.sleep(for: .milliseconds(100)) + } + while await MainActor.run(body: { appState.ascManager.isCreating }) { + try? await Task.sleep(for: .milliseconds(500)) + } + return await MainActor.run { appState.ascManager.writeError } + } + + func executeGetRejectionFeedback(_ args: [String: Any]) async throws -> [String: Any] { + let raw = await MainActor.run { () -> [String: Any] in + let asc = appState.ascManager + guard let appId = asc.app?.id else { + return ["error": "No app connected. Set up ASC credentials first."] + } + + let requestedVersion = args["version"] as? String + let version: String + if let requestedVersion { + version = requestedVersion + } else if let rejected = asc.appStoreVersions.first(where: { + $0.attributes.appStoreState == "REJECTED" + }) { + version = rejected.attributes.versionString + } else { + return ["error": "No rejected version found.", "appId": appId] + } + + if let cached = IrisFeedbackCache.load(appId: appId, versionString: version) { + let reasons = cached.reasons.map { reason in + ["section": reason.section, "description": reason.description, "code": reason.code] + } + let messages = cached.messages.map { message -> [String: String] in + var msg = ["body": message.body] + if let date = message.date { msg["date"] = date } + return msg + } + return [ + "appId": appId, + "version": version, + "fetchedAt": ISO8601DateFormatter().string(from: cached.fetchedAt), + "reasons": reasons, + "messages": messages, + "source": "cache" + ] + } + + return [ + "error": "No rejection feedback cached for version \(version). The user needs to sign in with their Apple ID in the ASC Overview tab to fetch feedback.", + "appId": appId, + "version": version + ] + } + return mcpJSON(raw) + } +} diff --git a/src/services/mcp/MCPExecutorAppNavigation.swift b/src/services/mcp/MCPExecutorAppNavigation.swift new file mode 100644 index 0000000..de27034 --- /dev/null +++ b/src/services/mcp/MCPExecutorAppNavigation.swift @@ -0,0 +1,99 @@ +import Foundation + +extension MCPExecutor { + // MARK: - App State Tools + + func executeAppGetState() async throws -> [String: Any] { + let state = await MainActor.run { () -> [String: Any] in + var result: [String: Any] = [ + "activeTab": appState.activeTab.rawValue, + "activeAppSubTab": appState.activeAppSubTab.rawValue, + "isStreaming": appState.simulatorStream.isCapturing + ] + if let project = appState.activeProject { + result["activeProject"] = [ + "id": project.id, + "name": project.name, + "path": project.path, + "type": project.type.rawValue + ] + } + if let udid = appState.simulatorManager.bootedDeviceId { + result["bootedSimulator"] = udid + } + let db = appState.databaseManager + if db.connectionStatus == .connected || db.backendProcess.isRunning { + result["database"] = [ + "url": db.backendProcess.baseURL, + "status": db.connectionStatus == .connected ? "connected" : "running" + ] + } + return result + } + return mcpJSON(state) + } + + // MARK: - Navigation Tools + + func executeNavSwitchTab(_ args: [String: Any]) async throws -> [String: Any] { + guard let tabStr = args["tab"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + + let legacySubTabMap: [String: AppSubTab] = [ + "simulator": .simulator, + "database": .database, + "tests": .tests, + "assets": .icon, + "icon": .icon, + "ascOverview": .overview, + "overview": .overview, + ] + + if let subTab = legacySubTabMap[tabStr] { + await MainActor.run { + appState.activeTab = .app + appState.activeAppSubTab = subTab + } + + if subTab == .database { + let status = await MainActor.run { appState.databaseManager.connectionStatus } + if status != .connected, let project = await MainActor.run(body: { appState.activeProject }) { + await appState.databaseManager.startAndConnect(projectId: project.id, projectPath: project.path) + } + } + + return mcpText("Switched to App > \(subTab.label)") + } + + guard let tab = AppTab(rawValue: tabStr) else { + throw MCPServerService.MCPError.invalidToolArgs + } + await MainActor.run { appState.activeTab = tab } + + return mcpText("Switched to tab: \(tab.label)") + } + + func executeNavListTabs() async -> [String: Any] { + let topLevel: [[String: Any]] = [ + ["name": "dashboard", "label": "Dashboard", "icon": "square.grid.2x2"], + [ + "name": "app", + "label": "App", + "icon": "app", + "subTabs": AppSubTab.allCases.map { + ["name": $0.rawValue, "label": $0.label, "icon": $0.systemImage] as [String: Any] + } + ], + ] + var groups: [[String: Any]] = [["group": "Top", "tabs": topLevel]] + for group in AppTab.Group.allCases { + let tabs = group.tabs.map { + ["name": $0.rawValue, "label": $0.label, "icon": $0.icon] as [String: Any] + } + groups.append(["group": group.rawValue, "tabs": tabs]) + } + groups.append(["group": "Other", "tabs": [["name": "settings", "label": "Settings", "icon": "gear"]]]) + return mcpJSON(["groups": groups]) + } +} diff --git a/src/services/mcp/MCPExecutorBuildPipeline.swift b/src/services/mcp/MCPExecutorBuildPipeline.swift new file mode 100644 index 0000000..ebd51df --- /dev/null +++ b/src/services/mcp/MCPExecutorBuildPipeline.swift @@ -0,0 +1,338 @@ +import Foundation + +extension MCPExecutor { + // MARK: - Build Pipeline Tools + + func executeSetupSigning(_ args: [String: Any]) async throws -> [String: Any] { + let (optCtx, err) = await requireBuildContext() + guard let ctx = optCtx else { return err! } + let project = ctx.project + let bundleId = ctx.bundleId + let service = ctx.service + let teamId = args["teamId"] as? String ?? (ctx.teamId.isEmpty ? nil : ctx.teamId) + + await MainActor.run { + appState.ascManager.buildPipelinePhase = .signingSetup + appState.ascManager.buildPipelineMessage = "Setting up signing…" + } + + let pipeline = BuildPipelineService() + let appStateRef = appState + do { + let projectPlatform = await MainActor.run { project.platform } + let result = try await withThrowingTimeout(seconds: 300) { + try await pipeline.setupSigning( + projectPath: project.path, + bundleId: bundleId, + teamId: teamId, + ascService: service, + platform: projectPlatform, + onProgress: { msg in + Task { @MainActor in + appStateRef.ascManager.buildPipelineMessage = msg + } + } + ) + } + + if !result.teamId.isEmpty { + await MainActor.run { + let storage = ProjectStorage() + guard var metadata = storage.readMetadata(projectId: project.id) else { return } + metadata.teamId = result.teamId + try? storage.writeMetadata(projectId: project.id, metadata: metadata) + } + } + + await MainActor.run { + appState.ascManager.buildPipelinePhase = .idle + appState.ascManager.buildPipelineMessage = "" + } + + var resultDict: [String: Any] = [ + "success": true, + "bundleIdResourceId": result.bundleIdResourceId, + "certificateId": result.certificateId, + "profileUUID": result.profileUUID, + "teamId": result.teamId, + "log": result.log + ] + if let installerCertId = result.installerCertificateId { + resultDict["installerCertificateId"] = installerCertId + } + return mcpJSON(resultDict) + } catch { + await MainActor.run { + appState.ascManager.buildPipelinePhase = .idle + appState.ascManager.buildPipelineMessage = "" + } + return mcpText("Error in signing setup: \(error.localizedDescription)") + } + } + + func executeBuildIPA(_ args: [String: Any]) async throws -> [String: Any] { + let (optCtx, err) = await requireBuildContext(needsTeamId: true) + guard let ctx = optCtx else { return err! } + let project = ctx.project + let bundleId = ctx.bundleId + let teamId = ctx.teamId + + let scheme = args["scheme"] as? String + let configuration = args["configuration"] as? String + + await MainActor.run { + appState.ascManager.buildPipelinePhase = .archiving + appState.ascManager.buildPipelineMessage = "Starting build…" + } + + let pipeline = BuildPipelineService() + let appStateRef = appState + do { + let buildPlatform = await MainActor.run { project.platform } + let result = try await pipeline.buildIPA( + projectPath: project.path, + bundleId: bundleId, + teamId: teamId, + scheme: scheme, + configuration: configuration, + platform: buildPlatform, + onProgress: { msg in + Task { @MainActor in + if msg.contains("ARCHIVE SUCCEEDED") || msg.contains("-exportArchive") { + appStateRef.ascManager.buildPipelinePhase = .exporting + } + appStateRef.ascManager.buildPipelineMessage = String(msg.prefix(120)) + } + } + ) + + await MainActor.run { + appState.ascManager.buildPipelinePhase = .idle + appState.ascManager.buildPipelineMessage = "" + } + + return mcpJSON([ + "success": true, + "ipaPath": result.ipaPath, + "archivePath": result.archivePath, + "log": result.log + ]) + } catch { + await MainActor.run { + appState.ascManager.buildPipelinePhase = .idle + appState.ascManager.buildPipelineMessage = "" + } + return mcpText("Error building IPA: \(error.localizedDescription)") + } + } + + func executeUploadToTestFlight(_ args: [String: Any]) async throws -> [String: Any] { + guard let credentials = await MainActor.run(body: { appState.ascManager.credentials }) else { + return mcpText("Error: ASC credentials not configured.") + } + guard await MainActor.run(body: { appState.activeProject }) != nil else { + return mcpText("Error: no active project.") + } + + let ipaPath: String + if let path = args["ipaPath"] as? String { + ipaPath = (path as NSString).expandingTildeInPath + } else { + let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory()) + let tmpContents = try FileManager.default.contentsOfDirectory( + at: tmpURL, + includingPropertiesForKeys: [.contentModificationDateKey] + ) + let exportDirs = tmpContents.filter { $0.lastPathComponent.hasPrefix("BlitzExport-") } + .sorted { a, b in + let aDate = (try? a.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) + ?? .distantPast + let bDate = (try? b.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) + ?? .distantPast + return aDate > bDate + } + + let searchExts: Set = ["ipa", "pkg"] + var foundArtifact: String? + for dir in exportDirs { + let files = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) + if let match = files.first(where: { searchExts.contains($0.pathExtension) }) { + foundArtifact = match.path + break + } + } + guard let found = foundArtifact else { + return mcpText("Error: no IPA/PKG path provided and no recent build found. Run app_store_build first.") + } + ipaPath = found + } + + guard FileManager.default.fileExists(atPath: ipaPath) else { + return mcpText("Error: IPA not found at \(ipaPath)") + } + + let skipPolling = args["skipPolling"] as? Bool ?? false + let appId = await MainActor.run { appState.ascManager.app?.id } + let service = await MainActor.run { appState.ascManager.service } + + let isIPA = ipaPath.hasSuffix(".ipa") + var existingVersions: Set = [] + do { + guard isIPA else { throw NSError(domain: "skip", code: 0) } + let plistXML = try await ProcessRunner.run( + "/bin/bash", + arguments: ["-c", "unzip -p '\(ipaPath)' 'Payload/*.app/Info.plist' | plutil -convert xml1 -o - -"] + ) + + let ipaVersion: String? = { + guard let range = plistXML.range(of: "CFBundleVersion"), + let valueStart = plistXML.range(of: "", range: range.upperBound..", range: valueStart.upperBound..ITSAppUsesNonExemptEncryption directly to Info.plist." + ) + } + + if let ipaVersion, !ipaVersion.isEmpty, let appId, let service { + let builds = try await service.fetchBuilds(appId: appId) + existingVersions = Set(builds.map(\.attributes.version)) + if existingVersions.contains(ipaVersion) { + let maxVersion = existingVersions.compactMap { Int($0) }.max() ?? 0 + return mcpText( + "Error: build version \(ipaVersion) already exists in App Store Connect. " + + "Existing build versions: \(existingVersions.sorted().joined(separator: ", ")). " + + "The next valid build version is \(maxVersion + 1). " + + "Update CFBundleVersion in Info.plist (or CURRENT_PROJECT_VERSION in the Xcode build settings) and rebuild." + ) + } + } + } catch { + // Non-fatal — proceed with upload and let altool catch any issues. + } + + if existingVersions.isEmpty, let appId, let service { + existingVersions = Set((try? await service.fetchBuilds(appId: appId))?.map(\.attributes.version) ?? []) + } + + await MainActor.run { + appState.ascManager.buildPipelinePhase = .uploading + appState.ascManager.buildPipelineMessage = "Uploading IPA…" + } + + let pipeline = BuildPipelineService() + let appStateRef = appState + do { + let uploadPlatform = await MainActor.run { appState.activeProject?.platform ?? .iOS } + let result = try await pipeline.uploadToTestFlight( + ipaPath: ipaPath, + keyId: credentials.keyId, + issuerId: credentials.issuerId, + privateKeyPEM: credentials.privateKey, + appId: appId, + ascService: service, + skipPolling: true, + platform: uploadPlatform, + onProgress: { msg in + Task { @MainActor in + appStateRef.ascManager.buildPipelineMessage = String(msg.prefix(120)) + } + } + ) + + var allLog = result.log + var finalState = result.processingState + var finalVersion = result.buildVersion + + if !skipPolling, let appId, let service { + await MainActor.run { + appStateRef.ascManager.buildPipelinePhase = .processing + appStateRef.ascManager.buildPipelineMessage = "Waiting for new build to appear…" + } + + let pollInterval: TimeInterval = 10 + let maxAttempts = 30 + + for attempt in 1...maxAttempts { + try? await Task.sleep(for: .seconds(pollInterval)) + + guard let builds = try? await service.fetchBuilds(appId: appId) else { continue } + + if let newBuild = builds.first(where: { !existingVersions.contains($0.attributes.version) }) { + let state = newBuild.attributes.processingState ?? "UNKNOWN" + let version = newBuild.attributes.version + let msg = "Poll \(attempt): build \(version) — \(state)" + allLog.append(msg) + await MainActor.run { + appStateRef.ascManager.buildPipelineMessage = msg + appStateRef.ascManager.builds = builds + } + + finalVersion = version + finalState = state + + if state == "VALID" { + allLog.append("Build processing complete!") + try? await service.patchBuildEncryption( + buildId: newBuild.id, + usesNonExemptEncryption: false + ) + let versionId = await MainActor.run(body: { + appStateRef.ascManager.pendingVersionId + }) + if let versionId { + do { + try await service.attachBuild(versionId: versionId, buildId: newBuild.id) + allLog.append("Build \(version) attached to app store version.") + } catch { + allLog.append("Warning: could not auto-attach build - \(error.localizedDescription)") + } + } + break + } else if state == "INVALID" { + allLog.append("Build processing failed with INVALID state.") + break + } + } else { + let msg = "Poll \(attempt): new build not yet visible…" + allLog.append(msg) + await MainActor.run { + appStateRef.ascManager.buildPipelineMessage = msg + } + } + } + } + + await MainActor.run { + appState.ascManager.buildPipelinePhase = .idle + appState.ascManager.buildPipelineMessage = "" + } + await appState.ascManager.refreshTabData(.builds) + + var response: [String: Any] = [ + "success": true, + "processingState": finalState ?? "UNKNOWN", + "log": allLog + ] + if let version = finalVersion { + response["buildVersion"] = version + } + return mcpJSON(response) + } catch { + await MainActor.run { + appState.ascManager.buildPipelinePhase = .idle + appState.ascManager.buildPipelineMessage = "" + } + return mcpText("Error uploading to TestFlight: \(error.localizedDescription)") + } + } +} diff --git a/src/services/mcp/MCPExecutorProjectEnvironment.swift b/src/services/mcp/MCPExecutorProjectEnvironment.swift new file mode 100644 index 0000000..271398c --- /dev/null +++ b/src/services/mcp/MCPExecutorProjectEnvironment.swift @@ -0,0 +1,194 @@ +import Foundation + +extension MCPExecutor { + // MARK: - Project Tools + + func executeProjectList() async -> [String: Any] { + await appState.projectManager.loadProjects() + let projects = await MainActor.run { + appState.projectManager.projects.map { project -> [String: Any] in + ["id": project.id, "name": project.name, "path": project.path, "type": project.type.rawValue] + } + } + return mcpJSON(["projects": projects]) + } + + func executeProjectGetActive() async -> [String: Any] { + let result = await MainActor.run { () -> [String: Any]? in + guard let project = appState.activeProject else { return nil } + return ["id": project.id, "name": project.name, "path": project.path, "type": project.type.rawValue] + } + if let result { + return mcpJSON(result) + } + return mcpText("No active project") + } + + func executeProjectOpen(_ args: [String: Any]) async throws -> [String: Any] { + guard let projectId = args["projectId"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + let storage = ProjectStorage() + storage.updateLastOpened(projectId: projectId) + await MainActor.run { appState.activeProjectId = projectId } + await appState.projectManager.loadProjects() + return mcpText("Opened project: \(projectId)") + } + + func executeProjectCreate(_ args: [String: Any]) async throws -> [String: Any] { + guard let name = args["name"] as? String, + let typeStr = args["type"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + + let storage = ProjectStorage() + let projectId = name.lowercased() + .replacingOccurrences(of: " ", with: "-") + .filter { $0.isLetter || $0.isNumber || $0 == "-" } + let projectDir = storage.baseDirectory.appendingPathComponent(projectId) + + try FileManager.default.createDirectory(at: projectDir, withIntermediateDirectories: true) + + let projectType = ProjectType(rawValue: typeStr) ?? .reactNative + let platformStr = args["platform"] as? String + let platform = ProjectPlatform(rawValue: platformStr ?? "iOS") ?? .iOS + let metadata = BlitzProjectMetadata( + name: name, + type: projectType, + platform: platform, + createdAt: Date(), + lastOpenedAt: Date() + ) + try storage.writeMetadata(projectId: projectId, metadata: metadata) + let (whitelistBlitzMCP, allowASCCLICalls) = await MainActor.run { + ( + SettingsService.shared.whitelistBlitzMCPTools, + SettingsService.shared.allowASCCLICalls + ) + } + storage.ensureMCPConfig( + projectId: projectId, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + await appState.projectManager.loadProjects() + + await MainActor.run { + appState.projectSetup.pendingSetupProjectId = projectId + appState.activeProjectId = projectId + } + + try? await Task.sleep(for: .seconds(2)) + let setupStarted = await MainActor.run { appState.projectSetup.isSettingUp } + if !setupStarted { + guard let project = await MainActor.run(body: { appState.activeProject }) else { + return mcpText("Created project '\(name)' but could not start setup (project not found)") + } + await appState.projectSetup.setup( + projectId: project.id, + projectName: project.name, + projectPath: project.path, + projectType: project.type, + platform: project.platform + ) + } else { + for _ in 0..<180 { + let done = await MainActor.run { !appState.projectSetup.isSettingUp } + if done { break } + try? await Task.sleep(for: .seconds(1)) + } + } + + let errorMsg = await MainActor.run { appState.projectSetup.errorMessage } + if let errorMsg { + return mcpText("Created project '\(name)' but setup failed: \(errorMsg)") + } + return mcpText("Created project '\(name)' (type: \(typeStr), id: \(projectId)) — setup complete") + } + + func executeProjectImport(_ args: [String: Any]) async throws -> [String: Any] { + guard let path = args["path"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + + let url = URL(fileURLWithPath: path) + let storage = ProjectStorage() + let projectId = try storage.openProject(at: url) + let (whitelistBlitzMCP, allowASCCLICalls) = await MainActor.run { + ( + SettingsService.shared.whitelistBlitzMCPTools, + SettingsService.shared.allowASCCLICalls + ) + } + storage.ensureMCPConfig( + projectId: projectId, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + await appState.projectManager.loadProjects() + await MainActor.run { appState.activeProjectId = projectId } + + return mcpText("Imported project from '\(path)' (id: \(projectId))") + } + + func executeProjectClose() async -> [String: Any] { + await MainActor.run { appState.activeProjectId = nil } + return mcpText("Project closed") + } + + // MARK: - Simulator Tools + + func executeSimulatorListDevices() async -> [String: Any] { + await appState.simulatorManager.loadSimulators() + let devices = await MainActor.run { + appState.simulatorManager.simulators.map { sim -> [String: Any] in + [ + "udid": sim.udid, + "name": sim.name, + "state": sim.state, + "isBooted": sim.isBooted + ] + } + } + return mcpJSON(["devices": devices]) + } + + func executeSimulatorSelectDevice(_ args: [String: Any]) async throws -> [String: Any] { + guard let udid = args["udid"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + + let service = SimulatorService() + try await service.boot(udid: udid) + await MainActor.run { appState.simulatorManager.bootedDeviceId = udid } + await appState.simulatorManager.loadSimulators() + + return mcpText("Booted simulator: \(udid)") + } + + // MARK: - Settings Tools + + func executeSettingsGet() async -> [String: Any] { + let settings = await MainActor.run { () -> [String: Any] in + [ + "showCursor": appState.settingsStore.showCursor, + "cursorSize": appState.settingsStore.cursorSize, + "defaultSimulatorUDID": appState.settingsStore.defaultSimulatorUDID ?? "" + ] + } + return mcpJSON(settings) + } + + func executeSettingsUpdate(_ args: [String: Any]) async -> [String: Any] { + await MainActor.run { + if let cursor = args["showCursor"] as? Bool { appState.settingsStore.showCursor = cursor } + if let size = args["cursorSize"] as? Double { appState.settingsStore.cursorSize = size } + } + return mcpText("Settings updated") + } + + func executeSettingsSave() async -> [String: Any] { + await MainActor.run { appState.settingsStore.save() } + return mcpText("Settings saved to disk") + } +} diff --git a/src/services/mcp/MCPExecutorTabState.swift b/src/services/mcp/MCPExecutorTabState.swift new file mode 100644 index 0000000..a5f135c --- /dev/null +++ b/src/services/mcp/MCPExecutorTabState.swift @@ -0,0 +1,308 @@ +import Foundation + +extension MCPExecutor { + // MARK: - Tab State Tool + + func executeGetTabState(_ args: [String: Any]) async throws -> [String: Any] { + let tabStr = args["tab"] as? String + let tab: AppTab + if let tabStr { + if tabStr == "ascOverview" || tabStr == "overview" { + tab = .app + } else if let parsed = AppTab(rawValue: tabStr) { + tab = parsed + } else { + tab = await MainActor.run { appState.activeTab } + } + } else { + tab = await MainActor.run { appState.activeTab } + } + + var result = await MainActor.run { () -> [String: Any] in + let asc = appState.ascManager + var value: [String: Any] = [ + "tab": tab.rawValue, + "isLoading": asc.isLoadingTab[tab] ?? false, + ] + if let error = asc.tabError[tab] { value["error"] = error } + if let writeErr = asc.writeError { value["writeError"] = writeErr } + if tab.isASCTab, let app = asc.app { + value["app"] = ["id": app.id, "name": app.name, "bundleId": app.bundleId] + } + return value + } + + if tab == .app { + await appState.ascManager.refreshSubmissionReadinessData() + } + + let tabData = await MainActor.run { () -> [String: Any] in + let projectId = appState.activeProjectId + return tabStateData(for: tab, asc: appState.ascManager, projectId: projectId) + } + for (key, value) in tabData { + result[key] = value + } + + return mcpJSON(result) + } + + /// Extract tab-specific state data. Must be called on MainActor. + @MainActor + func tabStateData(for tab: AppTab, asc: ASCManager, projectId: String?) -> [String: Any] { + switch tab { + case .app: + if let projectId { + asc.checkAppIcon(projectId: projectId) + } + return tabStateASCOverview(asc) + case .storeListing: + return tabStateStoreListing(asc) + case .appDetails: + return tabStateAppDetails(asc) + case .review: + return tabStateReview(asc) + case .screenshots: + return tabStateScreenshots(asc) + case .reviews: + return tabStateReviews(asc) + case .builds: + return tabStateBuilds(asc) + case .groups: + return tabStateGroups(asc) + case .betaInfo: + return tabStateBetaInfo(asc) + case .feedback: + return tabStateFeedback(asc) + default: + return ["note": "No structured state available for this tab"] + } + } + + @MainActor + func tabStateASCOverview(_ asc: ASCManager) -> [String: Any] { + let readiness = asc.submissionReadiness + var fields: [[String: Any]] = [] + for field in readiness.fields { + let filled = field.value != nil && !(field.value?.isEmpty ?? true) + var entry: [String: Any] = [ + "label": field.label, + "value": field.value as Any, + "required": field.required, + "filled": filled + ] + if let hint = field.hint { + entry["hint"] = hint + } + fields.append(entry) + } + var result: [String: Any] = [ + "submissionReadiness": [ + "isComplete": readiness.isComplete, + "fields": fields, + "missingRequired": readiness.missingRequired.map { $0.label } + ], + "totalVersions": asc.appStoreVersions.count, + "isSubmitting": asc.isSubmitting + ] + if let version = asc.appStoreVersions.first { + result["latestVersion"] = [ + "id": version.id, + "versionString": version.attributes.versionString, + "state": version.attributes.appStoreState ?? "unknown" + ] + } + if let error = asc.submissionError { + result["submissionError"] = error + } + if let cached = asc.cachedFeedback { + result["rejectionFeedback"] = [ + "version": cached.versionString, + "reasonCount": cached.reasons.count, + "messageCount": cached.messages.count, + "hint": "Use get_rejection_feedback tool for full details" + ] + } + return result + } + + @MainActor + func tabStateStoreListing(_ asc: ASCManager) -> [String: Any] { + let localization = asc.localizations.first + let infoLoc = asc.appInfoLocalization + let localizationState: [String: Any] = [ + "locale": localization?.attributes.locale ?? "", + "name": infoLoc?.attributes.name ?? localization?.attributes.title ?? "", + "subtitle": infoLoc?.attributes.subtitle ?? localization?.attributes.subtitle ?? "", + "description": localization?.attributes.description ?? "", + "keywords": localization?.attributes.keywords ?? "", + "promotionalText": localization?.attributes.promotionalText ?? "", + "marketingUrl": localization?.attributes.marketingUrl ?? "", + "supportUrl": localization?.attributes.supportUrl ?? "", + "whatsNew": localization?.attributes.whatsNew ?? "" + ] + + return [ + "localization": localizationState, + "privacyPolicyUrl": infoLoc?.attributes.privacyPolicyUrl ?? "", + "localeCount": asc.localizations.count + ] + } + + @MainActor + func tabStateAppDetails(_ asc: ASCManager) -> [String: Any] { + var result: [String: Any] = [ + "appInfo": [ + "primaryCategory": asc.appInfo?.primaryCategoryId ?? "", + "contentRightsDeclaration": asc.app?.contentRightsDeclaration ?? "" + ], + "versionCount": asc.appStoreVersions.count + ] + if let version = asc.appStoreVersions.first { + result["latestVersion"] = [ + "versionString": version.attributes.versionString, + "state": version.attributes.appStoreState ?? "unknown" + ] + } + return result + } + + @MainActor + func tabStateReview(_ asc: ASCManager) -> [String: Any] { + var result: [String: Any] = [:] + + if let ageRating = asc.ageRatingDeclaration { + let attrs = ageRating.attributes + var ageRatingDict: [String: Any] = ["id": ageRating.id] + ageRatingDict["gambling"] = attrs.gambling ?? false + ageRatingDict["messagingAndChat"] = attrs.messagingAndChat ?? false + ageRatingDict["unrestrictedWebAccess"] = attrs.unrestrictedWebAccess ?? false + ageRatingDict["userGeneratedContent"] = attrs.userGeneratedContent ?? false + ageRatingDict["advertising"] = attrs.advertising ?? false + ageRatingDict["lootBox"] = attrs.lootBox ?? false + ageRatingDict["healthOrWellnessTopics"] = attrs.healthOrWellnessTopics ?? false + ageRatingDict["parentalControls"] = attrs.parentalControls ?? false + ageRatingDict["ageAssurance"] = attrs.ageAssurance ?? false + ageRatingDict["alcoholTobaccoOrDrugUseOrReferences"] = attrs.alcoholTobaccoOrDrugUseOrReferences ?? "NONE" + ageRatingDict["contests"] = attrs.contests ?? "NONE" + ageRatingDict["gamblingSimulated"] = attrs.gamblingSimulated ?? "NONE" + ageRatingDict["gunsOrOtherWeapons"] = attrs.gunsOrOtherWeapons ?? "NONE" + ageRatingDict["horrorOrFearThemes"] = attrs.horrorOrFearThemes ?? "NONE" + ageRatingDict["matureOrSuggestiveThemes"] = attrs.matureOrSuggestiveThemes ?? "NONE" + ageRatingDict["medicalOrTreatmentInformation"] = attrs.medicalOrTreatmentInformation ?? "NONE" + ageRatingDict["profanityOrCrudeHumor"] = attrs.profanityOrCrudeHumor ?? "NONE" + ageRatingDict["sexualContentGraphicAndNudity"] = attrs.sexualContentGraphicAndNudity ?? "NONE" + ageRatingDict["sexualContentOrNudity"] = attrs.sexualContentOrNudity ?? "NONE" + ageRatingDict["violenceCartoonOrFantasy"] = attrs.violenceCartoonOrFantasy ?? "NONE" + ageRatingDict["violenceRealistic"] = attrs.violenceRealistic ?? "NONE" + ageRatingDict["violenceRealisticProlongedGraphicOrSadistic"] = attrs.violenceRealisticProlongedGraphicOrSadistic ?? "NONE" + result["ageRating"] = ageRatingDict + } + + if let reviewDetail = asc.reviewDetail { + let attrs = reviewDetail.attributes + result["reviewContact"] = [ + "contactFirstName": attrs.contactFirstName ?? "", + "contactLastName": attrs.contactLastName ?? "", + "contactEmail": attrs.contactEmail ?? "", + "contactPhone": attrs.contactPhone ?? "", + "notes": attrs.notes ?? "", + "demoAccountRequired": attrs.demoAccountRequired ?? false, + "demoAccountName": attrs.demoAccountName ?? "", + "demoAccountPassword": attrs.demoAccountPassword ?? "" + ] + } + + result["builds"] = asc.builds.prefix(10).map { build -> [String: Any] in + [ + "id": build.id, + "version": build.attributes.version, + "processingState": build.attributes.processingState ?? "unknown", + "uploadedDate": build.attributes.uploadedDate ?? "" + ] + } + return result + } + + @MainActor + func tabStateScreenshots(_ asc: ASCManager) -> [String: Any] { + let sets = asc.screenshotSets.map { set -> [String: Any] in + var value: [String: Any] = ["id": set.id, "displayType": set.attributes.screenshotDisplayType] + if let shots = asc.screenshots[set.id] { + value["screenshotCount"] = shots.count + value["screenshots"] = shots.map { + ["id": $0.id, "fileName": $0.attributes.fileName ?? ""] + } + } + return value + } + return ["screenshotSets": sets, "localeCount": asc.localizations.count] + } + + @MainActor + func tabStateReviews(_ asc: ASCManager) -> [String: Any] { + let reviews = asc.customerReviews.prefix(20).map { review -> [String: Any] in + [ + "id": review.id, + "title": review.attributes.title ?? "", + "body": review.attributes.body ?? "", + "rating": review.attributes.rating, + "reviewerNickname": review.attributes.reviewerNickname ?? "" + ] + } + return ["reviews": reviews, "totalReviews": asc.customerReviews.count] + } + + @MainActor + func tabStateBuilds(_ asc: ASCManager) -> [String: Any] { + let builds = asc.builds.prefix(20).map { build -> [String: Any] in + [ + "id": build.id, + "version": build.attributes.version, + "processingState": build.attributes.processingState ?? "unknown", + "uploadedDate": build.attributes.uploadedDate ?? "" + ] + } + return ["builds": builds] + } + + @MainActor + func tabStateGroups(_ asc: ASCManager) -> [String: Any] { + let groups = asc.betaGroups.map { group -> [String: Any] in + [ + "id": group.id, + "name": group.attributes.name, + "isInternalGroup": group.attributes.isInternalGroup ?? false + ] + } + return ["betaGroups": groups] + } + + @MainActor + func tabStateBetaInfo(_ asc: ASCManager) -> [String: Any] { + let localizations = asc.betaLocalizations.map { localization -> [String: Any] in + [ + "id": localization.id, + "locale": localization.attributes.locale, + "description": localization.attributes.description ?? "" + ] + } + return ["betaLocalizations": localizations] + } + + @MainActor + func tabStateFeedback(_ asc: ASCManager) -> [String: Any] { + var items: [[String: Any]] = [] + for (buildId, feedbackItems) in asc.betaFeedback { + for item in feedbackItems { + items.append([ + "buildId": buildId, + "id": item.id, + "comment": item.attributes.comment ?? "", + "timestamp": item.attributes.timestamp ?? "" + ]) + } + } + return ["feedback": items, "selectedBuildId": asc.selectedBuildId ?? ""] + } +} diff --git a/src/services/MCPToolRegistry.swift b/src/services/mcp/MCPRegistry.swift similarity index 99% rename from src/services/MCPToolRegistry.swift rename to src/services/mcp/MCPRegistry.swift index bce5196..bde1051 100644 --- a/src/services/MCPToolRegistry.swift +++ b/src/services/mcp/MCPRegistry.swift @@ -1,7 +1,7 @@ import Foundation /// Static definitions for all MCP tools exposed by Blitz -enum MCPToolRegistry { +enum MCPRegistry { /// Returns all tool definitions for the MCP tools/list response static func allTools() -> [[String: Any]] { diff --git a/src/services/MCPServerService.swift b/src/services/mcp/MCPServerService.swift similarity index 98% rename from src/services/MCPServerService.swift rename to src/services/mcp/MCPServerService.swift index 409c791..c96cb14 100644 --- a/src/services/MCPServerService.swift +++ b/src/services/mcp/MCPServerService.swift @@ -9,10 +9,10 @@ actor MCPServerService { private var serverSocket: Int32 = -1 private(set) var isRunning = false - private let toolExecutor: MCPToolExecutor + private let toolExecutor: MCPExecutor init(appState: AppState) { - self.toolExecutor = MCPToolExecutor(appState: appState) + self.toolExecutor = MCPExecutor(appState: appState) Task { @MainActor in appState.toolExecutor = self.toolExecutor @@ -235,7 +235,7 @@ actor MCPServerService { case "tools/list": result = [ - "tools": MCPToolRegistry.allTools() + "tools": MCPRegistry.allTools() ] case "tools/call": diff --git a/src/services/project/MacSwiftProjectSetupService.swift b/src/services/project/MacSwiftProjectSetupService.swift new file mode 100644 index 0000000..267a012 --- /dev/null +++ b/src/services/project/MacSwiftProjectSetupService.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Scaffolds a new macOS Swift/SwiftUI project from the bundled template. +/// Sandboxed by default for Mac App Store submission. +struct MacSwiftProjectSetupService { + + /// Set up a new macOS Swift project from the bundled template. + static func setup( + projectId: String, + projectName: String, + projectPath: String, + onStep: @MainActor (ProjectSetupService.SetupStep) -> Void + ) async throws { + let appName = SwiftProjectSetupService.toSwiftAppName(projectId) + let bundleId = SwiftProjectSetupService.toBundleId(appName) + let spec = ProjectTemplateSpec( + templateName: "swift-mac-template", + missingTemplateMessage: "Bundled macOS Swift template not found", + replacements: [ + "__APP_NAME__": appName, + "__BUNDLE_ID__": bundleId + ], + sampleDevVars: nil, + cleanupPaths: [], + logPrefix: "mac-swift-setup" + ) + try await ProjectTemplateScaffolder.scaffold( + spec: spec, + projectPath: projectPath, + onStep: onStep + ) + } +} diff --git a/src/utilities/ProjectStorage.swift b/src/services/project/ProjectAgentConfigService.swift similarity index 62% rename from src/utilities/ProjectStorage.swift rename to src/services/project/ProjectAgentConfigService.swift index 5775e16..3de9e3f 100644 --- a/src/utilities/ProjectStorage.swift +++ b/src/services/project/ProjectAgentConfigService.swift @@ -1,7 +1,7 @@ import Foundation -/// Filesystem operations for ~/.blitz/projects/ -struct ProjectStorage { +/// Writes Blitz-owned agent config and installs Blitz-managed helper content. +struct ProjectAgentConfigService { let baseDirectory: URL private enum ProjectSkillRoot: String, CaseIterable { @@ -9,148 +9,12 @@ struct ProjectStorage { case agents = ".agents" } - init() { - self.baseDirectory = BlitzPaths.projects - } - - /// List all projects in ~/.blitz/projects/ - func listProjects() async -> [Project] { - let fm = FileManager.default - guard let entries = try? fm.contentsOfDirectory(at: baseDirectory, includingPropertiesForKeys: [.isDirectoryKey]) else { - return [] - } - - var projects: [Project] = [] - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - for entry in entries { - // fileExists(atPath:isDirectory:) follows symlinks; - // isDirectoryKey does NOT, so symlinked project dirs would be skipped. - var isDir: ObjCBool = false - guard fm.fileExists(atPath: entry.path, isDirectory: &isDir), isDir.boolValue else { continue } - - let metadataFile = entry.appendingPathComponent(".blitz/project.json") - guard let data = try? Data(contentsOf: metadataFile), - let metadata = try? decoder.decode(BlitzProjectMetadata.self, from: data) else { - continue - } - - let project = Project( - id: entry.lastPathComponent, - metadata: metadata, - path: entry.path - ) - projects.append(project) - } - - return projects.sorted { ($0.metadata.lastOpenedAt ?? .distantPast) > ($1.metadata.lastOpenedAt ?? .distantPast) } - } - - /// Read a specific project's metadata - func readMetadata(projectId: String) -> BlitzProjectMetadata? { - let metadataFile = baseDirectory - .appendingPathComponent(projectId) - .appendingPathComponent(".blitz/project.json") - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - guard let data = try? Data(contentsOf: metadataFile) else { return nil } - return try? decoder.decode(BlitzProjectMetadata.self, from: data) - } - - /// Write project metadata into ~/.blitz/projects/{id}/.blitz/project.json - func writeMetadata(projectId: String, metadata: BlitzProjectMetadata) throws { - let projectDir = baseDirectory.appendingPathComponent(projectId) - try writeMetadataToDirectory(projectDir, metadata: metadata) - } - - /// Write project metadata into an arbitrary directory (e.g. the original project path before symlinking). - func writeMetadataToDirectory(_ dir: URL, metadata: BlitzProjectMetadata) throws { - let blitzDir = dir.appendingPathComponent(".blitz") - try FileManager.default.createDirectory(at: blitzDir, withIntermediateDirectories: true) - - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(metadata) - try data.write(to: blitzDir.appendingPathComponent("project.json")) - } - - /// Delete a project directory - func deleteProject(projectId: String) throws { - let projectDir = baseDirectory.appendingPathComponent(projectId) - let path = projectDir.path - // Check if this is a symlink — if so, only remove the symlink itself, not the target - var isSymlink = false - if let attrs = try? FileManager.default.attributesOfItem(atPath: path), - attrs[.type] as? FileAttributeType == .typeSymbolicLink { - isSymlink = true - } - if isSymlink { - // unlink only removes the symlink, not the target directory - unlink(path) - } else { - try FileManager.default.removeItem(at: projectDir) - } - } - - /// Open a project at the given URL. Validates .blitz/project.json exists, - /// registers it in ~/.blitz/projects/ if needed, and returns the projectId. - func openProject(at url: URL) throws -> String { - let metadataFile = url.appendingPathComponent(".blitz/project.json") - guard FileManager.default.fileExists(atPath: metadataFile.path) else { - throw ProjectOpenError.notABlitzProject - } - - var folderName = url.lastPathComponent - let existingDir = baseDirectory.appendingPathComponent(folderName) - - if FileManager.default.fileExists(atPath: existingDir.path) { - // Check if it resolves to the same location - let resolvedExisting = existingDir.resolvingSymlinksInPath().path - let resolvedNew = url.resolvingSymlinksInPath().path - if resolvedExisting == resolvedNew { - updateLastOpened(projectId: folderName) - return folderName - } - // Name collision with different project — disambiguate - var counter = 2 - while FileManager.default.fileExists( - atPath: baseDirectory.appendingPathComponent("\(folderName)-\(counter)").path - ) { counter += 1 } - folderName = "\(folderName)-\(counter)" - } - - // Create symlink: ~/.blitz/projects/{folderName} → selectedPath - let symlinkDir = baseDirectory.appendingPathComponent(folderName) - try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true) - try FileManager.default.createSymbolicLink(at: symlinkDir, withDestinationURL: url) - - updateLastOpened(projectId: folderName) - return folderName - } - - /// Update lastOpenedAt timestamp for a project - func updateLastOpened(projectId: String) { - guard var metadata = readMetadata(projectId: projectId) else { return } - metadata.lastOpenedAt = Date() - do { - try writeMetadata(projectId: projectId, metadata: metadata) - } catch { - print("[ProjectStorage] Failed to update lastOpenedAt for \(projectId): \(error)") - } - } - - /// Ensure ~/.blitz/mcps/ has MCP configs, CLAUDE.md, and skills so that - /// agent sessions launched outside a project (e.g. onboarding ASC setup) can - /// access Blitz MCP tools. Idempotent — safe to call on every launch. func ensureGlobalMCPConfigs(whitelistBlitzMCP: Bool = true, allowASCCLICalls: Bool = false) { let fm = FileManager.default let mcpsDir = BlitzPaths.mcps try? fm.createDirectory(at: mcpsDir, withIntermediateDirectories: true) - // 1. .mcp.json + .codex/config.toml + opencode.json (reuse project-level logic) ensureMCPConfig( in: mcpsDir, whitelistBlitzMCP: whitelistBlitzMCP, @@ -158,7 +22,6 @@ struct ProjectStorage { includeProjectDocFallback: false ) - // 2. .claude/settings.local.json let claudeDir = mcpsDir.appendingPathComponent(".claude") let settingsFile = claudeDir.appendingPathComponent("settings.local.json") try? fm.createDirectory(at: claudeDir, withIntermediateDirectories: true) @@ -175,15 +38,12 @@ struct ProjectStorage { } let settings: [String: Any] = [ "enabledMcpjsonServers": ["blitz-macos", "blitz-iphone"], - "permissions": [ - "allow": allowList - ] + "permissions": ["allow": allowList] ] if let data = try? JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) { try? data.write(to: settingsFile) } - // 3. CLAUDE.md let claudeMd = mcpsDir.appendingPathComponent("CLAUDE.md") let claudeMdContent = """ # Blitz — Global Agent Context @@ -203,7 +63,6 @@ struct ProjectStorage { """ try? claudeMdContent.write(to: claudeMd, atomically: true, encoding: .utf8) - // 4. Skills — copy bundled skills (e.g. asc-team-key-create) let skillDirectories = ProjectSkillRoot.allCases.map { mcpsDir.appendingPathComponent($0.rawValue).appendingPathComponent("skills") } @@ -219,8 +78,6 @@ struct ProjectStorage { } } - /// Ensure agent MCP configs for every existing project under ~/.blitz/projects/. - /// This backfills new config fields for users with older project files. func ensureAllProjectMCPConfigs(whitelistBlitzMCP: Bool = true, allowASCCLICalls: Bool = false) { let fm = FileManager.default guard let entries = try? fm.contentsOfDirectory(at: baseDirectory, includingPropertiesForKeys: [.isDirectoryKey]) else { @@ -239,10 +96,6 @@ struct ProjectStorage { } } - /// Ensure .mcp.json contains blitz-macos and blitz-iphone MCP server entries. - /// If the file exists, merges into the existing mcpServers key without overwriting other entries. - /// If it doesn't exist, creates it. - /// Also removes the deprecated blitz-ios entry if present. func ensureMCPConfig( projectId: String, whitelistBlitzMCP: Bool = true, @@ -257,7 +110,7 @@ struct ProjectStorage { ) } - /// Shared implementation: writes .mcp.json, .codex/config.toml, and opencode.json into `directory`. + /// Writes `.mcp.json`, `.codex/config.toml`, and `opencode.json` into `directory`. func ensureMCPConfig( in directory: URL, whitelistBlitzMCP: Bool = true, @@ -267,12 +120,7 @@ struct ProjectStorage { let mcpFile = directory.appendingPathComponent(".mcp.json") let helperPath = BlitzPaths.mcpHelper.path - let blitzMacosEntry: [String: Any] = [ - "command": helperPath - ] - // Use full path to npx from Blitz's bundled Node.js runtime. - // Also set PATH env so that #!/usr/bin/env node resolves correctly — - // npx and the packages it runs use env shebang lookups. + let blitzMacosEntry: [String: Any] = ["command": helperPath] let nodeRuntimeBin = BlitzPaths.nodeDir.path let blitzIphoneEntry: [String: Any] = [ "command": nodeRuntimeBin + "/npx", @@ -289,7 +137,7 @@ struct ProjectStorage { var servers = root["mcpServers"] as? [String: Any] ?? [:] servers["blitz-macos"] = blitzMacosEntry servers["blitz-iphone"] = blitzIphoneEntry - servers.removeValue(forKey: "blitz-ios") // deprecated + servers.removeValue(forKey: "blitz-ios") root["mcpServers"] = servers } else { root = ["mcpServers": [ @@ -298,14 +146,15 @@ struct ProjectStorage { ]] } - guard let data = try? JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) else { return } + guard let data = try? JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) else { + return + } do { try data.write(to: mcpFile) } catch { - print("[ProjectStorage] Failed to write .mcp.json: \(error)") + print("[ProjectAgentConfigService] Failed to write .mcp.json: \(error)") } - // Codex config — explicit Blitz MCP servers and tool allowlists. let codexDir = directory.appendingPathComponent(".codex") let codexConfig = codexDir.appendingPathComponent("config.toml") let codexMacEnabledTools = whitelistBlitzMCP ? Self.blitzMacosToolNames() : Self.minimalBlitzMacosToolNames() @@ -317,17 +166,10 @@ struct ProjectStorage { .map { "\"\(Self.escapeTOMLString($0))\"" } .joined(separator: ", ") let codexIphonePathEnv = "\(nodeRuntimeBin):/usr/bin:/bin:/usr/sbin:/sbin" - let codexProjectDocFallbackLine: String - let codexProjectDocMaxBytesLine: String - if includeProjectDocFallback { - codexProjectDocFallbackLine = """ - project_doc_fallback_filenames = [".claude/rules/blitz.md", ".claude/rules/teenybase.md"] - """ - codexProjectDocMaxBytesLine = "project_doc_max_bytes = 65536" - } else { - codexProjectDocFallbackLine = "" - codexProjectDocMaxBytesLine = "" - } + let codexProjectDocFallbackLine = includeProjectDocFallback + ? "project_doc_fallback_filenames = [\".claude/rules/blitz.md\", \".claude/rules/teenybase.md\"]" + : "" + let codexProjectDocMaxBytesLine = includeProjectDocFallback ? "project_doc_max_bytes = 65536" : "" let toml = """ \(codexProjectDocFallbackLine) \(codexProjectDocMaxBytesLine) @@ -350,10 +192,9 @@ struct ProjectStorage { try FileManager.default.createDirectory(at: codexDir, withIntermediateDirectories: true) try toml.write(to: codexConfig, atomically: true, encoding: .utf8) } catch { - print("[ProjectStorage] Failed to write .codex/config.toml: \(error)") + print("[ProjectAgentConfigService] Failed to write .codex/config.toml: \(error)") } - // Codex ASC bash allowlist — managed as a dedicated Blitz-owned rules file. let codexRulesDir = codexDir.appendingPathComponent("rules") let codexBlitzRulesFile = codexRulesDir.appendingPathComponent("blitz.rules") if allowASCCLICalls { @@ -367,13 +208,12 @@ struct ProjectStorage { try FileManager.default.createDirectory(at: codexRulesDir, withIntermediateDirectories: true) try rules.write(to: codexBlitzRulesFile, atomically: true, encoding: .utf8) } catch { - print("[ProjectStorage] Failed to write .codex/rules/blitz.rules: \(error)") + print("[ProjectAgentConfigService] Failed to write .codex/rules/blitz.rules: \(error)") } } else if FileManager.default.fileExists(atPath: codexBlitzRulesFile.path) { try? FileManager.default.removeItem(at: codexBlitzRulesFile) } - // OpenCode config let opencodeConfig = directory.appendingPathComponent("opencode.json") var opencodeRoot: [String: Any] = [:] if let data = try? Data(contentsOf: opencodeConfig), @@ -398,7 +238,7 @@ struct ProjectStorage { "PATH": "\(nodeRuntimeBin):/usr/bin:/bin:/usr/sbin:/sbin", ], ] - opencodeMcp.removeValue(forKey: "blitz-ios") // deprecated + opencodeMcp.removeValue(forKey: "blitz-ios") opencodeRoot["mcp"] = opencodeMcp var opencodePermission: [String: Any] = [:] @@ -454,55 +294,11 @@ struct ProjectStorage { do { try data.write(to: opencodeConfig) } catch { - print("[ProjectStorage] Failed to write opencode.json: \(error)") + print("[ProjectAgentConfigService] Failed to write opencode.json: \(error)") } } } - /// All Blitz MCP tool permission strings for both blitz-macos and blitz-iphone servers. - static func allBlitzMCPToolPermissions() -> [String] { - // blitz-macos tools — from MCPToolRegistry - let macTools = blitzMacosToolNames().map { "mcp__blitz-macos__\($0)" } - // blitz-iphone tools — from @blitzdev/iphone-mcp - let iphoneTools = blitzIphoneToolNames().map { "mcp__blitz-iphone__\($0)" } - return macTools + iphoneTools - } - - private static func blitzMacosToolNames() -> [String] { - MCPToolRegistry.allToolNames() - } - - private static func minimalBlitzMacosToolNames() -> [String] { - ["asc_set_credentials", "asc_web_auth"] - } - - private static func blitzIphoneToolNames() -> [String] { - [ - "list_devices", "setup_device", "launch_app", "list_apps", - "get_screenshot", "scan_ui", "describe_screen", "device_action", - "device_actions", "get_execution_context", - ] - } - - private static func allOpenCodeBlitzMCPPermissionKeys() -> [String] { - let macTools = blitzMacosToolNames().map { "blitz-macos_\($0)" } - let iphoneTools = blitzIphoneToolNames().map { "blitz-iphone_\($0)" } - return macTools + iphoneTools - } - - private static func escapeTOMLString(_ value: String) -> String { - value - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - } - - private static func escapeStarlarkString(_ value: String) -> String { - value - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - } - - /// Ensure CLAUDE.md, .claude/settings.local.json, and .claude/rules/ exist for a project. func ensureClaudeFiles( projectId: String, projectType: ProjectType, @@ -513,8 +309,6 @@ struct ProjectStorage { let projectDir = baseDirectory.appendingPathComponent(projectId) let claudeDir = projectDir.appendingPathComponent(".claude") - // 1. .claude/settings.local.json - // Always update enabledMcpjsonServers (Blitz-owned structural setting). let settingsFile = claudeDir.appendingPathComponent("settings.local.json") try? fm.createDirectory(at: claudeDir, withIntermediateDirectories: true) let correctServers = ["blitz-macos", "blitz-iphone"] @@ -522,13 +316,10 @@ struct ProjectStorage { if fm.fileExists(atPath: settingsFile.path), let data = try? Data(contentsOf: settingsFile), var existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - // Preserve user customisations; only force-update the server list existing["enabledMcpjsonServers"] = correctServers - // Remove deprecated blitz-ios permission entries if var perms = existing["permissions"] as? [String: Any], var allow = perms["allow"] as? [String] { allow.removeAll { $0.contains("blitz-ios") } - // Inject whitelist if enabled if whitelistBlitzMCP { let blitzTools = Self.allBlitzMCPToolPermissions() for tool in blitzTools where !allow.contains(tool) { @@ -562,9 +353,7 @@ struct ProjectStorage { Self.ensureAllowPermission("Bash(asc:*)", in: &defaultAllow) } settings = [ - "permissions": [ - "allow": defaultAllow - ], + "permissions": ["allow": defaultAllow], "enabledMcpjsonServers": correctServers, ] } @@ -572,16 +361,12 @@ struct ProjectStorage { try? data.write(to: settingsFile) } - // 2. CLAUDE.md — write only if absent; user may have their own let claudeMdFile = projectDir.appendingPathComponent("CLAUDE.md") if !fm.fileExists(atPath: claudeMdFile.path) { - let content = Self.claudeMdContent(projectType: projectType) - try? content.write(to: claudeMdFile, atomically: true, encoding: .utf8) + try? Self.claudeMdContent(projectType: projectType) + .write(to: claudeMdFile, atomically: true, encoding: .utf8) } - // 3. .claude/rules/ — Blitz-owned files, always overwrite. - // These auto-load in every Claude Code session alongside any existing CLAUDE.md, - // so agents get Blitz/Teenybase context even on projects with pre-existing docs. let rulesDir = claudeDir.appendingPathComponent("rules") try? fm.createDirectory(at: rulesDir, withIntermediateDirectories: true) @@ -592,34 +377,22 @@ struct ProjectStorage { try? Self.teenybaseRulesContent(projectDir: projectDir, projectType: projectType) .write(to: teenybaseRules, atomically: true, encoding: .utf8) - // 4. App Store Review Agent — clone from public repo, symlink into .claude/agents/ ensureReviewerAgent(projectDir: projectDir) - - // 5. Project skills — copy bundled Blitz skills and sync ASC CLI skills - // into supported local agent skill directories. ensureProjectSkills(projectDir: projectDir) - } - /// Clone or update the app-store-review-agent repo and symlink the agent - /// into .claude/agents/ where Claude Code can discover it. - /// Runs git operations on a background queue so it never blocks the UI. func ensureReviewerAgent(projectDir: URL) { let fm = FileManager.default let claudeDir = projectDir.appendingPathComponent(".claude") let agentRepoDir = claudeDir.appendingPathComponent("app-store-review-agent") let agentsDir = claudeDir.appendingPathComponent("agents") let symlinkPath = agentsDir.appendingPathComponent("reviewer.md") - - // If symlink already exists and resolves to a real file, nothing to do. - // (Still dispatch a background pull to pick up rule updates.) let symlinkExists = fm.fileExists(atPath: symlinkPath.path) DispatchQueue.global(qos: .utility).async { let repoURL = BlitzPaths.reviewerAgentRepo if fm.fileExists(atPath: agentRepoDir.appendingPathComponent(".git").path) { - // Already cloned — pull latest in background let pull = Process() pull.executableURL = URL(fileURLWithPath: "/usr/bin/git") pull.arguments = ["-C", agentRepoDir.path, "pull", "--quiet", "--ff-only"] @@ -628,7 +401,6 @@ struct ProjectStorage { try? pull.run() pull.waitUntilExit() } else { - // First time — clone let clone = Process() clone.executableURL = URL(fileURLWithPath: "/usr/bin/git") clone.arguments = ["clone", "--quiet", "--depth", "1", repoURL, agentRepoDir.path] @@ -637,28 +409,22 @@ struct ProjectStorage { try? clone.run() clone.waitUntilExit() guard clone.terminationStatus == 0 else { - print("[ProjectStorage] Failed to clone app-store-review-agent") + print("[ProjectAgentConfigService] Failed to clone app-store-review-agent") return } } - // Create .claude/agents/ and symlink reviewer.md if !symlinkExists { try? fm.createDirectory(at: agentsDir, withIntermediateDirectories: true) - // Relative symlink so it works regardless of absolute project path try? fm.createSymbolicLink( atPath: symlinkPath.path, withDestinationPath: "../app-store-review-agent/agents/reviewer.md" ) - print("[ProjectStorage] Reviewer agent installed") + print("[ProjectAgentConfigService] Reviewer agent installed") } } } - /// Copy bundled Blitz project skills and sync the ASC CLI skills repo into - /// each supported local agent skills directory. - /// Overwrites asc-app-create-ui/SKILL.md with the pre-cached-session version. - /// Runs git operations on a background queue so it never blocks the UI. func ensureProjectSkills(projectDir: URL) { let fm = FileManager.default let claudeDir = projectDir.appendingPathComponent(".claude") @@ -670,18 +436,13 @@ struct ProjectStorage { } if let bundledSkillsDir = Self.bundledProjectSkillsDirectory() { - Self.syncSkillDirectories( - from: bundledSkillsDir, - into: skillDirectories, - using: fm - ) + Self.syncSkillDirectories(from: bundledSkillsDir, into: skillDirectories, using: fm) } DispatchQueue.global(qos: .utility).async { let repoURL = BlitzPaths.ascSkillsRepo if fm.fileExists(atPath: repoDir.appendingPathComponent(".git").path) { - // Already cloned — pull latest let pull = Process() pull.executableURL = URL(fileURLWithPath: "/usr/bin/git") pull.arguments = ["-C", repoDir.path, "pull", "--quiet", "--ff-only"] @@ -690,7 +451,6 @@ struct ProjectStorage { try? pull.run() pull.waitUntilExit() } else { - // First time — clone let clone = Process() clone.executableURL = URL(fileURLWithPath: "/usr/bin/git") clone.arguments = ["clone", "--quiet", "--depth", "1", repoURL, repoDir.path] @@ -699,7 +459,7 @@ struct ProjectStorage { try? clone.run() clone.waitUntilExit() guard clone.terminationStatus == 0 else { - print("[ProjectStorage] Failed to clone asc-skills") + print("[ProjectAgentConfigService] Failed to clone asc-skills") return } } @@ -707,12 +467,10 @@ struct ProjectStorage { let repoSkillsDir = repoDir.appendingPathComponent("skills") Self.syncSkillDirectories(from: repoSkillsDir, into: skillDirectories, using: fm) - // Ensure bundled Blitz skills always win over upstream repo copies. if let bundledSkillsDir = Self.bundledProjectSkillsDirectory() { Self.syncSkillDirectories(from: bundledSkillsDir, into: skillDirectories, using: fm) } - // Overwrite asc-app-create-ui/SKILL.md with Blitz's pre-cached-session version for skillsDir in skillDirectories { let ascCreateSkillFile = skillsDir .appendingPathComponent("asc-app-create-ui") @@ -721,10 +479,8 @@ struct ProjectStorage { .write(to: ascCreateSkillFile, atomically: true, encoding: .utf8) } - let installedRoots = skillDirectories - .map(\.path) - .joined(separator: ", ") - print("[ProjectStorage] Project skills installed in \(installedRoots)") + let installedRoots = skillDirectories.map(\.path).joined(separator: ", ") + print("[ProjectAgentConfigService] Project skills installed in \(installedRoots)") } } @@ -736,11 +492,50 @@ struct ProjectStorage { } } + static func allBlitzMCPToolPermissions() -> [String] { + let macTools = blitzMacosToolNames().map { "mcp__blitz-macos__\($0)" } + let iphoneTools = blitzIphoneToolNames().map { "mcp__blitz-iphone__\($0)" } + return macTools + iphoneTools + } + + private static func blitzMacosToolNames() -> [String] { + MCPRegistry.allToolNames() + } + + private static func minimalBlitzMacosToolNames() -> [String] { + ["asc_set_credentials", "asc_web_auth"] + } + + private static func blitzIphoneToolNames() -> [String] { + [ + "list_devices", "setup_device", "launch_app", "list_apps", + "get_screenshot", "scan_ui", "describe_screen", "device_action", + "device_actions", "get_execution_context", + ] + } + + private static func allOpenCodeBlitzMCPPermissionKeys() -> [String] { + let macTools = blitzMacosToolNames().map { "blitz-macos_\($0)" } + let iphoneTools = blitzIphoneToolNames().map { "blitz-iphone_\($0)" } + return macTools + iphoneTools + } + + private static func escapeTOMLString(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + + private static func escapeStarlarkString(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + private static func bundledProjectSkillsDirectory() -> URL? { let fm = FileManager.default - if let bundleSkills = Bundle.main.resourceURL? - .appendingPathComponent("claude-skills"), + if let bundleSkills = Bundle.main.resourceURL?.appendingPathComponent("claude-skills"), fm.fileExists(atPath: bundleSkills.path) { return bundleSkills } @@ -750,6 +545,7 @@ struct ProjectStorage { .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() + .deletingLastPathComponent() .appendingPathComponent(".claude/skills") if fm.fileExists(atPath: repoSkills.path) { return repoSkills @@ -785,8 +581,6 @@ struct ProjectStorage { allowList.append(permission) } - /// Content for the Blitz-specific asc-app-create-ui skill that uses - /// iris APIs via the web session file managed by Blitz. private static func ascAppCreateSkillContent() -> String { return ##""" --- @@ -839,7 +633,6 @@ struct ProjectStorage { APP_NAME = 'APP_NAME_HERE' LOCALE = 'LOCALE_HERE' - # Read web session from file (synced by Blitz auth bridge) session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') if not os.path.isfile(session_path): print('ERROR: No web session found. Call asc_web_auth MCP tool first.') @@ -1012,160 +805,12 @@ struct ProjectStorage { return "# Teenybase Backend\n" } - content = content.replacingOccurrences(of: "{{DEVVARS_PATH}}", with: backendDir.appendingPathComponent(".dev.vars").path) + content = content.replacingOccurrences( + of: "{{DEVVARS_PATH}}", + with: backendDir.appendingPathComponent(".dev.vars").path + ) content = content.replacingOccurrences(of: "{{SCHEMA_PATH}}", with: schemaPath) content = content.replacingOccurrences(of: "{{COMMAND_PREFIX}}", with: commandPrefix) return content } - - // MARK: - Teenybase backend scaffolding - - /// Copy Teenybase backend files into a project if not already present. - /// RN projects get files at the project root; Swift/Flutter get a backend/ subdirectory. - func ensureTeenybaseBackend(projectId: String, projectType: ProjectType) { - let fm = FileManager.default - let projectDir = baseDirectory.appendingPathComponent(projectId) - - guard let templateURL = Bundle.appResources.url( - forResource: "rn-notes-template", withExtension: nil, subdirectory: "templates" - ) else { - print("[ProjectStorage] Teenybase template not found in bundle") - return - } - - switch projectType { - case .reactNative: - copyTeenybaseFiles(from: templateURL, to: projectDir, fm: fm) - mergeTeenybaseScripts(into: projectDir.appendingPathComponent("package.json"), fm: fm) - case .swift, .flutter: - let backendDir = projectDir.appendingPathComponent("backend") - try? fm.createDirectory(at: backendDir, withIntermediateDirectories: true) - copyTeenybaseFiles(from: templateURL, to: backendDir, fm: fm) - ensureStandalonePackageJson(at: backendDir.appendingPathComponent("package.json"), - projectId: projectId, fm: fm) - } - } - - /// Copy teenybase.ts, wrangler.toml, src-backend/worker.ts, and .dev.vars into dest. - /// Skips each file if it already exists so existing configs are never overwritten. - private func copyTeenybaseFiles(from templateURL: URL, to dest: URL, fm: FileManager) { - // teenybase.ts — skip if present (indicates backend already set up) - let teenybaseDest = dest.appendingPathComponent("teenybase.ts") - guard !fm.fileExists(atPath: teenybaseDest.path) else { return } - - try? fm.copyItem(at: templateURL.appendingPathComponent("teenybase.ts"), to: teenybaseDest) - - // wrangler.toml - let wranglerDest = dest.appendingPathComponent("wrangler.toml") - if !fm.fileExists(atPath: wranglerDest.path) { - let src = templateURL.appendingPathComponent("wrangler.toml") - if var content = try? String(contentsOf: src, encoding: .utf8) { - content = content.replacingOccurrences(of: "sample-app", with: dest.deletingLastPathComponent().lastPathComponent) - try? content.write(to: wranglerDest, atomically: true, encoding: .utf8) - } - } - - // src-backend/worker.ts - let srcBackendDest = dest.appendingPathComponent("src-backend") - try? fm.createDirectory(at: srcBackendDest, withIntermediateDirectories: true) - let workerDest = srcBackendDest.appendingPathComponent("worker.ts") - if !fm.fileExists(atPath: workerDest.path) { - try? fm.copyItem( - at: templateURL.appendingPathComponent("src-backend/worker.ts"), - to: workerDest - ) - } - - // .dev.vars — from sample.vars - let devVarsDest = dest.appendingPathComponent(".dev.vars") - if !fm.fileExists(atPath: devVarsDest.path) { - let sampleVars = templateURL.appendingPathComponent("sample.vars") - if fm.fileExists(atPath: sampleVars.path) { - try? fm.copyItem(at: sampleVars, to: devVarsDest) - } - } - } - - /// For RN projects: merge teenybase scripts + devDependency into existing package.json. - /// No-op if teenybase is already in devDependencies. - private func mergeTeenybaseScripts(into packageJsonURL: URL, fm: FileManager) { - guard fm.fileExists(atPath: packageJsonURL.path), - let data = try? Data(contentsOf: packageJsonURL), - var pkg = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } - - var devDeps = pkg["devDependencies"] as? [String: Any] ?? [:] - guard devDeps["teenybase"] == nil else { return } // already set up - - devDeps["teenybase"] = "0.0.10" - pkg["devDependencies"] = devDeps - - var scripts = pkg["scripts"] as? [String: Any] ?? [:] - let backendScripts: [String: String] = [ - "generate:backend": "teeny generate --local", - "migrate:backend": "teeny migrate --local", - "dev:backend": "teeny dev --local", - "build:backend": "teeny build --local", - "exec:backend": "teeny exec --local", - "deploy:backend:remote": "teeny deploy --migrate --remote", - ] - for (key, value) in backendScripts where scripts[key] == nil { - scripts[key] = value - } - pkg["scripts"] = scripts - - if let updated = try? JSONSerialization.data(withJSONObject: pkg, options: [.prettyPrinted, .sortedKeys]) { - try? updated.write(to: packageJsonURL) - } - } - - /// For Swift/Flutter: write a standalone package.json for the backend/ subdirectory. - private func ensureStandalonePackageJson(at url: URL, projectId: String, fm: FileManager) { - guard !fm.fileExists(atPath: url.path) else { return } - let content = """ - { - "name": "\(projectId)-backend", - "version": "1.0.0", - "scripts": { - "generate": "teeny generate --local", - "migrate": "teeny migrate --local", - "dev": "teeny dev --local", - "build": "teeny build --local", - "exec": "teeny exec --local", - "deploy": "teeny deploy --migrate --remote" - }, - "devDependencies": { - "teenybase": "0.0.10" - } - } - """ - try? content.write(to: url, atomically: true, encoding: .utf8) - } - - /// Clear lastOpenedAt on all projects - func clearRecentProjects() { - let fm = FileManager.default - guard let entries = try? fm.contentsOfDirectory(at: baseDirectory, includingPropertiesForKeys: [.isDirectoryKey]) else { return } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - for entry in entries { - var isDir: ObjCBool = false - guard fm.fileExists(atPath: entry.path, isDirectory: &isDir), isDir.boolValue else { continue } - let projectId = entry.lastPathComponent - guard var metadata = readMetadata(projectId: projectId) else { continue } - metadata.lastOpenedAt = nil - try? writeMetadata(projectId: projectId, metadata: metadata) - } - } -} - -enum ProjectOpenError: LocalizedError { - case notABlitzProject - - var errorDescription: String? { - switch self { - case .notABlitzProject: - return "Not a Blitz project. The selected folder does not contain .blitz/project.json. Use Import to add an external project." - } - } } diff --git a/src/services/project/ProjectRepository.swift b/src/services/project/ProjectRepository.swift new file mode 100644 index 0000000..b4e072e --- /dev/null +++ b/src/services/project/ProjectRepository.swift @@ -0,0 +1,156 @@ +import Darwin +import Foundation + +/// Repository for `~/.blitz/projects` metadata and symlink registration. +struct ProjectRepository { + let baseDirectory: URL + + func listProjects() async -> [Project] { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(at: baseDirectory, includingPropertiesForKeys: [.isDirectoryKey]) else { + return [] + } + + var projects: [Project] = [] + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + for entry in entries { + var isDir: ObjCBool = false + guard fm.fileExists(atPath: entry.path, isDirectory: &isDir), isDir.boolValue else { continue } + + let metadataFile = metadataURL(for: entry.lastPathComponent) + guard let data = try? Data(contentsOf: metadataFile), + let metadata = try? decoder.decode(BlitzProjectMetadata.self, from: data) else { + continue + } + + projects.append( + Project( + id: entry.lastPathComponent, + metadata: metadata, + path: entry.path + ) + ) + } + + return projects.sorted { ($0.metadata.lastOpenedAt ?? .distantPast) > ($1.metadata.lastOpenedAt ?? .distantPast) } + } + + func readMetadata(projectId: String) -> BlitzProjectMetadata? { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + guard let data = try? Data(contentsOf: metadataURL(for: projectId)) else { return nil } + return try? decoder.decode(BlitzProjectMetadata.self, from: data) + } + + func writeMetadata(projectId: String, metadata: BlitzProjectMetadata) throws { + try writeMetadataToDirectory(baseDirectory.appendingPathComponent(projectId), metadata: metadata) + } + + func writeMetadataToDirectory(_ dir: URL, metadata: BlitzProjectMetadata) throws { + let blitzDir = dir.appendingPathComponent(".blitz") + try FileManager.default.createDirectory(at: blitzDir, withIntermediateDirectories: true) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(metadata) + try data.write(to: blitzDir.appendingPathComponent("project.json")) + } + + func deleteProject(projectId: String) throws { + let projectDir = baseDirectory.appendingPathComponent(projectId) + let path = projectDir.path + var isSymlink = false + if let attrs = try? FileManager.default.attributesOfItem(atPath: path), + attrs[.type] as? FileAttributeType == .typeSymbolicLink { + isSymlink = true + } + + if isSymlink { + unlink(path) + } else { + try FileManager.default.removeItem(at: projectDir) + } + } + + /// Validates `.blitz/project.json` exists, registers a symlink under + /// `~/.blitz/projects/` if needed, and returns the project ID. + func openProject(at url: URL) throws -> String { + let metadataFile = url.appendingPathComponent(".blitz/project.json") + guard FileManager.default.fileExists(atPath: metadataFile.path) else { + throw ProjectOpenError.notABlitzProject + } + + var folderName = url.lastPathComponent + let existingDir = baseDirectory.appendingPathComponent(folderName) + + if FileManager.default.fileExists(atPath: existingDir.path) { + let resolvedExisting = existingDir.resolvingSymlinksInPath().path + let resolvedNew = url.resolvingSymlinksInPath().path + if resolvedExisting == resolvedNew { + updateLastOpened(projectId: folderName) + return folderName + } + + var counter = 2 + while FileManager.default.fileExists( + atPath: baseDirectory.appendingPathComponent("\(folderName)-\(counter)").path + ) { + counter += 1 + } + folderName = "\(folderName)-\(counter)" + } + + let symlinkDir = baseDirectory.appendingPathComponent(folderName) + try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: symlinkDir, withDestinationURL: url) + + updateLastOpened(projectId: folderName) + return folderName + } + + func updateLastOpened(projectId: String) { + guard var metadata = readMetadata(projectId: projectId) else { return } + metadata.lastOpenedAt = Date() + do { + try writeMetadata(projectId: projectId, metadata: metadata) + } catch { + print("[ProjectRepository] Failed to update lastOpenedAt for \(projectId): \(error)") + } + } + + func clearRecentProjects() { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(at: baseDirectory, includingPropertiesForKeys: [.isDirectoryKey]) else { + return + } + + for entry in entries { + var isDir: ObjCBool = false + guard fm.fileExists(atPath: entry.path, isDirectory: &isDir), isDir.boolValue else { continue } + let projectId = entry.lastPathComponent + guard var metadata = readMetadata(projectId: projectId) else { continue } + metadata.lastOpenedAt = nil + try? writeMetadata(projectId: projectId, metadata: metadata) + } + } + + private func metadataURL(for projectId: String) -> URL { + baseDirectory + .appendingPathComponent(projectId) + .appendingPathComponent(".blitz/project.json") + } +} + +enum ProjectOpenError: LocalizedError { + case notABlitzProject + + var errorDescription: String? { + switch self { + case .notABlitzProject: + return "Not a Blitz project. The selected folder does not contain .blitz/project.json. Use Import to add an external project." + } + } +} diff --git a/src/services/project/ProjectSetupService.swift b/src/services/project/ProjectSetupService.swift new file mode 100644 index 0000000..6fde8b3 --- /dev/null +++ b/src/services/project/ProjectSetupService.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Scaffolds a new React Native / Blitz project from the bundled template. +/// Handles the full lifecycle: copy template → patch placeholders → write .dev.vars +/// The AI agent handles npm install, pod install, metro, and builds. +struct ProjectSetupService { + + enum SetupStep: String { + case copying = "Copying template..." + case ready = "Ready" + } + + struct SetupError: LocalizedError { + let message: String + var errorDescription: String? { message } + } + + private static let sampleDevVars = """ + JWT_SECRET_MAIN=this_is_the_main_secret_used_for_all_tables_and_admin + JWT_SECRET_USERS=secret_used_for_users_table_appended_to_the_main_secret + ADMIN_SERVICE_TOKEN=password_for_accessing_the_backend_as_admin + ADMIN_JWT_SECRET=this_will_be_used_for_jwt_token_for_admin_operations + POCKET_UI_VIEWER_PASSWORD=admin_db_password_for_readonly_mode + POCKET_UI_EDITOR_PASSWORD=admin_db_password_for_readwrite_mode + MAILGUN_API_KEY=api-key-from-mailgun + API_ROUTE=NA + """ + + private static let projectNamePlaceholder = "__PROJECT_NAME__" + + /// Set up a new project from the bundled RN template. + /// Calls `onStep` on the main actor as each phase begins. + static func setup( + projectId: String, + projectName: String, + projectPath: String, + onStep: @MainActor (SetupStep) -> Void + ) async throws { + let spec = ProjectTemplateSpec( + templateName: "rn-notes-template", + missingTemplateMessage: "Bundled RN template not found", + replacements: [projectNamePlaceholder: projectName], + sampleDevVars: sampleDevVars, + cleanupPaths: [".local-persist"], + logPrefix: "setup" + ) + try await ProjectTemplateScaffolder.scaffold( + spec: spec, + projectPath: projectPath, + onStep: onStep + ) + } +} diff --git a/src/services/project/ProjectStorage.swift b/src/services/project/ProjectStorage.swift new file mode 100644 index 0000000..02232d2 --- /dev/null +++ b/src/services/project/ProjectStorage.swift @@ -0,0 +1,114 @@ +import Foundation + +/// High-level facade for project storage, agent config, and scaffolding. +/// Call sites keep using `ProjectStorage`, but responsibilities now live in +/// focused collaborators under `src/services/project`. +struct ProjectStorage { + let baseDirectory: URL + + private var repository: ProjectRepository { + ProjectRepository(baseDirectory: baseDirectory) + } + + private var agentConfigService: ProjectAgentConfigService { + ProjectAgentConfigService(baseDirectory: baseDirectory) + } + + private var teenybaseScaffolder: ProjectTeenybaseScaffolder { + ProjectTeenybaseScaffolder(baseDirectory: baseDirectory) + } + + init(baseDirectory: URL = BlitzPaths.projects) { + self.baseDirectory = baseDirectory + } + + func listProjects() async -> [Project] { + await repository.listProjects() + } + + func readMetadata(projectId: String) -> BlitzProjectMetadata? { + repository.readMetadata(projectId: projectId) + } + + func writeMetadata(projectId: String, metadata: BlitzProjectMetadata) throws { + try repository.writeMetadata(projectId: projectId, metadata: metadata) + } + + func writeMetadataToDirectory(_ dir: URL, metadata: BlitzProjectMetadata) throws { + try repository.writeMetadataToDirectory(dir, metadata: metadata) + } + + func deleteProject(projectId: String) throws { + try repository.deleteProject(projectId: projectId) + } + + func openProject(at url: URL) throws -> String { + try repository.openProject(at: url) + } + + func updateLastOpened(projectId: String) { + repository.updateLastOpened(projectId: projectId) + } + + func clearRecentProjects() { + repository.clearRecentProjects() + } + + func ensureGlobalMCPConfigs(whitelistBlitzMCP: Bool = true, allowASCCLICalls: Bool = false) { + agentConfigService.ensureGlobalMCPConfigs( + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + } + + func ensureAllProjectMCPConfigs(whitelistBlitzMCP: Bool = true, allowASCCLICalls: Bool = false) { + agentConfigService.ensureAllProjectMCPConfigs( + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + } + + func ensureMCPConfig( + projectId: String, + whitelistBlitzMCP: Bool = true, + allowASCCLICalls: Bool = false + ) { + agentConfigService.ensureMCPConfig( + projectId: projectId, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + } + + func ensureMCPConfig( + in directory: URL, + whitelistBlitzMCP: Bool = true, + allowASCCLICalls: Bool = false, + includeProjectDocFallback: Bool = true + ) { + agentConfigService.ensureMCPConfig( + in: directory, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls, + includeProjectDocFallback: includeProjectDocFallback + ) + } + + func ensureClaudeFiles( + projectId: String, + projectType: ProjectType, + whitelistBlitzMCP: Bool = true, + allowASCCLICalls: Bool = false + ) { + agentConfigService.ensureClaudeFiles( + projectId: projectId, + projectType: projectType, + whitelistBlitzMCP: whitelistBlitzMCP, + allowASCCLICalls: allowASCCLICalls + ) + } + + func ensureTeenybaseBackend(projectId: String, projectType: ProjectType) { + teenybaseScaffolder.ensureTeenybaseBackend(projectId: projectId, projectType: projectType) + } +} diff --git a/src/services/project/ProjectTeenybaseScaffolder.swift b/src/services/project/ProjectTeenybaseScaffolder.swift new file mode 100644 index 0000000..05d47c6 --- /dev/null +++ b/src/services/project/ProjectTeenybaseScaffolder.swift @@ -0,0 +1,129 @@ +import Foundation + +/// Scaffolds Blitz-managed Teenybase backend files into projects. +struct ProjectTeenybaseScaffolder { + let baseDirectory: URL + + func ensureTeenybaseBackend(projectId: String, projectType: ProjectType) { + let fm = FileManager.default + let projectDir = baseDirectory.appendingPathComponent(projectId) + + guard let templateURL = Bundle.appResources.url( + forResource: "rn-notes-template", + withExtension: nil, + subdirectory: "templates" + ) else { + print("[ProjectTeenybaseScaffolder] Teenybase template not found in bundle") + return + } + + switch projectType { + case .reactNative: + copyTeenybaseFiles(from: templateURL, to: projectDir, fm: fm) + mergeTeenybaseScripts(into: projectDir.appendingPathComponent("package.json"), fm: fm) + case .swift, .flutter: + let backendDir = projectDir.appendingPathComponent("backend") + try? fm.createDirectory(at: backendDir, withIntermediateDirectories: true) + copyTeenybaseFiles(from: templateURL, to: backendDir, fm: fm) + ensureStandalonePackageJson( + at: backendDir.appendingPathComponent("package.json"), + projectId: projectId, + fm: fm + ) + } + } + + /// Copies backend files into `dest` and never overwrites an existing setup. + private func copyTeenybaseFiles(from templateURL: URL, to dest: URL, fm: FileManager) { + let teenybaseDest = dest.appendingPathComponent("teenybase.ts") + guard !fm.fileExists(atPath: teenybaseDest.path) else { return } + + try? fm.copyItem(at: templateURL.appendingPathComponent("teenybase.ts"), to: teenybaseDest) + + let wranglerDest = dest.appendingPathComponent("wrangler.toml") + if !fm.fileExists(atPath: wranglerDest.path) { + let src = templateURL.appendingPathComponent("wrangler.toml") + if var content = try? String(contentsOf: src, encoding: .utf8) { + let appName = resolvedProjectName(for: dest) + content = content.replacingOccurrences(of: "sample-app", with: appName) + try? content.write(to: wranglerDest, atomically: true, encoding: .utf8) + } + } + + let srcBackendDest = dest.appendingPathComponent("src-backend") + try? fm.createDirectory(at: srcBackendDest, withIntermediateDirectories: true) + let workerDest = srcBackendDest.appendingPathComponent("worker.ts") + if !fm.fileExists(atPath: workerDest.path) { + try? fm.copyItem( + at: templateURL.appendingPathComponent("src-backend/worker.ts"), + to: workerDest + ) + } + + let devVarsDest = dest.appendingPathComponent(".dev.vars") + if !fm.fileExists(atPath: devVarsDest.path) { + let sampleVars = templateURL.appendingPathComponent("sample.vars") + if fm.fileExists(atPath: sampleVars.path) { + try? fm.copyItem(at: sampleVars, to: devVarsDest) + } + } + } + + private func mergeTeenybaseScripts(into packageJsonURL: URL, fm: FileManager) { + guard fm.fileExists(atPath: packageJsonURL.path), + let data = try? Data(contentsOf: packageJsonURL), + var pkg = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } + + var devDeps = pkg["devDependencies"] as? [String: Any] ?? [:] + guard devDeps["teenybase"] == nil else { return } + + devDeps["teenybase"] = "0.0.10" + pkg["devDependencies"] = devDeps + + var scripts = pkg["scripts"] as? [String: Any] ?? [:] + let backendScripts: [String: String] = [ + "generate:backend": "teeny generate --local", + "migrate:backend": "teeny migrate --local", + "dev:backend": "teeny dev --local", + "build:backend": "teeny build --local", + "exec:backend": "teeny exec --local", + "deploy:backend:remote": "teeny deploy --migrate --remote", + ] + for (key, value) in backendScripts where scripts[key] == nil { + scripts[key] = value + } + pkg["scripts"] = scripts + + if let updated = try? JSONSerialization.data(withJSONObject: pkg, options: [.prettyPrinted, .sortedKeys]) { + try? updated.write(to: packageJsonURL) + } + } + + private func ensureStandalonePackageJson(at url: URL, projectId: String, fm: FileManager) { + guard !fm.fileExists(atPath: url.path) else { return } + let content = """ + { + "name": "\(projectId)-backend", + "version": "1.0.0", + "scripts": { + "generate": "teeny generate --local", + "migrate": "teeny migrate --local", + "dev": "teeny dev --local", + "build": "teeny build --local", + "exec": "teeny exec --local", + "deploy": "teeny deploy --migrate --remote" + }, + "devDependencies": { + "teenybase": "0.0.10" + } + } + """ + try? content.write(to: url, atomically: true, encoding: .utf8) + } + + private func resolvedProjectName(for destination: URL) -> String { + destination.lastPathComponent == "backend" + ? destination.deletingLastPathComponent().lastPathComponent + : destination.lastPathComponent + } +} diff --git a/src/services/project/ProjectTemplateScaffolder.swift b/src/services/project/ProjectTemplateScaffolder.swift new file mode 100644 index 0000000..3ee9998 --- /dev/null +++ b/src/services/project/ProjectTemplateScaffolder.swift @@ -0,0 +1,104 @@ +import Foundation + +struct ProjectTemplateSpec { + let templateName: String + let missingTemplateMessage: String + let replacements: [String: String] + let sampleDevVars: String? + let cleanupPaths: [String] + let logPrefix: String +} + +/// Shared template copier used by React Native, Swift, and macOS Swift setup services. +enum ProjectTemplateScaffolder { + static func scaffold( + spec: ProjectTemplateSpec, + projectPath: String, + onStep: @MainActor (ProjectSetupService.SetupStep) -> Void + ) async throws { + let fm = FileManager.default + + await onStep(.copying) + print("[\(spec.logPrefix)] Copying bundled template") + + guard let templateURL = Bundle.appResources.url( + forResource: spec.templateName, + withExtension: nil, + subdirectory: "templates" + ) else { + throw ProjectSetupService.SetupError(message: spec.missingTemplateMessage) + } + + let metadataPath = projectPath + "/.blitz/project.json" + let metadataData = try? Data(contentsOf: URL(fileURLWithPath: metadataPath)) + + if fm.fileExists(atPath: projectPath) { + try fm.removeItem(atPath: projectPath) + } + try fm.createDirectory(atPath: projectPath, withIntermediateDirectories: true) + + try copyTemplateDir( + src: templateURL, + dest: URL(fileURLWithPath: projectPath), + replacements: spec.replacements + ) + + for cleanupPath in spec.cleanupPaths { + let absolutePath = URL(fileURLWithPath: projectPath).appendingPathComponent(cleanupPath).path + if fm.fileExists(atPath: absolutePath) { + try? fm.removeItem(atPath: absolutePath) + } + } + + let blitzDir = projectPath + "/.blitz" + if !fm.fileExists(atPath: blitzDir) { + try fm.createDirectory(atPath: blitzDir, withIntermediateDirectories: true) + } + if let data = metadataData { + try data.write(to: URL(fileURLWithPath: metadataPath)) + } + + if let sampleDevVars = spec.sampleDevVars { + let devVarsPath = projectPath + "/.dev.vars" + if !fm.fileExists(atPath: devVarsPath) { + let sampleVarsPath = projectPath + "/sample.vars" + if fm.fileExists(atPath: sampleVarsPath) { + try fm.copyItem(atPath: sampleVarsPath, toPath: devVarsPath) + } else { + try sampleDevVars.write(toFile: devVarsPath, atomically: true, encoding: .utf8) + } + } + } + + await onStep(.ready) + print("[\(spec.logPrefix)] Project setup complete") + } + + private static func copyTemplateDir(src: URL, dest: URL, replacements: [String: String]) throws { + let fm = FileManager.default + try fm.createDirectory(at: dest, withIntermediateDirectories: true) + + let entries = try fm.contentsOfDirectory(at: src, includingPropertiesForKeys: [.isDirectoryKey]) + for entry in entries { + let resolvedName = applyReplacements(to: entry.lastPathComponent, replacements: replacements) + let destPath = dest.appendingPathComponent(resolvedName) + + var isDir: ObjCBool = false + fm.fileExists(atPath: entry.path, isDirectory: &isDir) + + if isDir.boolValue { + try copyTemplateDir(src: entry, dest: destPath, replacements: replacements) + } else { + var content = try String(contentsOf: entry, encoding: .utf8) + content = applyReplacements(to: content, replacements: replacements) + try content.write(to: destPath, atomically: true, encoding: .utf8) + } + } + } + + private static func applyReplacements(to value: String, replacements: [String: String]) -> String { + replacements.reduce(value) { partialResult, replacement in + partialResult.replacingOccurrences(of: replacement.key, with: replacement.value) + } + } +} diff --git a/src/services/project/SwiftProjectSetupService.swift b/src/services/project/SwiftProjectSetupService.swift new file mode 100644 index 0000000..a4c5365 --- /dev/null +++ b/src/services/project/SwiftProjectSetupService.swift @@ -0,0 +1,56 @@ +import Foundation + +/// Scaffolds a new Swift/SwiftUI project from the bundled template. +/// Mirrors the logic in blitz-cn's create-swift-project.ts. +struct SwiftProjectSetupService { + + /// Convert a project ID like "my-cool-app" → "MyCoolApp". + static func toSwiftAppName(_ projectId: String) -> String { + let parts = projectId.components(separatedBy: CharacterSet.alphanumerics.inverted) + let camel = parts + .filter { !$0.isEmpty } + .map { $0.prefix(1).uppercased() + $0.dropFirst() } + .joined() + + // Ensure starts with a letter + var result = camel + while let first = result.first, !first.isLetter { + result = String(result.dropFirst()) + } + return result.isEmpty ? "App" : result + } + + /// Derive a bundle ID: "MyCoolApp" → "dev.blitz.MyCoolApp". + static func toBundleId(_ appName: String) -> String { + let safe = appName.filter { $0.isLetter || $0.isNumber } + return "dev.blitz.\(safe.isEmpty ? "App" : safe)" + } + + /// Set up a new Swift project from the bundled template. + /// Calls `onStep` on the main actor as each phase begins. + static func setup( + projectId: String, + projectName: String, + projectPath: String, + onStep: @MainActor (ProjectSetupService.SetupStep) -> Void + ) async throws { + let appName = toSwiftAppName(projectId) + let bundleId = toBundleId(appName) + let spec = ProjectTemplateSpec( + templateName: "swift-hello-template", + missingTemplateMessage: "Bundled Swift template not found", + replacements: [ + "__APP_NAME__": appName, + "__BUNDLE_ID__": bundleId + ], + sampleDevVars: nil, + cleanupPaths: [], + logPrefix: "swift-setup" + ) + try await ProjectTemplateScaffolder.scaffold( + spec: spec, + projectPath: projectPath, + onStep: onStep + ) + } +} diff --git a/src/services/DeviceInteractionService.swift b/src/services/simulator/DeviceInteractionService.swift similarity index 100% rename from src/services/DeviceInteractionService.swift rename to src/services/simulator/DeviceInteractionService.swift diff --git a/src/IDBProtocol.swift b/src/services/simulator/IDBClient.swift similarity index 100% rename from src/IDBProtocol.swift rename to src/services/simulator/IDBClient.swift diff --git a/src/services/MetalRenderer.swift b/src/services/simulator/MetalRenderer.swift similarity index 100% rename from src/services/MetalRenderer.swift rename to src/services/simulator/MetalRenderer.swift diff --git a/src/SimctlClient.swift b/src/services/simulator/SimctlClient.swift similarity index 100% rename from src/SimctlClient.swift rename to src/services/simulator/SimctlClient.swift diff --git a/src/services/SimulatorCaptureService.swift b/src/services/simulator/SimulatorCaptureService.swift similarity index 100% rename from src/services/SimulatorCaptureService.swift rename to src/services/simulator/SimulatorCaptureService.swift diff --git a/src/services/SimulatorService.swift b/src/services/simulator/SimulatorService.swift similarity index 100% rename from src/services/SimulatorService.swift rename to src/services/simulator/SimulatorService.swift diff --git a/src/views/build/DatabaseView.swift b/src/views/build/database/DatabaseView.swift similarity index 100% rename from src/views/build/DatabaseView.swift rename to src/views/build/database/DatabaseView.swift diff --git a/src/views/build/DeviceSelectorView.swift b/src/views/build/simulator/DeviceSelectorView.swift similarity index 100% rename from src/views/build/DeviceSelectorView.swift rename to src/views/build/simulator/DeviceSelectorView.swift diff --git a/src/views/build/MetalFrameView.swift b/src/views/build/simulator/MetalFrameView.swift similarity index 100% rename from src/views/build/MetalFrameView.swift rename to src/views/build/simulator/MetalFrameView.swift diff --git a/src/views/build/SimulatorView.swift b/src/views/build/simulator/SimulatorView.swift similarity index 100% rename from src/views/build/SimulatorView.swift rename to src/views/build/simulator/SimulatorView.swift diff --git a/src/views/build/TouchOverlayView.swift b/src/views/build/simulator/TouchOverlayView.swift similarity index 100% rename from src/views/build/TouchOverlayView.swift rename to src/views/build/simulator/TouchOverlayView.swift From 661a05d70c35f48c4a386da16f092e0f36a70ebe Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Wed, 25 Mar 2026 22:58:57 -0700 Subject: [PATCH 37/51] update --- src/views/settings/SettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/settings/SettingsView.swift b/src/views/settings/SettingsView.swift index d7cc13b..b58cfed 100644 --- a/src/views/settings/SettingsView.swift +++ b/src/views/settings/SettingsView.swift @@ -264,7 +264,7 @@ struct SettingsView: View { )) learnMore(isExpanded: $showAskAIDetail) { - Text("When you click \"Ask AI\", Blitz launches \(currentAgent.displayName) in \(currentTerminal.displayName). Right-click the button to open the panel instead.") + Text("When enabled, Blitz automatically sends a context-aware prompt to \(currentAgent.displayName) based on the current tab (e.g. Store Listing, Screenshots, App Review). Disable to start with a blank session.") } } From 7da719a333e684d87be95ac3eeaa8721debe4977 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Thu, 26 Mar 2026 00:57:52 -0700 Subject: [PATCH 38/51] cleanup / remove debug code --- src/AppState.swift | 2 +- src/BlitzApp.swift | 180 +++++++++++++-------------- src/services/AutoUpdateService.swift | 9 ++ src/views/WelcomeWindow.swift | 5 +- 4 files changed, 103 insertions(+), 93 deletions(-) diff --git a/src/AppState.swift b/src/AppState.swift index 6ca76ed..30edae8 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -136,7 +136,7 @@ final class AppState { var projectManager = ProjectManager() var simulatorManager = SimulatorManager() var simulatorStream = SimulatorStreamManager() -var settingsStore = SettingsService.shared + var settingsStore = SettingsService.shared var databaseManager = DatabaseManager() var projectSetup = ProjectSetupManager() var ascManager = ASCManager() diff --git a/src/BlitzApp.swift b/src/BlitzApp.swift index bdc1ee0..ba9a039 100644 --- a/src/BlitzApp.swift +++ b/src/BlitzApp.swift @@ -1,5 +1,82 @@ import SwiftUI +final class BlitzAppDelegate: NSObject, NSApplicationDelegate { + var appState: AppState? + + func applicationDidFinishLaunching(_ notification: Notification) { + if let fileMenu = NSApp.mainMenu?.item(withTitle: "File") { + fileMenu.title = "Project" + } + // Set dock icon from bundled resource (needed for swift run / non-.app launches) + if let icon = Bundle.appResources.image(forResource: "blitz-icon") { + NSApp.applicationIconImage = icon + } + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + false + } + + func applicationWillTerminate(_ notification: Notification) { + MCPBootstrap.shared.shutdown() + // Don't block termination with synchronous simctl shutdown — + // this prevents macOS TCC "Quit & Reopen" from relaunching the app. + // Fire-and-forget: let simctl handle cleanup in the background. + if let udid = appState?.simulatorManager.bootedDeviceId { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["simctl", "shutdown", udid] + try? process.run() + // Do NOT call waitUntilExit() — let the app terminate immediately + } + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if !flag { + if appState?.activeProjectId != nil { + for window in NSApp.windows where window.canBecomeMain { + window.makeKeyAndOrderFront(nil) + return false + } + } + for window in NSApp.windows { + window.makeKeyAndOrderFront(nil) + return false + } + } + return true + } +} + +@main +struct BlitzApp: App { + @NSApplicationDelegateAdaptor private var appDelegate: BlitzAppDelegate + @State private var appState = AppState() + + var body: some Scene { + Window("Welcome to Blitz", id: "welcome") { + WelcomeWindow(appState: appState) + .frame(width: 700, height: 440) + .onAppear { + appDelegate.appState = appState + } + } + .windowResizability(.contentSize) + .windowStyle(.hiddenTitleBar) + .defaultSize(width: 700, height: 440) + .commands { + AppCommands(appState: appState) + } + + WindowGroup(id: "main", for: String.self) { _ in + ContentView(appState: appState) + .frame(minWidth: 800, minHeight: 600) + } + .defaultSize(width: 1200, height: 900) + .windowToolbarStyle(.unified(showsTitle: false)) + } +} + /// Manages MCP server lifecycle independently of SwiftUI view callbacks. @MainActor final class MCPBootstrap { @@ -57,7 +134,7 @@ final class MCPBootstrap { // Look for embedded skills in the app bundle guard let bundleSkills = Bundle.main.resourceURL? - .appendingPathComponent("claude-skills") else { return } + .appendingPathComponent("claude-skills") else { return } guard fm.fileExists(atPath: bundleSkills.path) else { return } do { @@ -123,14 +200,14 @@ final class MCPBootstrap { // Keep the old script path working for manually created configs while // new project configs point directly at the helper executable. let bridgeScript = """ - #!/bin/bash - HELPER="$HOME/.blitz/blitz-macos-mcp" - if [ ! -x "$HELPER" ]; then - echo '{"jsonrpc":"2.0","id":null,"error":{"code":-1,"message":"Blitz MCP helper is not installed. Start Blitz first."}}' >&2 - exit 1 - fi - exec "$HELPER" "$@" - """ + #!/bin/bash + HELPER="$HOME/.blitz/blitz-macos-mcp" + if [ ! -x "$HELPER" ]; then + echo '{"jsonrpc":"2.0","id":null,"error":{"code":-1,"message":"Blitz MCP helper is not installed. Start Blitz first."}}' >&2 + exit 1 + fi + exec "$HELPER" "$@" + """ try? bridgeScript.write(to: BlitzPaths.mcpBridge, atomically: true, encoding: .utf8) try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: BlitzPaths.mcpBridge.path) } @@ -144,15 +221,15 @@ final class MCPBootstrap { let fm = FileManager.default let bundledHelper = Bundle.main.bundleURL - .appendingPathComponent("Contents/Helpers/blitz-macos-mcp") + .appendingPathComponent("Contents/Helpers/blitz-macos-mcp") if fm.isExecutableFile(atPath: bundledHelper.path) { return bundledHelper } if let executableURL = Bundle.main.executableURL { let siblingHelper = executableURL - .deletingLastPathComponent() - .appendingPathComponent("blitz-macos-mcp") + .deletingLastPathComponent() + .appendingPathComponent("blitz-macos-mcp") if fm.isExecutableFile(atPath: siblingHelper.path) { return siblingHelper } @@ -160,81 +237,4 @@ final class MCPBootstrap { return nil } -} - -final class BlitzAppDelegate: NSObject, NSApplicationDelegate { - var appState: AppState? - - func applicationDidFinishLaunching(_ notification: Notification) { - if let fileMenu = NSApp.mainMenu?.item(withTitle: "File") { - fileMenu.title = "Project" - } - // Set dock icon from bundled resource (needed for swift run / non-.app launches) - if let icon = Bundle.appResources.image(forResource: "blitz-icon") { - NSApp.applicationIconImage = icon - } - } - - func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - false - } - - func applicationWillTerminate(_ notification: Notification) { - MCPBootstrap.shared.shutdown() - // Don't block termination with synchronous simctl shutdown — - // this prevents macOS TCC "Quit & Reopen" from relaunching the app. - // Fire-and-forget: let simctl handle cleanup in the background. - if let udid = appState?.simulatorManager.bootedDeviceId { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") - process.arguments = ["simctl", "shutdown", udid] - try? process.run() - // Do NOT call waitUntilExit() — let the app terminate immediately - } - } - - func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - if !flag { - if appState?.activeProjectId != nil { - for window in NSApp.windows where window.canBecomeMain { - window.makeKeyAndOrderFront(nil) - return false - } - } - for window in NSApp.windows { - window.makeKeyAndOrderFront(nil) - return false - } - } - return true - } -} - -@main -struct BlitzApp: App { - @NSApplicationDelegateAdaptor private var appDelegate: BlitzAppDelegate - @State private var appState = AppState() - - var body: some Scene { - Window("Welcome to Blitz", id: "welcome") { - WelcomeWindow(appState: appState) - .frame(width: 700, height: 440) - .onAppear { - appDelegate.appState = appState - } - } - .windowResizability(.contentSize) - .windowStyle(.hiddenTitleBar) - .defaultSize(width: 700, height: 440) - .commands { - AppCommands(appState: appState) - } - - WindowGroup(id: "main", for: String.self) { _ in - ContentView(appState: appState) - .frame(minWidth: 800, minHeight: 600) - } - .defaultSize(width: 1200, height: 900) - .windowToolbarStyle(.unified(showsTitle: false)) - } -} +} \ No newline at end of file diff --git a/src/services/AutoUpdateService.swift b/src/services/AutoUpdateService.swift index fe66d55..35bca18 100644 --- a/src/services/AutoUpdateService.swift +++ b/src/services/AutoUpdateService.swift @@ -48,6 +48,15 @@ final class AutoUpdateManager { func checkForUpdate() async { state = .checking + // TODO - remove before release: DEBUG use local zip instead of fetching from GitHub + // let debugLocalZip = "SET_TO_YOUR_LOCAL_PATH/Blitz.app.zip" + // latestVersion = "1.0.30" + // downloadURL = "file://\(debugLocalZip)" + // downloadFilename = "Blitz.app.zip" + // state = .available(version: "1.0.30", releaseNotes: "Debug test update") + // return + // END DEBUG + do { var request = URLRequest(url: URL(string: Self.releasesURL)!) request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") diff --git a/src/views/WelcomeWindow.swift b/src/views/WelcomeWindow.swift index 680bf2e..5ecc950 100644 --- a/src/views/WelcomeWindow.swift +++ b/src/views/WelcomeWindow.swift @@ -31,8 +31,9 @@ struct WelcomeWindow: View { }) .task { // Show onboarding on first launch - // TODO: revert — temporarily always show onboarding for testing - showOnboarding = true + if !appState.settingsStore.hasCompletedOnboarding { + showOnboarding = true + } } .task { if appState.projectManager.projects.isEmpty { From d38186125dd7a4b573032277ea3c7a5faff122cc Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Thu, 26 Mar 2026 00:58:31 -0700 Subject: [PATCH 39/51] move overriding skills to src/resources --- scripts/bundle.sh | 4 +- src/resources/{ => rules}/blitz-rules.md | 0 .../{ => rules}/teenybase-rules-backend.md | 0 .../{ => rules}/teenybase-rules-no-backend.md | 0 src/resources/skills/asc-iap-attach/SKILL.md | 251 ++++++++++++++++ .../asc-privacy-nutrition-labels/SKILL.md | 281 ++++++++++++++++++ .../skills/asc-team-key-create/SKILL.md | 212 +++++++++++++ .../project/ProjectAgentConfigService.swift | 3 +- 8 files changed, 747 insertions(+), 4 deletions(-) rename src/resources/{ => rules}/blitz-rules.md (100%) rename src/resources/{ => rules}/teenybase-rules-backend.md (100%) rename src/resources/{ => rules}/teenybase-rules-no-backend.md (100%) create mode 100644 src/resources/skills/asc-iap-attach/SKILL.md create mode 100644 src/resources/skills/asc-privacy-nutrition-labels/SKILL.md create mode 100644 src/resources/skills/asc-team-key-create/SKILL.md diff --git a/scripts/bundle.sh b/scripts/bundle.sh index 524a0c5..f086530 100755 --- a/scripts/bundle.sh +++ b/scripts/bundle.sh @@ -180,8 +180,8 @@ for bundle_dir in .build/${CONFIG}/*.bundle; do fi done -# Embed Claude skills in .app bundle (installed to ~/.claude/skills/ at app startup) -SKILLS_SRC="$ROOT_DIR/.claude/skills" +# Embed Claude skills in .app bundle +SKILLS_SRC="$ROOT_DIR/src/resources/skills" SKILLS_DST="$BUNDLE_DIR/Contents/Resources/claude-skills" if [ -d "$SKILLS_SRC" ]; then rm -rf "$SKILLS_DST" diff --git a/src/resources/blitz-rules.md b/src/resources/rules/blitz-rules.md similarity index 100% rename from src/resources/blitz-rules.md rename to src/resources/rules/blitz-rules.md diff --git a/src/resources/teenybase-rules-backend.md b/src/resources/rules/teenybase-rules-backend.md similarity index 100% rename from src/resources/teenybase-rules-backend.md rename to src/resources/rules/teenybase-rules-backend.md diff --git a/src/resources/teenybase-rules-no-backend.md b/src/resources/rules/teenybase-rules-no-backend.md similarity index 100% rename from src/resources/teenybase-rules-no-backend.md rename to src/resources/rules/teenybase-rules-no-backend.md diff --git a/src/resources/skills/asc-iap-attach/SKILL.md b/src/resources/skills/asc-iap-attach/SKILL.md new file mode 100644 index 0000000..a5b7723 --- /dev/null +++ b/src/resources/skills/asc-iap-attach/SKILL.md @@ -0,0 +1,251 @@ +--- +name: asc-iap-attach +description: Attach in-app purchases and subscriptions to an app version for App Store review. Use when the user has IAPs or subscriptions in "Ready to Submit" state that need to be included with a first-time version submission. Works for both first-time and subsequent submissions. +--- + +# asc iap attach + +Use this skill to attach in-app purchases and/or subscriptions to an app version for App Store review. This is the equivalent of checking the boxes in the "Add In-App Purchases or Subscriptions" modal on the version page in App Store Connect. + +## When to use + +- User is preparing an app version for submission and has IAPs or subscriptions to include +- User says "attach IAPs", "add subscriptions to version", "include in-app purchases for review", "select in-app purchases" +- The app version page in ASC shows an "In-App Purchases and Subscriptions" section with items to select +- IAPs/subscriptions have been created and are in "Ready to Submit" state + +## Background + +Apple's official App Store Connect API (`POST /v1/subscriptionSubmissions`, `POST /v1/inAppPurchaseSubmissions`) returns `FIRST_SUBSCRIPTION_MUST_BE_SUBMITTED_ON_VERSION` for first-time IAP/subscription submissions. The `reviewSubmissionItems` API also does not support `subscription` or `inAppPurchase` relationship types. + +This skill uses Apple's internal iris API (`/iris/v1/subscriptionSubmissions`) via cached web session cookies, which supports the `submitWithNextAppStoreVersion` attribute that the public API lacks. This is the same mechanism the ASC web UI uses when you check the checkbox in the modal. + +## Preconditions + +- Web session file available at `~/.blitz/asc-agent/web-session.json`. If no session exists or it has expired (401), call the `asc_web_auth` MCP tool first — this opens the Apple ID login window in Blitz and captures the session automatically. +- Know your app ID. +- IAPs and/or subscriptions already exist and are in **Ready to Submit** state. +- A build is uploaded and attached to the current app version. + +## Workflow + +### 1. Check for an existing web session + +```bash +test -f ~/.blitz/asc-agent/web-session.json && echo "SESSION_EXISTS" || echo "NO_SESSION" +``` + +- If `NO_SESSION`: call the `asc_web_auth` MCP tool first. Wait for it to complete before proceeding. +- If `SESSION_EXISTS`: proceed to the next step. + +### 2. List subscriptions and IAPs to identify items to attach + +Use the iris API to list subscription groups (with subscriptions) and in-app purchases. Replace `APP_ID` with the actual app ID. + +```bash +python3 -c " +import json, os, urllib.request, sys + +APP_ID = 'APP_ID_HERE' + +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): + print('ERROR: No web session found. Call asc_web_auth MCP tool first.') + sys.exit(1) +with open(session_path) as f: + raw = f.read() + +store = json.loads(raw) +session = store['sessions'][store['last_key']] +cookie_str = '; '.join( + f'{c[\"name\"]}={c[\"value\"]}' + for cl in session['cookies'].values() for c in cl + if c.get('name') and c.get('value') +) + +headers = { + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'Origin': 'https://appstoreconnect.apple.com', + 'Referer': 'https://appstoreconnect.apple.com/', + 'Cookie': cookie_str +} + +def iris_get(path): + url = f'https://appstoreconnect.apple.com/iris/v1/{path}' + req = urllib.request.Request(url, method='GET', headers=headers) + try: + resp = urllib.request.urlopen(req) + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + if e.code == 401: + print('ERROR: Session expired. Call asc_web_auth MCP tool to re-authenticate.') + sys.exit(1) + print(f'ERROR: HTTP {e.code} — {e.read().decode()[:200]}') + sys.exit(1) + +# List subscription groups with subscriptions included +print('=== Subscription Groups ===') +sg = iris_get(f'apps/{APP_ID}/subscriptionGroups?include=subscriptions&limit=300&fields%5Bsubscriptions%5D=productId,name,state,submitWithNextAppStoreVersion') +for group in sg.get('data', []): + print(f'Group: {group[\"attributes\"][\"referenceName\"]} (id={group[\"id\"]})') +for sub in sg.get('included', []): + if sub['type'] == 'subscriptions': + a = sub['attributes'] + attached = a.get('submitWithNextAppStoreVersion', False) + print(f' Subscription: {a.get(\"name\",\"?\")} | productId={a.get(\"productId\",\"?\")} | state={a.get(\"state\",\"?\")} | attached={attached} | id={sub[\"id\"]}') + +# List in-app purchases +print() +print('=== In-App Purchases ===') +iaps = iris_get(f'apps/{APP_ID}/inAppPurchasesV2?limit=300&fields%5BinAppPurchases%5D=productId,name,state,submitWithNextAppStoreVersion') +for iap in iaps.get('data', []): + a = iap['attributes'] + attached = a.get('submitWithNextAppStoreVersion', False) + print(f'IAP: {a.get(\"name\",\"?\")} | productId={a.get(\"productId\",\"?\")} | state={a.get(\"state\",\"?\")} | attached={attached} | id={iap[\"id\"]}') +" +``` + +Look for items with `state=READY_TO_SUBMIT` and `attached=False`. Note their IDs. + +### 3. Attach subscriptions via iris API + +Use the following script to attach subscriptions. **Do not print or log the cookies** — they contain sensitive session tokens. + +```bash +python3 -c " +import json, os, urllib.request, sys + +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): + print('ERROR: No web session found. Call asc_web_auth MCP tool first.') + sys.exit(1) +with open(session_path) as f: + raw = f.read() + +store = json.loads(raw) +session = store['sessions'][store['last_key']] +cookie_str = '; '.join( + f'{c[\"name\"]}={c[\"value\"]}' + for cl in session['cookies'].values() for c in cl + if c.get('name') and c.get('value') +) + +def iris_attach_subscription(sub_id): + body = json.dumps({'data': { + 'type': 'subscriptionSubmissions', + 'attributes': {'submitWithNextAppStoreVersion': True}, + 'relationships': {'subscription': {'data': {'type': 'subscriptions', 'id': sub_id}}} + }}).encode() + req = urllib.request.Request( + 'https://appstoreconnect.apple.com/iris/v1/subscriptionSubmissions', + data=body, method='POST', + headers={ + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'Origin': 'https://appstoreconnect.apple.com', + 'Referer': 'https://appstoreconnect.apple.com/', + 'Cookie': cookie_str + }) + try: + resp = urllib.request.urlopen(req) + print(f'Attached subscription {sub_id}: HTTP {resp.status}') + except urllib.error.HTTPError as e: + body = e.read().decode() + if 'already set to submit' in body: + print(f'Subscription {sub_id} already attached (OK)') + elif e.code == 401: + print(f'ERROR: Session expired. Call asc_web_auth MCP tool to re-authenticate.') + else: + print(f'ERROR attaching {sub_id}: HTTP {e.code} — {body[:200]}') + +# Replace with actual subscription IDs: +iris_attach_subscription('SUB_ID_1') +iris_attach_subscription('SUB_ID_2') +" +``` + +For in-app purchases (non-subscription), change the type and relationship: + +```bash +python3 -c " +import json, os, urllib.request, sys + +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): + print('ERROR: No web session found. Call asc_web_auth MCP tool first.') + sys.exit(1) +with open(session_path) as f: + raw = f.read() + +store = json.loads(raw) +session = store['sessions'][store['last_key']] +cookie_str = '; '.join( + f'{c[\"name\"]}={c[\"value\"]}' + for cl in session['cookies'].values() for c in cl + if c.get('name') and c.get('value') +) + +def iris_attach_iap(iap_id): + body = json.dumps({'data': { + 'type': 'inAppPurchaseSubmissions', + 'attributes': {'submitWithNextAppStoreVersion': True}, + 'relationships': {'inAppPurchaseV2': {'data': {'type': 'inAppPurchases', 'id': iap_id}}} + }}).encode() + req = urllib.request.Request( + 'https://appstoreconnect.apple.com/iris/v1/inAppPurchaseSubmissions', + data=body, method='POST', + headers={ + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'Origin': 'https://appstoreconnect.apple.com', + 'Referer': 'https://appstoreconnect.apple.com/', + 'Cookie': cookie_str + }) + try: + resp = urllib.request.urlopen(req) + print(f'Attached IAP {iap_id}: HTTP {resp.status}') + except urllib.error.HTTPError as e: + body = e.read().decode() + if 'already set to submit' in body: + print(f'IAP {iap_id} already attached (OK)') + elif e.code == 401: + print(f'ERROR: Session expired. Call asc_web_auth MCP tool to re-authenticate.') + else: + print(f'ERROR attaching {iap_id}: HTTP {e.code} — {body[:200]}') + +# Replace with actual IAP IDs: +iris_attach_iap('IAP_ID') +" +``` + +### 4. Verify attachments + +After attachment, call `get_tab_state` for `ascOverview` to refresh the submission readiness checklist. The MCP tool auto-refreshes monetization data and will reflect the updated attachment state. + +## Common Errors + +### "Subscription is already set to submit with next AppStoreVersion" +The subscription is already attached — this is safe to ignore. HTTP 409 with this message means the item was previously attached. + +### 401 Not Authorized (iris API) +The web session has expired. Call the `asc_web_auth` MCP tool to open the Apple ID login window in Blitz — this captures a fresh session and refreshes `~/.blitz/asc-agent/web-session.json` automatically. The user will need to complete Apple ID login + 2FA in the popup. After the tool returns success, retry the iris API calls. + +## Agent Behavior + +- Always list IAPs and subscriptions first (using Step 2) to identify which are in `READY_TO_SUBMIT` state. +- If the user specifies particular items, match by reference name or product ID. +- If the user says "all", attach every item in `READY_TO_SUBMIT` state. +- **NEVER print, log, or echo session cookies.** The python scripts handle cookies internally without exposing them. +- Use the self-contained python scripts above — do NOT extract cookies separately or pass them as shell variables. +- If iris API returns 409 "already set to submit", treat as success. +- If iris API returns 401, call the `asc_web_auth` MCP tool to open the login window in Blitz, then retry. +- After attachment, call `get_tab_state` for `ascOverview` to refresh the submission readiness checklist. + +## Notes + +- This skill handles the "attach to version" step only. +- The iris API (`/iris/v1`) mirrors the official ASC API resource types (same JSON:API format) but supports additional attributes like `submitWithNextAppStoreVersion` that the public API lacks. +- The iris API is rate-limited; keep a minimum 350ms interval between requests. diff --git a/src/resources/skills/asc-privacy-nutrition-labels/SKILL.md b/src/resources/skills/asc-privacy-nutrition-labels/SKILL.md new file mode 100644 index 0000000..6b9a4c3 --- /dev/null +++ b/src/resources/skills/asc-privacy-nutrition-labels/SKILL.md @@ -0,0 +1,281 @@ +--- +name: asc-privacy-nutrition-labels +description: Set up App Store privacy nutrition labels (data collection declarations) for an app. Use when the user needs to declare what data their app collects, how it's used, and whether it's linked to the user. Handles both "no data collected" and full data collection declarations. +--- + +# asc privacy nutrition labels + +Use this skill to configure App Store privacy nutrition labels for an app. This is the "App Privacy" section in App Store Connect where you declare what data your app collects, what purposes it's used for, and how it's protected. + +## When to use + +- User says "set up privacy labels", "configure nutrition labels", "app privacy", "data collection declaration" +- The submission readiness checklist shows "Privacy Nutrition Labels" as incomplete +- User is preparing an app for first submission and needs to declare data practices +- User needs to update privacy declarations after adding new data collection + +## Preconditions + +- Web session authenticated (cached in keychain from prior `asc web auth login`, or call `asc_web_auth` MCP tool) +- Know your app ID (`ASC_APP_ID` or `--app`) + +## Data Model + +Each privacy declaration is a tuple of three dimensions: + +### Categories (what data is collected) + +Grouped by type: + +| Grouping | Categories | +|----------|-----------| +| CONTACT_INFO | `NAME`, `EMAIL_ADDRESS`, `PHONE_NUMBER`, `PHYSICAL_ADDRESS`, `OTHER_CONTACT_INFO` | +| HEALTH_AND_FITNESS | `HEALTH`, `FITNESS` | +| FINANCIAL_INFO | `PAYMENT_INFORMATION`, `CREDIT_AND_FRAUD`, `OTHER_FINANCIAL_INFO` | +| LOCATION | `PRECISE_LOCATION`, `COARSE_LOCATION` | +| SENSITIVE_INFO | `SENSITIVE_INFO` | +| CONTACTS | `CONTACTS` | +| USER_CONTENT | `EMAILS_OR_TEXT_MESSAGES`, `PHOTOS_OR_VIDEOS`, `AUDIO`, `GAMEPLAY_CONTENT`, `CUSTOMER_SUPPORT`, `OTHER_USER_CONTENT` | +| BROWSING_HISTORY | `BROWSING_HISTORY` | +| SEARCH_HISTORY | `SEARCH_HISTORY` | +| IDENTIFIERS | `USER_ID`, `DEVICE_ID` | +| PURCHASES | `PURCHASE_HISTORY` | +| USAGE_DATA | `PRODUCT_INTERACTION`, `ADVERTISING_DATA`, `OTHER_USAGE_DATA` | +| DIAGNOSTICS | `CRASH_DATA`, `PERFORMANCE_DATA`, `OTHER_DIAGNOSTIC_DATA` | +| OTHER_DATA | `OTHER_DATA_TYPES` | + +### Purposes (why it's collected) + +| Purpose ID | Meaning | +|-----------|---------| +| `APP_FUNCTIONALITY` | Required for the app to work | +| `ANALYTICS` | Used for analytics | +| `PRODUCT_PERSONALIZATION` | Used to personalize the product | +| `DEVELOPERS_ADVERTISING` | Used for developer's advertising | +| `THIRD_PARTY_ADVERTISING` | Used for third-party advertising | +| `OTHER_PURPOSES` | Other purposes | + +### Data Protections (how it's handled) + +| Protection ID | Meaning | +|--------------|---------| +| `DATA_NOT_COLLECTED` | App does not collect this data (mutually exclusive with others) | +| `DATA_LINKED_TO_YOU` | Collected and linked to user identity | +| `DATA_NOT_LINKED_TO_YOU` | Collected but not linked to identity | +| `DATA_USED_TO_TRACK_YOU` | Used for tracking (no purpose needed) | + +## Workflow + +### 1. Ask the user what data the app collects + +Before proceeding, understand the app's data practices. Ask: + +- Does the app collect any user data? If no → use the "No data collected" flow +- What types of data does it collect? (e.g., name, email, location, analytics) +- What purposes? (e.g., app functionality, analytics, advertising) +- Is the data linked to the user's identity? +- Is any data used for tracking? + +If the user is unsure, analyze the app's source code to determine data collection practices (look for analytics SDKs, location APIs, user accounts, etc.). + +### 2a. "No data collected" flow + +If the app collects no data: + +```bash +# Create the declaration file +cat > /tmp/privacy.json << 'EOF' +{ + "schemaVersion": 1, + "dataUsages": [] +} +EOF + +# Apply (this sets DATA_NOT_COLLECTED) +asc web privacy apply --app "APP_ID" --file /tmp/privacy.json --allow-deletes --confirm + +# Publish +asc web privacy publish --app "APP_ID" --confirm +``` + +### 2b. Full data collection flow + +Create a declaration file listing all collected data types with their purposes and protections: + +```bash +cat > /tmp/privacy.json << 'EOF' +{ + "schemaVersion": 1, + "dataUsages": [ + { + "category": "EMAIL_ADDRESS", + "purposes": ["APP_FUNCTIONALITY"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + }, + { + "category": "NAME", + "purposes": ["APP_FUNCTIONALITY"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + }, + { + "category": "CRASH_DATA", + "purposes": ["ANALYTICS"], + "dataProtections": ["DATA_NOT_LINKED_TO_YOU"] + } + ] +} +EOF +``` + +Each entry in `dataUsages` specifies: +- `category` — one category ID from the table above +- `purposes` — array of purpose IDs (what the data is used for) +- `dataProtections` — array of protection IDs (how it's handled) + +Each combination of (category, purpose, protection) becomes a separate tuple in the API. For tracking data, use `DATA_USED_TO_TRACK_YOU` as the protection (no purpose needed for tracking entries). + +### 3. Preview changes + +```bash +asc web privacy plan --app "APP_ID" --file /tmp/privacy.json --pretty +``` + +This shows a diff of what will be created, updated, or deleted. Review with the user before applying. + +### 4. Apply changes + +```bash +asc web privacy apply --app "APP_ID" --file /tmp/privacy.json --allow-deletes --confirm +``` + +- `--allow-deletes` removes remote entries not in the local file +- `--confirm` confirms destructive operations +- This command **never auto-publishes** + +### 5. Publish + +```bash +asc web privacy publish --app "APP_ID" --confirm +``` + +This makes the declarations live. Must be done after apply. + +### 6. Verify + +```bash +asc web privacy pull --app "APP_ID" --pretty +``` + +## Common App Patterns + +### Simple app with no tracking, no user accounts + +```json +{ + "schemaVersion": 1, + "dataUsages": [ + { + "category": "CRASH_DATA", + "purposes": ["ANALYTICS"], + "dataProtections": ["DATA_NOT_LINKED_TO_YOU"] + } + ] +} +``` + +### App with user accounts + analytics + +```json +{ + "schemaVersion": 1, + "dataUsages": [ + { + "category": "NAME", + "purposes": ["APP_FUNCTIONALITY"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + }, + { + "category": "EMAIL_ADDRESS", + "purposes": ["APP_FUNCTIONALITY"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + }, + { + "category": "USER_ID", + "purposes": ["APP_FUNCTIONALITY"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + }, + { + "category": "PRODUCT_INTERACTION", + "purposes": ["ANALYTICS"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + }, + { + "category": "CRASH_DATA", + "purposes": ["ANALYTICS"], + "dataProtections": ["DATA_NOT_LINKED_TO_YOU"] + } + ] +} +``` + +### Health/fitness app + +```json +{ + "schemaVersion": 1, + "dataUsages": [ + { + "category": "HEALTH", + "purposes": ["APP_FUNCTIONALITY"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + }, + { + "category": "FITNESS", + "purposes": ["APP_FUNCTIONALITY"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + }, + { + "category": "PRECISE_LOCATION", + "purposes": ["APP_FUNCTIONALITY"], + "dataProtections": ["DATA_LINKED_TO_YOU"] + } + ] +} +``` + +## Session Authentication + +If `asc web privacy` commands fail with 401 or session errors, authenticate via the Blitz MCP tool: + +``` +Call the asc_web_auth MCP tool to open the Apple ID login window +``` + +Or ask the user to run in their terminal: +``` +asc web auth login --apple-id "EMAIL" +``` + +## Validation Rules + +- `DATA_NOT_COLLECTED` (empty `dataUsages` array) is mutually exclusive — cannot coexist with collected data entries +- Each collected-data entry requires at least one `purpose` and one `dataProtection` +- `DATA_USED_TO_TRACK_YOU` entries are stored without a purpose (tracking is category-wide) +- The `publish` step is required after `apply` — changes are not live until published + +## Agent Behavior + +- Always ask the user what data their app collects before creating the declaration +- If the user is unsure, analyze the source code for data collection patterns (SDKs, APIs, user auth) +- Use `plan` to preview changes before `apply` — show the diff to the user +- Always `publish` after successful `apply` +- Use `pull` to verify the final state +- **NEVER print session cookies.** All `asc web` commands handle auth internally +- If auth fails, call `asc_web_auth` MCP tool or ask user to run `asc web auth login` + +## Notes + +- Privacy nutrition labels are required for App Store submission +- Changes are not visible until published +- The declaration file format (`schemaVersion: 1`) is the canonical format used by `asc web privacy` +- Use `asc web privacy catalog` to get the full list of available tokens if needed diff --git a/src/resources/skills/asc-team-key-create/SKILL.md b/src/resources/skills/asc-team-key-create/SKILL.md new file mode 100644 index 0000000..1d7daa9 --- /dev/null +++ b/src/resources/skills/asc-team-key-create/SKILL.md @@ -0,0 +1,212 @@ +--- +name: asc-team-key-create +description: Create a new App Store Connect Team API Key with Admin permissions, download the one-time .p8 private key, and store it in ~/.blitz. Use when the user needs a new ASC API key for CLI auth, CI/CD, or external tooling. +--- + +# asc team key create + +Use this skill to create a new App Store Connect API Key with Admin permissions via Apple's iris API, download the one-time .p8 private key, and save it to `~/.blitz`. + +## When to use + +- User asks to "create an API key", "generate a team key", "new ASC key" +- User needs a fresh key for `asc auth login`, CI/CD pipelines, or external tooling +- User wants to rotate or replace an existing API key + +## Preconditions + +- Web session file available at `~/.blitz/asc-agent/web-session.json`. If no session exists or it has expired (401), call the `asc_web_auth` MCP tool first — this opens the Apple ID login window in Blitz and captures the session automatically. +- The authenticated Apple ID must have Account Holder or Admin role. + +## Workflow + +### 1. Check for an existing web session + +Before anything else, check if a web session file already exists: + +```bash +test -f ~/.blitz/asc-agent/web-session.json && echo "SESSION_EXISTS" || echo "NO_SESSION" +``` + +- If `NO_SESSION`: call the `asc_web_auth` MCP tool first to open the Apple ID login window in Blitz. Wait for it to complete before proceeding. +- If `SESSION_EXISTS`: proceed to the next step. + +### 2. Ask the user for a key name + +Ask the user what they want to name the key (the `nickname` field in ASC). This is a required input — do not guess or use a default. + +### 3. Create the key, download the .p8, and save it + +Use the following self-contained script. Replace `KEY_NAME` with the user's chosen name. **Do not print or log cookies** — they contain sensitive session tokens. + +```bash +python3 -c " +import json, urllib.request, base64, os, sys, time + +KEY_NAME = 'KEY_NAME_HERE' + +# Read web session file (silent — never print these) +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): + print('ERROR: No web session found. Call asc_web_auth MCP tool first.') + sys.exit(1) +with open(session_path) as f: + raw = f.read() + +store = json.loads(raw) +session = store['sessions'][store['last_key']] +cookie_str = '; '.join( + f'{c[\"name\"]}={c[\"value\"]}' + for cl in session['cookies'].values() for c in cl + if c.get('name') and c.get('value') +) + +headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'Origin': 'https://appstoreconnect.apple.com', + 'Referer': 'https://appstoreconnect.apple.com/', + 'Cookie': cookie_str +} + +# Step 1: Create the API key +create_body = json.dumps({ + 'data': { + 'type': 'apiKeys', + 'attributes': { + 'nickname': KEY_NAME, + 'roles': ['ADMIN'], + 'allAppsVisible': True, + 'keyType': 'PUBLIC_API' + } + } +}).encode() + +req = urllib.request.Request( + 'https://appstoreconnect.apple.com/iris/v1/apiKeys', + data=create_body, method='POST', headers=headers) +try: + resp = urllib.request.urlopen(req) + create_data = json.loads(resp.read().decode()) +except urllib.error.HTTPError as e: + body = e.read().decode() + if e.code == 401: + print('ERROR: Session expired. Call asc_web_auth MCP tool to re-authenticate.') + elif e.code == 409: + print(f'ERROR: A key with this name may already exist. Details: {body[:300]}') + else: + print(f'ERROR creating key: HTTP {e.code} — {body[:300]}') + sys.exit(1) + +key_id = create_data['data']['id'] +can_download = create_data['data']['attributes'].get('canDownload', False) +print(f'Created API key \"{KEY_NAME}\" — Key ID: {key_id}') + +if not can_download: + print('ERROR: Key created but canDownload is false. Cannot retrieve private key.') + sys.exit(1) + +# Step 2: Download the one-time private key +time.sleep(0.5) +dl_headers = dict(headers) +dl_headers.pop('Content-Type', None) +req = urllib.request.Request( + f'https://appstoreconnect.apple.com/iris/v1/apiKeys/{key_id}?fields%5BapiKeys%5D=privateKey', + method='GET', headers=dl_headers) +try: + resp = urllib.request.urlopen(req) + dl_data = json.loads(resp.read().decode()) +except urllib.error.HTTPError as e: + print(f'ERROR downloading key: HTTP {e.code} — {e.read().decode()[:300]}') + sys.exit(1) + +pk_b64 = dl_data['data']['attributes'].get('privateKey') +if not pk_b64: + print('ERROR: No privateKey in response. The key may have already been downloaded.') + sys.exit(1) + +private_key_pem = base64.b64decode(pk_b64).decode() + +# Step 3: Get the issuer ID from the provider relationship +time.sleep(0.35) +req = urllib.request.Request( + f'https://appstoreconnect.apple.com/iris/v1/apiKeys/{key_id}?include=provider', + method='GET', headers=dl_headers) +try: + resp = urllib.request.urlopen(req) + provider_data = json.loads(resp.read().decode()) + issuer_id = None + for inc in provider_data.get('included', []): + if inc['type'] == 'contentProviders': + issuer_id = inc['id'] + break + if not issuer_id: + issuer_id = provider_data['data']['relationships']['provider']['data']['id'] +except Exception: + issuer_id = 'UNKNOWN' + +# Step 4: Save .p8 file to ~/.blitz +blitz_dir = os.path.expanduser('~/.blitz') +os.makedirs(blitz_dir, exist_ok=True) +p8_path = os.path.join(blitz_dir, f'AuthKey_{key_id}.p8') +with open(p8_path, 'w') as f: + f.write(private_key_pem) +os.chmod(p8_path, 0o600) + +print(f'Private key saved to: {p8_path}') +print(f'Issuer ID: {issuer_id}') +print(f'Key ID: {key_id}') +print() +print('To use with asc CLI:') +print(f' asc auth login --key-id {key_id} --issuer-id {issuer_id} --private-key-path {p8_path}') +print() +print('WARNING: This .p8 file can only be downloaded ONCE. Keep it safe.') +" +``` + +### 4. Fill the credential form via MCP + +After the script succeeds, call the `asc_set_credentials` MCP tool to pre-fill the Blitz credential form: + +``` +asc_set_credentials(issuerId: "", keyId: "", privateKeyPath: "~/.blitz/AuthKey_.p8") +``` + +This lets the user visually verify the values and click "Save Credentials" in Blitz. + +### 5. Report results to the user + +After the script runs, report: +- Key name and Key ID +- Issuer ID +- File path of the saved .p8 +- That the credential form has been pre-filled — they should verify and click Save + +## Common Errors + +### 401 Not Authorized +The web session has expired or doesn't exist. Call the `asc_web_auth` MCP tool — this opens the Apple ID login window in Blitz and refreshes `~/.blitz/asc-agent/web-session.json` automatically. Then retry the key creation script. + +### 409 Conflict +A key with the same name may already exist, or another conflict occurred. Try a different name. + +### "No privateKey in response" +The key's one-time download window has passed (`canDownload` flipped to `false`). This happens if the key was already downloaded. The key must be revoked and a new one created. + +## Agent Behavior + +- **Always ask the user for the key name** before creating. Do not use defaults. +- **NEVER print, log, or echo session cookies.** The python script handles cookies internally. +- Use the self-contained python script above — do NOT extract cookies separately or pass them as shell variables. +- After creation, confirm the .p8 file exists and report the path. +- If iris API returns 401, tell the user to re-authenticate via Blitz or `asc web auth login`. +- The iris API is rate-limited; the script includes 350ms+ delays between requests. +- The .p8 private key file is saved with `0600` permissions (owner read/write only). + +## Notes + +- The private key can only be downloaded **once** from Apple. After the first download, `canDownload` flips to `false` permanently. The saved .p8 file is the only copy. +- The issuer ID is the same for all keys in the team — it's the content provider UUID. +- Keys are created with `allAppsVisible: true` (access to all apps in the team). +- To revoke a key later, use `PATCH /iris/v1/apiKeys/{keyId}` with `{"data": {"type": "apiKeys", "id": "KEY_ID", "attributes": {"isActive": false}}}`. diff --git a/src/services/project/ProjectAgentConfigService.swift b/src/services/project/ProjectAgentConfigService.swift index 3de9e3f..d985845 100644 --- a/src/services/project/ProjectAgentConfigService.swift +++ b/src/services/project/ProjectAgentConfigService.swift @@ -545,8 +545,7 @@ struct ProjectAgentConfigService { .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent(".claude/skills") + .appendingPathComponent("resources/skills") if fm.fileExists(atPath: repoSkills.path) { return repoSkills } From f66fa1d867d6455a74d0f1e2827b9515a557b1b5 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Thu, 26 Mar 2026 00:58:51 -0700 Subject: [PATCH 40/51] support nav back to sub app tabs --- src/services/mcp/MCPExecutor.swift | 44 +++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/src/services/mcp/MCPExecutor.swift b/src/services/mcp/MCPExecutor.swift index 798d70e..13b7a54 100644 --- a/src/services/mcp/MCPExecutor.swift +++ b/src/services/mcp/MCPExecutor.swift @@ -24,6 +24,11 @@ func withThrowingTimeout( /// Executes MCP tool calls against AppState. /// Holds pending approval continuations for destructive operations. actor MCPExecutor { + private struct NavigationState: Sendable { + let tab: AppTab + let appSubTab: AppSubTab + } + let appState: AppState private var pendingContinuations: [String: CheckedContinuation] = [:] @@ -36,11 +41,11 @@ actor MCPExecutor { let category = MCPRegistry.category(for: name) // Pre-navigate for ASC form tools so the user sees the target tab before approving. - var previousTab: AppTab? + var previousNavigation: NavigationState? if name == "asc_fill_form" || name == "asc_open_submit_preview" || name == "asc_create_iap" || name == "asc_create_subscription" || name == "asc_set_app_price" || name == "screenshots_add_asset" || name == "screenshots_set_track" || name == "screenshots_save" { - previousTab = await preNavigateASCTool(name: name, arguments: arguments) + previousNavigation = await preNavigateASCTool(name: name, arguments: arguments) } let request = ApprovalRequest( @@ -54,8 +59,11 @@ actor MCPExecutor { if request.requiresApproval(permissionToggles: await SettingsService.shared.permissionToggles) { let approved = await requestApproval(request) guard approved else { - if let prev = previousTab { - await MainActor.run { appState.activeTab = prev } + if let prev = previousNavigation { + await MainActor.run { + appState.activeTab = prev.tab + appState.activeAppSubTab = prev.appSubTab + } _ = await MainActor.run { appState.ascManager.pendingFormValues.removeAll() } } return mcpText("Tool '\(name)' was denied by the user.") @@ -66,11 +74,14 @@ actor MCPExecutor { } /// Navigate to the appropriate tab before approval, and set pending form values. - /// Returns the previous tab so we can navigate back if denied. - private func preNavigateASCTool(name: String, arguments: [String: Any]) async -> AppTab? { - let previousTab = await MainActor.run { appState.activeTab } + /// Returns the previous navigation state so we can navigate back if denied. + private func preNavigateASCTool(name: String, arguments: [String: Any]) async -> NavigationState { + let previousNavigation = await MainActor.run { + NavigationState(tab: appState.activeTab, appSubTab: appState.activeAppSubTab) + } let targetTab: AppTab? + let targetAppSubTab: AppSubTab? if name == "asc_fill_form" { let tab = arguments["tab"] as? String ?? "" switch tab { @@ -87,22 +98,35 @@ actor MCPExecutor { default: targetTab = nil } + targetAppSubTab = nil } else if name == "asc_open_submit_preview" { targetTab = .app + targetAppSubTab = .overview } else if name == "screenshots_add_asset" || name == "screenshots_set_track" || name == "screenshots_save" { targetTab = .screenshots + targetAppSubTab = nil } else if name == "asc_set_app_price" { targetTab = .monetization + targetAppSubTab = nil } else if name == "asc_create_iap" || name == "asc_create_subscription" { targetTab = .monetization + targetAppSubTab = nil } else { targetTab = nil + targetAppSubTab = nil } if let targetTab { - await MainActor.run { appState.activeTab = targetTab } - if targetTab.isASCTab { + await MainActor.run { + appState.activeTab = targetTab + if let targetAppSubTab { + appState.activeAppSubTab = targetAppSubTab + } + } + if targetTab == .app, targetAppSubTab == .overview { + await appState.ascManager.ensureTabData(.app) + } else if targetTab.isASCTab { await appState.ascManager.fetchTabData(targetTab) } } @@ -145,7 +169,7 @@ actor MCPExecutor { } } - return previousTab + return previousNavigation } /// Resume a pending approval. From cf30c8660ad54f65bcf8c19df5853ca43fa3b5bd Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Thu, 26 Mar 2026 01:10:07 -0700 Subject: [PATCH 41/51] unify how custom skills overwrite fetched ones from remote app-store-connect-cli-skills --- Package.swift | 2 +- .../skills/asc-app-create-ui/SKILL.md | 166 ++++++++++++++++ .../project/ProjectAgentConfigService.swift | 179 ------------------ 3 files changed, 167 insertions(+), 180 deletions(-) create mode 100644 src/resources/skills/asc-app-create-ui/SKILL.md diff --git a/Package.swift b/Package.swift index 86803c0..f3d9ecb 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,7 @@ let package = Package( name: "Blitz", dependencies: ["SwiftTerm", "BlitzMCPCommon"], path: "src", - exclude: ["metal"], + exclude: ["metal", "resources/skills"], resources: [.process("resources"), .copy("templates")], linkerSettings: [ .linkedFramework("ScreenCaptureKit"), diff --git a/src/resources/skills/asc-app-create-ui/SKILL.md b/src/resources/skills/asc-app-create-ui/SKILL.md new file mode 100644 index 0000000..392abf5 --- /dev/null +++ b/src/resources/skills/asc-app-create-ui/SKILL.md @@ -0,0 +1,166 @@ +--- +name: asc-app-create-ui +description: Create an App Store Connect app via iris API using web session from Blitz +--- + +Create an App Store Connect app using Apple's iris API. Authentication is handled via a web session file at `~/.blitz/asc-agent/web-session.json` managed by Blitz. + +Extract from the conversation context: +- `bundleId` — the bundle identifier (e.g. `com.blitz.myapp`) +- `sku` — the SKU string (may be provided; if missing, generate one from the app name) + +## Workflow + +### 1. Check for an existing web session + +```bash +test -f ~/.blitz/asc-agent/web-session.json && echo "SESSION_EXISTS" || echo "NO_SESSION" +``` + +- If `NO_SESSION`: call the `asc_web_auth` MCP tool first. Wait for it to complete before proceeding. +- If `SESSION_EXISTS`: proceed. + +### 2. Ask the user for the primary language + +Ask what primary language/locale the app should use. Common choices: `en-US` (English US), `en-GB` (English UK), `ja` (Japanese), `zh-Hans` (Simplified Chinese), `ko` (Korean), `fr-FR` (French), `de-DE` (German). + +### 3. Derive the app name + +Take the last component of the bundle ID after the final `.`, capitalize the first letter. Confirm with the user. + +### 4. Create the app via iris API + +Use the following self-contained script. Replace `BUNDLE_ID`, `SKU`, `APP_NAME`, and `LOCALE` with the resolved values. **Do not print or log cookies.** + +Key differences from the public REST API: +- Uses `appstoreconnect.apple.com/iris/v1/` (not `api.appstoreconnect.apple.com`) +- Authenticated via web session cookies (not JWT) +- Uses `appInfos` relationship (not `bundleId` relationship) +- App name goes on `appInfoLocalizations` (not `appStoreVersionLocalizations`) +- Uses `${new-...}` placeholder IDs for inline-created resources + +```bash +python3 -c " +import json, os, urllib.request, sys + +BUNDLE_ID = 'BUNDLE_ID_HERE' +SKU = 'SKU_HERE' +APP_NAME = 'APP_NAME_HERE' +LOCALE = 'LOCALE_HERE' + +session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') +if not os.path.isfile(session_path): + print('ERROR: No web session found. Call asc_web_auth MCP tool first.') + sys.exit(1) +with open(session_path) as f: + raw = f.read() + +store = json.loads(raw) +session = store['sessions'][store['last_key']] +cookie_str = '; '.join( + (f'{c[\"name\"]}=\"{c[\"value\"]}\"' if c['name'].startswith('DES') else f'{c[\"name\"]}={c[\"value\"]}') + for cl in session['cookies'].values() for c in cl + if c.get('name') and c.get('value') +) + +headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'Origin': 'https://appstoreconnect.apple.com', + 'Referer': 'https://appstoreconnect.apple.com/', + 'Cookie': cookie_str +} + +create_body = json.dumps({ + 'data': { + 'type': 'apps', + 'attributes': { + 'bundleId': BUNDLE_ID, + 'sku': SKU, + 'primaryLocale': LOCALE, + }, + 'relationships': { + 'appStoreVersions': { + 'data': [{'type': 'appStoreVersions', 'id': '\${new-appStoreVersion-1}'}] + }, + 'appInfos': { + 'data': [{'type': 'appInfos', 'id': '\${new-appInfo-1}'}] + } + } + }, + 'included': [ + { + 'type': 'appStoreVersions', + 'id': '\${new-appStoreVersion-1}', + 'attributes': {'platform': 'IOS', 'versionString': '1.0'}, + 'relationships': { + 'appStoreVersionLocalizations': { + 'data': [{'type': 'appStoreVersionLocalizations', 'id': '\${new-appStoreVersionLocalization-1}'}] + } + } + }, + { + 'type': 'appStoreVersionLocalizations', + 'id': '\${new-appStoreVersionLocalization-1}', + 'attributes': {'locale': LOCALE} + }, + { + 'type': 'appInfos', + 'id': '\${new-appInfo-1}', + 'relationships': { + 'appInfoLocalizations': { + 'data': [{'type': 'appInfoLocalizations', 'id': '\${new-appInfoLocalization-1}'}] + } + } + }, + { + 'type': 'appInfoLocalizations', + 'id': '\${new-appInfoLocalization-1}', + 'attributes': {'locale': LOCALE, 'name': APP_NAME} + } + ] +}).encode() + +req = urllib.request.Request( + 'https://appstoreconnect.apple.com/iris/v1/apps', + data=create_body, method='POST', headers=headers) +try: + resp = urllib.request.urlopen(req) + result = json.loads(resp.read().decode()) + app_id = result['data']['id'] + print(f'App created successfully!') + print(f'App ID: {app_id}') + print(f'Bundle ID: {BUNDLE_ID}') + print(f'Name: {APP_NAME}') + print(f'SKU: {SKU}') +except urllib.error.HTTPError as e: + body = e.read().decode() + if e.code == 401: + print('ERROR: Session expired. Call asc_web_auth MCP tool to re-authenticate.') + elif e.code == 409: + print(f'ERROR: App may already exist or conflict. Details: {body[:500]}') + else: + print(f'ERROR creating app: HTTP {e.code} — {body[:500]}') + sys.exit(1) +" +``` + +### 5. Report results + +After success, report the App ID, bundle ID, name, and SKU to the user. + +## Common Errors + +### 401 Not Authorized +Call the `asc_web_auth` MCP tool to open the Apple ID login window in Blitz. Then retry. + +### 409 Conflict +An app with the same bundle ID or SKU may already exist. Try a different SKU. + +## Agent Behavior + +- **Do NOT ask for Apple ID email** — authentication is handled via cached web session file, not email. +- **NEVER print, log, or echo session cookies.** +- Use the self-contained python script — do NOT extract cookies separately. +- If iris API returns 401, call `asc_web_auth` MCP tool and retry. \ No newline at end of file diff --git a/src/services/project/ProjectAgentConfigService.swift b/src/services/project/ProjectAgentConfigService.swift index d985845..44fe781 100644 --- a/src/services/project/ProjectAgentConfigService.swift +++ b/src/services/project/ProjectAgentConfigService.swift @@ -471,14 +471,6 @@ struct ProjectAgentConfigService { Self.syncSkillDirectories(from: bundledSkillsDir, into: skillDirectories, using: fm) } - for skillsDir in skillDirectories { - let ascCreateSkillFile = skillsDir - .appendingPathComponent("asc-app-create-ui") - .appendingPathComponent("SKILL.md") - try? Self.ascAppCreateSkillContent() - .write(to: ascCreateSkillFile, atomically: true, encoding: .utf8) - } - let installedRoots = skillDirectories.map(\.path).joined(separator: ", ") print("[ProjectAgentConfigService] Project skills installed in \(installedRoots)") } @@ -580,177 +572,6 @@ struct ProjectAgentConfigService { allowList.append(permission) } - private static func ascAppCreateSkillContent() -> String { - return ##""" - --- - name: asc-app-create-ui - description: Create an App Store Connect app via iris API using web session from Blitz - --- - - Create an App Store Connect app using Apple's iris API. Authentication is handled via a web session file at `~/.blitz/asc-agent/web-session.json` managed by Blitz. - - Extract from the conversation context: - - `bundleId` — the bundle identifier (e.g. `com.blitz.myapp`) - - `sku` — the SKU string (may be provided; if missing, generate one from the app name) - - ## Workflow - - ### 1. Check for an existing web session - - ```bash - test -f ~/.blitz/asc-agent/web-session.json && echo "SESSION_EXISTS" || echo "NO_SESSION" - ``` - - - If `NO_SESSION`: call the `asc_web_auth` MCP tool first. Wait for it to complete before proceeding. - - If `SESSION_EXISTS`: proceed. - - ### 2. Ask the user for the primary language - - Ask what primary language/locale the app should use. Common choices: `en-US` (English US), `en-GB` (English UK), `ja` (Japanese), `zh-Hans` (Simplified Chinese), `ko` (Korean), `fr-FR` (French), `de-DE` (German). - - ### 3. Derive the app name - - Take the last component of the bundle ID after the final `.`, capitalize the first letter. Confirm with the user. - - ### 4. Create the app via iris API - - Use the following self-contained script. Replace `BUNDLE_ID`, `SKU`, `APP_NAME`, and `LOCALE` with the resolved values. **Do not print or log cookies.** - - Key differences from the public REST API: - - Uses `appstoreconnect.apple.com/iris/v1/` (not `api.appstoreconnect.apple.com`) - - Authenticated via web session cookies (not JWT) - - Uses `appInfos` relationship (not `bundleId` relationship) - - App name goes on `appInfoLocalizations` (not `appStoreVersionLocalizations`) - - Uses `${new-...}` placeholder IDs for inline-created resources - - ```bash - python3 -c " - import json, os, urllib.request, sys - - BUNDLE_ID = 'BUNDLE_ID_HERE' - SKU = 'SKU_HERE' - APP_NAME = 'APP_NAME_HERE' - LOCALE = 'LOCALE_HERE' - - session_path = os.path.expanduser('~/.blitz/asc-agent/web-session.json') - if not os.path.isfile(session_path): - print('ERROR: No web session found. Call asc_web_auth MCP tool first.') - sys.exit(1) - with open(session_path) as f: - raw = f.read() - - store = json.loads(raw) - session = store['sessions'][store['last_key']] - cookie_str = '; '.join( - (f'{c[\"name\"]}=\"{c[\"value\"]}\"' if c['name'].startswith('DES') else f'{c[\"name\"]}={c[\"value\"]}') - for cl in session['cookies'].values() for c in cl - if c.get('name') and c.get('value') - ) - - headers = { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - 'Origin': 'https://appstoreconnect.apple.com', - 'Referer': 'https://appstoreconnect.apple.com/', - 'Cookie': cookie_str - } - - create_body = json.dumps({ - 'data': { - 'type': 'apps', - 'attributes': { - 'bundleId': BUNDLE_ID, - 'sku': SKU, - 'primaryLocale': LOCALE, - }, - 'relationships': { - 'appStoreVersions': { - 'data': [{'type': 'appStoreVersions', 'id': '\${new-appStoreVersion-1}'}] - }, - 'appInfos': { - 'data': [{'type': 'appInfos', 'id': '\${new-appInfo-1}'}] - } - } - }, - 'included': [ - { - 'type': 'appStoreVersions', - 'id': '\${new-appStoreVersion-1}', - 'attributes': {'platform': 'IOS', 'versionString': '1.0'}, - 'relationships': { - 'appStoreVersionLocalizations': { - 'data': [{'type': 'appStoreVersionLocalizations', 'id': '\${new-appStoreVersionLocalization-1}'}] - } - } - }, - { - 'type': 'appStoreVersionLocalizations', - 'id': '\${new-appStoreVersionLocalization-1}', - 'attributes': {'locale': LOCALE} - }, - { - 'type': 'appInfos', - 'id': '\${new-appInfo-1}', - 'relationships': { - 'appInfoLocalizations': { - 'data': [{'type': 'appInfoLocalizations', 'id': '\${new-appInfoLocalization-1}'}] - } - } - }, - { - 'type': 'appInfoLocalizations', - 'id': '\${new-appInfoLocalization-1}', - 'attributes': {'locale': LOCALE, 'name': APP_NAME} - } - ] - }).encode() - - req = urllib.request.Request( - 'https://appstoreconnect.apple.com/iris/v1/apps', - data=create_body, method='POST', headers=headers) - try: - resp = urllib.request.urlopen(req) - result = json.loads(resp.read().decode()) - app_id = result['data']['id'] - print(f'App created successfully!') - print(f'App ID: {app_id}') - print(f'Bundle ID: {BUNDLE_ID}') - print(f'Name: {APP_NAME}') - print(f'SKU: {SKU}') - except urllib.error.HTTPError as e: - body = e.read().decode() - if e.code == 401: - print('ERROR: Session expired. Call asc_web_auth MCP tool to re-authenticate.') - elif e.code == 409: - print(f'ERROR: App may already exist or conflict. Details: {body[:500]}') - else: - print(f'ERROR creating app: HTTP {e.code} — {body[:500]}') - sys.exit(1) - " - ``` - - ### 5. Report results - - After success, report the App ID, bundle ID, name, and SKU to the user. - - ## Common Errors - - ### 401 Not Authorized - Call the `asc_web_auth` MCP tool to open the Apple ID login window in Blitz. Then retry. - - ### 409 Conflict - An app with the same bundle ID or SKU may already exist. Try a different SKU. - - ## Agent Behavior - - - **Do NOT ask for Apple ID email** — authentication is handled via cached web session file, not email. - - **NEVER print, log, or echo session cookies.** - - Use the self-contained python script — do NOT extract cookies separately. - - If iris API returns 401, call `asc_web_auth` MCP tool and retry. - """## - } - private static func claudeMdContent(projectType: ProjectType) -> String { guard let templateURL = Bundle.appResources.url(forResource: "CLAUDE.md", withExtension: "template"), var template = try? String(contentsOf: templateURL, encoding: .utf8) else { From 5c13e2eec4ba9ef214364f0626d179c283e402d1 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Thu, 26 Mar 2026 01:39:23 -0700 Subject: [PATCH 42/51] open simulator in background --- src/services/simulator/SimulatorService.swift | 52 ++----------------- 1 file changed, 4 insertions(+), 48 deletions(-) diff --git a/src/services/simulator/SimulatorService.swift b/src/services/simulator/SimulatorService.swift index 1a70198..625b623 100644 --- a/src/services/simulator/SimulatorService.swift +++ b/src/services/simulator/SimulatorService.swift @@ -72,9 +72,8 @@ actor SimulatorService { try await Task.sleep(for: .seconds(1)) } - // Open Simulator.app behind Blitz — ScreenCaptureKit needs the window to exist - // but it captures occluded windows fine, so it doesn't need to be in front. - try await openSimulatorAppBehind() + // Open Simulator.app + try await openSimulatorApp() } /// Shutdown a simulator @@ -97,53 +96,10 @@ actor SimulatorService { try await simctl.screenshot(udid: udid, path: path) } - /// Open the Simulator.app (brings to foreground — used for initial boot) + /// Open the Simulator.app (-g flag opens in background) func openSimulatorApp() async throws { - _ = try await ProcessRunner.run("open", arguments: ["-a", "Simulator"]) - try await Task.sleep(for: .milliseconds(500)) - } - - /// Open Simulator.app behind Blitz's window. - /// Matches blitz-cn: `open -g -a Simulator` then AppleScript to bring Blitz to front. - /// Also moves Simulator's window behind Blitz using the Accessibility API. - /// - /// ScreenCaptureKit captures occluded windows fine, so Simulator - /// just needs to exist — it doesn't need to be visible. - func openSimulatorAppBehind() async throws { - // 1. Capture Blitz's frame for positioning - let blitzFrame = await MainActor.run { NSApp.mainWindow?.frame } - - // 2. Open Simulator without bringing to foreground (matches blitz-cn) _ = try await ProcessRunner.run("open", arguments: ["-g", "-a", "Simulator"]) - - // 3. Immediately bring Blitz to front via AppleScript (matches blitz-cn's hide_simulator_window) - Self.bringBlitzToFront() - - // 4. Wait for Simulator window to appear - try await Task.sleep(for: .milliseconds(800)) - - // 5. Move Simulator window behind Blitz and bring Blitz to front again - if let frame = blitzFrame { - Self.moveSimulatorWindowBehind(blitzFrame: frame) - } - Self.bringBlitzToFront() - } - - /// Bring Blitz to the foreground using AppleScript (matches blitz-cn's hide_simulator_window). - private static func bringBlitzToFront() { - let script = """ - tell application "System Events" - repeat with proc in (every process whose background only is false) - if name of proc contains "blitz" or name of proc contains "Blitz" then - set frontmost of proc to true - exit repeat - end if - end repeat - end tell - """ - let appleScript = NSAppleScript(source: script) - var error: NSDictionary? - appleScript?.executeAndReturnError(&error) + try await Task.sleep(for: .milliseconds(500)) } /// Move Simulator.app's window to the same position as Blitz's window From 6be613e86318f9e044322959da50ff5b08ce8c39 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Thu, 26 Mar 2026 02:51:03 -0700 Subject: [PATCH 43/51] rm strict codesign for now --- Tests/blitz_tests/AutoUpdateServiceTests.swift | 6 +++--- src/services/AutoUpdateService.swift | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/Tests/blitz_tests/AutoUpdateServiceTests.swift b/Tests/blitz_tests/AutoUpdateServiceTests.swift index f5e1e2c..1bd3232 100644 --- a/Tests/blitz_tests/AutoUpdateServiceTests.swift +++ b/Tests/blitz_tests/AutoUpdateServiceTests.swift @@ -2,12 +2,12 @@ import Foundation import Testing @testable import Blitz -@Test @MainActor func testAppUpdateInstallScriptVerifiesSignatureAndFailsOnScriptErrors() { +@Test @MainActor func testAppUpdateInstallScriptKeepsBundleGuardsAndFailsOnScriptErrors() { let zipPath = URL(fileURLWithPath: "/tmp/Blitz.app.zip") let script = AutoUpdateManager.appUpdateInstallScript(zipPath: zipPath) - #expect(script.contains("/usr/bin/codesign --verify --deep --strict")) - #expect(script.contains("/usr/sbin/spctl --assess --verbose=4")) + #expect(!script.contains("/usr/bin/codesign --verify --deep --strict")) + #expect(!script.contains("/usr/sbin/spctl --assess --verbose=4")) #expect(script.contains("CFBundleIdentifier")) #expect(script.contains("Contents/Helpers/ascd")) #expect(script.contains("BLITZ_UPDATE_CONTEXT='auto-update'")) diff --git a/src/services/AutoUpdateService.swift b/src/services/AutoUpdateService.swift index 35bca18..f667aea 100644 --- a/src/services/AutoUpdateService.swift +++ b/src/services/AutoUpdateService.swift @@ -290,8 +290,6 @@ final class AutoUpdateManager { unzip -qo \\"$TMPZIP\\" -d \\"$UNZIP_DIR\\"; \ APP_SRC=$(find \\"$UNZIP_DIR\\" -maxdepth 1 -name '*.app' -type d | head -1); \ if [ -z \\"$APP_SRC\\" ]; then echo 'Update failed: extracted app not found' >> \\"$UPDATE_LOG\\"; exit 1; fi; \ - /usr/bin/codesign --verify --deep --strict \\"$APP_SRC\\" >> \\"$UPDATE_LOG\\" 2>&1; \ - /usr/sbin/spctl --assess --verbose=4 \\"$APP_SRC\\" >> \\"$UPDATE_LOG\\" 2>&1; \ BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \\"$APP_SRC/Contents/Info.plist\\" 2>> \\"$UPDATE_LOG\\" || true); \ if [ \\"$BUNDLE_ID\\" != 'com.blitz.macos' ]; then echo 'Update failed: unexpected bundle identifier' >> \\"$UPDATE_LOG\\"; exit 1; fi; \ if [ ! -x \\"$APP_SRC/Contents/Helpers/ascd\\" ]; then echo 'Update failed: bundled ascd helper missing' >> \\"$UPDATE_LOG\\"; exit 1; fi; \ From d09c1c1cef55c69dddee32fe0fd21afa718309dc Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Thu, 26 Mar 2026 02:51:17 -0700 Subject: [PATCH 44/51] fix terminal input capture bug in simulator tab --- src/views/build/simulator/SimulatorView.swift | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/views/build/simulator/SimulatorView.swift b/src/views/build/simulator/SimulatorView.swift index 3a6fc27..623f561 100644 --- a/src/views/build/simulator/SimulatorView.swift +++ b/src/views/build/simulator/SimulatorView.swift @@ -1,5 +1,6 @@ import SwiftUI import MetalKit +import SwiftTerm /// Main Build tab view — simulator frame display + touch interaction struct SimulatorView: View { @@ -240,9 +241,8 @@ struct SimulatorView: View { return event } - // Don't capture if a text field or other responder has focus - if let responder = event.window?.firstResponder, - responder is NSTextView || responder is NSTextField { + // Don't capture if another text-input surface currently owns focus. + if simulatorKeyPassthroughShouldIgnore(event.window?.firstResponder) { return event } @@ -265,6 +265,24 @@ struct SimulatorView: View { } } + private func simulatorKeyPassthroughShouldIgnore(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + + if responder is NSTextView || responder is NSTextField || responder is TerminalView { + return true + } + + var view = responder as? NSView + while let current = view { + if current is TerminalView { + return true + } + view = current.superview + } + + return false + } + private func removeKeyMonitor() { if let monitor = keyMonitor { NSEvent.removeMonitor(monitor) From 591ec892b2c5927095bbfea1d26c501520390b48 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Thu, 26 Mar 2026 04:04:47 -0700 Subject: [PATCH 45/51] relaunch --- .../blitz_tests/AppRelaunchServiceTests.swift | 106 +++++++++++++++ src/BlitzApp.swift | 8 +- src/services/AppRelaunchService.swift | 127 ++++++++++++++++++ src/views/OnboardingView.swift | 1 + src/views/build/simulator/SimulatorView.swift | 1 + 5 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 Tests/blitz_tests/AppRelaunchServiceTests.swift create mode 100644 src/services/AppRelaunchService.swift diff --git a/Tests/blitz_tests/AppRelaunchServiceTests.swift b/Tests/blitz_tests/AppRelaunchServiceTests.swift new file mode 100644 index 0000000..b0c53c6 --- /dev/null +++ b/Tests/blitz_tests/AppRelaunchServiceTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +@testable import Blitz + +@MainActor +private func makeTestDefaults() -> UserDefaults { + let suiteName = "AppRelaunchServiceTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults +} + +@Test @MainActor func testScreenRecordingRelaunchSchedulesWhenPermissionWasGranted() { + let defaults = makeTestDefaults() + let appPath = "/Applications/Blitz's Test.app" + let start = Date(timeIntervalSince1970: 1_000) + let now = start + var launchedPath: String? + var launchedPID: Int32? + + let service = AppRelaunchService( + defaults: defaults, + now: { now }, + appURLProvider: { URL(fileURLWithPath: appPath) }, + screenRecordingAccessProvider: { true }, + launcher: { path, pid in + launchedPath = path + launchedPID = pid + return true + } + ) + + service.prepareForScreenRecordingPermissionRestart() + + let scheduled = service.schedulePendingScreenRecordingRelaunchIfNeeded(pid: 4242) + + #expect(scheduled) + #expect(launchedPath == appPath) + #expect(launchedPID == 4242) + + launchedPath = nil + launchedPID = nil + let scheduledAgain = service.schedulePendingScreenRecordingRelaunchIfNeeded(pid: 4242) + #expect(!scheduledAgain) + #expect(launchedPath == nil) + #expect(launchedPID == nil) +} + +@Test @MainActor func testScreenRecordingRelaunchDoesNotScheduleWhenRequestIsStale() { + let defaults = makeTestDefaults() + let appPath = "/Applications/Blitz.app" + let start = Date(timeIntervalSince1970: 2_000) + var now = start + var launched = false + + let service = AppRelaunchService( + defaults: defaults, + now: { now }, + appURLProvider: { URL(fileURLWithPath: appPath) }, + screenRecordingAccessProvider: { true }, + launcher: { _, _ in + launched = true + return true + } + ) + + service.prepareForScreenRecordingPermissionRestart() + now = start.addingTimeInterval(AppRelaunchService.pendingWindow + 1) + + let scheduled = service.schedulePendingScreenRecordingRelaunchIfNeeded(pid: 111) + + #expect(!scheduled) + #expect(!launched) +} + +@Test @MainActor func testScreenRecordingRelaunchDoesNotScheduleWithoutGrantedPermission() { + let defaults = makeTestDefaults() + var launched = false + + let service = AppRelaunchService( + defaults: defaults, + now: { Date(timeIntervalSince1970: 3_000) }, + appURLProvider: { URL(fileURLWithPath: "/Applications/Blitz.app") }, + screenRecordingAccessProvider: { false }, + launcher: { _, _ in + launched = true + return true + } + ) + + service.prepareForScreenRecordingPermissionRestart() + let scheduled = service.schedulePendingScreenRecordingRelaunchIfNeeded(pid: 222) + + #expect(!scheduled) + #expect(!launched) +} + +@Test func testRelaunchShellCommandQuotesAppPaths() { + let command = AppRelaunchService.relaunchShellCommand( + appPath: "/Applications/Blitz's Test.app", + pid: 9876 + ) + + #expect(command.contains("while kill -0 9876 2>/dev/null; do sleep 0.2; done;")) + #expect(command.contains("open '/Applications/Blitz'\\''s Test.app'")) +} diff --git a/src/BlitzApp.swift b/src/BlitzApp.swift index ba9a039..ae5b2ea 100644 --- a/src/BlitzApp.swift +++ b/src/BlitzApp.swift @@ -4,6 +4,7 @@ final class BlitzAppDelegate: NSObject, NSApplicationDelegate { var appState: AppState? func applicationDidFinishLaunching(_ notification: Notification) { + AppRelaunchService.shared.clearPendingRestart() if let fileMenu = NSApp.mainMenu?.item(withTitle: "File") { fileMenu.title = "Project" } @@ -13,11 +14,16 @@ final class BlitzAppDelegate: NSObject, NSApplicationDelegate { } } + func applicationDidBecomeActive(_ notification: Notification) { + AppRelaunchService.shared.clearPendingRestartAfterReturningToApp() + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } func applicationWillTerminate(_ notification: Notification) { + _ = AppRelaunchService.shared.schedulePendingScreenRecordingRelaunchIfNeeded() MCPBootstrap.shared.shutdown() // Don't block termination with synchronous simctl shutdown — // this prevents macOS TCC "Quit & Reopen" from relaunching the app. @@ -237,4 +243,4 @@ final class MCPBootstrap { return nil } -} \ No newline at end of file +} diff --git a/src/services/AppRelaunchService.swift b/src/services/AppRelaunchService.swift new file mode 100644 index 0000000..7836d3f --- /dev/null +++ b/src/services/AppRelaunchService.swift @@ -0,0 +1,127 @@ +import AppKit +import Foundation + +/// Tracks permission-driven restart flows and can reopen the current app bundle +/// after macOS terminates Blitz. +final class AppRelaunchService { + static let shared = AppRelaunchService() + + private enum Keys { + static let pendingReason = "blitz.pendingRelaunch.reason" + static let pendingCreatedAt = "blitz.pendingRelaunch.createdAt" + static let pendingAppPath = "blitz.pendingRelaunch.appPath" + } + + private enum PendingReason: String { + case screenRecordingPermission + } + + private let defaults: UserDefaults + private let now: () -> Date + private let appURLProvider: () -> URL? + private let screenRecordingAccessProvider: () -> Bool + private let launcher: (String, Int32) -> Bool + + static let pendingWindow: TimeInterval = 180 + + init( + defaults: UserDefaults = .standard, + now: @escaping () -> Date = Date.init, + appURLProvider: @escaping () -> URL? = AppRelaunchService.defaultAppURL, + screenRecordingAccessProvider: @escaping () -> Bool = { CGPreflightScreenCaptureAccess() }, + launcher: @escaping (String, Int32) -> Bool = AppRelaunchService.launchDetachedRelaunchProcess + ) { + self.defaults = defaults + self.now = now + self.appURLProvider = appURLProvider + self.screenRecordingAccessProvider = screenRecordingAccessProvider + self.launcher = launcher + } + + func prepareForScreenRecordingPermissionRestart() { + guard let appURL = appURLProvider() else { return } + defaults.set(PendingReason.screenRecordingPermission.rawValue, forKey: Keys.pendingReason) + defaults.set(now().timeIntervalSince1970, forKey: Keys.pendingCreatedAt) + defaults.set(appURL.path, forKey: Keys.pendingAppPath) + } + + func clearPendingRestart() { + defaults.removeObject(forKey: Keys.pendingReason) + defaults.removeObject(forKey: Keys.pendingCreatedAt) + defaults.removeObject(forKey: Keys.pendingAppPath) + } + + /// If Blitz becomes active again, the OS restart did not happen and the + /// pending relaunch should not survive future manual quits. + func clearPendingRestartAfterReturningToApp() { + guard pendingReason() != nil else { return } + clearPendingRestart() + } + + @discardableResult + func schedulePendingScreenRecordingRelaunchIfNeeded(pid: Int32 = ProcessInfo.processInfo.processIdentifier) -> Bool { + defer { clearPendingRestart() } + + guard pendingReason() == .screenRecordingPermission else { return false } + guard let createdAt = pendingCreatedAt(), now().timeIntervalSince(createdAt) <= Self.pendingWindow else { + return false + } + guard screenRecordingAccessProvider() else { return false } + guard let appPath = pendingAppPath() else { return false } + + return launcher(appPath, pid) + } + + private func pendingReason() -> PendingReason? { + guard let rawValue = defaults.string(forKey: Keys.pendingReason) else { return nil } + return PendingReason(rawValue: rawValue) + } + + private func pendingCreatedAt() -> Date? { + guard defaults.object(forKey: Keys.pendingCreatedAt) != nil else { return nil } + return Date(timeIntervalSince1970: defaults.double(forKey: Keys.pendingCreatedAt)) + } + + private func pendingAppPath() -> String? { + guard let path = defaults.string(forKey: Keys.pendingAppPath), !path.isEmpty else { return nil } + return path + } + + static func launchDetachedRelaunchProcess(appPath: String, pid: Int32) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", relaunchShellCommand(appPath: appPath, pid: pid)] + process.standardOutput = nil + process.standardError = nil + + do { + try process.run() + return true + } catch { + print("[Relaunch] Failed to schedule app relaunch: \(error)") + return false + } + } + + static func relaunchShellCommand(appPath: String, pid: Int32) -> String { + "while kill -0 \(pid) 2>/dev/null; do sleep 0.2; done; open \(shellQuote(appPath))" + } + + static func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private static func defaultAppURL() -> URL? { + let bundleURL = Bundle.main.bundleURL.standardizedFileURL + if bundleURL.pathExtension == "app" { + return bundleURL + } + + if let bundleID = Bundle.main.bundleIdentifier, + let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { + return appURL.standardizedFileURL + } + + return nil + } +} diff --git a/src/views/OnboardingView.swift b/src/views/OnboardingView.swift index e08aee5..933f90f 100644 --- a/src/views/OnboardingView.swift +++ b/src/views/OnboardingView.swift @@ -812,6 +812,7 @@ struct OnboardingView: View { ) Button("Open System Settings") { + AppRelaunchService.shared.prepareForScreenRecordingPermissionRestart() if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { NSWorkspace.shared.open(url) } diff --git a/src/views/build/simulator/SimulatorView.swift b/src/views/build/simulator/SimulatorView.swift index 623f561..2693f80 100644 --- a/src/views/build/simulator/SimulatorView.swift +++ b/src/views/build/simulator/SimulatorView.swift @@ -93,6 +93,7 @@ struct SimulatorView: View { HStack(spacing: 12) { Button("Open System Settings") { + AppRelaunchService.shared.prepareForScreenRecordingPermissionRestart() NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture")!) } Button("Retry") { From 0c2bbef1dc8500efced2139f394289411bc01638 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Thu, 26 Mar 2026 05:05:41 -0700 Subject: [PATCH 46/51] prepare 1.0.30 release --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 471d4d4..f36ae95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.0.30 +- New built-in terminal with tabs, split view, and better AI launch flow +- New Dashboard and App navigation, plus faster project switching +- Faster and more reliable App Store Connect workflows +- Improved release/update reliability and simulator behavior + ## 1.0.29 - Faster App Store Connect setup with improved onboarding, credential entry, and bundle ID guidance - Better review workflows with rejection feedback in Overview and Review, plus a submission history timeline diff --git a/package-lock.json b/package-lock.json index d265e1d..9fcde8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "blitz-macos", - "version": "1.0.29", + "version": "1.0.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blitz-macos", - "version": "1.0.29", + "version": "1.0.30", "devDependencies": { "@repalash/rclone.js": "*" } diff --git a/package.json b/package.json index 829b2a1..ae578bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blitz-macos", - "version": "1.0.29", + "version": "1.0.30", "type": "module", "private": true, "scripts": { From f33c217892e3337b86154538c23f34f4ba9a30a1 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Thu, 26 Mar 2026 12:21:50 -0700 Subject: [PATCH 47/51] Update helper submodule for session request stdout suppression --- deps/App-Store-Connect-CLI-helper | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/App-Store-Connect-CLI-helper b/deps/App-Store-Connect-CLI-helper index 5c4feee..e60ce03 160000 --- a/deps/App-Store-Connect-CLI-helper +++ b/deps/App-Store-Connect-CLI-helper @@ -1 +1 @@ -Subproject commit 5c4feee0bd288aa110392e16d73caa434492e181 +Subproject commit e60ce037a3b36be3cbd05c3dfd82c3b28b61aa93 From 200dd80b9e1da1462c5ac1f2a4aad84a6df7df53 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Thu, 26 Mar 2026 12:23:55 -0700 Subject: [PATCH 48/51] Shorten 1.0.30 changelog --- CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f36ae95..3504ac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,10 @@ # Changelog ## 1.0.30 -- New built-in terminal with tabs, split view, and better AI launch flow -- New Dashboard and App navigation, plus faster project switching -- Faster and more reliable App Store Connect workflows +- New built-in terminal +- New Dashboard and App navigation +- Project switching performance improvements +- Share ASC auth between asc-cli and Blitz MCP tools - Improved release/update reliability and simulator behavior ## 1.0.29 From b37f4da30c7fa3a3c0cce5475f3acedcc8c7f104 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Thu, 26 Mar 2026 14:43:57 -0700 Subject: [PATCH 49/51] Add locale-aware ASC listing and screenshot workflows --- .../ASCScreenshotsLocaleRegressionTests.swift | 106 ++++++ src/managers/asc/ASCDetailsManager.swift | 29 +- src/managers/asc/ASCManager.swift | 11 +- .../asc/ASCProjectLifecycleManager.swift | 39 +++ src/managers/asc/ASCReleaseManager.swift | 6 +- src/managers/asc/ASCScreenshotsManager.swift | 325 ++++++++++++++---- src/managers/asc/ASCStoreListingManager.swift | 159 ++++++++- .../asc/ASCSubmissionReadinessManager.swift | 24 +- src/managers/asc/ASCTabDataManager.swift | 86 ++--- src/services/asc/ASCService.swift | 38 +- src/services/mcp/MCPExecutor.swift | 33 +- src/services/mcp/MCPExecutorASC.swift | 303 ++++++++++++++-- src/services/mcp/MCPExecutorTabState.swift | 26 +- src/services/mcp/MCPRegistry.swift | 34 +- src/views/build/TestsView.swift | 2 +- src/views/release/ScreenshotsView.swift | 120 ++++++- src/views/release/StoreListingView.swift | 98 ++++-- 17 files changed, 1206 insertions(+), 233 deletions(-) create mode 100644 Tests/blitz_tests/ASCScreenshotsLocaleRegressionTests.swift diff --git a/Tests/blitz_tests/ASCScreenshotsLocaleRegressionTests.swift b/Tests/blitz_tests/ASCScreenshotsLocaleRegressionTests.swift new file mode 100644 index 0000000..469becf --- /dev/null +++ b/Tests/blitz_tests/ASCScreenshotsLocaleRegressionTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +@testable import Blitz + +@MainActor +@Test func loadTrackFromASCPreservesUnsavedLocaleTrack() { + let manager = ASCManager() + let locale = "en-US" + let displayType = "APP_IPHONE_67" + let set = makeScreenshotSet(id: "set-us", displayType: displayType, count: 1) + + manager.cacheScreenshots( + locale: locale, + sets: [set], + screenshots: [set.id: [makeScreenshot(id: "remote-1", fileName: "remote-1.png")]] + ) + manager.loadTrackFromASC(displayType: displayType, locale: locale) + + let trackKey = manager.screenshotTrackKey(displayType: displayType, locale: locale) + manager.trackSlots[trackKey] = [ + TrackSlot( + id: "local-1", + localPath: "/tmp/local-1.png", + localImage: nil, + ascScreenshot: nil, + isFromASC: false + ) + ] + Array(repeating: nil, count: 9) + + #expect(manager.hasUnsavedChanges(displayType: displayType, locale: locale)) + + manager.cacheScreenshots( + locale: locale, + sets: [set], + screenshots: [set.id: [makeScreenshot(id: "remote-2", fileName: "remote-2.png")]] + ) + manager.loadTrackFromASC(displayType: displayType, locale: locale) + + let slots = manager.trackSlotsForDisplayType(displayType, locale: locale) + #expect(slots[0]?.id == "local-1") + #expect(manager.hasUnsavedChanges(displayType: displayType, locale: locale)) +} + +@MainActor +@Test func submissionReadinessUsesPrimaryLocaleScreenshotCache() { + let manager = ASCManager() + manager.localizations = [ + makeLocalization(id: "loc-us", locale: "en-US"), + makeLocalization(id: "loc-gb", locale: "en-GB"), + ] + + let usSet = makeScreenshotSet(id: "set-us", displayType: "APP_IPHONE_67", count: 1) + manager.cacheScreenshots( + locale: "en-US", + sets: [usSet], + screenshots: [usSet.id: [makeScreenshot(id: "shot-us", fileName: "us.png")]] + ) + + manager.activeScreenshotsLocale = "en-GB" + manager.screenshotSets = [] + manager.screenshots = [:] + + let readiness = manager.submissionReadiness + let iphoneField = readiness.fields.first { $0.label == "iPhone Screenshots" } + + #expect(iphoneField?.value == "1 screenshot(s)") +} + +private func makeLocalization(id: String, locale: String) -> ASCVersionLocalization { + ASCVersionLocalization( + id: id, + attributes: ASCVersionLocalization.Attributes( + locale: locale, + title: nil, + subtitle: nil, + description: nil, + keywords: nil, + promotionalText: nil, + marketingUrl: nil, + supportUrl: nil, + whatsNew: nil + ) + ) +} + +private func makeScreenshotSet(id: String, displayType: String, count: Int?) -> ASCScreenshotSet { + ASCScreenshotSet( + id: id, + attributes: ASCScreenshotSet.Attributes( + screenshotDisplayType: displayType, + screenshotCount: count + ) + ) +} + +private func makeScreenshot(id: String, fileName: String) -> ASCScreenshot { + ASCScreenshot( + id: id, + attributes: ASCScreenshot.Attributes( + fileName: fileName, + fileSize: nil, + imageAsset: nil, + assetDeliveryState: nil + ) + ) +} diff --git a/src/managers/asc/ASCDetailsManager.swift b/src/managers/asc/ASCDetailsManager.swift index 14ab72c..524e3a3 100644 --- a/src/managers/asc/ASCDetailsManager.swift +++ b/src/managers/asc/ASCDetailsManager.swift @@ -49,20 +49,22 @@ extension ASCManager { } /// Update a field on appInfoLocalizations (name, subtitle, privacyPolicyUrl) - func updateAppInfoLocalizationField(_ field: String, value: String) async { - guard let service else { return } - guard let locId = appInfoLocalization?.id else { return } - writeError = nil - // Map UI field names to API field names - let apiField = (field == "title") ? "name" : field - do { - try await service.patchAppInfoLocalization(id: locId, fields: [apiField: value]) - if let infoId = appInfo?.id { - appInfoLocalization = try? await service.fetchAppInfoLocalization(appInfoId: infoId) - } - } catch { - writeError = error.localizedDescription + func updateAppInfoLocalizationField(_ field: String, value: String, locale: String? = nil) async { + let targetLocale = locale + ?? selectedStoreListingLocale + ?? appInfoLocalization?.attributes.locale + ?? localizations.first?.attributes.locale + + guard let targetLocale else { + writeError = "No app info localization selected." + return } + + await updateStoreListingFields( + versionFields: [:], + appInfoFields: [field: value], + locale: targetLocale + ) } // MARK: - Age Rating @@ -81,4 +83,3 @@ extension ASCManager { } } } - diff --git a/src/managers/asc/ASCManager.swift b/src/managers/asc/ASCManager.swift index af23a63..30967a2 100644 --- a/src/managers/asc/ASCManager.swift +++ b/src/managers/asc/ASCManager.swift @@ -23,8 +23,17 @@ final class ASCManager { // Per-tab data var appStoreVersions: [ASCAppStoreVersion] = [] var localizations: [ASCVersionLocalization] = [] + var selectedStoreListingLocale: String? + var appInfoLocalizationsByLocale: [String: ASCAppInfoLocalization] = [:] + var storeListingDataRevision: Int = 0 var screenshotSets: [ASCScreenshotSet] = [] var screenshots: [String: [ASCScreenshot]] = [:] // keyed by screenshotSet.id + var screenshotSetsByLocale: [String: [ASCScreenshotSet]] = [:] + var screenshotsByLocale: [String: [String: [ASCScreenshot]]] = [:] + var selectedScreenshotsLocale: String? + var activeScreenshotsLocale: String? + var lastScreenshotDataLocale: String? + var screenshotDataRevision: Int = 0 var customerReviews: [ASCCustomerReview] = [] var builds: [ASCBuild] = [] var betaGroups: [ASCBetaGroup] = [] @@ -101,7 +110,7 @@ final class ASCManager { var buildPipelineMessage: String = "" // Screenshot track state per device type - var trackSlots: [String: [TrackSlot?]] = [:] // keyed by ascDisplayType, 10-element arrays + var trackSlots: [String: [TrackSlot?]] = [:] // keyed by locale + ascDisplayType, 10-element arrays var savedTrackState: [String: [TrackSlot?]] = [:] // snapshot after last load/save var localScreenshotAssets: [LocalScreenshotAsset] = [] var isSyncing = false diff --git a/src/managers/asc/ASCProjectLifecycleManager.swift b/src/managers/asc/ASCProjectLifecycleManager.swift index a516c1e..c5cab8f 100644 --- a/src/managers/asc/ASCProjectLifecycleManager.swift +++ b/src/managers/asc/ASCProjectLifecycleManager.swift @@ -6,8 +6,17 @@ extension ASCManager { let app: ASCApp? let appStoreVersions: [ASCAppStoreVersion] let localizations: [ASCVersionLocalization] + let selectedStoreListingLocale: String? + let appInfoLocalizationsByLocale: [String: ASCAppInfoLocalization] + let storeListingDataRevision: Int let screenshotSets: [ASCScreenshotSet] let screenshots: [String: [ASCScreenshot]] + let screenshotSetsByLocale: [String: [ASCScreenshotSet]] + let screenshotsByLocale: [String: [String: [ASCScreenshot]]] + let selectedScreenshotsLocale: String? + let activeScreenshotsLocale: String? + let lastScreenshotDataLocale: String? + let screenshotDataRevision: Int let customerReviews: [ASCCustomerReview] let builds: [ASCBuild] let betaGroups: [ASCBetaGroup] @@ -48,8 +57,17 @@ extension ASCManager { app = manager.app appStoreVersions = manager.appStoreVersions localizations = manager.localizations + selectedStoreListingLocale = manager.selectedStoreListingLocale + appInfoLocalizationsByLocale = manager.appInfoLocalizationsByLocale + storeListingDataRevision = manager.storeListingDataRevision screenshotSets = manager.screenshotSets screenshots = manager.screenshots + screenshotSetsByLocale = manager.screenshotSetsByLocale + screenshotsByLocale = manager.screenshotsByLocale + selectedScreenshotsLocale = manager.selectedScreenshotsLocale + activeScreenshotsLocale = manager.activeScreenshotsLocale + lastScreenshotDataLocale = manager.lastScreenshotDataLocale + screenshotDataRevision = manager.screenshotDataRevision customerReviews = manager.customerReviews builds = manager.builds betaGroups = manager.betaGroups @@ -91,8 +109,17 @@ extension ASCManager { manager.app = app manager.appStoreVersions = appStoreVersions manager.localizations = localizations + manager.selectedStoreListingLocale = selectedStoreListingLocale + manager.appInfoLocalizationsByLocale = appInfoLocalizationsByLocale + manager.storeListingDataRevision = storeListingDataRevision manager.screenshotSets = screenshotSets manager.screenshots = screenshots + manager.screenshotSetsByLocale = screenshotSetsByLocale + manager.screenshotsByLocale = screenshotsByLocale + manager.selectedScreenshotsLocale = selectedScreenshotsLocale + manager.activeScreenshotsLocale = activeScreenshotsLocale + manager.lastScreenshotDataLocale = lastScreenshotDataLocale + manager.screenshotDataRevision = screenshotDataRevision manager.customerReviews = customerReviews manager.builds = builds manager.betaGroups = betaGroups @@ -307,8 +334,17 @@ extension ASCManager { isLoadingApp = false appStoreVersions = [] localizations = [] + selectedStoreListingLocale = nil + appInfoLocalizationsByLocale = [:] + storeListingDataRevision = 0 screenshotSets = [] screenshots = [:] + screenshotSetsByLocale = [:] + screenshotsByLocale = [:] + selectedScreenshotsLocale = nil + activeScreenshotsLocale = nil + lastScreenshotDataLocale = nil + screenshotDataRevision = 0 customerReviews = [] builds = [] betaGroups = [] @@ -338,6 +374,9 @@ extension ASCManager { appIconStatus = nil monetizationStatus = nil attachedSubmissionItemIDs = [] + trackSlots = [:] + savedTrackState = [:] + localScreenshotAssets = [] isLoadingTab = [:] tabError = [:] loadedTabs = [] diff --git a/src/managers/asc/ASCReleaseManager.swift b/src/managers/asc/ASCReleaseManager.swift index 7027e60..90b46bc 100644 --- a/src/managers/asc/ASCReleaseManager.swift +++ b/src/managers/asc/ASCReleaseManager.swift @@ -48,6 +48,7 @@ extension ASCManager { let appInfoLocFieldNames: Set = ["name", "title", "subtitle", "privacyPolicyUrl"] for (tab, fields) in pendingFormValues { if tab == "storeListing" { + let locale = effectiveStoreListingLocale() var versionLocFields: [String: String] = [:] var infoLocFields: [String: String] = [:] for (field, value) in fields { @@ -58,10 +59,10 @@ extension ASCManager { versionLocFields[field] = value } } - if !versionLocFields.isEmpty, let locId = localizations.first?.id { + if !versionLocFields.isEmpty, let locId = storeListingLocalization(locale: locale)?.id { try? await service.patchLocalization(id: locId, fields: versionLocFields) } - if !infoLocFields.isEmpty, let infoLocId = appInfoLocalization?.id { + if !infoLocFields.isEmpty, let infoLocId = appInfoLocalizationForLocale(locale)?.id { try? await service.patchAppInfoLocalization(id: infoLocId, fields: infoLocFields) } } @@ -80,4 +81,3 @@ extension ASCManager { }?.id ?? appStoreVersions.first?.id } } - diff --git a/src/managers/asc/ASCScreenshotsManager.swift b/src/managers/asc/ASCScreenshotsManager.swift index b803b6a..fe47b71 100644 --- a/src/managers/asc/ASCScreenshotsManager.swift +++ b/src/managers/asc/ASCScreenshotsManager.swift @@ -6,14 +6,190 @@ import ImageIO // Extension containing screenshot-related functionality for ASCManager extension ASCManager { - // MARK: - Track Synchronization + // MARK: - Screenshot Data - func syncTrackToASC(displayType: String, locale: String) async { - guard let service else { writeError = "ASC service not configured"; return } - isSyncing = true - writeError = nil + func screenshotTrackKey(displayType: String, locale: String) -> String { + "\(locale)::\(displayType)" + } + + func hasTrackState(displayType: String, locale: String = "en-US") -> Bool { + trackSlots[screenshotTrackKey(displayType: displayType, locale: locale)] != nil + } + + func trackSlotsForDisplayType(_ displayType: String, locale: String = "en-US") -> [TrackSlot?] { + trackSlots[screenshotTrackKey(displayType: displayType, locale: locale)] + ?? Array(repeating: nil, count: 10) + } + + func savedTrackStateForDisplayType(_ displayType: String, locale: String = "en-US") -> [TrackSlot?] { + savedTrackState[screenshotTrackKey(displayType: displayType, locale: locale)] + ?? Array(repeating: nil, count: 10) + } + + func loadScreenshots(locale: String, force: Bool = false) async { + guard let service else { return } + + if !force, + let cachedSets = screenshotSetsByLocale[locale], + let cachedScreenshots = screenshotsByLocale[locale] { + setActiveScreenshots(locale: locale, sets: cachedSets, screenshots: cachedScreenshots) + return + } + + await ensureScreenshotLocalizationsLoaded(service: service) + guard let loc = localizations.first(where: { $0.attributes.locale == locale }) + ?? localizations.first else { + activeScreenshotsLocale = nil + screenshotSets = [] + screenshots = [:] + return + } + + do { + let (fetchedSets, fetchedScreenshots) = try await fetchScreenshotData( + localizationId: loc.id, + service: service + ) + storeScreenshots( + locale: loc.attributes.locale, + sets: fetchedSets, + screenshots: fetchedScreenshots, + makeActive: true + ) + } catch { + print("Failed to load screenshots for locale \(loc.attributes.locale): \(error)") + } + } + + func storeScreenshots( + locale: String, + sets: [ASCScreenshotSet], + screenshots: [String: [ASCScreenshot]], + makeActive: Bool + ) { + screenshotSetsByLocale[locale] = sets + screenshotsByLocale[locale] = screenshots + if makeActive { + setActiveScreenshots(locale: locale, sets: sets, screenshots: screenshots) + } + lastScreenshotDataLocale = locale + screenshotDataRevision += 1 + } + + func cacheScreenshots( + locale: String, + sets: [ASCScreenshotSet], + screenshots: [String: [ASCScreenshot]] + ) { + screenshotSetsByLocale[locale] = sets + screenshotsByLocale[locale] = screenshots + } + + func fetchScreenshotData( + localizationId: String, + service: AppStoreConnectService + ) async throws -> ([ASCScreenshotSet], [String: [ASCScreenshot]]) { + let fetchedSets = try await service.fetchScreenshotSets(localizationId: localizationId) + let fetchedScreenshots = try await withThrowingTaskGroup(of: (String, [ASCScreenshot]).self) { group in + for set in fetchedSets { + group.addTask { + let screenshots = try await service.fetchScreenshots(setId: set.id) + return (set.id, screenshots) + } + } + + var pairs: [(String, [ASCScreenshot])] = [] + for try await pair in group { + pairs.append(pair) + } + return pairs + } + + return (fetchedSets, Dictionary(uniqueKeysWithValues: fetchedScreenshots)) + } + + func buildTrackSlotsFromASC( + displayType: String, + locale: String, + previousSlots: [TrackSlot?] = [] + ) -> [TrackSlot?] { + let localeSets = screenshotSetsByLocale[locale] + ?? ((activeScreenshotsLocale == nil || activeScreenshotsLocale == locale) ? screenshotSets : []) + let localeScreenshots = screenshotsByLocale[locale] + ?? ((activeScreenshotsLocale == nil || activeScreenshotsLocale == locale) ? screenshots : [:]) - // Ensure localizations are loaded + let set = localeSets.first { $0.attributes.screenshotDisplayType == displayType } + var slots: [TrackSlot?] = Array(repeating: nil, count: 10) + if let set, let shots = localeScreenshots[set.id] { + for (i, shot) in shots.prefix(10).enumerated() { + var localImage: NSImage? = nil + if shot.imageURL == nil, i < previousSlots.count, let prev = previousSlots[i] { + localImage = prev.localImage + } + slots[i] = TrackSlot( + id: shot.id, + localPath: nil, + localImage: localImage, + ascScreenshot: shot, + isFromASC: true + ) + } + } + return slots + } + + func invalidateStaleTrackSnapshots(displayType: String, locale: String) { + let trackKey = screenshotTrackKey(displayType: displayType, locale: locale) + let latestRemoteSlots = buildTrackSlotsFromASC( + displayType: displayType, + locale: locale, + previousSlots: trackSlots[trackKey] ?? [] + ) + let validRemoteIDs = Set(latestRemoteSlots.compactMap { slot -> String? in + guard let slot, slot.isFromASC else { return nil } + return slot.id + }) + + let current = trackSlots[trackKey] ?? Array(repeating: nil, count: 10) + let sanitizedCurrent = sanitizeTrackSlots(current, validRemoteScreenshotIDs: validRemoteIDs) + + trackSlots[trackKey] = sanitizedCurrent + savedTrackState[trackKey] = latestRemoteSlots + } + + private func sanitizeTrackSlots( + _ slots: [TrackSlot?], + validRemoteScreenshotIDs: Set + ) -> [TrackSlot?] { + let sanitized = slots.compactMap { slot -> TrackSlot? in + guard let slot else { return nil } + if slot.isFromASC && !validRemoteScreenshotIDs.contains(slot.id) { + return nil + } + return slot + } + + var padded = sanitized.map(Optional.some) + if padded.count > 10 { + padded = Array(padded.prefix(10)) + } + while padded.count < 10 { + padded.append(nil) + } + return padded + } + + private func setActiveScreenshots( + locale: String, + sets: [ASCScreenshotSet], + screenshots: [String: [ASCScreenshot]] + ) { + activeScreenshotsLocale = locale + screenshotSets = sets + self.screenshots = screenshots + } + + private func ensureScreenshotLocalizationsLoaded(service: AppStoreConnectService) async { if localizations.isEmpty, let versionId = appStoreVersions.first?.id { localizations = (try? await service.fetchLocalizations(versionId: versionId)) ?? [] } @@ -24,18 +200,37 @@ extension ASCManager { localizations = (try? await service.fetchLocalizations(versionId: versionId)) ?? [] } } + } + + // MARK: - Track Synchronization + + func syncTrackToASC(displayType: String, locale: String) async { + guard let service else { + writeError = "ASC service not configured" + return + } + + isSyncing = true + defer { isSyncing = false } + writeError = nil + + await ensureScreenshotLocalizationsLoaded(service: service) guard let loc = localizations.first(where: { $0.attributes.locale == locale }) ?? localizations.first else { writeError = "No localizations found for locale '\(locale)'." - isSyncing = false return } - let current = trackSlots[displayType] ?? Array(repeating: nil, count: 10) - let saved = savedTrackState[displayType] ?? Array(repeating: nil, count: 10) + let trackKey = screenshotTrackKey(displayType: displayType, locale: loc.attributes.locale) do { - // 1. Delete screenshots that were in saved state but not in current track + // Refresh the remote baseline before diffing so stale cached ASC IDs + // don't survive server-side edits made outside Blitz. + await loadScreenshots(locale: loc.attributes.locale, force: true) + invalidateStaleTrackSnapshots(displayType: displayType, locale: loc.attributes.locale) + + let current = trackSlots[trackKey] ?? Array(repeating: nil, count: 10) + let saved = savedTrackState[trackKey] ?? Array(repeating: nil, count: 10) let savedIds = Set(saved.compactMap { $0?.id }) let currentIds = Set(current.compactMap { $0?.id }) let toDelete = savedIds.subtracting(currentIds) @@ -43,7 +238,6 @@ extension ASCManager { try await service.deleteScreenshot(screenshotId: id) } - // 2. Check if existing ASC screenshots need reorder let currentASCIds = current.compactMap { slot -> String? in guard let slot, slot.isFromASC else { return nil } return slot.id @@ -56,22 +250,16 @@ extension ASCManager { let reorderNeeded = currentASCIds != savedASCIds.filter { remainingASCIds.contains($0) } if reorderNeeded { - // Delete remaining ASC screenshots and re-upload in new order - for id in currentASCIds { - if !toDelete.contains(id) { - try await service.deleteScreenshot(screenshotId: id) - } + for id in currentASCIds where !toDelete.contains(id) { + try await service.deleteScreenshot(screenshotId: id) } } - // 3. Upload local assets + re-upload reordered ASC screenshots for slot in current { guard let slot else { continue } if let path = slot.localPath { try await service.uploadScreenshot(localizationId: loc.id, path: path, displayType: displayType) } else if reorderNeeded, slot.isFromASC, let ascShot = slot.ascScreenshot { - // For reordered ASC screenshots, we need the original file - // Download from ASC URL and re-upload if let url = ascShot.imageURL, let (data, _) = try? await URLSession.shared.data(from: url), let fileName = ascShot.attributes.fileName { @@ -83,18 +271,11 @@ extension ASCManager { } } - // 4. Reload from ASC - let sets = try await service.fetchScreenshotSets(localizationId: loc.id) - screenshotSets = sets - for set in sets { - screenshots[set.id] = try await service.fetchScreenshots(setId: set.id) - } - loadTrackFromASC(displayType: displayType) + await loadScreenshots(locale: loc.attributes.locale, force: true) + loadTrackFromASC(displayType: displayType, locale: loc.attributes.locale, overwriteUnsaved: true) } catch { writeError = error.localizedDescription } - - isSyncing = false } // MARK: - Screenshot Deletion @@ -118,12 +299,14 @@ extension ASCManager { .filter { imageExtensions.contains($0.pathExtension.lowercased()) } .sorted { $0.lastPathComponent < $1.lastPathComponent } .compactMap { url in - // Try NSImage first, fall back to CGImageSource for WebP var image = NSImage(contentsOf: url) if image == nil || image!.representations.isEmpty { if let source = CGImageSourceCreateWithURL(url as CFURL, nil), let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) { - image = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) + image = NSImage( + cgImage: cgImage, + size: NSSize(width: cgImage.width, height: cgImage.height) + ) } } guard let image else { return nil } @@ -134,15 +317,19 @@ extension ASCManager { // MARK: - Track Management @discardableResult - func addAssetToTrack(displayType: String, slotIndex: Int, localPath: String) -> String? { + func addAssetToTrack( + displayType: String, + slotIndex: Int, + localPath: String, + locale: String = "en-US" + ) -> String? { guard slotIndex >= 0 && slotIndex < 10 else { return "Invalid slot index" } - guard let image = NSImage(contentsOfFile: localPath) else { return "Could not load image" } - // Validate dimensions - var pixelWidth = 0, pixelHeight = 0 + var pixelWidth = 0 + var pixelHeight = 0 if let rep = image.representations.first, rep.pixelsWide > 0, rep.pixelsHigh > 0 { pixelWidth = rep.pixelsWide pixelHeight = rep.pixelsHigh @@ -156,7 +343,8 @@ extension ASCManager { return error } - var slots = trackSlots[displayType] ?? Array(repeating: nil, count: 10) + let trackKey = screenshotTrackKey(displayType: displayType, locale: locale) + var slots = trackSlots[trackKey] ?? Array(repeating: nil, count: 10) let slot = TrackSlot( id: UUID().uuidString, localPath: localPath, @@ -164,67 +352,66 @@ extension ASCManager { ascScreenshot: nil, isFromASC: false ) - // If target slot occupied, shift right + if slots[slotIndex] != nil { slots.insert(slot, at: slotIndex) slots = Array(slots.prefix(10)) } else { slots[slotIndex] = slot } - // Pad back to 10 + while slots.count < 10 { slots.append(nil) } - trackSlots[displayType] = slots + trackSlots[trackKey] = slots return nil } - func removeFromTrack(displayType: String, slotIndex: Int) { + func removeFromTrack(displayType: String, slotIndex: Int, locale: String = "en-US") { guard slotIndex >= 0 && slotIndex < 10 else { return } - var slots = trackSlots[displayType] ?? Array(repeating: nil, count: 10) + let trackKey = screenshotTrackKey(displayType: displayType, locale: locale) + var slots = trackSlots[trackKey] ?? Array(repeating: nil, count: 10) slots.remove(at: slotIndex) - slots.append(nil) // maintain 10 elements - trackSlots[displayType] = slots + slots.append(nil) + trackSlots[trackKey] = slots } - func reorderTrack(displayType: String, fromIndex: Int, toIndex: Int) { + func reorderTrack( + displayType: String, + fromIndex: Int, + toIndex: Int, + locale: String = "en-US" + ) { guard fromIndex >= 0 && fromIndex < 10 && toIndex >= 0 && toIndex < 10 else { return } guard fromIndex != toIndex else { return } - var slots = trackSlots[displayType] ?? Array(repeating: nil, count: 10) + let trackKey = screenshotTrackKey(displayType: displayType, locale: locale) + var slots = trackSlots[trackKey] ?? Array(repeating: nil, count: 10) let item = slots.remove(at: fromIndex) slots.insert(item, at: toIndex) - trackSlots[displayType] = slots + trackSlots[trackKey] = slots } // MARK: - Track Loading - func loadTrackFromASC(displayType: String) { - let previousSlots = trackSlots[displayType] ?? [] - let set = screenshotSets.first { $0.attributes.screenshotDisplayType == displayType } - var slots: [TrackSlot?] = Array(repeating: nil, count: 10) - if let set, let shots = screenshots[set.id] { - for (i, shot) in shots.prefix(10).enumerated() { - // If ASC hasn't processed the image yet, carry forward the local preview - var localImage: NSImage? = nil - if shot.imageURL == nil, i < previousSlots.count, let prev = previousSlots[i] { - localImage = prev.localImage - } - slots[i] = TrackSlot( - id: shot.id, - localPath: nil, - localImage: localImage, - ascScreenshot: shot, - isFromASC: true - ) - } + func loadTrackFromASC( + displayType: String, + locale: String = "en-US", + overwriteUnsaved: Bool = false + ) { + if !overwriteUnsaved, hasUnsavedChanges(displayType: displayType, locale: locale) { + return } - trackSlots[displayType] = slots - savedTrackState[displayType] = slots + let trackKey = screenshotTrackKey(displayType: displayType, locale: locale) + let previousSlots = trackSlots[trackKey] ?? [] + let slots = buildTrackSlotsFromASC(displayType: displayType, locale: locale, previousSlots: previousSlots) + trackSlots[trackKey] = slots + savedTrackState[trackKey] = slots } // MARK: - Validation - func hasUnsavedChanges(displayType: String) -> Bool { - let current = trackSlots[displayType] ?? Array(repeating: nil, count: 10) - let saved = savedTrackState[displayType] ?? Array(repeating: nil, count: 10) + func hasUnsavedChanges(displayType: String, locale: String = "en-US") -> Bool { + let trackKey = screenshotTrackKey(displayType: displayType, locale: locale) + let current = trackSlots[trackKey] ?? Array(repeating: nil, count: 10) + let saved = savedTrackState[trackKey] ?? Array(repeating: nil, count: 10) return zip(current, saved).contains { c, s in c?.id != s?.id } } diff --git a/src/managers/asc/ASCStoreListingManager.swift b/src/managers/asc/ASCStoreListingManager.swift index d396b21..23f01df 100644 --- a/src/managers/asc/ASCStoreListingManager.swift +++ b/src/managers/asc/ASCStoreListingManager.swift @@ -4,20 +4,167 @@ import Foundation // Extension containing store listing-related functionality for ASCManager extension ASCManager { + // MARK: - Locale Selection + + func effectiveStoreListingLocale() -> String? { + if let selectedStoreListingLocale, + localizations.contains(where: { $0.attributes.locale == selectedStoreListingLocale }) { + return selectedStoreListingLocale + } + return localizations.first?.attributes.locale + } + + func storeListingLocalization(locale: String? = nil) -> ASCVersionLocalization? { + if let locale, + let localization = localizations.first(where: { $0.attributes.locale == locale }) { + return localization + } + if let effectiveLocale = effectiveStoreListingLocale() { + return localizations.first(where: { $0.attributes.locale == effectiveLocale }) ?? localizations.first + } + return localizations.first + } + + func appInfoLocalizationForLocale(_ locale: String? = nil) -> ASCAppInfoLocalization? { + if let locale { + return appInfoLocalizationsByLocale[locale] + } + if let effectiveLocale = effectiveStoreListingLocale() { + return appInfoLocalizationsByLocale[effectiveLocale] + } + return appInfoLocalization + } + + func setSelectedStoreListingLocale(_ locale: String?) { + let locales = Set(localizations.map(\.attributes.locale)) + if let locale, locales.contains(locale) { + selectedStoreListingLocale = locale + } else { + selectedStoreListingLocale = localizations.first?.attributes.locale + } + } + + // MARK: - Data Hydration + + func applyStoreListingMetadata( + versionLocalizations: [ASCVersionLocalization], + appInfoLocalizations: [ASCAppInfoLocalization] + ) { + localizations = versionLocalizations + appInfoLocalizationsByLocale = Dictionary(uniqueKeysWithValues: appInfoLocalizations.map { + ($0.attributes.locale, $0) + }) + + let primaryLocale = versionLocalizations.first?.attributes.locale + appInfoLocalization = primaryLocale.flatMap { appInfoLocalizationsByLocale[$0] } ?? appInfoLocalizations.first + + setSelectedStoreListingLocale(selectedStoreListingLocale) + storeListingDataRevision += 1 + } + + func refreshStoreListingMetadata( + service: AppStoreConnectService, + appId: String, + preferredLocale: String? = nil + ) async throws { + async let versionsTask = service.fetchAppStoreVersions(appId: appId) + async let appInfoTask: ASCAppInfo? = try? await service.fetchAppInfo(appId: appId) + + let versions = try await versionsTask + let fetchedAppInfo = await appInfoTask ?? appInfo + + appStoreVersions = versions + appInfo = fetchedAppInfo + + let versionLocalizations: [ASCVersionLocalization] + if let latestId = versions.first?.id { + versionLocalizations = try await service.fetchLocalizations(versionId: latestId) + } else { + versionLocalizations = [] + } + + let fetchedAppInfoLocalizations: [ASCAppInfoLocalization] + if let infoId = fetchedAppInfo?.id { + fetchedAppInfoLocalizations = try await service.fetchAppInfoLocalizations(appInfoId: infoId) + } else { + fetchedAppInfoLocalizations = [] + } + + if let preferredLocale { + selectedStoreListingLocale = preferredLocale + } + + applyStoreListingMetadata( + versionLocalizations: versionLocalizations, + appInfoLocalizations: fetchedAppInfoLocalizations + ) + } + // MARK: - Localization Updates - func updateLocalizationField(_ field: String, value: String, locId: String) async { + private func mappedAppInfoLocalizationFields(_ fields: [String: String]) -> [String: String] { + var mapped: [String: String] = [:] + for (field, value) in fields { + mapped[field == "title" ? "name" : field] = value + } + return mapped + } + + func updateLocalizationField(_ field: String, value: String, locale: String) async { + await updateStoreListingFields( + versionFields: [field: value], + appInfoFields: [:], + locale: locale + ) + } + + func updateStoreListingFields( + versionFields: [String: String], + appInfoFields rawAppInfoFields: [String: String], + locale: String + ) async { guard let service else { return } + let trimmedLocale = locale.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedLocale.isEmpty else { + writeError = "No store listing locale selected." + return + } + writeError = nil + do { - try await service.patchLocalization(id: locId, fields: [field: value]) - if let latestId = appStoreVersions.first?.id { - localizations = try await service.fetchLocalizations(versionId: latestId) + if !versionFields.isEmpty { + guard let locId = storeListingLocalization(locale: trimmedLocale)?.id else { + throw ASCError.notFound("Version localization for locale '\(trimmedLocale)'") + } + try await service.patchLocalization(id: locId, fields: versionFields) } + + let appInfoFields = mappedAppInfoLocalizationFields(rawAppInfoFields) + if !appInfoFields.isEmpty { + guard let infoId = appInfo?.id else { + throw ASCError.notFound("AppInfo") + } + + if let locId = appInfoLocalizationForLocale(trimmedLocale)?.id { + try await service.patchAppInfoLocalization(id: locId, fields: appInfoFields) + } else { + _ = try await service.createAppInfoLocalization( + appInfoId: infoId, + locale: trimmedLocale, + fields: appInfoFields + ) + } + } + + guard let appId = app?.id else { return } + try await refreshStoreListingMetadata( + service: service, + appId: appId, + preferredLocale: trimmedLocale + ) } catch { writeError = error.localizedDescription } } - } - diff --git a/src/managers/asc/ASCSubmissionReadinessManager.swift b/src/managers/asc/ASCSubmissionReadinessManager.swift index 8fdc727..f9ff34b 100644 --- a/src/managers/asc/ASCSubmissionReadinessManager.swift +++ b/src/managers/asc/ASCSubmissionReadinessManager.swift @@ -20,11 +20,27 @@ extension ASCManager { let review = reviewDetail let demoRequired = review?.attributes.demoAccountRequired == true let version = appStoreVersions.first + let readinessLocale = localization?.attributes.locale + let readinessScreenshotSets: [ASCScreenshotSet] + let readinessScreenshots: [String: [ASCScreenshot]] - let macScreenshots = screenshotSets.first { $0.attributes.screenshotDisplayType == "APP_DESKTOP" } + if let readinessLocale, + let cachedSets = screenshotSetsByLocale[readinessLocale], + let cachedScreenshots = screenshotsByLocale[readinessLocale] { + readinessScreenshotSets = cachedSets + readinessScreenshots = cachedScreenshots + } else if readinessLocale == nil || activeScreenshotsLocale == readinessLocale { + readinessScreenshotSets = screenshotSets + readinessScreenshots = screenshots + } else { + readinessScreenshotSets = [] + readinessScreenshots = [:] + } + + let macScreenshots = readinessScreenshotSets.first { $0.attributes.screenshotDisplayType == "APP_DESKTOP" } let isMacApp = macScreenshots != nil - let iphoneScreenshots = screenshotSets.first { $0.attributes.screenshotDisplayType == "APP_IPHONE_67" } - let ipadScreenshots = screenshotSets.first { $0.attributes.screenshotDisplayType == "APP_IPAD_PRO_3GEN_129" } + let iphoneScreenshots = readinessScreenshotSets.first { $0.attributes.screenshotDisplayType == "APP_IPHONE_67" } + let ipadScreenshots = readinessScreenshotSets.first { $0.attributes.screenshotDisplayType == "APP_IPAD_PRO_3GEN_129" } let privacyUrl: String? = app.map { "https://appstoreconnect.apple.com/apps/\($0.id)/distribution/privacy" @@ -73,7 +89,7 @@ extension ASCManager { func validCount(for set: ASCScreenshotSet?) -> Int { guard let set else { return 0 } - if let screenshots = screenshots[set.id] { + if let screenshots = readinessScreenshots[set.id] { return screenshots.filter { !$0.hasError }.count } return set.attributes.screenshotCount ?? 0 diff --git a/src/managers/asc/ASCTabDataManager.swift b/src/managers/asc/ASCTabDataManager.swift index b0ce546..d2e30ac 100644 --- a/src/managers/asc/ASCTabDataManager.swift +++ b/src/managers/asc/ASCTabDataManager.swift @@ -124,15 +124,13 @@ extension ASCManager { projectId: String?, appId: String, firstLocalizationId: String?, + firstLocalizationLocale: String?, appInfoId: String?, service: AppStoreConnectService ) async { - if let firstLocalizationId { + if let firstLocalizationId, let firstLocalizationLocale { do { let fetchedSets = try await service.fetchScreenshotSets(localizationId: firstLocalizationId) - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - screenshotSets = fetchedSets - let fetchedScreenshots = try await withThrowingTaskGroup(of: (String, [ASCScreenshot]).self) { group in for set in fetchedSets { group.addTask { @@ -149,7 +147,11 @@ extension ASCManager { } guard !Task.isCancelled, isCurrentProject(projectId) else { return } - screenshots = Dictionary(uniqueKeysWithValues: fetchedScreenshots) + cacheScreenshots( + locale: firstLocalizationLocale, + sets: fetchedSets, + screenshots: Dictionary(uniqueKeysWithValues: fetchedScreenshots) + ) finishOverviewReadinessLoading(Self.overviewScreenshotFieldLabels) } catch { print("Failed to hydrate overview screenshots: \(error)") @@ -193,31 +195,22 @@ extension ASCManager { private func hydrateScreenshotsSecondaryData( projectId: String?, + locale: String, localizationId: String, service: AppStoreConnectService ) async { do { - let fetchedSets = try await service.fetchScreenshotSets(localizationId: localizationId) - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - screenshotSets = fetchedSets - - let fetchedScreenshots = try await withThrowingTaskGroup(of: (String, [ASCScreenshot]).self) { group in - for set in fetchedSets { - group.addTask { - let screenshots = try await service.fetchScreenshots(setId: set.id) - return (set.id, screenshots) - } - } - - var pairs: [(String, [ASCScreenshot])] = [] - for try await pair in group { - pairs.append(pair) - } - return pairs - } - + let (fetchedSets, fetchedScreenshots) = try await fetchScreenshotData( + localizationId: localizationId, + service: service + ) guard !Task.isCancelled, isCurrentProject(projectId) else { return } - screenshots = Dictionary(uniqueKeysWithValues: fetchedScreenshots) + storeScreenshots( + locale: locale, + sets: fetchedSets, + screenshots: fetchedScreenshots, + makeActive: true + ) } catch { print("Failed to hydrate screenshots: \(error)") } @@ -330,6 +323,7 @@ extension ASCManager { finishOverviewReadinessLoading(Self.overviewBuildFieldLabels) var firstLocalizationId: String? + var firstLocalizationLocale: String? if let latestId = versions.first?.id { async let localizationsTask = service.fetchLocalizations(versionId: latestId) async let reviewDetailTask: ASCReviewDetail? = try? service.fetchReviewDetail(versionId: latestId) @@ -337,6 +331,7 @@ extension ASCManager { let fetchedLocalizations = try await localizationsTask localizations = fetchedLocalizations firstLocalizationId = fetchedLocalizations.first?.id + firstLocalizationLocale = fetchedLocalizations.first?.attributes.locale finishOverviewReadinessLoading(Self.overviewLocalizationFieldLabels) reviewDetail = await reviewDetailTask finishOverviewReadinessLoading(Self.overviewReviewFieldLabels) @@ -356,28 +351,18 @@ extension ASCManager { projectId: projectId, appId: appId, firstLocalizationId: firstLocalizationId, + firstLocalizationLocale: firstLocalizationLocale, appInfoId: currentAppInfoId, service: service ) } case .storeListing: - async let versionsTask = service.fetchAppStoreVersions(appId: appId) - async let appInfoTask: ASCAppInfo? = try? await service.fetchAppInfo(appId: appId) - - let versions = try await versionsTask - appStoreVersions = versions - if let latestId = versions.first?.id { - localizations = try await service.fetchLocalizations(versionId: latestId) - } else { - localizations = [] - } - appInfo = await appInfoTask - if let infoId = appInfo?.id { - appInfoLocalization = try? await service.fetchAppInfoLocalization(appInfoId: infoId) - } else { - appInfoLocalization = nil - } + try await refreshStoreListingMetadata( + service: service, + appId: appId, + preferredLocale: selectedStoreListingLocale + ) case .screenshots: let versions = try await service.fetchAppStoreVersions(appId: appId) @@ -385,23 +370,38 @@ extension ASCManager { if let latestId = versions.first?.id { let localizations = try await service.fetchLocalizations(versionId: latestId) self.localizations = localizations - if let firstLocalizationId = localizations.first?.id { + let preferredLocale = selectedScreenshotsLocale ?? activeScreenshotsLocale + let targetLocalization = localizations.first(where: { $0.attributes.locale == preferredLocale }) + ?? localizations.first + if let targetLocalization { + selectedScreenshotsLocale = targetLocalization.attributes.locale let projectId = loadedProjectId startBackgroundHydration(for: .screenshots) { await self.hydrateScreenshotsSecondaryData( projectId: projectId, - localizationId: firstLocalizationId, + locale: targetLocalization.attributes.locale, + localizationId: targetLocalization.id, service: service ) } } else { screenshotSets = [] screenshots = [:] + screenshotSetsByLocale = [:] + screenshotsByLocale = [:] + selectedScreenshotsLocale = nil + activeScreenshotsLocale = nil + lastScreenshotDataLocale = nil } } else { localizations = [] screenshotSets = [] screenshots = [:] + screenshotSetsByLocale = [:] + screenshotsByLocale = [:] + selectedScreenshotsLocale = nil + activeScreenshotsLocale = nil + lastScreenshotDataLocale = nil } case .appDetails: diff --git a/src/services/asc/ASCService.swift b/src/services/asc/ASCService.swift index cc71a0d..50f528b 100644 --- a/src/services/asc/ASCService.swift +++ b/src/services/asc/ASCService.swift @@ -1291,18 +1291,50 @@ final class AppStoreConnectService { // MARK: - Fetch: AppInfoLocalization - func fetchAppInfoLocalization(appInfoId: String) async throws -> ASCAppInfoLocalization { + func fetchAppInfoLocalizations(appInfoId: String) async throws -> [ASCAppInfoLocalization] { let resp = try await get( "appInfos/\(appInfoId)/appInfoLocalizations", - queryItems: [URLQueryItem(name: "limit", value: "1")], + queryItems: [URLQueryItem(name: "limit", value: "200")], as: ASCListResponse.self ) - guard let loc = resp.data.first else { + return resp.data + } + + func fetchAppInfoLocalization(appInfoId: String) async throws -> ASCAppInfoLocalization { + let localizations = try await fetchAppInfoLocalizations(appInfoId: appInfoId) + guard let loc = localizations.first else { throw ASCError.notFound("AppInfoLocalization for appInfo \(appInfoId)") } return loc } + func createAppInfoLocalization( + appInfoId: String, + locale: String, + fields: [String: String] = [:] + ) async throws -> ASCAppInfoLocalization { + var attributes = fields + attributes["locale"] = locale + + let body: [String: Any] = [ + "data": [ + "type": "appInfoLocalizations", + "attributes": attributes, + "relationships": [ + "appInfo": [ + "data": [ + "type": "appInfos", + "id": appInfoId + ] + ] + ] + ] + ] + + let data = try await post(path: "appInfoLocalizations", body: body) + return try JSONDecoder().decode(ASCSingleResponse.self, from: data).data + } + // MARK: - Pricing Check /// Check if pricing has been configured for an app. diff --git a/src/services/mcp/MCPExecutor.swift b/src/services/mcp/MCPExecutor.swift index 13b7a54..99f4a15 100644 --- a/src/services/mcp/MCPExecutor.swift +++ b/src/services/mcp/MCPExecutor.swift @@ -43,7 +43,9 @@ actor MCPExecutor { // Pre-navigate for ASC form tools so the user sees the target tab before approving. var previousNavigation: NavigationState? if name == "asc_fill_form" || name == "asc_open_submit_preview" + || name == "store_listing_switch_localization" || name == "asc_create_iap" || name == "asc_create_subscription" || name == "asc_set_app_price" + || name == "screenshots_switch_localization" || name == "screenshots_add_asset" || name == "screenshots_set_track" || name == "screenshots_save" { previousNavigation = await preNavigateASCTool(name: name, arguments: arguments) } @@ -99,10 +101,14 @@ actor MCPExecutor { targetTab = nil } targetAppSubTab = nil + } else if name == "store_listing_switch_localization" { + targetTab = .storeListing + targetAppSubTab = nil } else if name == "asc_open_submit_preview" { targetTab = .app targetAppSubTab = .overview - } else if name == "screenshots_add_asset" + } else if name == "screenshots_switch_localization" + || name == "screenshots_add_asset" || name == "screenshots_set_track" || name == "screenshots_save" { targetTab = .screenshots targetAppSubTab = nil @@ -118,6 +124,27 @@ actor MCPExecutor { } if let targetTab { + if targetTab == .storeListing { + let storeListingLocale: String? + if name == "store_listing_switch_localization" { + storeListingLocale = arguments["locale"] as? String + } else if name == "asc_fill_form", (arguments["tab"] as? String) == "storeListing" { + storeListingLocale = arguments["locale"] as? String + } else { + storeListingLocale = nil + } + + if let storeListingLocale { + let trimmedStoreListingLocale = storeListingLocale.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedStoreListingLocale.isEmpty else { + return previousNavigation + } + await MainActor.run { + appState.ascManager.selectedStoreListingLocale = trimmedStoreListingLocale + } + } + } + await MainActor.run { appState.activeTab = targetTab if let targetAppSubTab { @@ -256,6 +283,10 @@ actor MCPExecutor { return await executeASCSetCredentials(arguments) case "asc_fill_form": return try await executeASCFillForm(arguments) + case "store_listing_switch_localization": + return try await executeStoreListingSwitchLocalization(arguments) + case "screenshots_switch_localization": + return try await executeScreenshotsSwitchLocalization(arguments) case "screenshots_add_asset": return try await executeScreenshotsAddAsset(arguments) case "screenshots_set_track": diff --git a/src/services/mcp/MCPExecutorASC.swift b/src/services/mcp/MCPExecutorASC.swift index ed10571..942db1a 100644 --- a/src/services/mcp/MCPExecutorASC.swift +++ b/src/services/mcp/MCPExecutorASC.swift @@ -30,6 +30,109 @@ extension MCPExecutor { "phone": "contactPhone", ] + private func screenshotsDisplayTypesForActiveProject() async -> [String] { + await MainActor.run { + switch appState.activeProject?.platform ?? .iOS { + case .iOS: + return ["APP_IPHONE_67", "APP_IPAD_PRO_3GEN_129"] + case .macOS: + return ["APP_DESKTOP"] + } + } + } + + private func resolveStoreListingLocale(from args: [String: Any]) async -> String { + if let requestedLocale = (args["locale"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !requestedLocale.isEmpty { + return requestedLocale + } + + return await MainActor.run { + appState.ascManager.selectedStoreListingLocale + ?? appState.ascManager.localizations.first?.attributes.locale + ?? "en-US" + } + } + + private func prepareStoreListingLocale( + _ locale: String, + forceRefresh: Bool = false + ) async -> String? { + let trimmedLocale = locale.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedLocale.isEmpty else { + return "Error: locale is required." + } + + let needsRefresh = await MainActor.run { () -> Bool in + let asc = appState.ascManager + asc.selectedStoreListingLocale = trimmedLocale + return forceRefresh + || asc.localizations.isEmpty + || !asc.localizations.contains(where: { $0.attributes.locale == trimmedLocale }) + || asc.appInfoLocalizationsByLocale.isEmpty + } + + if needsRefresh { + await appState.ascManager.refreshTabData(.storeListing) + } + + let availableLocales = await MainActor.run { + appState.ascManager.localizations.map(\.attributes.locale).sorted() + } + guard availableLocales.contains(trimmedLocale) else { + let availableText = availableLocales.isEmpty ? "none" : availableLocales.joined(separator: ", ") + return "Error: store listing localization '\(trimmedLocale)' was not found after refreshing from ASC. " + + "Available localizations: \(availableText)" + } + + await MainActor.run { + appState.ascManager.selectedStoreListingLocale = trimmedLocale + } + return nil + } + + private func resolveScreenshotsLocale(from args: [String: Any]) async -> (locale: String, explicitlyRequested: Bool) { + if let requestedLocale = (args["locale"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !requestedLocale.isEmpty { + return (requestedLocale, true) + } + + let locale = await MainActor.run { + appState.ascManager.selectedScreenshotsLocale + ?? appState.ascManager.activeScreenshotsLocale + ?? appState.ascManager.localizations.first?.attributes.locale + ?? "en-US" + } + return (locale, false) + } + + private func validateScreenshotsLocaleSelection( + locale: String, + explicitlyRequested: Bool + ) async -> String? { + await MainActor.run { + guard explicitlyRequested else { return nil } + let selectedLocale = appState.ascManager.selectedScreenshotsLocale + guard selectedLocale == locale else { + return "Error: screenshots locale '\(locale)' is not selected in Blitz. " + + "Call screenshots_switch_localization first." + } + return nil + } + } + + private func prepareScreenshotsTrackIfNeeded(displayType: String, locale: String) async { + await MainActor.run { + let asc = appState.ascManager + if !asc.hasTrackState(displayType: displayType, locale: locale), + asc.selectedScreenshotsLocale == locale || asc.activeScreenshotsLocale == locale { + asc.loadTrackFromASC(displayType: displayType, locale: locale) + } + } + } + func executeASCSetCredentials(_ args: [String: Any]) async -> [String: Any] { guard let issuerId = args["issuerId"] as? String, let keyId = args["keyId"] as? String, @@ -115,41 +218,27 @@ extension MCPExecutor { let appInfoLocFields: Set = ["name", "title", "subtitle", "privacyPolicyUrl"] var versionLocFields: [String: String] = [:] var infoLocFields: [String: String] = [:] + let locale = await resolveStoreListingLocale(from: args) + + if let localeError = await prepareStoreListingLocale(locale) { + _ = await MainActor.run { appState.ascManager.pendingFormValues.removeValue(forKey: tab) } + return mcpText(localeError) + } for (field, value) in fieldMap { if appInfoLocFields.contains(field) { - let apiField = (field == "title") ? "name" : field - infoLocFields[apiField] = value + infoLocFields[field] = value } else { versionLocFields[field] = value } } - if !infoLocFields.isEmpty { - for (field, value) in infoLocFields { - await appState.ascManager.updateAppInfoLocalizationField(field, value: value) - } - if let err = await checkASCWriteError(tab: tab) { return err } - } - - if !versionLocFields.isEmpty { - guard let locId = await MainActor.run(body: { appState.ascManager.localizations.first?.id }) else { - return mcpText("Error: no version localizations found.") - } - do { - guard let service = await MainActor.run(body: { appState.ascManager.service }) else { - return mcpText("Error: ASC service not configured") - } - try await service.patchLocalization(id: locId, fields: versionLocFields) - if let versionId = await MainActor.run(body: { appState.ascManager.appStoreVersions.first?.id }) { - let localizations = try await service.fetchLocalizations(versionId: versionId) - await MainActor.run { appState.ascManager.localizations = localizations } - } - } catch { - _ = await MainActor.run { appState.ascManager.pendingFormValues.removeValue(forKey: tab) } - return mcpText("Error: \(error.localizedDescription)") - } - } + await appState.ascManager.updateStoreListingFields( + versionFields: versionLocFields, + appInfoFields: infoLocFields, + locale: locale + ) + if let err = await checkASCWriteError(tab: tab) { return err } case "appDetails": for (field, value) in fieldMap { @@ -226,7 +315,53 @@ extension MCPExecutor { } _ = await MainActor.run { appState.ascManager.pendingFormValues.removeValue(forKey: tab) } - return mcpJSON(["success": true, "tab": tab, "fieldsUpdated": fieldMap.count]) + var response: [String: Any] = ["success": true, "tab": tab, "fieldsUpdated": fieldMap.count] + if tab == "storeListing" { + response["locale"] = await resolveStoreListingLocale(from: args) + } + return mcpJSON(response) + } + + func executeStoreListingSwitchLocalization(_ args: [String: Any]) async throws -> [String: Any] { + guard let rawLocale = args["locale"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + + let locale = rawLocale.trimmingCharacters(in: .whitespacesAndNewlines) + guard !locale.isEmpty else { + throw MCPServerService.MCPError.invalidToolArgs + } + + if let localeError = await prepareStoreListingLocale(locale, forceRefresh: true) { + return mcpText(localeError) + } + + let state = await MainActor.run { () -> [String: Any] in + let asc = appState.ascManager + let versionLocalization = asc.storeListingLocalization(locale: locale) + let appInfoLocalization = asc.appInfoLocalizationForLocale(locale) + let fields: [String: String] = [ + "name": appInfoLocalization?.attributes.name ?? versionLocalization?.attributes.title ?? "", + "subtitle": appInfoLocalization?.attributes.subtitle ?? versionLocalization?.attributes.subtitle ?? "", + "description": versionLocalization?.attributes.description ?? "", + "keywords": versionLocalization?.attributes.keywords ?? "", + "promotionalText": versionLocalization?.attributes.promotionalText ?? "", + "marketingUrl": versionLocalization?.attributes.marketingUrl ?? "", + "supportUrl": versionLocalization?.attributes.supportUrl ?? "", + "whatsNew": versionLocalization?.attributes.whatsNew ?? "", + "privacyPolicyUrl": appInfoLocalization?.attributes.privacyPolicyUrl ?? "" + ] + var response: [String: Any] = [ + "success": true, + "locale": locale, + "availableLocales": asc.localizations.map(\.attributes.locale).sorted(), + "hasAppInfoLocalization": appInfoLocalization != nil + ] + response["fields"] = fields + return response + } + + return mcpJSON(state) } func executeScreenshotsAddAsset(_ args: [String: Any]) async throws -> [String: Any] { @@ -262,6 +397,60 @@ extension MCPExecutor { return mcpJSON(["success": true, "fileName": fileName]) } + func executeScreenshotsSwitchLocalization(_ args: [String: Any]) async throws -> [String: Any] { + guard let rawLocale = args["locale"] as? String else { + throw MCPServerService.MCPError.invalidToolArgs + } + let locale = rawLocale.trimmingCharacters(in: .whitespacesAndNewlines) + guard !locale.isEmpty else { + throw MCPServerService.MCPError.invalidToolArgs + } + + let previousLocale = await MainActor.run { + let previous = appState.ascManager.selectedScreenshotsLocale + appState.ascManager.selectedScreenshotsLocale = locale + return previous + } + + await appState.ascManager.refreshTabData(.screenshots) + + let availableLocales = await MainActor.run { + appState.ascManager.localizations.map(\.attributes.locale).sorted() + } + guard availableLocales.contains(locale) else { + await MainActor.run { + appState.ascManager.selectedScreenshotsLocale = previousLocale + } + let availableText = availableLocales.isEmpty ? "none" : availableLocales.joined(separator: ", ") + return mcpText( + "Error: screenshot localization '\(locale)' was not found after refreshing from ASC. " + + "Available localizations: \(availableText)" + ) + } + + await appState.ascManager.loadScreenshots(locale: locale, force: true) + + let displayTypes = await screenshotsDisplayTypesForActiveProject() + let trackCounts = await MainActor.run { () -> [String: Int] in + var counts: [String: Int] = [:] + for displayType in displayTypes { + appState.ascManager.loadTrackFromASC(displayType: displayType, locale: locale) + counts[displayType] = appState.ascManager + .trackSlotsForDisplayType(displayType, locale: locale) + .compactMap { $0 } + .count + } + return counts + } + + return mcpJSON([ + "success": true, + "locale": locale, + "availableLocales": availableLocales, + "trackCounts": trackCounts + ]) + } + func executeScreenshotsSetTrack(_ args: [String: Any]) async throws -> [String: Any] { guard let assetFileName = args["assetFileName"] as? String else { throw MCPServerService.MCPError.invalidToolArgs @@ -272,6 +461,14 @@ extension MCPExecutor { } let slotIndex = slotRaw - 1 let displayType = args["displayType"] as? String ?? "APP_IPHONE_67" + let (locale, explicitlyRequestedLocale) = await resolveScreenshotsLocale(from: args) + + if let selectionError = await validateScreenshotsLocaleSelection( + locale: locale, + explicitlyRequested: explicitlyRequestedLocale + ) { + return mcpText(selectionError) + } guard let projectId = await MainActor.run(body: { appState.activeProjectId }) else { return mcpText("Error: no active project") @@ -284,22 +481,58 @@ extension MCPExecutor { return mcpText("Error: asset '\(assetFileName)' not found in local screenshots library") } + await prepareScreenshotsTrackIfNeeded(displayType: displayType, locale: locale) + let trackReady = await MainActor.run { + appState.ascManager.hasTrackState(displayType: displayType, locale: locale) + } + guard trackReady else { + return mcpText( + "Error: screenshot locale '\(locale)' is not prepared in Blitz. " + + "Call screenshots_switch_localization first." + ) + } + let error = await MainActor.run { - appState.ascManager.addAssetToTrack(displayType: displayType, slotIndex: slotIndex, localPath: filePath) + appState.ascManager.addAssetToTrack( + displayType: displayType, + slotIndex: slotIndex, + localPath: filePath, + locale: locale + ) } if let error { return mcpText("Error: \(error)") } - return mcpJSON(["success": true, "slot": slotRaw]) + return mcpJSON(["success": true, "slot": slotRaw, "locale": locale]) } func executeScreenshotsSave(_ args: [String: Any]) async throws -> [String: Any] { let displayType = args["displayType"] as? String ?? "APP_IPHONE_67" - let locale = args["locale"] as? String ?? "en-US" + let (locale, explicitlyRequestedLocale) = await resolveScreenshotsLocale(from: args) + + if let selectionError = await validateScreenshotsLocaleSelection( + locale: locale, + explicitlyRequested: explicitlyRequestedLocale + ) { + return mcpText(selectionError) + } - let hasChanges = await MainActor.run { appState.ascManager.hasUnsavedChanges(displayType: displayType) } + await prepareScreenshotsTrackIfNeeded(displayType: displayType, locale: locale) + let trackReady = await MainActor.run { + appState.ascManager.hasTrackState(displayType: displayType, locale: locale) + } + guard trackReady else { + return mcpText( + "Error: screenshot locale '\(locale)' is not prepared in Blitz. " + + "Call screenshots_switch_localization first." + ) + } + + let hasChanges = await MainActor.run { + appState.ascManager.hasUnsavedChanges(displayType: displayType, locale: locale) + } guard hasChanges else { - return mcpJSON(["success": true, "message": "No changes to save"]) + return mcpJSON(["success": true, "message": "No changes to save", "locale": locale]) } await appState.ascManager.syncTrackToASC(displayType: displayType, locale: locale) @@ -307,9 +540,9 @@ extension MCPExecutor { if let err = await checkASCWriteError(tab: "screenshots") { return err } let slotCount = await MainActor.run { - (appState.ascManager.trackSlots[displayType] ?? []).compactMap { $0 }.count + appState.ascManager.trackSlotsForDisplayType(displayType, locale: locale).compactMap { $0 }.count } - return mcpJSON(["success": true, "synced": slotCount]) + return mcpJSON(["success": true, "synced": slotCount, "locale": locale]) } func executeASCOpenSubmitPreview() async -> [String: Any] { diff --git a/src/services/mcp/MCPExecutorTabState.swift b/src/services/mcp/MCPExecutorTabState.swift index a5f135c..e58e1fd 100644 --- a/src/services/mcp/MCPExecutorTabState.swift +++ b/src/services/mcp/MCPExecutorTabState.swift @@ -128,8 +128,9 @@ extension MCPExecutor { @MainActor func tabStateStoreListing(_ asc: ASCManager) -> [String: Any] { - let localization = asc.localizations.first - let infoLoc = asc.appInfoLocalization + let selectedLocale = asc.selectedStoreListingLocale ?? asc.localizations.first?.attributes.locale ?? "" + let localization = asc.storeListingLocalization(locale: selectedLocale) + let infoLoc = asc.appInfoLocalizationForLocale(selectedLocale) let localizationState: [String: Any] = [ "locale": localization?.attributes.locale ?? "", "name": infoLoc?.attributes.name ?? localization?.attributes.title ?? "", @@ -143,8 +144,11 @@ extension MCPExecutor { ] return [ + "selectedLocale": selectedLocale, + "availableLocales": asc.localizations.map(\.attributes.locale), "localization": localizationState, "privacyPolicyUrl": infoLoc?.attributes.privacyPolicyUrl ?? "", + "hasAppInfoLocalization": infoLoc != nil, "localeCount": asc.localizations.count ] } @@ -226,9 +230,14 @@ extension MCPExecutor { @MainActor func tabStateScreenshots(_ asc: ASCManager) -> [String: Any] { - let sets = asc.screenshotSets.map { set -> [String: Any] in + let selectedLocale = asc.selectedScreenshotsLocale ?? asc.activeScreenshotsLocale ?? asc.localizations.first?.attributes.locale ?? "" + let activeLocale = asc.activeScreenshotsLocale ?? asc.localizations.first?.attributes.locale ?? "" + let dataLocale = asc.screenshotSetsByLocale.keys.contains(selectedLocale) ? selectedLocale : activeLocale + let screenshotSets = asc.screenshotSetsByLocale[dataLocale] ?? asc.screenshotSets + let screenshots = asc.screenshotsByLocale[dataLocale] ?? asc.screenshots + let sets = screenshotSets.map { set -> [String: Any] in var value: [String: Any] = ["id": set.id, "displayType": set.attributes.screenshotDisplayType] - if let shots = asc.screenshots[set.id] { + if let shots = screenshots[set.id] { value["screenshotCount"] = shots.count value["screenshots"] = shots.map { ["id": $0.id, "fileName": $0.attributes.fileName ?? ""] @@ -236,7 +245,14 @@ extension MCPExecutor { } return value } - return ["screenshotSets": sets, "localeCount": asc.localizations.count] + return [ + "selectedLocale": selectedLocale, + "activeLocale": activeLocale, + "dataLocale": dataLocale, + "availableLocales": asc.localizations.map(\.attributes.locale), + "screenshotSets": sets, + "localeCount": asc.localizations.count + ] } @MainActor diff --git a/src/services/mcp/MCPRegistry.swift b/src/services/mcp/MCPRegistry.swift index bde1051..dff2e90 100644 --- a/src/services/mcp/MCPRegistry.swift +++ b/src/services/mcp/MCPRegistry.swift @@ -181,11 +181,12 @@ enum MCPRegistry { // -- ASC Form Tools -- tools.append(tool( name: "asc_fill_form", - description: "Fill one or more App Store Connect form fields. Navigates to the tab automatically if auto-nav is enabled. See CLAUDE.md for complete field reference.", + description: "Fill one or more App Store Connect form fields. Navigates to the tab automatically if auto-nav is enabled. For storeListing, pass locale to target a specific localization safely. See CLAUDE.md for complete field reference.", properties: [ "tab": ["type": "string", "description": "Target form tab", "enum": [ "storeListing", "appDetails", "monetization", "review.ageRating", "review.contact", "settings.bundleId" ]], + "locale": ["type": "string", "description": "For storeListing only: locale code to target (for example en-US or ja)."], "fields": [ "type": "array", "items": [ @@ -201,7 +202,25 @@ enum MCPRegistry { required: ["tab", "fields"] )) + tools.append(tool( + name: "store_listing_switch_localization", + description: "Refresh store-listing localizations from App Store Connect and switch the Blitz store-listing tab to the requested locale.", + properties: [ + "locale": ["type": "string", "description": "Locale code to select in the store-listing tab (for example en-US or ja)"] + ], + required: ["locale"] + )) + // -- Screenshot Track Tools -- + tools.append(tool( + name: "screenshots_switch_localization", + description: "Refresh screenshot localizations from App Store Connect, switch the Blitz screenshots tab to the requested locale, and hydrate that locale's screenshot tracks. Call this before screenshots_set_track or screenshots_save when targeting a specific locale.", + properties: [ + "locale": ["type": "string", "description": "Locale code to select in the screenshots tab (for example en-US or en-GB)"] + ], + required: ["locale"] + )) + tools.append(tool( name: "screenshots_add_asset", description: "Copy a screenshot file into the project's local screenshots asset library.", @@ -214,21 +233,22 @@ enum MCPRegistry { tools.append(tool( name: "screenshots_set_track", - description: "Place a local screenshot asset into a specific track slot (1-10) for upload staging.", + description: "Place a local screenshot asset into a specific track slot (1-10) for upload staging. If you are targeting a specific locale, call screenshots_switch_localization first.", properties: [ "assetFileName": ["type": "string", "description": "File name of the asset in the local screenshots library"], "slotIndex": ["type": "integer", "description": "Track slot position (1-10)"], - "displayType": ["type": "string", "description": "Display type (default APP_IPHONE_67)", "enum": ["APP_IPHONE_67", "APP_IPAD_PRO_3GEN_129", "APP_DESKTOP"]] + "displayType": ["type": "string", "description": "Display type (default APP_IPHONE_67)", "enum": ["APP_IPHONE_67", "APP_IPAD_PRO_3GEN_129", "APP_DESKTOP"]], + "locale": ["type": "string", "description": "Locale code. Must match the currently selected screenshots locale in Blitz."] ], required: ["assetFileName", "slotIndex"] )) tools.append(tool( name: "screenshots_save", - description: "Save the current screenshot track to App Store Connect. Syncs all changes (additions, removals, reorder) for the specified device type.", + description: "Save the current screenshot track to App Store Connect. Syncs all changes (additions, removals, reorder) for the specified device type. If you are targeting a specific locale, call screenshots_switch_localization first.", properties: [ "displayType": ["type": "string", "description": "Display type (default APP_IPHONE_67)", "enum": ["APP_IPHONE_67", "APP_IPAD_PRO_3GEN_129", "APP_DESKTOP"]], - "locale": ["type": "string", "description": "Locale code (default en-US)"] + "locale": ["type": "string", "description": "Locale code. Must match the currently selected screenshots locale in Blitz."] ], required: [] )) @@ -352,7 +372,9 @@ enum MCPRegistry { return .query case "asc_fill_form": return .ascFormMutation - case "screenshots_add_asset", "screenshots_set_track", "screenshots_save": + case "store_listing_switch_localization": + return .ascFormMutation + case "screenshots_switch_localization", "screenshots_add_asset", "screenshots_set_track", "screenshots_save": return .ascScreenshotMutation case "asc_open_submit_preview": return .ascSubmitMutation diff --git a/src/views/build/TestsView.swift b/src/views/build/TestsView.swift index f4e8d52..1268052 100644 --- a/src/views/build/TestsView.swift +++ b/src/views/build/TestsView.swift @@ -95,7 +95,7 @@ struct TestsView: View { 1. Navigate to each screen and configure the UI — scroll, tap into views, fill in data 2. Call `get_screenshot` to capture each state at full resolution - 3. Use `screenshots_add_asset` → `screenshots_set_track` → `screenshots_save` to upload to App Store Connect + 3. Use `screenshots_switch_localization` → `screenshots_add_asset` → `screenshots_set_track` → `screenshots_save` to upload to App Store Connect You get pixel-perfect, context-rich screenshots without touching the simulator. """ diff --git a/src/views/release/ScreenshotsView.swift b/src/views/release/ScreenshotsView.swift index 570016f..0fe9000 100644 --- a/src/views/release/ScreenshotsView.swift +++ b/src/views/release/ScreenshotsView.swift @@ -70,6 +70,7 @@ struct ScreenshotsView: View { private var asc: ASCManager { appState.ascManager } private var platform: ProjectPlatform { appState.activeProject?.platform ?? .iOS } + @State private var selectedLocale: String = "" @State private var selectedDevice: ScreenshotDeviceType = .iPhone @State private var selectedAssetId: UUID? // selected in asset library @State private var selectedTrackIndex: Int? // selected in track @@ -82,12 +83,25 @@ struct ScreenshotsView: View { ScreenshotDeviceType.types(for: platform) } + private var effectiveLocale: String { + if asc.localizations.contains(where: { $0.attributes.locale == selectedLocale }) { + return selectedLocale + } + if let selectedScreenshotsLocale = asc.selectedScreenshotsLocale, + asc.localizations.contains(where: { $0.attributes.locale == selectedScreenshotsLocale }) { + return selectedScreenshotsLocale + } + return asc.activeScreenshotsLocale + ?? asc.localizations.first?.attributes.locale + ?? "en-US" + } + private var currentTrack: [TrackSlot?] { - asc.trackSlots[selectedDevice.ascDisplayType] ?? Array(repeating: nil, count: 10) + asc.trackSlotsForDisplayType(selectedDevice.ascDisplayType, locale: effectiveLocale) } private var hasChanges: Bool { - asc.hasUnsavedChanges(displayType: selectedDevice.ascDisplayType) + asc.hasUnsavedChanges(displayType: selectedDevice.ascDisplayType, locale: effectiveLocale) } private var filledSlotCount: Int { @@ -106,6 +120,15 @@ struct ScreenshotsView: View { HStack { Text("Screenshots") .font(.title2.weight(.semibold)) + if !asc.localizations.isEmpty { + Picker("Locale", selection: $selectedLocale) { + ForEach(asc.localizations) { localization in + Text(localization.attributes.locale).tag(localization.attributes.locale) + } + } + .pickerStyle(.menu) + .frame(width: 160) + } Spacer() ASCTabRefreshButton(asc: asc, tab: .screenshots, helpText: "Refresh screenshots") } @@ -131,7 +154,29 @@ struct ScreenshotsView: View { .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { await loadData() } + .onChange(of: selectedLocale) { _, _ in + if asc.selectedScreenshotsLocale != selectedLocale { + asc.selectedScreenshotsLocale = selectedLocale + } + Task { await loadSelectedLocaleData() } + } .onChange(of: selectedDevice) { _, _ in loadTrackForDevice() } + .onChange(of: asc.localizations.count) { _, _ in + syncSelectedLocaleFromAvailable() + } + .onChange(of: asc.selectedScreenshotsLocale) { _, newValue in + guard let newValue else { return } + guard newValue != selectedLocale else { return } + guard asc.localizations.contains(where: { $0.attributes.locale == newValue }) else { return } + selectedLocale = newValue + } + .onChange(of: asc.screenshotDataRevision) { _, _ in + guard asc.lastScreenshotDataLocale == effectiveLocale else { return } + guard !asc.hasUnsavedChanges(displayType: selectedDevice.ascDisplayType, locale: effectiveLocale) else { + return + } + loadTrackForDevice(force: true) + } .alert("Import Error", isPresented: Binding( get: { importError != nil }, set: { if !$0 { importError = nil } } @@ -157,7 +202,7 @@ struct ScreenshotsView: View { Text(device.label) .font(.callout) Spacer() - if asc.hasUnsavedChanges(displayType: device.ascDisplayType) { + if asc.hasUnsavedChanges(displayType: device.ascDisplayType, locale: effectiveLocale) { Circle() .fill(.orange) .frame(width: 6, height: 6) @@ -406,7 +451,7 @@ struct ScreenshotsView: View { private func trackSlotView(index: Int) -> some View { let slot = currentTrack[index] - let saved = (asc.savedTrackState[selectedDevice.ascDisplayType] ?? Array(repeating: nil, count: 10))[index] + let saved = asc.savedTrackStateForDisplayType(selectedDevice.ascDisplayType, locale: effectiveLocale)[index] let isSynced = slot?.id == saved?.id && slot != nil let hasError = slot?.ascScreenshot?.hasError == true @@ -454,7 +499,13 @@ struct ScreenshotsView: View { // Delete button (top-right) Button { - withAnimation { asc.removeFromTrack(displayType: selectedDevice.ascDisplayType, slotIndex: index) } + withAnimation { + asc.removeFromTrack( + displayType: selectedDevice.ascDisplayType, + slotIndex: index, + locale: effectiveLocale + ) + } if selectedTrackIndex == index { selectedTrackIndex = nil } } label: { Image(systemName: "xmark.circle.fill") @@ -502,6 +553,7 @@ struct ScreenshotsView: View { .onDrop(of: [.text], delegate: TrackSlotDropDelegate( targetIndex: index, displayType: selectedDevice.ascDisplayType, + locale: effectiveLocale, asc: asc, localAssets: asc.localScreenshotAssets, draggedAssetId: $draggedAssetId, @@ -552,19 +604,44 @@ struct ScreenshotsView: View { asc.scanLocalAssets(projectId: projectId) } - loadTrackForDevice() - await asc.ensureTabData(.screenshots) + syncSelectedLocaleFromAvailable() + await loadSelectedLocaleData() + } + + private func syncSelectedLocaleFromAvailable() { + let locales = Set(asc.localizations.map(\.attributes.locale)) + guard let first = asc.localizations.first?.attributes.locale else { + selectedLocale = "" + if asc.selectedScreenshotsLocale != nil { + asc.selectedScreenshotsLocale = nil + } + return + } - // Load track from ASC - loadTrackForDevice() + let preferredLocale = asc.selectedScreenshotsLocale.flatMap { locales.contains($0) ? $0 : nil } + ?? (locales.contains(selectedLocale) ? selectedLocale : first) + + if selectedLocale != preferredLocale { + selectedLocale = preferredLocale + } + if asc.selectedScreenshotsLocale != preferredLocale { + asc.selectedScreenshotsLocale = preferredLocale + } } - private func loadTrackForDevice() { + private func loadSelectedLocaleData(force: Bool = false) async { + syncSelectedLocaleFromAvailable() + guard !selectedLocale.isEmpty else { return } + await asc.loadScreenshots(locale: selectedLocale, force: force) + loadTrackForDevice(force: force) + } + + private func loadTrackForDevice(force: Bool = false) { let displayType = selectedDevice.ascDisplayType - // Only load from ASC if not already populated (preserves unsaved changes) - if asc.trackSlots[displayType] == nil { - asc.loadTrackFromASC(displayType: displayType) + let locale = effectiveLocale + if force || !asc.hasTrackState(displayType: displayType, locale: locale) { + asc.loadTrackFromASC(displayType: displayType, locale: locale) } } @@ -683,7 +760,7 @@ struct ScreenshotsView: View { private func save() async { await asc.syncTrackToASC( displayType: selectedDevice.ascDisplayType, - locale: "en-US" + locale: effectiveLocale ) } } @@ -693,6 +770,7 @@ struct ScreenshotsView: View { private struct TrackSlotDropDelegate: DropDelegate { let targetIndex: Int let displayType: String + let locale: String let asc: ASCManager let localAssets: [LocalScreenshotAsset] @Binding var draggedAssetId: UUID? @@ -708,7 +786,12 @@ private struct TrackSlotDropDelegate: DropDelegate { // Drop from asset library if let assetId = draggedAssetId, let asset = localAssets.first(where: { $0.id == assetId }) { - let error = asc.addAssetToTrack(displayType: displayType, slotIndex: targetIndex, localPath: asset.url.path) + let error = asc.addAssetToTrack( + displayType: displayType, + slotIndex: targetIndex, + localPath: asset.url.path, + locale: locale + ) if let error { importError = "Cannot add \(asset.fileName): \(error)" return false @@ -719,7 +802,12 @@ private struct TrackSlotDropDelegate: DropDelegate { // Reorder within track if let fromIndex = draggedTrackIndex, fromIndex != targetIndex { withAnimation { - asc.reorderTrack(displayType: displayType, fromIndex: fromIndex, toIndex: targetIndex) + asc.reorderTrack( + displayType: displayType, + fromIndex: fromIndex, + toIndex: targetIndex, + locale: locale + ) } return true } diff --git a/src/views/release/StoreListingView.swift b/src/views/release/StoreListingView.swift index 904d3bb..7653172 100644 --- a/src/views/release/StoreListingView.swift +++ b/src/views/release/StoreListingView.swift @@ -21,6 +21,17 @@ struct StoreListingView: View { @State private var isSaving = false @State private var lastSavedField: String? + private var effectiveLocale: String { + if asc.localizations.contains(where: { $0.attributes.locale == selectedLocale }) { + return selectedLocale + } + if let selectedStoreListingLocale = asc.selectedStoreListingLocale, + asc.localizations.contains(where: { $0.attributes.locale == selectedStoreListingLocale }) { + return selectedStoreListingLocale + } + return asc.localizations.first?.attributes.locale ?? "" + } + var body: some View { ASCCredentialGate( appState: appState, @@ -34,6 +45,36 @@ struct StoreListingView: View { } .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { await asc.ensureTabData(.storeListing) + syncSelectedLocaleFromAvailable() + populateFields( + from: asc.storeListingLocalization(locale: effectiveLocale), + infoLocalization: asc.appInfoLocalizationForLocale(effectiveLocale) + ) + applyPendingValues() + } + .onChange(of: selectedLocale) { _, _ in + asc.setSelectedStoreListingLocale(selectedLocale) + populateFields( + from: asc.storeListingLocalization(locale: effectiveLocale), + infoLocalization: asc.appInfoLocalizationForLocale(effectiveLocale) + ) + } + .onChange(of: asc.selectedStoreListingLocale) { _, newValue in + guard let newValue else { return } + guard asc.localizations.contains(where: { $0.attributes.locale == newValue }) else { return } + guard newValue != selectedLocale else { return } + selectedLocale = newValue + } + .onChange(of: asc.localizations.count) { _, _ in + syncSelectedLocaleFromAvailable() + } + .onChange(of: asc.storeListingDataRevision) { _, _ in + syncSelectedLocaleFromAvailable() + guard focusedField == nil else { return } + populateFields( + from: asc.storeListingLocalization(locale: effectiveLocale), + infoLocalization: asc.appInfoLocalizationForLocale(effectiveLocale) + ) } .onDisappear { Task { await flushChanges() } @@ -43,8 +84,8 @@ struct StoreListingView: View { @ViewBuilder private var listingContent: some View { let locales = asc.localizations - let current = locales.first { $0.attributes.locale == selectedLocale } - ?? locales.first + let current = asc.storeListingLocalization(locale: effectiveLocale) + let currentAppInfoLocalization = asc.appInfoLocalizationForLocale(effectiveLocale) let isLoading = asc.isTabLoading(.storeListing) VStack(spacing: 0) { @@ -58,15 +99,8 @@ struct StoreListingView: View { } .pickerStyle(.menu) .frame(width: 160) - .onChange(of: locales.count) { _, _ in - if selectedLocale.isEmpty, let first = asc.localizations.first { - selectedLocale = first.attributes.locale - } - } .onAppear { - if selectedLocale.isEmpty, let first = locales.first { - selectedLocale = first.attributes.locale - } + syncSelectedLocaleFromAvailable() } } Spacer() @@ -122,11 +156,8 @@ struct StoreListingView: View { } } } - .onChange(of: current?.id) { _, _ in - populateFields(from: current) - } .onAppear { - populateFields(from: current) + populateFields(from: current, infoLocalization: currentAppInfoLocalization) applyPendingValues() } .onChange(of: asc.pendingFormVersion) { _, _ in @@ -139,12 +170,32 @@ struct StoreListingView: View { } } - private func populateFields(from loc: ASCVersionLocalization?) { + private func syncSelectedLocaleFromAvailable() { + let locales = Set(asc.localizations.map(\.attributes.locale)) + guard let first = asc.localizations.first?.attributes.locale else { + selectedLocale = "" + if asc.selectedStoreListingLocale != nil { + asc.selectedStoreListingLocale = nil + } + return + } + + let preferredLocale = asc.selectedStoreListingLocale.flatMap { locales.contains($0) ? $0 : nil } + ?? (locales.contains(selectedLocale) ? selectedLocale : first) + + if selectedLocale != preferredLocale { + selectedLocale = preferredLocale + } + if asc.selectedStoreListingLocale != preferredLocale { + asc.selectedStoreListingLocale = preferredLocale + } + } + + private func populateFields(from loc: ASCVersionLocalization?, infoLocalization: ASCAppInfoLocalization?) { // name and subtitle come from appInfoLocalization, not version localization - let infoLoc = asc.appInfoLocalization - title = infoLoc?.attributes.name ?? loc?.attributes.title ?? "" - subtitle = infoLoc?.attributes.subtitle ?? loc?.attributes.subtitle ?? "" - privacyPolicyUrl = infoLoc?.attributes.privacyPolicyUrl ?? "" + title = infoLocalization?.attributes.name ?? loc?.attributes.title ?? "" + subtitle = infoLocalization?.attributes.subtitle ?? loc?.attributes.subtitle ?? "" + privacyPolicyUrl = infoLocalization?.attributes.privacyPolicyUrl ?? "" // The rest come from version localization descriptionText = loc?.attributes.description ?? "" keywords = loc?.attributes.keywords ?? "" @@ -194,14 +245,9 @@ struct StoreListingView: View { isSaving = true if Self.appInfoLocFields.contains(field) { // These fields live on appInfoLocalizations, not version localizations - await asc.updateAppInfoLocalizationField(field, value: value) + await asc.updateAppInfoLocalizationField(field, value: value, locale: effectiveLocale) } else { - guard let locId = (asc.localizations.first { $0.attributes.locale == selectedLocale } - ?? asc.localizations.first)?.id else { - isSaving = false - return - } - await asc.updateLocalizationField(field, value: value, locId: locId) + await asc.updateLocalizationField(field, value: value, locale: effectiveLocale) } isSaving = false } From e45db79e2bb6bc4e47048fab5909aed7d5889768 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Thu, 26 Mar 2026 16:53:17 -0700 Subject: [PATCH 50/51] Trim ASC locale flows and fix overview primary localization --- .../ASCScreenshotsLocaleRegressionTests.swift | 111 ++++++++++++++++-- src/managers/asc/ASCDetailsManager.swift | 6 +- src/managers/asc/ASCManager.swift | 6 - .../asc/ASCProjectLifecycleManager.swift | 30 ----- src/managers/asc/ASCReleaseManager.swift | 2 +- src/managers/asc/ASCScreenshotsManager.swift | 74 +++++------- src/managers/asc/ASCStoreListingManager.swift | 88 +++++++------- .../asc/ASCSubmissionReadinessManager.swift | 21 +--- src/managers/asc/ASCTabDataManager.swift | 90 ++++++-------- src/services/mcp/MCPExecutor.swift | 73 ++++-------- src/services/mcp/MCPExecutorASC.swift | 107 ++++++----------- src/services/mcp/MCPExecutorTabState.swift | 12 +- src/views/release/ScreenshotsView.swift | 93 +++++---------- src/views/release/StoreListingView.swift | 96 +++++---------- 14 files changed, 335 insertions(+), 474 deletions(-) diff --git a/Tests/blitz_tests/ASCScreenshotsLocaleRegressionTests.swift b/Tests/blitz_tests/ASCScreenshotsLocaleRegressionTests.swift index 469becf..a535d81 100644 --- a/Tests/blitz_tests/ASCScreenshotsLocaleRegressionTests.swift +++ b/Tests/blitz_tests/ASCScreenshotsLocaleRegressionTests.swift @@ -9,7 +9,7 @@ import Testing let displayType = "APP_IPHONE_67" let set = makeScreenshotSet(id: "set-us", displayType: displayType, count: 1) - manager.cacheScreenshots( + manager.updateScreenshotCache( locale: locale, sets: [set], screenshots: [set.id: [makeScreenshot(id: "remote-1", fileName: "remote-1.png")]] @@ -29,7 +29,7 @@ import Testing #expect(manager.hasUnsavedChanges(displayType: displayType, locale: locale)) - manager.cacheScreenshots( + manager.updateScreenshotCache( locale: locale, sets: [set], screenshots: [set.id: [makeScreenshot(id: "remote-2", fileName: "remote-2.png")]] @@ -44,21 +44,20 @@ import Testing @MainActor @Test func submissionReadinessUsesPrimaryLocaleScreenshotCache() { let manager = ASCManager() + manager.app = makeApp(primaryLocale: "en-US") manager.localizations = [ - makeLocalization(id: "loc-us", locale: "en-US"), makeLocalization(id: "loc-gb", locale: "en-GB"), + makeLocalization(id: "loc-us", locale: "en-US"), ] let usSet = makeScreenshotSet(id: "set-us", displayType: "APP_IPHONE_67", count: 1) - manager.cacheScreenshots( + manager.updateScreenshotCache( locale: "en-US", sets: [usSet], screenshots: [usSet.id: [makeScreenshot(id: "shot-us", fileName: "us.png")]] ) - manager.activeScreenshotsLocale = "en-GB" - manager.screenshotSets = [] - manager.screenshots = [:] + manager.selectedScreenshotsLocale = "en-GB" let readiness = manager.submissionReadiness let iphoneField = readiness.fields.first { $0.label == "iPhone Screenshots" } @@ -66,23 +65,111 @@ import Testing #expect(iphoneField?.value == "1 screenshot(s)") } -private func makeLocalization(id: String, locale: String) -> ASCVersionLocalization { +@MainActor +@Test func submissionReadinessUsesPrimaryLocaleMetadataWhenAPIOrderDiffers() { + let manager = ASCManager() + manager.app = makeApp(primaryLocale: "en-US") + manager.localizations = [ + makeLocalization( + id: "loc-ja", + locale: "ja", + title: "Japanese Title", + description: "Japanese Description", + keywords: "japanese,keywords", + supportUrl: "https://example.com/ja/support" + ), + makeLocalization( + id: "loc-us", + locale: "en-US", + title: "English Title", + description: "English Description", + keywords: "english,keywords", + supportUrl: "https://example.com/en/support" + ), + ] + manager.appInfoLocalizationsByLocale = [ + "ja": makeAppInfoLocalization( + id: "info-ja", + locale: "ja", + name: "Japanese Name", + privacyPolicyUrl: "https://example.com/ja/privacy" + ), + "en-US": makeAppInfoLocalization( + id: "info-us", + locale: "en-US", + name: "English Name", + privacyPolicyUrl: "https://example.com/en/privacy" + ), + ] + manager.appInfoLocalization = manager.appInfoLocalizationsByLocale["ja"] + + func value(for label: String) -> String? { + manager.submissionReadiness.fields.first(where: { $0.label == label })?.value + } + + #expect(value(for: "App Name") == "English Name") + #expect(value(for: "Description") == "English Description") + #expect(value(for: "Keywords") == "english,keywords") + #expect(value(for: "Support URL") == "https://example.com/en/support") + #expect(value(for: "Privacy Policy URL") == "https://example.com/en/privacy") +} + +private func makeApp(primaryLocale: String?) -> ASCApp { + ASCApp( + id: "app-id", + attributes: ASCApp.Attributes( + bundleId: "com.example.blitz", + name: "Blitz", + primaryLocale: primaryLocale, + vendorNumber: nil, + contentRightsDeclaration: nil + ) + ) +} + +private func makeLocalization( + id: String, + locale: String, + title: String? = nil, + description: String? = nil, + keywords: String? = nil, + supportUrl: String? = nil +) -> ASCVersionLocalization { ASCVersionLocalization( id: id, attributes: ASCVersionLocalization.Attributes( locale: locale, - title: nil, + title: title, subtitle: nil, - description: nil, - keywords: nil, + description: description, + keywords: keywords, promotionalText: nil, marketingUrl: nil, - supportUrl: nil, + supportUrl: supportUrl, whatsNew: nil ) ) } +private func makeAppInfoLocalization( + id: String, + locale: String, + name: String? = nil, + privacyPolicyUrl: String? = nil +) -> ASCAppInfoLocalization { + ASCAppInfoLocalization( + id: id, + attributes: ASCAppInfoLocalization.Attributes( + locale: locale, + name: name, + subtitle: nil, + privacyPolicyUrl: privacyPolicyUrl, + privacyChoicesUrl: nil, + privacyPolicyText: nil + ) + ) +} + private func makeScreenshotSet(id: String, displayType: String, count: Int?) -> ASCScreenshotSet { ASCScreenshotSet( id: id, diff --git a/src/managers/asc/ASCDetailsManager.swift b/src/managers/asc/ASCDetailsManager.swift index 524e3a3..d619bc1 100644 --- a/src/managers/asc/ASCDetailsManager.swift +++ b/src/managers/asc/ASCDetailsManager.swift @@ -50,11 +50,7 @@ extension ASCManager { /// Update a field on appInfoLocalizations (name, subtitle, privacyPolicyUrl) func updateAppInfoLocalizationField(_ field: String, value: String, locale: String? = nil) async { - let targetLocale = locale - ?? selectedStoreListingLocale - ?? appInfoLocalization?.attributes.locale - ?? localizations.first?.attributes.locale - + let targetLocale = locale ?? activeStoreListingLocale() guard let targetLocale else { writeError = "No app info localization selected." return diff --git a/src/managers/asc/ASCManager.swift b/src/managers/asc/ASCManager.swift index 30967a2..5272781 100644 --- a/src/managers/asc/ASCManager.swift +++ b/src/managers/asc/ASCManager.swift @@ -25,15 +25,9 @@ final class ASCManager { var localizations: [ASCVersionLocalization] = [] var selectedStoreListingLocale: String? var appInfoLocalizationsByLocale: [String: ASCAppInfoLocalization] = [:] - var storeListingDataRevision: Int = 0 - var screenshotSets: [ASCScreenshotSet] = [] - var screenshots: [String: [ASCScreenshot]] = [:] // keyed by screenshotSet.id var screenshotSetsByLocale: [String: [ASCScreenshotSet]] = [:] var screenshotsByLocale: [String: [String: [ASCScreenshot]]] = [:] var selectedScreenshotsLocale: String? - var activeScreenshotsLocale: String? - var lastScreenshotDataLocale: String? - var screenshotDataRevision: Int = 0 var customerReviews: [ASCCustomerReview] = [] var builds: [ASCBuild] = [] var betaGroups: [ASCBetaGroup] = [] diff --git a/src/managers/asc/ASCProjectLifecycleManager.swift b/src/managers/asc/ASCProjectLifecycleManager.swift index c5cab8f..f7cecc4 100644 --- a/src/managers/asc/ASCProjectLifecycleManager.swift +++ b/src/managers/asc/ASCProjectLifecycleManager.swift @@ -6,17 +6,9 @@ extension ASCManager { let app: ASCApp? let appStoreVersions: [ASCAppStoreVersion] let localizations: [ASCVersionLocalization] - let selectedStoreListingLocale: String? let appInfoLocalizationsByLocale: [String: ASCAppInfoLocalization] - let storeListingDataRevision: Int - let screenshotSets: [ASCScreenshotSet] - let screenshots: [String: [ASCScreenshot]] let screenshotSetsByLocale: [String: [ASCScreenshotSet]] let screenshotsByLocale: [String: [String: [ASCScreenshot]]] - let selectedScreenshotsLocale: String? - let activeScreenshotsLocale: String? - let lastScreenshotDataLocale: String? - let screenshotDataRevision: Int let customerReviews: [ASCCustomerReview] let builds: [ASCBuild] let betaGroups: [ASCBetaGroup] @@ -57,17 +49,9 @@ extension ASCManager { app = manager.app appStoreVersions = manager.appStoreVersions localizations = manager.localizations - selectedStoreListingLocale = manager.selectedStoreListingLocale appInfoLocalizationsByLocale = manager.appInfoLocalizationsByLocale - storeListingDataRevision = manager.storeListingDataRevision - screenshotSets = manager.screenshotSets - screenshots = manager.screenshots screenshotSetsByLocale = manager.screenshotSetsByLocale screenshotsByLocale = manager.screenshotsByLocale - selectedScreenshotsLocale = manager.selectedScreenshotsLocale - activeScreenshotsLocale = manager.activeScreenshotsLocale - lastScreenshotDataLocale = manager.lastScreenshotDataLocale - screenshotDataRevision = manager.screenshotDataRevision customerReviews = manager.customerReviews builds = manager.builds betaGroups = manager.betaGroups @@ -109,17 +93,9 @@ extension ASCManager { manager.app = app manager.appStoreVersions = appStoreVersions manager.localizations = localizations - manager.selectedStoreListingLocale = selectedStoreListingLocale manager.appInfoLocalizationsByLocale = appInfoLocalizationsByLocale - manager.storeListingDataRevision = storeListingDataRevision - manager.screenshotSets = screenshotSets - manager.screenshots = screenshots manager.screenshotSetsByLocale = screenshotSetsByLocale manager.screenshotsByLocale = screenshotsByLocale - manager.selectedScreenshotsLocale = selectedScreenshotsLocale - manager.activeScreenshotsLocale = activeScreenshotsLocale - manager.lastScreenshotDataLocale = lastScreenshotDataLocale - manager.screenshotDataRevision = screenshotDataRevision manager.customerReviews = customerReviews manager.builds = builds manager.betaGroups = betaGroups @@ -336,15 +312,9 @@ extension ASCManager { localizations = [] selectedStoreListingLocale = nil appInfoLocalizationsByLocale = [:] - storeListingDataRevision = 0 - screenshotSets = [] - screenshots = [:] screenshotSetsByLocale = [:] screenshotsByLocale = [:] selectedScreenshotsLocale = nil - activeScreenshotsLocale = nil - lastScreenshotDataLocale = nil - screenshotDataRevision = 0 customerReviews = [] builds = [] betaGroups = [] diff --git a/src/managers/asc/ASCReleaseManager.swift b/src/managers/asc/ASCReleaseManager.swift index 90b46bc..941354f 100644 --- a/src/managers/asc/ASCReleaseManager.swift +++ b/src/managers/asc/ASCReleaseManager.swift @@ -48,7 +48,7 @@ extension ASCManager { let appInfoLocFieldNames: Set = ["name", "title", "subtitle", "privacyPolicyUrl"] for (tab, fields) in pendingFormValues { if tab == "storeListing" { - let locale = effectiveStoreListingLocale() + let locale = activeStoreListingLocale() var versionLocFields: [String: String] = [:] var infoLocFields: [String: String] = [:] for (field, value) in fields { diff --git a/src/managers/asc/ASCScreenshotsManager.swift b/src/managers/asc/ASCScreenshotsManager.swift index fe47b71..6c9fe87 100644 --- a/src/managers/asc/ASCScreenshotsManager.swift +++ b/src/managers/asc/ASCScreenshotsManager.swift @@ -30,18 +30,14 @@ extension ASCManager { guard let service else { return } if !force, - let cachedSets = screenshotSetsByLocale[locale], - let cachedScreenshots = screenshotsByLocale[locale] { - setActiveScreenshots(locale: locale, sets: cachedSets, screenshots: cachedScreenshots) + screenshotSetsByLocale[locale] != nil, + screenshotsByLocale[locale] != nil { return } await ensureScreenshotLocalizationsLoaded(service: service) guard let loc = localizations.first(where: { $0.attributes.locale == locale }) ?? localizations.first else { - activeScreenshotsLocale = nil - screenshotSets = [] - screenshots = [:] return } @@ -50,39 +46,46 @@ extension ASCManager { localizationId: loc.id, service: service ) - storeScreenshots( - locale: loc.attributes.locale, - sets: fetchedSets, - screenshots: fetchedScreenshots, - makeActive: true - ) + updateScreenshotCache(locale: loc.attributes.locale, sets: fetchedSets, screenshots: fetchedScreenshots) } catch { print("Failed to load screenshots for locale \(loc.attributes.locale): \(error)") } } - func storeScreenshots( - locale: String, - sets: [ASCScreenshotSet], - screenshots: [String: [ASCScreenshot]], - makeActive: Bool - ) { - screenshotSetsByLocale[locale] = sets - screenshotsByLocale[locale] = screenshots - if makeActive { - setActiveScreenshots(locale: locale, sets: sets, screenshots: screenshots) - } - lastScreenshotDataLocale = locale - screenshotDataRevision += 1 + func screenshotSetsForLocale(_ locale: String) -> [ASCScreenshotSet] { + screenshotSetsByLocale[locale] ?? [] + } + + func screenshotsForLocale(_ locale: String) -> [String: [ASCScreenshot]] { + screenshotsByLocale[locale] ?? [:] } - func cacheScreenshots( + func updateScreenshotCache( locale: String, sets: [ASCScreenshotSet], screenshots: [String: [ASCScreenshot]] ) { screenshotSetsByLocale[locale] = sets screenshotsByLocale[locale] = screenshots + for displayType in trackDisplayTypes(for: locale) { + loadTrackFromASC(displayType: displayType, locale: locale) + } + } + + private func trackDisplayTypes(for locale: String) -> Set { + var displayTypes = Set(screenshotSetsForLocale(locale).map(\.attributes.screenshotDisplayType)) + for key in Set(trackSlots.keys).union(savedTrackState.keys) { + if let displayType = displayType(fromTrackKey: key, locale: locale) { + displayTypes.insert(displayType) + } + } + return displayTypes + } + + private func displayType(fromTrackKey key: String, locale: String) -> String? { + let prefix = "\(locale)::" + guard key.hasPrefix(prefix) else { return nil } + return String(key.dropFirst(prefix.count)) } func fetchScreenshotData( @@ -113,14 +116,9 @@ extension ASCManager { locale: String, previousSlots: [TrackSlot?] = [] ) -> [TrackSlot?] { - let localeSets = screenshotSetsByLocale[locale] - ?? ((activeScreenshotsLocale == nil || activeScreenshotsLocale == locale) ? screenshotSets : []) - let localeScreenshots = screenshotsByLocale[locale] - ?? ((activeScreenshotsLocale == nil || activeScreenshotsLocale == locale) ? screenshots : [:]) - - let set = localeSets.first { $0.attributes.screenshotDisplayType == displayType } + let set = screenshotSetsForLocale(locale).first { $0.attributes.screenshotDisplayType == displayType } var slots: [TrackSlot?] = Array(repeating: nil, count: 10) - if let set, let shots = localeScreenshots[set.id] { + if let set, let shots = screenshotsForLocale(locale)[set.id] { for (i, shot) in shots.prefix(10).enumerated() { var localImage: NSImage? = nil if shot.imageURL == nil, i < previousSlots.count, let prev = previousSlots[i] { @@ -179,16 +177,6 @@ extension ASCManager { return padded } - private func setActiveScreenshots( - locale: String, - sets: [ASCScreenshotSet], - screenshots: [String: [ASCScreenshot]] - ) { - activeScreenshotsLocale = locale - screenshotSets = sets - self.screenshots = screenshots - } - private func ensureScreenshotLocalizationsLoaded(service: AppStoreConnectService) async { if localizations.isEmpty, let versionId = appStoreVersions.first?.id { localizations = (try? await service.fetchLocalizations(versionId: versionId)) ?? [] diff --git a/src/managers/asc/ASCStoreListingManager.swift b/src/managers/asc/ASCStoreListingManager.swift index 23f01df..e46f149 100644 --- a/src/managers/asc/ASCStoreListingManager.swift +++ b/src/managers/asc/ASCStoreListingManager.swift @@ -6,60 +6,53 @@ import Foundation extension ASCManager { // MARK: - Locale Selection - func effectiveStoreListingLocale() -> String? { - if let selectedStoreListingLocale, - localizations.contains(where: { $0.attributes.locale == selectedStoreListingLocale }) { - return selectedStoreListingLocale + /// Primary store-listing locale from ASC app settings, falling back to the first loaded localization. + func primaryLocalizationLocale() -> String? { + if let primaryLocale = app?.primaryLocale, + localizations.contains(where: { $0.attributes.locale == primaryLocale }) { + return primaryLocale } return localizations.first?.attributes.locale } - func storeListingLocalization(locale: String? = nil) -> ASCVersionLocalization? { - if let locale, - let localization = localizations.first(where: { $0.attributes.locale == locale }) { - return localization - } - if let effectiveLocale = effectiveStoreListingLocale() { - return localizations.first(where: { $0.attributes.locale == effectiveLocale }) ?? localizations.first - } - return localizations.first + /// Primary version-localization record used for overview/readiness, independent of the active editor locale. + func primaryVersionLocalization(in candidates: [ASCVersionLocalization]? = nil) -> ASCVersionLocalization? { + let candidates = candidates ?? localizations + guard let primaryLocale = app?.primaryLocale else { return candidates.first } + return candidates.first(where: { $0.attributes.locale == primaryLocale }) ?? candidates.first } - func appInfoLocalizationForLocale(_ locale: String? = nil) -> ASCAppInfoLocalization? { - if let locale { - return appInfoLocalizationsByLocale[locale] - } - if let effectiveLocale = effectiveStoreListingLocale() { - return appInfoLocalizationsByLocale[effectiveLocale] - } - return appInfoLocalization - } + /// Primary app-info-localization record used for overview/readiness, independent of the active editor locale. + func primaryAppInfoLocalization(in candidates: [ASCAppInfoLocalization]? = nil) -> ASCAppInfoLocalization? { + let primaryLocale = app?.primaryLocale - func setSelectedStoreListingLocale(_ locale: String?) { - let locales = Set(localizations.map(\.attributes.locale)) - if let locale, locales.contains(locale) { - selectedStoreListingLocale = locale - } else { - selectedStoreListingLocale = localizations.first?.attributes.locale + if let primaryLocale, + let match = candidates?.first(where: { $0.attributes.locale == primaryLocale }) ?? appInfoLocalizationsByLocale[primaryLocale] { + return match } - } - // MARK: - Data Hydration + return candidates?.first ?? appInfoLocalization + } - func applyStoreListingMetadata( - versionLocalizations: [ASCVersionLocalization], - appInfoLocalizations: [ASCAppInfoLocalization] - ) { - localizations = versionLocalizations - appInfoLocalizationsByLocale = Dictionary(uniqueKeysWithValues: appInfoLocalizations.map { - ($0.attributes.locale, $0) - }) + /// Active store-listing locale for the UI/editor, preferring the user's selected locale when it is still valid. + func activeStoreListingLocale() -> String? { + selectedStoreListingLocale.flatMap { locale in + localizations.contains(where: { $0.attributes.locale == locale }) ? locale : nil + } ?? primaryLocalizationLocale() + } - let primaryLocale = versionLocalizations.first?.attributes.locale - appInfoLocalization = primaryLocale.flatMap { appInfoLocalizationsByLocale[$0] } ?? appInfoLocalizations.first + func storeListingLocalization(locale: String? = nil) -> ASCVersionLocalization? { + if let locale { + return localizations.first(where: { $0.attributes.locale == locale }) + } + return primaryVersionLocalization() + } - setSelectedStoreListingLocale(selectedStoreListingLocale) - storeListingDataRevision += 1 + func appInfoLocalizationForLocale(_ locale: String? = nil) -> ASCAppInfoLocalization? { + if let resolvedLocale = locale ?? activeStoreListingLocale() { + return appInfoLocalizationsByLocale[resolvedLocale] + } + return primaryAppInfoLocalization() } func refreshStoreListingMetadata( @@ -94,10 +87,13 @@ extension ASCManager { selectedStoreListingLocale = preferredLocale } - applyStoreListingMetadata( - versionLocalizations: versionLocalizations, - appInfoLocalizations: fetchedAppInfoLocalizations - ) + localizations = versionLocalizations + appInfoLocalizationsByLocale = Dictionary(uniqueKeysWithValues: fetchedAppInfoLocalizations.map { + ($0.attributes.locale, $0) + }) + + appInfoLocalization = primaryAppInfoLocalization(in: fetchedAppInfoLocalizations) + selectedStoreListingLocale = activeStoreListingLocale() } // MARK: - Localization Updates diff --git a/src/managers/asc/ASCSubmissionReadinessManager.swift b/src/managers/asc/ASCSubmissionReadinessManager.swift index f9ff34b..0bb061a 100644 --- a/src/managers/asc/ASCSubmissionReadinessManager.swift +++ b/src/managers/asc/ASCSubmissionReadinessManager.swift @@ -15,27 +15,14 @@ extension ASCManager { } var submissionReadiness: SubmissionReadiness { - let localization = localizations.first - let appInfoLocalization = appInfoLocalization + let localization = primaryVersionLocalization() + let appInfoLocalization = primaryAppInfoLocalization() let review = reviewDetail let demoRequired = review?.attributes.demoAccountRequired == true let version = appStoreVersions.first let readinessLocale = localization?.attributes.locale - let readinessScreenshotSets: [ASCScreenshotSet] - let readinessScreenshots: [String: [ASCScreenshot]] - - if let readinessLocale, - let cachedSets = screenshotSetsByLocale[readinessLocale], - let cachedScreenshots = screenshotsByLocale[readinessLocale] { - readinessScreenshotSets = cachedSets - readinessScreenshots = cachedScreenshots - } else if readinessLocale == nil || activeScreenshotsLocale == readinessLocale { - readinessScreenshotSets = screenshotSets - readinessScreenshots = screenshots - } else { - readinessScreenshotSets = [] - readinessScreenshots = [:] - } + let readinessScreenshotSets = readinessLocale.map(screenshotSetsForLocale) ?? [] + let readinessScreenshots = readinessLocale.map(screenshotsForLocale) ?? [:] let macScreenshots = readinessScreenshotSets.first { $0.attributes.screenshotDisplayType == "APP_DESKTOP" } let isMacApp = macScreenshots != nil diff --git a/src/managers/asc/ASCTabDataManager.swift b/src/managers/asc/ASCTabDataManager.swift index d2e30ac..3f7e3f3 100644 --- a/src/managers/asc/ASCTabDataManager.swift +++ b/src/managers/asc/ASCTabDataManager.swift @@ -120,17 +120,29 @@ extension ASCManager { return loadedProjectId == projectId } + private struct OverviewPrimaryLocalization { + /// ASC localization record ID used in follow-up API calls like `fetchScreenshotSets(localizationId:)`. + let localizationId: String + /// Locale code used when storing overview data in Blitz's locale-keyed caches. + let locale: String + + init?(_ localization: ASCVersionLocalization?) { + guard let localization else { return nil } + localizationId = localization.id + locale = localization.attributes.locale + } + } + private func hydrateOverviewSecondaryData( projectId: String?, appId: String, - firstLocalizationId: String?, - firstLocalizationLocale: String?, + primaryLocalization: OverviewPrimaryLocalization?, appInfoId: String?, service: AppStoreConnectService ) async { - if let firstLocalizationId, let firstLocalizationLocale { + if let primaryLocalization { do { - let fetchedSets = try await service.fetchScreenshotSets(localizationId: firstLocalizationId) + let fetchedSets = try await service.fetchScreenshotSets(localizationId: primaryLocalization.localizationId) let fetchedScreenshots = try await withThrowingTaskGroup(of: (String, [ASCScreenshot]).self) { group in for set in fetchedSets { group.addTask { @@ -147,8 +159,8 @@ extension ASCManager { } guard !Task.isCancelled, isCurrentProject(projectId) else { return } - cacheScreenshots( - locale: firstLocalizationLocale, + updateScreenshotCache( + locale: primaryLocalization.locale, sets: fetchedSets, screenshots: Dictionary(uniqueKeysWithValues: fetchedScreenshots) ) @@ -163,16 +175,22 @@ extension ASCManager { if let appInfoId { async let ageRatingTask: ASCAgeRatingDeclaration? = try? service.fetchAgeRating(appInfoId: appInfoId) - async let appInfoLocalizationTask: ASCAppInfoLocalization? = try? service.fetchAppInfoLocalization(appInfoId: appInfoId) + async let appInfoLocalizationsTask: [ASCAppInfoLocalization]? = try? service.fetchAppInfoLocalizations(appInfoId: appInfoId) let fetchedAgeRating = await ageRatingTask - let fetchedAppInfoLocalization = await appInfoLocalizationTask + let fetchedAppInfoLocalizations = await appInfoLocalizationsTask ?? [] guard !Task.isCancelled, isCurrentProject(projectId) else { return } ageRatingDeclaration = fetchedAgeRating - appInfoLocalization = fetchedAppInfoLocalization + appInfoLocalizationsByLocale = Dictionary(uniqueKeysWithValues: fetchedAppInfoLocalizations.map { + ($0.attributes.locale, $0) + }) + appInfoLocalization = primaryAppInfoLocalization(in: fetchedAppInfoLocalizations) finishOverviewReadinessLoading(Self.overviewMetadataFieldLabels) } else { + ageRatingDeclaration = nil + appInfoLocalizationsByLocale = [:] + appInfoLocalization = nil finishOverviewReadinessLoading(Self.overviewMetadataFieldLabels) } @@ -193,29 +211,6 @@ extension ASCManager { finishOverviewReadinessLoading(Self.overviewPricingFieldLabels) } - private func hydrateScreenshotsSecondaryData( - projectId: String?, - locale: String, - localizationId: String, - service: AppStoreConnectService - ) async { - do { - let (fetchedSets, fetchedScreenshots) = try await fetchScreenshotData( - localizationId: localizationId, - service: service - ) - guard !Task.isCancelled, isCurrentProject(projectId) else { return } - storeScreenshots( - locale: locale, - sets: fetchedSets, - screenshots: fetchedScreenshots, - makeActive: true - ) - } catch { - print("Failed to hydrate screenshots: \(error)") - } - } - private func hydrateReviewSecondaryData( projectId: String?, appId: String, @@ -322,16 +317,16 @@ extension ASCManager { builds = try await buildsTask finishOverviewReadinessLoading(Self.overviewBuildFieldLabels) - var firstLocalizationId: String? - var firstLocalizationLocale: String? + var primaryLocalization: OverviewPrimaryLocalization? if let latestId = versions.first?.id { async let localizationsTask = service.fetchLocalizations(versionId: latestId) async let reviewDetailTask: ASCReviewDetail? = try? service.fetchReviewDetail(versionId: latestId) let fetchedLocalizations = try await localizationsTask localizations = fetchedLocalizations - firstLocalizationId = fetchedLocalizations.first?.id - firstLocalizationLocale = fetchedLocalizations.first?.attributes.locale + primaryLocalization = OverviewPrimaryLocalization( + primaryVersionLocalization(in: fetchedLocalizations) + ) finishOverviewReadinessLoading(Self.overviewLocalizationFieldLabels) reviewDetail = await reviewDetailTask finishOverviewReadinessLoading(Self.overviewReviewFieldLabels) @@ -350,8 +345,7 @@ extension ASCManager { await self.hydrateOverviewSecondaryData( projectId: projectId, appId: appId, - firstLocalizationId: firstLocalizationId, - firstLocalizationLocale: firstLocalizationLocale, + primaryLocalization: primaryLocalization, appInfoId: currentAppInfoId, service: service ) @@ -370,38 +364,22 @@ extension ASCManager { if let latestId = versions.first?.id { let localizations = try await service.fetchLocalizations(versionId: latestId) self.localizations = localizations - let preferredLocale = selectedScreenshotsLocale ?? activeScreenshotsLocale + let preferredLocale = selectedScreenshotsLocale let targetLocalization = localizations.first(where: { $0.attributes.locale == preferredLocale }) ?? localizations.first if let targetLocalization { selectedScreenshotsLocale = targetLocalization.attributes.locale - let projectId = loadedProjectId - startBackgroundHydration(for: .screenshots) { - await self.hydrateScreenshotsSecondaryData( - projectId: projectId, - locale: targetLocalization.attributes.locale, - localizationId: targetLocalization.id, - service: service - ) - } + await loadScreenshots(locale: targetLocalization.attributes.locale, force: true) } else { - screenshotSets = [] - screenshots = [:] screenshotSetsByLocale = [:] screenshotsByLocale = [:] selectedScreenshotsLocale = nil - activeScreenshotsLocale = nil - lastScreenshotDataLocale = nil } } else { localizations = [] - screenshotSets = [] - screenshots = [:] screenshotSetsByLocale = [:] screenshotsByLocale = [:] selectedScreenshotsLocale = nil - activeScreenshotsLocale = nil - lastScreenshotDataLocale = nil } case .appDetails: diff --git a/src/services/mcp/MCPExecutor.swift b/src/services/mcp/MCPExecutor.swift index 99f4a15..aff6fc3 100644 --- a/src/services/mcp/MCPExecutor.swift +++ b/src/services/mcp/MCPExecutor.swift @@ -36,6 +36,31 @@ actor MCPExecutor { self.appState = appState } + func parseFieldMap(_ rawFields: Any?, applyAliases: Bool) -> [String: String] { + var fieldMap: [String: String] = [:] + let mapField: (String) -> String = { field in + applyAliases ? (Self.fieldAliases[field] ?? field) : field + } + + if let fieldsArray = rawFields as? [[String: Any]] { + for item in fieldsArray { + if let field = item["field"] as? String, let value = item["value"] as? String { + fieldMap[mapField(field)] = value + } + } + } else if let fieldsDict = rawFields as? [String: Any] { + for (key, value) in fieldsDict { + fieldMap[mapField(key)] = "\(value)" + } + } else if let fieldsString = rawFields as? String, + let data = fieldsString.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) { + fieldMap = parseFieldMap(parsed, applyAliases: applyAliases) + } + + return fieldMap + } + /// Execute a tool call, requesting approval if needed. func execute(name: String, arguments: [String: Any]) async throws -> [String: Any] { let category = MCPRegistry.category(for: name) @@ -124,27 +149,6 @@ actor MCPExecutor { } if let targetTab { - if targetTab == .storeListing { - let storeListingLocale: String? - if name == "store_listing_switch_localization" { - storeListingLocale = arguments["locale"] as? String - } else if name == "asc_fill_form", (arguments["tab"] as? String) == "storeListing" { - storeListingLocale = arguments["locale"] as? String - } else { - storeListingLocale = nil - } - - if let storeListingLocale { - let trimmedStoreListingLocale = storeListingLocale.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedStoreListingLocale.isEmpty else { - return previousNavigation - } - await MainActor.run { - appState.ascManager.selectedStoreListingLocale = trimmedStoreListingLocale - } - } - } - await MainActor.run { appState.activeTab = targetTab if let targetAppSubTab { @@ -160,32 +164,7 @@ actor MCPExecutor { if name == "asc_fill_form", let tab = arguments["tab"] as? String { - var fieldMap: [String: String] = [:] - if let fieldsArray = arguments["fields"] as? [[String: Any]] { - for item in fieldsArray { - if let field = item["field"] as? String, let value = item["value"] as? String { - fieldMap[field] = value - } - } - } else if let fieldsDict = arguments["fields"] as? [String: Any] { - for (key, value) in fieldsDict { - fieldMap[key] = "\(value)" - } - } else if let fieldsString = arguments["fields"] as? String, - let data = fieldsString.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data) { - if let dict = parsed as? [String: Any] { - for (key, value) in dict { - fieldMap[key] = "\(value)" - } - } else if let array = parsed as? [[String: Any]] { - for item in array { - if let field = item["field"] as? String, let value = item["value"] as? String { - fieldMap[field] = value - } - } - } - } + let fieldMap = parseFieldMap(arguments["fields"], applyAliases: false) if !fieldMap.isEmpty { let fieldMapCopy = fieldMap diff --git a/src/services/mcp/MCPExecutorASC.swift b/src/services/mcp/MCPExecutorASC.swift index 942db1a..19afdcc 100644 --- a/src/services/mcp/MCPExecutorASC.swift +++ b/src/services/mcp/MCPExecutorASC.swift @@ -49,9 +49,7 @@ extension MCPExecutor { } return await MainActor.run { - appState.ascManager.selectedStoreListingLocale - ?? appState.ascManager.localizations.first?.attributes.locale - ?? "en-US" + appState.ascManager.activeStoreListingLocale() ?? "en-US" } } @@ -101,38 +99,12 @@ extension MCPExecutor { let locale = await MainActor.run { appState.ascManager.selectedScreenshotsLocale - ?? appState.ascManager.activeScreenshotsLocale ?? appState.ascManager.localizations.first?.attributes.locale ?? "en-US" } return (locale, false) } - private func validateScreenshotsLocaleSelection( - locale: String, - explicitlyRequested: Bool - ) async -> String? { - await MainActor.run { - guard explicitlyRequested else { return nil } - let selectedLocale = appState.ascManager.selectedScreenshotsLocale - guard selectedLocale == locale else { - return "Error: screenshots locale '\(locale)' is not selected in Blitz. " - + "Call screenshots_switch_localization first." - } - return nil - } - } - - private func prepareScreenshotsTrackIfNeeded(displayType: String, locale: String) async { - await MainActor.run { - let asc = appState.ascManager - if !asc.hasTrackState(displayType: displayType, locale: locale), - asc.selectedScreenshotsLocale == locale || asc.activeScreenshotsLocale == locale { - asc.loadTrackFromASC(displayType: displayType, locale: locale) - } - } - } - func executeASCSetCredentials(_ args: [String: Any]) async -> [String: Any] { guard let issuerId = args["issuerId"] as? String, let keyId = args["keyId"] as? String, @@ -163,37 +135,13 @@ extension MCPExecutor { throw MCPServerService.MCPError.invalidToolArgs } - var fieldMap: [String: String] = [:] - if let fieldsArray = args["fields"] as? [[String: Any]] { - for item in fieldsArray { - if let field = item["field"] as? String, let value = item["value"] as? String { - fieldMap[Self.fieldAliases[field] ?? field] = value - } - } - } else if let fieldsDict = args["fields"] as? [String: Any] { - for (key, value) in fieldsDict { - fieldMap[Self.fieldAliases[key] ?? key] = "\(value)" - } - } else if let fieldsString = args["fields"] as? String, - let data = fieldsString.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data) { - if let dict = parsed as? [String: Any] { - for (key, value) in dict { - fieldMap[Self.fieldAliases[key] ?? key] = "\(value)" - } - } else if let array = parsed as? [[String: Any]] { - for item in array { - if let field = item["field"] as? String, let value = item["value"] as? String { - fieldMap[Self.fieldAliases[field] ?? field] = value - } - } - } - } - + let fieldMap = parseFieldMap(args["fields"], applyAliases: true) guard !fieldMap.isEmpty else { throw MCPServerService.MCPError.invalidToolArgs } + var resolvedStoreListingLocale: String? + if let validFields = Self.validFieldsByTab[tab] { let invalid = fieldMap.keys.filter { !validFields.contains($0) } if !invalid.isEmpty { @@ -219,6 +167,7 @@ extension MCPExecutor { var versionLocFields: [String: String] = [:] var infoLocFields: [String: String] = [:] let locale = await resolveStoreListingLocale(from: args) + resolvedStoreListingLocale = locale if let localeError = await prepareStoreListingLocale(locale) { _ = await MainActor.run { appState.ascManager.pendingFormValues.removeValue(forKey: tab) } @@ -316,8 +265,8 @@ extension MCPExecutor { _ = await MainActor.run { appState.ascManager.pendingFormValues.removeValue(forKey: tab) } var response: [String: Any] = ["success": true, "tab": tab, "fieldsUpdated": fieldMap.count] - if tab == "storeListing" { - response["locale"] = await resolveStoreListingLocale(from: args) + if let resolvedStoreListingLocale { + response["locale"] = resolvedStoreListingLocale } return mcpJSON(response) } @@ -463,11 +412,14 @@ extension MCPExecutor { let displayType = args["displayType"] as? String ?? "APP_IPHONE_67" let (locale, explicitlyRequestedLocale) = await resolveScreenshotsLocale(from: args) - if let selectionError = await validateScreenshotsLocaleSelection( - locale: locale, - explicitlyRequested: explicitlyRequestedLocale - ) { - return mcpText(selectionError) + let selectedLocale = await MainActor.run { + appState.ascManager.selectedScreenshotsLocale ?? appState.ascManager.localizations.first?.attributes.locale + } + if explicitlyRequestedLocale, selectedLocale != locale { + return mcpText( + "Error: screenshots locale '\(locale)' is not selected in Blitz. " + + "Call screenshots_switch_localization first." + ) } guard let projectId = await MainActor.run(body: { appState.activeProjectId }) else { @@ -481,7 +433,13 @@ extension MCPExecutor { return mcpText("Error: asset '\(assetFileName)' not found in local screenshots library") } - await prepareScreenshotsTrackIfNeeded(displayType: displayType, locale: locale) + await MainActor.run { + let asc = appState.ascManager + if !asc.hasTrackState(displayType: displayType, locale: locale), + selectedLocale == locale { + asc.loadTrackFromASC(displayType: displayType, locale: locale) + } + } let trackReady = await MainActor.run { appState.ascManager.hasTrackState(displayType: displayType, locale: locale) } @@ -510,14 +468,23 @@ extension MCPExecutor { let displayType = args["displayType"] as? String ?? "APP_IPHONE_67" let (locale, explicitlyRequestedLocale) = await resolveScreenshotsLocale(from: args) - if let selectionError = await validateScreenshotsLocaleSelection( - locale: locale, - explicitlyRequested: explicitlyRequestedLocale - ) { - return mcpText(selectionError) + let selectedLocale = await MainActor.run { + appState.ascManager.selectedScreenshotsLocale ?? appState.ascManager.localizations.first?.attributes.locale + } + if explicitlyRequestedLocale, selectedLocale != locale { + return mcpText( + "Error: screenshots locale '\(locale)' is not selected in Blitz. " + + "Call screenshots_switch_localization first." + ) } - await prepareScreenshotsTrackIfNeeded(displayType: displayType, locale: locale) + await MainActor.run { + let asc = appState.ascManager + if !asc.hasTrackState(displayType: displayType, locale: locale), + selectedLocale == locale { + asc.loadTrackFromASC(displayType: displayType, locale: locale) + } + } let trackReady = await MainActor.run { appState.ascManager.hasTrackState(displayType: displayType, locale: locale) } diff --git a/src/services/mcp/MCPExecutorTabState.swift b/src/services/mcp/MCPExecutorTabState.swift index e58e1fd..151cdab 100644 --- a/src/services/mcp/MCPExecutorTabState.swift +++ b/src/services/mcp/MCPExecutorTabState.swift @@ -128,7 +128,7 @@ extension MCPExecutor { @MainActor func tabStateStoreListing(_ asc: ASCManager) -> [String: Any] { - let selectedLocale = asc.selectedStoreListingLocale ?? asc.localizations.first?.attributes.locale ?? "" + let selectedLocale = asc.activeStoreListingLocale() ?? "" let localization = asc.storeListingLocalization(locale: selectedLocale) let infoLoc = asc.appInfoLocalizationForLocale(selectedLocale) let localizationState: [String: Any] = [ @@ -230,11 +230,9 @@ extension MCPExecutor { @MainActor func tabStateScreenshots(_ asc: ASCManager) -> [String: Any] { - let selectedLocale = asc.selectedScreenshotsLocale ?? asc.activeScreenshotsLocale ?? asc.localizations.first?.attributes.locale ?? "" - let activeLocale = asc.activeScreenshotsLocale ?? asc.localizations.first?.attributes.locale ?? "" - let dataLocale = asc.screenshotSetsByLocale.keys.contains(selectedLocale) ? selectedLocale : activeLocale - let screenshotSets = asc.screenshotSetsByLocale[dataLocale] ?? asc.screenshotSets - let screenshots = asc.screenshotsByLocale[dataLocale] ?? asc.screenshots + let selectedLocale = asc.selectedScreenshotsLocale ?? asc.localizations.first?.attributes.locale ?? "" + let screenshotSets = asc.screenshotSetsForLocale(selectedLocale) + let screenshots = asc.screenshotsForLocale(selectedLocale) let sets = screenshotSets.map { set -> [String: Any] in var value: [String: Any] = ["id": set.id, "displayType": set.attributes.screenshotDisplayType] if let shots = screenshots[set.id] { @@ -247,8 +245,6 @@ extension MCPExecutor { } return [ "selectedLocale": selectedLocale, - "activeLocale": activeLocale, - "dataLocale": dataLocale, "availableLocales": asc.localizations.map(\.attributes.locale), "screenshotSets": sets, "localeCount": asc.localizations.count diff --git a/src/views/release/ScreenshotsView.swift b/src/views/release/ScreenshotsView.swift index 0fe9000..cbca147 100644 --- a/src/views/release/ScreenshotsView.swift +++ b/src/views/release/ScreenshotsView.swift @@ -13,7 +13,7 @@ private enum ScreenshotDeviceType: String, CaseIterable, Identifiable { var label: String { switch self { - case .iPhone: "iPhone 6.5\"" + case .iPhone: "iPhone 6.7\"" case .iPad: "iPad Pro 12.9\"" case .mac: "Mac" } @@ -70,7 +70,6 @@ struct ScreenshotsView: View { private var asc: ASCManager { appState.ascManager } private var platform: ProjectPlatform { appState.activeProject?.platform ?? .iOS } - @State private var selectedLocale: String = "" @State private var selectedDevice: ScreenshotDeviceType = .iPhone @State private var selectedAssetId: UUID? // selected in asset library @State private var selectedTrackIndex: Int? // selected in track @@ -83,25 +82,31 @@ struct ScreenshotsView: View { ScreenshotDeviceType.types(for: platform) } - private var effectiveLocale: String { - if asc.localizations.contains(where: { $0.attributes.locale == selectedLocale }) { - return selectedLocale - } + private var currentLocale: String { if let selectedScreenshotsLocale = asc.selectedScreenshotsLocale, asc.localizations.contains(where: { $0.attributes.locale == selectedScreenshotsLocale }) { return selectedScreenshotsLocale } - return asc.activeScreenshotsLocale - ?? asc.localizations.first?.attributes.locale - ?? "en-US" + // fallback + return asc.localizations.first?.attributes.locale ?? "en-US" + } + + private var selectedLocaleBinding: Binding { + Binding( + get: { currentLocale }, + set: { newValue in + asc.selectedScreenshotsLocale = newValue + Task { await loadSelectedLocaleData() } + } + ) } private var currentTrack: [TrackSlot?] { - asc.trackSlotsForDisplayType(selectedDevice.ascDisplayType, locale: effectiveLocale) + asc.trackSlotsForDisplayType(selectedDevice.ascDisplayType, locale: currentLocale) } private var hasChanges: Bool { - asc.hasUnsavedChanges(displayType: selectedDevice.ascDisplayType, locale: effectiveLocale) + asc.hasUnsavedChanges(displayType: selectedDevice.ascDisplayType, locale: currentLocale) } private var filledSlotCount: Int { @@ -121,7 +126,7 @@ struct ScreenshotsView: View { Text("Screenshots") .font(.title2.weight(.semibold)) if !asc.localizations.isEmpty { - Picker("Locale", selection: $selectedLocale) { + Picker("Locale", selection: selectedLocaleBinding) { ForEach(asc.localizations) { localization in Text(localization.attributes.locale).tag(localization.attributes.locale) } @@ -154,29 +159,7 @@ struct ScreenshotsView: View { .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { await loadData() } - .onChange(of: selectedLocale) { _, _ in - if asc.selectedScreenshotsLocale != selectedLocale { - asc.selectedScreenshotsLocale = selectedLocale - } - Task { await loadSelectedLocaleData() } - } .onChange(of: selectedDevice) { _, _ in loadTrackForDevice() } - .onChange(of: asc.localizations.count) { _, _ in - syncSelectedLocaleFromAvailable() - } - .onChange(of: asc.selectedScreenshotsLocale) { _, newValue in - guard let newValue else { return } - guard newValue != selectedLocale else { return } - guard asc.localizations.contains(where: { $0.attributes.locale == newValue }) else { return } - selectedLocale = newValue - } - .onChange(of: asc.screenshotDataRevision) { _, _ in - guard asc.lastScreenshotDataLocale == effectiveLocale else { return } - guard !asc.hasUnsavedChanges(displayType: selectedDevice.ascDisplayType, locale: effectiveLocale) else { - return - } - loadTrackForDevice(force: true) - } .alert("Import Error", isPresented: Binding( get: { importError != nil }, set: { if !$0 { importError = nil } } @@ -202,7 +185,7 @@ struct ScreenshotsView: View { Text(device.label) .font(.callout) Spacer() - if asc.hasUnsavedChanges(displayType: device.ascDisplayType, locale: effectiveLocale) { + if asc.hasUnsavedChanges(displayType: device.ascDisplayType, locale: currentLocale) { Circle() .fill(.orange) .frame(width: 6, height: 6) @@ -451,7 +434,7 @@ struct ScreenshotsView: View { private func trackSlotView(index: Int) -> some View { let slot = currentTrack[index] - let saved = asc.savedTrackStateForDisplayType(selectedDevice.ascDisplayType, locale: effectiveLocale)[index] + let saved = asc.savedTrackStateForDisplayType(selectedDevice.ascDisplayType, locale: currentLocale)[index] let isSynced = slot?.id == saved?.id && slot != nil let hasError = slot?.ascScreenshot?.hasError == true @@ -503,7 +486,7 @@ struct ScreenshotsView: View { asc.removeFromTrack( displayType: selectedDevice.ascDisplayType, slotIndex: index, - locale: effectiveLocale + locale: currentLocale ) } if selectedTrackIndex == index { selectedTrackIndex = nil } @@ -553,7 +536,7 @@ struct ScreenshotsView: View { .onDrop(of: [.text], delegate: TrackSlotDropDelegate( targetIndex: index, displayType: selectedDevice.ascDisplayType, - locale: effectiveLocale, + locale: currentLocale, asc: asc, localAssets: asc.localScreenshotAssets, draggedAssetId: $draggedAssetId, @@ -605,41 +588,21 @@ struct ScreenshotsView: View { } await asc.ensureTabData(.screenshots) - syncSelectedLocaleFromAvailable() - await loadSelectedLocaleData() - } - - private func syncSelectedLocaleFromAvailable() { - let locales = Set(asc.localizations.map(\.attributes.locale)) - guard let first = asc.localizations.first?.attributes.locale else { - selectedLocale = "" - if asc.selectedScreenshotsLocale != nil { - asc.selectedScreenshotsLocale = nil - } - return - } - - let preferredLocale = asc.selectedScreenshotsLocale.flatMap { locales.contains($0) ? $0 : nil } - ?? (locales.contains(selectedLocale) ? selectedLocale : first) - - if selectedLocale != preferredLocale { - selectedLocale = preferredLocale - } - if asc.selectedScreenshotsLocale != preferredLocale { - asc.selectedScreenshotsLocale = preferredLocale + if asc.selectedScreenshotsLocale == nil { + asc.selectedScreenshotsLocale = asc.localizations.first?.attributes.locale } + await loadSelectedLocaleData() } private func loadSelectedLocaleData(force: Bool = false) async { - syncSelectedLocaleFromAvailable() - guard !selectedLocale.isEmpty else { return } - await asc.loadScreenshots(locale: selectedLocale, force: force) + guard !currentLocale.isEmpty else { return } + await asc.loadScreenshots(locale: currentLocale, force: force) loadTrackForDevice(force: force) } private func loadTrackForDevice(force: Bool = false) { let displayType = selectedDevice.ascDisplayType - let locale = effectiveLocale + let locale = currentLocale if force || !asc.hasTrackState(displayType: displayType, locale: locale) { asc.loadTrackFromASC(displayType: displayType, locale: locale) } @@ -760,7 +723,7 @@ struct ScreenshotsView: View { private func save() async { await asc.syncTrackToASC( displayType: selectedDevice.ascDisplayType, - locale: effectiveLocale + locale: currentLocale ) } } diff --git a/src/views/release/StoreListingView.swift b/src/views/release/StoreListingView.swift index 7653172..e3fcc7b 100644 --- a/src/views/release/StoreListingView.swift +++ b/src/views/release/StoreListingView.swift @@ -4,7 +4,6 @@ struct StoreListingView: View { var appState: AppState private var asc: ASCManager { appState.ascManager } - @State private var selectedLocale: String = "" @FocusState private var focusedField: String? // Editable field values @@ -19,17 +18,19 @@ struct StoreListingView: View { @State private var privacyPolicyUrl: String = "" @State private var isSaving = false - @State private var lastSavedField: String? - private var effectiveLocale: String { - if asc.localizations.contains(where: { $0.attributes.locale == selectedLocale }) { - return selectedLocale - } - if let selectedStoreListingLocale = asc.selectedStoreListingLocale, - asc.localizations.contains(where: { $0.attributes.locale == selectedStoreListingLocale }) { - return selectedStoreListingLocale - } - return asc.localizations.first?.attributes.locale ?? "" + private var currentLocale: String { + asc.activeStoreListingLocale() ?? "" + } + + private var selectedLocaleBinding: Binding { + Binding( + get: { currentLocale }, + set: { newValue in + asc.selectedStoreListingLocale = newValue + populateCurrentFields() + } + ) } var body: some View { @@ -45,36 +46,17 @@ struct StoreListingView: View { } .task(id: "\(appState.activeProjectId ?? ""):\(asc.credentialActivationRevision)") { await asc.ensureTabData(.storeListing) - syncSelectedLocaleFromAvailable() - populateFields( - from: asc.storeListingLocalization(locale: effectiveLocale), - infoLocalization: asc.appInfoLocalizationForLocale(effectiveLocale) - ) + populateCurrentFields() applyPendingValues() } - .onChange(of: selectedLocale) { _, _ in - asc.setSelectedStoreListingLocale(selectedLocale) - populateFields( - from: asc.storeListingLocalization(locale: effectiveLocale), - infoLocalization: asc.appInfoLocalizationForLocale(effectiveLocale) - ) - } - .onChange(of: asc.selectedStoreListingLocale) { _, newValue in - guard let newValue else { return } - guard asc.localizations.contains(where: { $0.attributes.locale == newValue }) else { return } - guard newValue != selectedLocale else { return } - selectedLocale = newValue - } - .onChange(of: asc.localizations.count) { _, _ in - syncSelectedLocaleFromAvailable() + .onChange(of: asc.selectedStoreListingLocale) { _, _ in + guard focusedField == nil else { return } + populateCurrentFields() } - .onChange(of: asc.storeListingDataRevision) { _, _ in - syncSelectedLocaleFromAvailable() + .onChange(of: asc.isTabLoading(.storeListing)) { wasLoading, isLoading in + guard wasLoading, !isLoading else { return } guard focusedField == nil else { return } - populateFields( - from: asc.storeListingLocalization(locale: effectiveLocale), - infoLocalization: asc.appInfoLocalizationForLocale(effectiveLocale) - ) + populateCurrentFields() } .onDisappear { Task { await flushChanges() } @@ -84,24 +66,20 @@ struct StoreListingView: View { @ViewBuilder private var listingContent: some View { let locales = asc.localizations - let current = asc.storeListingLocalization(locale: effectiveLocale) - let currentAppInfoLocalization = asc.appInfoLocalizationForLocale(effectiveLocale) + let current = asc.storeListingLocalization(locale: currentLocale) let isLoading = asc.isTabLoading(.storeListing) VStack(spacing: 0) { // Toolbar HStack { if !locales.isEmpty { - Picker("Locale", selection: $selectedLocale) { + Picker("Locale", selection: selectedLocaleBinding) { ForEach(locales) { loc in Text(loc.attributes.locale).tag(loc.attributes.locale) } } .pickerStyle(.menu) .frame(width: 160) - .onAppear { - syncSelectedLocaleFromAvailable() - } } Spacer() if isSaving { @@ -156,10 +134,6 @@ struct StoreListingView: View { } } } - .onAppear { - populateFields(from: current, infoLocalization: currentAppInfoLocalization) - applyPendingValues() - } .onChange(of: asc.pendingFormVersion) { _, _ in applyPendingValues() } @@ -170,25 +144,11 @@ struct StoreListingView: View { } } - private func syncSelectedLocaleFromAvailable() { - let locales = Set(asc.localizations.map(\.attributes.locale)) - guard let first = asc.localizations.first?.attributes.locale else { - selectedLocale = "" - if asc.selectedStoreListingLocale != nil { - asc.selectedStoreListingLocale = nil - } - return - } - - let preferredLocale = asc.selectedStoreListingLocale.flatMap { locales.contains($0) ? $0 : nil } - ?? (locales.contains(selectedLocale) ? selectedLocale : first) - - if selectedLocale != preferredLocale { - selectedLocale = preferredLocale - } - if asc.selectedStoreListingLocale != preferredLocale { - asc.selectedStoreListingLocale = preferredLocale - } + private func populateCurrentFields() { + populateFields( + from: asc.storeListingLocalization(locale: currentLocale), + infoLocalization: asc.appInfoLocalizationForLocale(currentLocale) + ) } private func populateFields(from loc: ASCVersionLocalization?, infoLocalization: ASCAppInfoLocalization?) { @@ -245,9 +205,9 @@ struct StoreListingView: View { isSaving = true if Self.appInfoLocFields.contains(field) { // These fields live on appInfoLocalizations, not version localizations - await asc.updateAppInfoLocalizationField(field, value: value, locale: effectiveLocale) + await asc.updateAppInfoLocalizationField(field, value: value, locale: currentLocale) } else { - await asc.updateLocalizationField(field, value: value, locale: effectiveLocale) + await asc.updateLocalizationField(field, value: value, locale: currentLocale) } isSaving = false } From 93aceffa7ba351d74d82969a236ae8be02196f45 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Thu, 26 Mar 2026 16:55:02 -0700 Subject: [PATCH 51/51] localization --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3504ac0..3346f4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Project switching performance improvements - Share ASC auth between asc-cli and Blitz MCP tools - Improved release/update reliability and simulator behavior +- Localization fixes (screenshot, store listing, overview tab) ## 1.0.29 - Faster App Store Connect setup with improved onboarding, credential entry, and bundle ID guidance