From 43b5bd272738e6a5c934483c5426f69484ff3ed3 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Mon, 2 Feb 2026 18:09:49 +0900 Subject: [PATCH 1/9] feat(push): integrate iOS setup with push notification wizard Add comprehensive push notification setup workflow: - Create PushSetupWizard with P8 file detection and validation - Add APNS key creation guide with Apple Developer Portal integration - Implement Firebase OAuth authentication for project selection - Auto-detect Team ID from Xcode project settings (DEVELOPMENT_TEAM) - Create GuidedSetupWizard for NSE file generation guidance - Integrate /ios-setup with /push-setup into unified IosSetupFlow - Add P8 file auto-discovery in current directory --- src/commands/ios-setup/index.tsx | 191 +++- src/lib/commands/__tests__/registry.test.ts | 19 + src/lib/commands/ios-setup.tsx | 36 + src/lib/commands/registry.ts | 2 + src/lib/ios/entitlements-manager.ts | 4 +- src/lib/ios/extension-generator.ts | 140 +++ src/lib/ios/extension-templates.ts | 49 + src/lib/ios/index.ts | 20 +- src/lib/ios/project-analyzer.ts | 27 + src/lib/push/__tests__/p8-validator.test.ts | 163 +++ src/lib/push/constants.ts | 47 + src/lib/push/index.ts | 9 + src/lib/push/p8-validator.ts | 153 +++ src/lib/push/types.ts | 55 + src/lib/services/firebase/types.ts | 2 + src/ui/IosSetupUI.tsx | 72 +- src/ui/chat/ChatApp.tsx | 8 + src/ui/chat/hooks/useCommandHandler.ts | 7 + src/ui/chat/hooks/useOverlays.ts | 15 + src/ui/components/GuidedSetupWizard.tsx | 405 +++++++ src/ui/components/IosSetupFlow.tsx | 286 +++++ src/ui/components/PushSetupWizard.tsx | 1138 +++++++++++++++++++ 22 files changed, 2799 insertions(+), 49 deletions(-) create mode 100644 src/lib/commands/ios-setup.tsx create mode 100644 src/lib/ios/extension-generator.ts create mode 100644 src/lib/ios/extension-templates.ts create mode 100644 src/lib/push/__tests__/p8-validator.test.ts create mode 100644 src/lib/push/constants.ts create mode 100644 src/lib/push/index.ts create mode 100644 src/lib/push/p8-validator.ts create mode 100644 src/lib/push/types.ts create mode 100644 src/ui/components/GuidedSetupWizard.tsx create mode 100644 src/ui/components/IosSetupFlow.tsx create mode 100644 src/ui/components/PushSetupWizard.tsx diff --git a/src/commands/ios-setup/index.tsx b/src/commands/ios-setup/index.tsx index 1d4107b..3024b8e 100644 --- a/src/commands/ios-setup/index.tsx +++ b/src/commands/ios-setup/index.tsx @@ -1,8 +1,12 @@ -import { render } from 'ink'; -import type { AgentInfo } from '../../lib/agents'; -import type { AgentExecutor, AgentMessage } from '../../lib/executor'; -import { generateAgentPrompt } from '../../lib/ios'; -import { AgentExecutionUI } from '../../ui/AgentExecutionUI'; +import { Box, render, Text, useInput } from 'ink'; +import type React from 'react'; +import type { PushSetupResult } from '../../lib/push'; +import { + type GuidedSetupContext, + type GuidedSetupResult, + GuidedSetupWizard, +} from '../../ui/components/GuidedSetupWizard'; +import { PushSetupWizard } from '../../ui/components/PushSetupWizard'; import { type IosSetupOptions, type IosSetupResult, IosSetupUI } from '../../ui/IosSetupUI'; import { type FinalOutputResult, printFinalOutput } from '../../ui/utils/finalOutput'; @@ -47,7 +51,7 @@ function toDirectSetupOutput(result: IosSetupResult): FinalOutputResult { type: 'success', title: 'Direct setup completed', message: result.agentContext - ? 'Portal sync and entitlements configured. Starting agent for remaining tasks...' + ? 'Portal sync and entitlements configured. Proceeding to extension setup...' : 'Portal sync and entitlements configured.', details: details.length > 0 ? details : undefined, }; @@ -88,42 +92,164 @@ async function runDirectSetup(options: IosSetupCommandOptions): Promise { +): Promise { if (!directResult.agentContext) { return undefined; } - const agentPrompt = generateAgentPrompt(directResult.agentContext); + const context: GuidedSetupContext = { + bundleId: directResult.agentContext.bundleId, + appGroupId: directResult.agentContext.appGroupId, + appName: directResult.agentContext.appName, + iosDir: directResult.agentContext.iosDir, + entitlementsPath: directResult.agentContext.entitlementsPath, + }; - // Create execute function for agent - async function* executeAgent( - executor: AgentExecutor, - _agent: AgentInfo, - ): AsyncGenerator { - yield* executor.execute(agentPrompt); - } + return new Promise((resolve) => { + const { unmount } = render( + { + unmount(); + resolve(result); + }} + />, + { incrementalRendering: true }, + ); + }); +} + +/** + * Confirmation prompt component for push setup + */ +function PushSetupConfirmation({ + onYes, + onNo, +}: { + onYes: () => void; + onNo: () => void; +}): React.ReactElement { + useInput((input, key) => { + if (input.toLowerCase() === 'y' || key.return) { + onYes(); + } else if (input.toLowerCase() === 'n' || key.escape) { + onNo(); + } + }); + + return ( + + + ✓ iOS setup completed! + + + + Set up APNS key for Firebase push notifications? [Y/n] + + + + ); +} + +/** + * Ask user if they want to set up push notifications + */ +async function askPushSetupConfirmation(): Promise { + return new Promise((resolve) => { + const { unmount } = render( + { + unmount(); + resolve(true); + }} + onNo={() => { + unmount(); + resolve(false); + }} + />, + { incrementalRendering: true }, + ); + }); +} + +/** + * Run the push setup wizard (Phase 3) + */ +async function runPushSetup(directResult: IosSetupResult): Promise { + const projectPath = process.cwd(); return new Promise((resolve) => { const { unmount } = render( - { unmount(); resolve(result); }} + onCancel={() => { + unmount(); + resolve(null); + }} />, { incrementalRendering: true }, ); }); } -export async function iosSetupCommand(options: IosSetupCommandOptions): Promise { +/** + * Print consolidated output for all phases + */ +function printConsolidatedOutput( + directResult: IosSetupResult, + guidedResult: GuidedSetupResult | undefined, + pushResult: PushSetupResult | null, +): void { + console.log('\n'); + console.log('═══════════════════════════════════════════════'); + console.log(' iOS Push Setup Complete! '); + console.log('═══════════════════════════════════════════════'); + console.log(''); + + // Phase 1 summary + if (directResult.success) { + console.log('✓ Capabilities configured'); + console.log('✓ Entitlements created'); + } + + // Phase 2 summary + if (guidedResult?.success) { + console.log('✓ Extension files created'); + if (guidedResult.createdFiles.length > 0) { + for (const file of guidedResult.createdFiles) { + console.log(` • ${file}`); + } + } + } + + // Phase 3 summary + if (pushResult?.success) { + console.log('✓ APNS key registered with Firebase'); + if (pushResult.context?.pushKey) { + console.log(` Key ID: ${pushResult.context.pushKey.apnsKeyId}`); + console.log(` Team ID: ${pushResult.context.pushKey.teamId}`); + } + } else if (pushResult === null) { + console.log('○ APNS key setup skipped'); + } + + console.log(''); + console.log('Your iOS app is ready to receive push notifications!'); + console.log(''); +} + +export async function runIosSetupCommand(options: IosSetupCommandOptions): Promise { // Phase 1: Direct implementation (Portal sync + Entitlements) const directResult = await runDirectSetup(options); @@ -135,13 +261,24 @@ export async function iosSetupCommand(options: IosSetupCommandOptions): Promise< // Show direct setup completion printFinalOutput(toDirectSetupOutput(directResult)); - // Phase 2: Agent completion (Xcode project modifications, Extension setup) + // Phase 2: Guided setup (Extension file generation + Xcode configuration guide) + let guidedResult: GuidedSetupResult | undefined; if (directResult.agentContext) { - console.log('\n'); // Add spacing before agent phase - const agentResult = await runAgentCompletion(directResult); + console.log('\n'); // Add spacing before guided setup phase + guidedResult = await runGuidedSetup(directResult); + } + + // Phase 3: Push setup (optional - APNS key + Firebase) + // Only ask if Phase 1 & 2 were successful + if (directResult.success && (!guidedResult || guidedResult.success)) { + console.log('\n'); // Add spacing before push setup prompt + const shouldSetupPush = await askPushSetupConfirmation(); - if (agentResult) { - printFinalOutput(agentResult); + if (shouldSetupPush) { + const pushResult = await runPushSetup(directResult); + printConsolidatedOutput(directResult, guidedResult, pushResult); + } else { + printConsolidatedOutput(directResult, guidedResult, null); } } } diff --git a/src/lib/commands/__tests__/registry.test.ts b/src/lib/commands/__tests__/registry.test.ts index e823367..46cd257 100644 --- a/src/lib/commands/__tests__/registry.test.ts +++ b/src/lib/commands/__tests__/registry.test.ts @@ -145,6 +145,25 @@ describe('Command Registry', () => { const transfer = getCommand('transfer'); expect(transfer?.aliases).toContain('t'); }); + + test('should have ios-setup command with aliases', () => { + const iosSetup = getCommand('ios-setup'); + expect(iosSetup).toBeDefined(); + expect(iosSetup?.name).toBe('ios-setup'); + expect(iosSetup?.aliases).toContain('capabilities'); + expect(iosSetup?.aliases).toContain('ios-capabilities'); + }); + + test('should find ios-setup by alias', () => { + const iosSetup = getCommand('capabilities'); + expect(iosSetup).toBeDefined(); + expect(iosSetup?.name).toBe('ios-setup'); + }); + + test('ios-setup should be local-jsx type not skill', () => { + const iosSetup = getCommand('ios-setup'); + expect(iosSetup?.type).toBe('local-jsx'); + }); }); describe('skill commands', () => { diff --git a/src/lib/commands/ios-setup.tsx b/src/lib/commands/ios-setup.tsx new file mode 100644 index 0000000..15d16f0 --- /dev/null +++ b/src/lib/commands/ios-setup.tsx @@ -0,0 +1,36 @@ +/** + * iOS Setup command - Configure iOS capabilities, NSE, and APNS key. + * + * @module commands/ios-setup + */ + +import type { ReactNode } from 'react'; +import { runIosSetupCommand } from '@/commands/ios-setup/index'; +import type { Command, CommandDoneCallback } from './types'; + +/** + * iOS Setup command for chat mode. + * Delegates to the CLI implementation. + */ +export const iosSetupCommand: Command = { + type: 'local-jsx', + name: 'ios-setup', + description: 'Configure iOS capabilities, NSE, and APNS key for push notifications', + aliases: ['capabilities', 'ios-capabilities'], + isEnabled: true, + isHidden: false, + + userFacingName() { + return '/ios-setup'; + }, + + async call(onDone: CommandDoneCallback): Promise { + // Run the iOS setup command (skipPortal=true by default for chat mode) + await runIosSetupCommand({ + skipPortal: true, + }); + + onDone?.('iOS setup complete'); + return null; + }, +}; diff --git a/src/lib/commands/registry.ts b/src/lib/commands/registry.ts index b48fe83..0f50d11 100644 --- a/src/lib/commands/registry.ts +++ b/src/lib/commands/registry.ts @@ -12,6 +12,7 @@ import { exitCommand } from './exit'; import { firebaseCommand } from './firebase'; import { helpCommand } from './help'; import { installMcpCommand } from './install-mcp'; +import { iosSetupCommand } from './ios-setup'; import { loginCommand } from './login'; import { logoutCommand } from './logout'; import { newCommand } from './new'; @@ -34,6 +35,7 @@ const BUILT_IN_COMMANDS: Command[] = [ agentCommand, debugCommand, firebaseCommand, + iosSetupCommand, transferCommand, resumeCommand, installMcpCommand, diff --git a/src/lib/ios/entitlements-manager.ts b/src/lib/ios/entitlements-manager.ts index 89b5050..c4864ed 100644 --- a/src/lib/ios/entitlements-manager.ts +++ b/src/lib/ios/entitlements-manager.ts @@ -1,6 +1,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import plist from '@expo/plist'; +import plist, { type PlistValue } from 'plist'; export interface EntitlementsConfig { /** Push notification environment: development or production */ @@ -39,7 +39,7 @@ export async function writeEntitlements( entitlementsPath: string, data: EntitlementsData, ): Promise { - const content = plist.build(data); + const content = plist.build(data as PlistValue); // Ensure directory exists const dir = path.dirname(entitlementsPath); diff --git a/src/lib/ios/extension-generator.ts b/src/lib/ios/extension-generator.ts new file mode 100644 index 0000000..dcd667c --- /dev/null +++ b/src/lib/ios/extension-generator.ts @@ -0,0 +1,140 @@ +/** + * Notification Service Extension file generator + * Creates the necessary files for NSE without requiring AI agent + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { generateExtensionEntitlements, writeEntitlements } from './entitlements-manager'; +import { + EXTENSION_INFO_PLIST_TEMPLATE, + NOTIFICATION_SERVICE_TEMPLATE, +} from './extension-templates'; + +export interface ExtensionContext { + appName: string; + bundleId: string; + iosDir: string; + pushEnvironment?: 'development' | 'production'; +} + +export interface ExtensionGeneratorResult { + success: boolean; + createdFiles: string[]; + extensionDir: string; + extensionName: string; + error?: string; +} + +/** + * Get the extension name from app name + */ +export function getExtensionName(appName: string): string { + return `${appName}NotificationServiceExtension`; +} + +/** + * Get the extension bundle ID from main app bundle ID + */ +export function getExtensionBundleId(bundleId: string, appName: string): string { + return `${bundleId}.${getExtensionName(appName)}`; +} + +/** + * Check if extension files already exist + */ +export function extensionFilesExist(iosDir: string, appName: string): boolean { + const extensionName = getExtensionName(appName); + const extensionDir = path.join(iosDir, extensionName); + const swiftPath = path.join(extensionDir, 'NotificationService.swift'); + + return fs.existsSync(swiftPath); +} + +/** + * Create Notification Service Extension files + */ +export async function createExtensionFiles( + context: ExtensionContext, +): Promise { + const extensionName = getExtensionName(context.appName); + const extensionDir = path.join(context.iosDir, extensionName); + const createdFiles: string[] = []; + const pushEnvironment = context.pushEnvironment || 'development'; + + try { + // 1. Create extension directory + if (!fs.existsSync(extensionDir)) { + fs.mkdirSync(extensionDir, { recursive: true }); + } + + // 2. Create NotificationService.swift + const swiftPath = path.join(extensionDir, 'NotificationService.swift'); + if (!fs.existsSync(swiftPath)) { + fs.writeFileSync(swiftPath, NOTIFICATION_SERVICE_TEMPLATE.trim()); + createdFiles.push(swiftPath); + } + + // 3. Create Info.plist + const plistPath = path.join(extensionDir, 'Info.plist'); + if (!fs.existsSync(plistPath)) { + fs.writeFileSync(plistPath, EXTENSION_INFO_PLIST_TEMPLATE.trim()); + createdFiles.push(plistPath); + } + + // 4. Create extension entitlements + const entitlementsPath = path.join(extensionDir, `${extensionName}.entitlements`); + if (!fs.existsSync(entitlementsPath)) { + const entitlements = generateExtensionEntitlements(context.bundleId, pushEnvironment); + await writeEntitlements(entitlementsPath, entitlements); + createdFiles.push(entitlementsPath); + } + + return { + success: true, + createdFiles, + extensionDir, + extensionName, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + createdFiles, + extensionDir, + extensionName, + error: message, + }; + } +} + +/** + * Verify extension files are complete + */ +export function verifyExtensionFiles( + iosDir: string, + appName: string, +): { complete: boolean; missingFiles: string[] } { + const extensionName = getExtensionName(appName); + const extensionDir = path.join(iosDir, extensionName); + + const requiredFiles = [ + 'NotificationService.swift', + 'Info.plist', + `${extensionName}.entitlements`, + ]; + + const missingFiles: string[] = []; + + for (const file of requiredFiles) { + const filePath = path.join(extensionDir, file); + if (!fs.existsSync(filePath)) { + missingFiles.push(file); + } + } + + return { + complete: missingFiles.length === 0, + missingFiles, + }; +} diff --git a/src/lib/ios/extension-templates.ts b/src/lib/ios/extension-templates.ts new file mode 100644 index 0000000..81ac90b --- /dev/null +++ b/src/lib/ios/extension-templates.ts @@ -0,0 +1,49 @@ +/** + * Templates for Notification Service Extension files + */ + +/** + * NotificationService.swift template for Clix SDK integration + */ +export const NOTIFICATION_SERVICE_TEMPLATE = `import UserNotifications +import Clix + +class NotificationService: ClixNotificationServiceExtension { + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + // Register with Clix (replace with your project ID from https://console.clix.so/) + register(projectId: "YOUR_PROJECT_ID") + + super.didReceive(request, withContentHandler: contentHandler) + } +} +`; + +/** + * Extension Info.plist template + */ +export const EXTENSION_INFO_PLIST_TEMPLATE = ` + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + +`; + +/** + * CocoaPods Podfile snippet for extension target + */ +export function generatePodfileSnippet(extensionName: string): string { + return `target '${extensionName}' do + pod 'Clix' +end`; +} diff --git a/src/lib/ios/index.ts b/src/lib/ios/index.ts index 6e5df3d..74c9671 100644 --- a/src/lib/ios/index.ts +++ b/src/lib/ios/index.ts @@ -1,11 +1,7 @@ // iOS project analysis -// Agent prompt generation for remaining tasks -export { - type AgentContext, - buildAgentContext, - generateAgentPrompt, -} from './agent-prompt-generator'; +// Agent context (used to pass setup context between phases) +export { type AgentContext, buildAgentContext } from './agent-prompt-generator'; // Apple Developer Portal integration export { @@ -20,7 +16,6 @@ export { syncCapabilities, validateCredentials, } from './apple-portal'; - // Entitlements management export { type EntitlementsConfig, @@ -34,6 +29,17 @@ export { updateEntitlementsForClix, writeEntitlements, } from './entitlements-manager'; +// Extension file generation (replaces agent-based approach) +export { + createExtensionFiles, + type ExtensionContext, + type ExtensionGeneratorResult, + extensionFilesExist, + getExtensionBundleId, + getExtensionName, + verifyExtensionFiles, +} from './extension-generator'; +export { generatePodfileSnippet } from './extension-templates'; export { analyzeIosProject, findEntitlementsFiles, diff --git a/src/lib/ios/project-analyzer.ts b/src/lib/ios/project-analyzer.ts index f5fbc37..e48defc 100644 --- a/src/lib/ios/project-analyzer.ts +++ b/src/lib/ios/project-analyzer.ts @@ -14,6 +14,8 @@ export interface IosProjectInfo { targets: string[]; /** Existing entitlements file paths */ entitlementsFiles: string[]; + /** Apple Team ID from project settings (DEVELOPMENT_TEAM) */ + teamId?: string; } export interface ProjectAnalysisResult { @@ -65,6 +67,9 @@ export async function analyzeIosProject(cwd: string): Promise { + let tempDir: string; + + beforeAll(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'p8-test-')); + }); + + afterAll(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('validateP8File', () => { + test('should validate a valid P8 file', () => { + const validP8Content = `-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg... +-----END PRIVATE KEY-----`; + const filePath = path.join(tempDir, 'AuthKey_ABCD123456.p8'); + fs.writeFileSync(filePath, validP8Content); + + const result = validateP8File(filePath); + expect(result.valid).toBe(true); + expect(result.content).toBe(validP8Content); + expect(result.suggestedKeyId).toBe('ABCD123456'); + }); + + test('should return error for non-existent file', () => { + const result = validateP8File('/nonexistent/path/file.p8'); + expect(result.valid).toBe(false); + expect(result.error).toContain('File not found'); + }); + + test('should return error for invalid file format (missing BEGIN)', () => { + const invalidContent = `-----END PRIVATE KEY-----`; + const filePath = path.join(tempDir, 'invalid-begin.p8'); + fs.writeFileSync(filePath, invalidContent); + + const result = validateP8File(filePath); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid P8 file format'); + }); + + test('should return error for invalid file format (missing END)', () => { + const invalidContent = `-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg...`; + const filePath = path.join(tempDir, 'invalid-end.p8'); + fs.writeFileSync(filePath, invalidContent); + + const result = validateP8File(filePath); + expect(result.valid).toBe(false); + expect(result.error).toContain('Private key is incomplete'); + }); + + test('should handle ~ expansion in path', () => { + // This test ensures the ~ is handled, even if it returns an error for the path + const result = validateP8File('~/nonexistent.p8'); + expect(result.valid).toBe(false); + expect(result.error).toContain('File not found'); + }); + }); + + describe('extractKeyIdFromFilename', () => { + test('should extract Key ID from AuthKey_XXXXXXXXXX.p8 format', () => { + expect(extractKeyIdFromFilename('AuthKey_ABCD123456.p8')).toBe('ABCD123456'); + }); + + test('should handle lowercase extension', () => { + expect(extractKeyIdFromFilename('AuthKey_ABCD123456.P8')).toBe('ABCD123456'); + }); + + test('should return null for non-matching filename', () => { + expect(extractKeyIdFromFilename('my-key.p8')).toBeNull(); + }); + + test('should return null for wrong Key ID length', () => { + expect(extractKeyIdFromFilename('AuthKey_ABC.p8')).toBeNull(); + }); + + test('should return null for non-p8 file', () => { + expect(extractKeyIdFromFilename('AuthKey_ABCD123456.txt')).toBeNull(); + }); + }); + + describe('validateKeyId', () => { + test('should validate 10 character alphanumeric Key ID', () => { + expect(validateKeyId('ABCD123456')).toBe(true); + }); + + test('should reject Key ID shorter than 10 characters', () => { + expect(validateKeyId('ABC123')).toBe(false); + }); + + test('should reject Key ID longer than 10 characters', () => { + expect(validateKeyId('ABCD12345678')).toBe(false); + }); + + test('should reject Key ID with special characters', () => { + expect(validateKeyId('ABCD!@#$56')).toBe(false); + }); + + test('should be case-insensitive', () => { + expect(validateKeyId('abcd123456')).toBe(true); + }); + }); + + describe('validateTeamId', () => { + test('should validate 10 character alphanumeric Team ID', () => { + expect(validateTeamId('TEAMID1234')).toBe(true); + }); + + test('should reject Team ID shorter than 10 characters', () => { + expect(validateTeamId('TEAM')).toBe(false); + }); + + test('should reject Team ID longer than 10 characters', () => { + expect(validateTeamId('TEAMID123456')).toBe(false); + }); + }); + + describe('getKeyIdError', () => { + test('should return error for empty Key ID', () => { + expect(getKeyIdError('')).toBe('Key ID is required'); + }); + + test('should return error for invalid format', () => { + expect(getKeyIdError('ABC')).toBe('Key ID must be 10 alphanumeric characters'); + }); + + test('should return null for valid Key ID', () => { + expect(getKeyIdError('ABCD123456')).toBeNull(); + }); + }); + + describe('getTeamIdError', () => { + test('should return error for empty Team ID', () => { + expect(getTeamIdError('')).toBe('Team ID is required'); + }); + + test('should return error for invalid format', () => { + expect(getTeamIdError('TEAM')).toBe('Team ID must be 10 alphanumeric characters'); + }); + + test('should return null for valid Team ID', () => { + expect(getTeamIdError('TEAMID1234')).toBeNull(); + }); + }); +}); diff --git a/src/lib/push/constants.ts b/src/lib/push/constants.ts new file mode 100644 index 0000000..ddbbb9a --- /dev/null +++ b/src/lib/push/constants.ts @@ -0,0 +1,47 @@ +/** + * Constants for iOS Push Notification setup. + * + * @module push/constants + */ + +/** + * URLs for push notification setup. + */ +export const PUSH_SETUP_URLS = { + /** Apple Developer Portal - Keys list */ + appleKeysPortal: 'https://developer.apple.com/account/resources/authkeys/list', + /** Apple Developer Portal - Create new key */ + appleCreateKey: 'https://developer.apple.com/account/resources/authkeys/add', + /** Apple Developer Portal - Team ID (Membership details) */ + appleTeamId: 'https://developer.apple.com/account#MembershipDetailsCard', + /** Firebase Console - Cloud Messaging settings */ + firebaseConsole: (projectId: string) => + `https://console.firebase.google.com/project/${projectId}/settings/cloudmessaging`, + /** Firebase Console - Project settings (fallback if no project ID) */ + firebaseConsoleGeneric: 'https://console.firebase.google.com/', +} as const; + +/** + * APNS Key creation steps for Apple Developer Portal. + */ +export const APNS_KEY_CREATION_STEPS = [ + 'Click "+" to create a new key', + 'Enter a key name (e.g., "Push Notifications Key")', + 'Check "Apple Push Notifications service (APNs)"', + 'Click "Continue" then "Register"', + 'Download the .p8 file (you can only download once!)', + 'Note the Key ID shown on the page', + 'Copy the .p8 file to this project directory', +] as const; + +/** + * Firebase upload steps. + */ +export const FIREBASE_UPLOAD_STEPS = [ + 'Go to "iOS app configuration" section', + 'Click "Upload" under APNs Authentication Key', + 'Select your .p8 file', + 'Enter the Key ID', + 'Enter your Team ID', + 'Click "Upload"', +] as const; diff --git a/src/lib/push/index.ts b/src/lib/push/index.ts new file mode 100644 index 0000000..040d8ae --- /dev/null +++ b/src/lib/push/index.ts @@ -0,0 +1,9 @@ +/** + * iOS Push Notification setup module. + * + * @module push + */ + +export * from './constants'; +export * from './p8-validator'; +export * from './types'; diff --git a/src/lib/push/p8-validator.ts b/src/lib/push/p8-validator.ts new file mode 100644 index 0000000..8b83587 --- /dev/null +++ b/src/lib/push/p8-validator.ts @@ -0,0 +1,153 @@ +/** + * P8 file validation utilities for APNS keys. + * + * @module push/p8-validator + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * P8 file validation result. + */ +export interface P8ValidationResult { + valid: boolean; + content?: string; + suggestedKeyId?: string; + error?: string; +} + +/** + * Validate a P8 file. + * + * @param filePath - Path to the P8 file + * @returns Validation result + */ +export function validateP8File(filePath: string): P8ValidationResult { + // Expand ~ to home directory + const expandedPath = filePath.startsWith('~') + ? path.join(process.env.HOME || '', filePath.slice(1)) + : filePath; + + // Check file exists + if (!fs.existsSync(expandedPath)) { + return { + valid: false, + error: `File not found: ${filePath}`, + }; + } + + // Read file content + let content: string; + try { + content = fs.readFileSync(expandedPath, 'utf-8'); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + // Check for macOS permission error + if (errorMessage.includes('EPERM') || errorMessage.includes('operation not permitted')) { + return { + valid: false, + error: + `Permission denied: Cannot read file from this location.\n` + + ` Try copying the file to your project directory:\n` + + ` cp "${expandedPath}" ./\n` + + ` Then enter: ./${path.basename(expandedPath)}`, + }; + } + return { + valid: false, + error: `Failed to read file: ${errorMessage}`, + }; + } + + // Validate content format + if (!content.includes('-----BEGIN PRIVATE KEY-----')) { + return { + valid: false, + error: 'Invalid P8 file format. File should contain a private key.', + }; + } + + if (!content.includes('-----END PRIVATE KEY-----')) { + return { + valid: false, + error: 'Invalid P8 file format. Private key is incomplete.', + }; + } + + // Try to extract Key ID from filename + const filename = path.basename(expandedPath); + const suggestedKeyId = extractKeyIdFromFilename(filename); + + return { + valid: true, + content, + suggestedKeyId: suggestedKeyId || undefined, + }; +} + +/** + * Extract Key ID from Apple's default filename format. + * Apple names downloaded keys as: AuthKey_XXXXXXXXXX.p8 + * + * @param filename - Filename to extract from + * @returns Key ID or null if not found + */ +export function extractKeyIdFromFilename(filename: string): string | null { + const match = filename.match(/AuthKey_([A-Z0-9]{10})\.p8$/i); + return match ? match[1].toUpperCase() : null; +} + +/** + * Validate Key ID format. + * Key ID should be 10 alphanumeric characters. + * + * @param keyId - Key ID to validate + * @returns True if valid + */ +export function validateKeyId(keyId: string): boolean { + return /^[A-Z0-9]{10}$/i.test(keyId); +} + +/** + * Validate Team ID format. + * Team ID should be 10 alphanumeric characters. + * + * @param teamId - Team ID to validate + * @returns True if valid + */ +export function validateTeamId(teamId: string): boolean { + return /^[A-Z0-9]{10}$/i.test(teamId); +} + +/** + * Get validation error message for Key ID. + * + * @param keyId - Key ID to validate + * @returns Error message or null if valid + */ +export function getKeyIdError(keyId: string): string | null { + if (!keyId) { + return 'Key ID is required'; + } + if (!validateKeyId(keyId)) { + return 'Key ID must be 10 alphanumeric characters'; + } + return null; +} + +/** + * Get validation error message for Team ID. + * + * @param teamId - Team ID to validate + * @returns Error message or null if valid + */ +export function getTeamIdError(teamId: string): string | null { + if (!teamId) { + return 'Team ID is required'; + } + if (!validateTeamId(teamId)) { + return 'Team ID must be 10 alphanumeric characters'; + } + return null; +} diff --git a/src/lib/push/types.ts b/src/lib/push/types.ts new file mode 100644 index 0000000..9c36647 --- /dev/null +++ b/src/lib/push/types.ts @@ -0,0 +1,55 @@ +/** + * Types for iOS Push Notification setup. + * + * @module push/types + */ + +/** + * APNS Push Key information. + * Based on EAS CLI's PushKey type. + */ +export interface ApnsPushKey { + /** P8 file content */ + apnsKeyP8: string; + /** 10-character Key ID (e.g., ABCD123456) */ + apnsKeyId: string; + /** Apple Team ID */ + teamId: string; +} + +/** + * Push setup wizard context. + */ +export interface PushSetupContext { + // Project info + bundleId: string | null; + firebaseProjectId: string | null; + // APNS Key info + pushKey: ApnsPushKey | null; + p8FilePath: string | null; +} + +/** + * Push setup wizard phases. + */ +export type PushSetupPhase = + | 'detecting' // Analyzing project + | 'status' // Showing current status + | 'key_source' // Asking if user has existing key + | 'apple_guide' // Apple Portal guide (optional) + | 'p8_input' // P8 + Key ID + Team ID input + | 'validation' // Validating inputs + | 'firebase_auth' // Authenticating with Firebase + | 'firebase_projects' // Selecting Firebase project + | 'firebase_upload' // Firebase upload guide + | 'complete' // Setup complete + | 'error'; // Error state + +/** + * Push setup result. + */ +export interface PushSetupResult { + success: boolean; + message: string; + context?: PushSetupContext; +} diff --git a/src/lib/services/firebase/types.ts b/src/lib/services/firebase/types.ts index 1f7e847..75d1520 100644 --- a/src/lib/services/firebase/types.ts +++ b/src/lib/services/firebase/types.ts @@ -78,6 +78,8 @@ export interface GoogleServiceInfoPlist { IS_APPINVITE_ENABLED?: boolean; IS_GCM_ENABLED?: boolean; IS_SIGNIN_ENABLED?: boolean; + /** Apple Team ID (optional, may be present in some Firebase configs) */ + TEAM_ID?: string; } /** diff --git a/src/ui/IosSetupUI.tsx b/src/ui/IosSetupUI.tsx index 681d111..a1f891a 100644 --- a/src/ui/IosSetupUI.tsx +++ b/src/ui/IosSetupUI.tsx @@ -1,6 +1,6 @@ -import { Box, Text, useApp } from 'ink'; +import { Box, Text, useApp, useInput } from 'ink'; import type React from 'react'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { type AgentContext, analyzeIosProject, @@ -17,6 +17,8 @@ import { syncCapabilities, updateEntitlementsForClix, } from '@/lib/ios'; +import { FirebaseService } from '@/lib/services/firebase'; +import type { GoogleServiceInfoPlist, GoogleServicesJson } from '@/lib/services/firebase/types'; import { Header } from '@/ui/components/Header'; import { StatusMessage } from '@/ui/components/StatusMessage'; @@ -51,6 +53,12 @@ export interface IosSetupResult { error?: string; /** Context for agent to complete remaining tasks */ agentContext?: AgentContext; + /** Bundle ID for push setup integration */ + bundleId?: string; + /** Firebase Project ID for push setup integration */ + firebaseProjectId?: string | null; + /** Apple Team ID for push setup integration (from GoogleService-Info.plist) */ + teamId?: string | null; } interface IosSetupUIProps { @@ -204,6 +212,27 @@ async function runSetup( entitlementsResult.iosDir, ); + // Detect Firebase project ID and Team ID for push setup integration + result.bundleId = bundleId; + // Use Team ID from Xcode project settings (DEVELOPMENT_TEAM) if available + result.teamId = project.teamId || null; + try { + const firebaseService = new FirebaseService(process.cwd()); + const firebaseDetection = await firebaseService.detect(); + const iosContent = firebaseDetection.ios?.content as GoogleServiceInfoPlist | undefined; + const androidContent = firebaseDetection.android?.content as GoogleServicesJson | undefined; + result.firebaseProjectId = + iosContent?.PROJECT_ID || androidContent?.project_info?.project_id || null; + // Override Team ID from Firebase config if available (more likely to be correct) + if (iosContent?.TEAM_ID) { + result.teamId = iosContent.TEAM_ID; + } + } catch { + // Firebase detection is optional, don't fail if it errors + result.firebaseProjectId = null; + // Keep the teamId from project settings if Firebase detection fails + } + result.success = true; setState((s) => ({ ...s, phase: 'complete' })); return result; @@ -218,30 +247,40 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = updatedFiles: [], errorMessage: '', }); + const [result, setResult] = useState(null); const { phase, projectInfo, portalResult, updatedFiles, errorMessage } = state; + // Handle user input for complete/error phases + const handleContinue = useCallback(() => { + if (phase === 'complete' && result) { + onComplete?.(result); + if (!onComplete) exit(); + } else if (phase === 'error') { + onComplete?.({ success: false, entitlementsUpdated: [], error: errorMessage }); + if (!onComplete) exit(); + } + }, [phase, result, errorMessage, onComplete, exit]); + + useInput((_input, key) => { + if ((phase === 'complete' || phase === 'error') && key.return) { + handleContinue(); + } + }); + useEffect(() => { const execute = async () => { try { - const result = await runSetup(options, setState); - setTimeout(() => { - onComplete?.(result); - if (!onComplete) exit(); - }, 1500); + const setupResult = await runSetup(options, setState); + setResult(setupResult); } catch (error) { const message = error instanceof Error ? getAppleApiErrorMessage(error) : String(error); setState((s) => ({ ...s, errorMessage: message, phase: 'error' })); - - setTimeout(() => { - onComplete?.({ success: false, entitlementsUpdated: [], error: message }); - if (!onComplete) exit(); - }, 1500); } }; execute(); - }, [options, onComplete, exit]); + }, [options]); return ( @@ -291,6 +330,9 @@ export const IosSetupUI: React.FC = ({ options, onComplete }) = + + Press Enter to continue + )} @@ -386,5 +428,9 @@ const CompletePhase: React.FC<{ {!skipPortal && 4. Regenerate provisioning profiles if needed} + + + Press Enter to continue + ); diff --git a/src/ui/chat/ChatApp.tsx b/src/ui/chat/ChatApp.tsx index f1be67d..5177f89 100644 --- a/src/ui/chat/ChatApp.tsx +++ b/src/ui/chat/ChatApp.tsx @@ -6,6 +6,7 @@ import type { InstallationMethod, UpdateCheckResult } from '../../lib/services/u import { AgentSelector } from '../components/AgentSelector'; import { DebugPrompt } from '../components/DebugPrompt'; import { FirebaseWizard } from '../components/FirebaseWizard'; +import { IosSetupFlow } from '../components/IosSetupFlow'; import { MCPInstallSelector } from '../components/MCPInstallSelector'; import { SessionSelector } from '../components/SessionSelector'; import { TransferSelector } from '../components/TransferSelector'; @@ -228,6 +229,13 @@ const ChatAppInner: React.FC onCancel={overlays.handleFirebaseCancel} /> )} + {overlays.activeOverlay === 'ios-setup' && ( + { + overlays.handleIosSetupComplete(result.message); + }} + /> + )} {overlays.activeOverlay === 'login' && ( { diff --git a/src/ui/chat/hooks/useCommandHandler.ts b/src/ui/chat/hooks/useCommandHandler.ts index 0bef416..9dc4c5a 100644 --- a/src/ui/chat/hooks/useCommandHandler.ts +++ b/src/ui/chat/hooks/useCommandHandler.ts @@ -46,6 +46,7 @@ interface UseCommandHandlerOptions { | 'showMCPInstallSelector' | 'showDebugPrompt' | 'showFirebaseWizard' + | 'showIosSetupOverlay' | 'showLoginOverlay' | 'showLogoutOverlay' | 'showWhoamiOverlay' @@ -89,6 +90,7 @@ export function useCommandHandler(options: UseCommandHandlerOptions) { showMCPInstallSelector, showDebugPrompt, showFirebaseWizard, + showIosSetupOverlay, showLoginOverlay, showLogoutOverlay, showWhoamiOverlay, @@ -165,6 +167,10 @@ export function useCommandHandler(options: UseCommandHandlerOptions) { showFirebaseWizard(); return; + case 'ios-setup': + showIosSetupOverlay(); + return; + case 'login': showLoginOverlay(); return; @@ -211,6 +217,7 @@ export function useCommandHandler(options: UseCommandHandlerOptions) { showMCPInstallSelector, showDebugPrompt, showFirebaseWizard, + showIosSetupOverlay, showLoginOverlay, showLogoutOverlay, showWhoamiOverlay, diff --git a/src/ui/chat/hooks/useOverlays.ts b/src/ui/chat/hooks/useOverlays.ts index 967d932..a8af511 100644 --- a/src/ui/chat/hooks/useOverlays.ts +++ b/src/ui/chat/hooks/useOverlays.ts @@ -24,6 +24,7 @@ export type OverlayType = | 'mcp' | 'debug' | 'firebase' + | 'ios-setup' | 'login' | 'logout' | 'whoami' @@ -115,6 +116,7 @@ export function useOverlays(options: UseOverlaysOptions) { const showDebugPrompt = useCallback(() => setActiveOverlay('debug'), []); const showFirebaseWizard = useCallback(() => setActiveOverlay('firebase'), []); + const showIosSetupOverlay = useCallback(() => setActiveOverlay('ios-setup'), []); const showLoginOverlay = useCallback(() => setActiveOverlay('login'), []); const showLogoutOverlay = useCallback(() => setActiveOverlay('logout'), []); const showWhoamiOverlay = useCallback(() => setActiveOverlay('whoami'), []); @@ -253,6 +255,15 @@ export function useOverlays(options: UseOverlaysOptions) { addSystemMessage('Firebase setup cancelled'); }, [addSystemMessage]); + // iOS setup handlers + const handleIosSetupComplete = useCallback( + (message: string) => { + setActiveOverlay(null); + addSystemMessage(message); + }, + [addSystemMessage], + ); + // Login handlers const handleLoginComplete = useCallback( (message: string) => { @@ -320,6 +331,10 @@ export function useOverlays(options: UseOverlaysOptions) { handleFirebaseComplete, handleFirebaseCancel, + // iOS setup + showIosSetupOverlay, + handleIosSetupComplete, + // Auth overlays showLoginOverlay, handleLoginComplete, diff --git a/src/ui/components/GuidedSetupWizard.tsx b/src/ui/components/GuidedSetupWizard.tsx new file mode 100644 index 0000000..8acc5f7 --- /dev/null +++ b/src/ui/components/GuidedSetupWizard.tsx @@ -0,0 +1,405 @@ +/** + * Guided Setup Wizard - Replaces AI Agent for Xcode configuration + * Provides step-by-step guidance for manual Xcode tasks + */ + +import { Box, Text, useInput } from 'ink'; +import type React from 'react'; +import { useEffect, useState } from 'react'; +import { + createExtensionFiles, + type ExtensionContext, + type ExtensionGeneratorResult, + getExtensionBundleId, + getExtensionName, + verifyExtensionFiles, +} from '@/lib/ios/extension-generator'; +import { generatePodfileSnippet } from '@/lib/ios/extension-templates'; +import { Header } from './Header'; +import { StatusMessage } from './StatusMessage'; + +export type GuidedSetupPhase = + | 'creating_files' + | 'xcode_target' + | 'build_settings' + | 'dependencies' + | 'verification' + | 'complete'; + +export interface GuidedSetupContext { + bundleId: string; + appGroupId: string; + appName: string; + iosDir: string; + entitlementsPath: string; + pushEnvironment?: 'development' | 'production'; +} + +export interface GuidedSetupResult { + success: boolean; + extensionCreated: boolean; + extensionDir?: string; + createdFiles: string[]; + error?: string; +} + +interface GuidedSetupWizardProps { + context: GuidedSetupContext; + onComplete: (result: GuidedSetupResult) => void; +} + +interface WizardState { + phase: GuidedSetupPhase; + extensionResult: ExtensionGeneratorResult | null; + error: string | null; +} + +export const GuidedSetupWizard: React.FC = ({ context, onComplete }) => { + const [state, setState] = useState({ + phase: 'creating_files', + extensionResult: null, + error: null, + }); + + const { phase, extensionResult, error } = state; + const extensionName = getExtensionName(context.appName); + const extensionBundleId = getExtensionBundleId(context.bundleId, context.appName); + + // Create extension files on mount + useEffect(() => { + if (phase !== 'creating_files') return; + + const create = async () => { + const extContext: ExtensionContext = { + appName: context.appName, + bundleId: context.bundleId, + iosDir: context.iosDir, + pushEnvironment: context.pushEnvironment, + }; + + const result = await createExtensionFiles(extContext); + + if (!result.success) { + setState((s) => ({ + ...s, + phase: 'complete', + extensionResult: result, + error: result.error || 'Failed to create extension files', + })); + return; + } + + setState((s) => ({ + ...s, + phase: 'xcode_target', + extensionResult: result, + })); + }; + + create(); + }, [phase, context]); + + // Handle keyboard input for navigation + useInput((input, key) => { + if (key.return || input === ' ') { + switch (phase) { + case 'xcode_target': + setState((s) => ({ ...s, phase: 'build_settings' })); + break; + case 'build_settings': + setState((s) => ({ ...s, phase: 'dependencies' })); + break; + case 'dependencies': + setState((s) => ({ ...s, phase: 'verification' })); + break; + case 'verification': + setState((s) => ({ ...s, phase: 'complete' })); + break; + } + } + + if (key.escape && phase !== 'creating_files' && phase !== 'complete') { + setState((s) => ({ ...s, phase: 'complete' })); + } + }); + + // Complete handler + useEffect(() => { + if (phase === 'complete') { + const verification = verifyExtensionFiles(context.iosDir, context.appName); + + setTimeout(() => { + onComplete({ + success: !error && verification.complete, + extensionCreated: !!extensionResult?.createdFiles.length, + extensionDir: extensionResult?.extensionDir, + createdFiles: extensionResult?.createdFiles || [], + error: error || undefined, + }); + }, 500); + } + }, [phase, error, extensionResult, context, onComplete]); + + return ( + +
+ + {/* Phase: Creating Files */} + {phase === 'creating_files' && ( + + )} + + {/* Phase: Xcode Target Guide */} + {phase === 'xcode_target' && ( + + )} + + {/* Phase: Build Settings Guide */} + {phase === 'build_settings' && ( + + )} + + {/* Phase: Dependencies Guide */} + {phase === 'dependencies' && } + + {/* Phase: Verification */} + {phase === 'verification' && ( + + )} + + {/* Phase: Complete */} + {phase === 'complete' && } + + ); +}; + +// Sub-components for each phase + +const XcodeTargetGuide: React.FC<{ + extensionName: string; + extensionBundleId: string; + extensionResult: ExtensionGeneratorResult | null; + appGroupId: string; +}> = ({ extensionName, extensionBundleId, extensionResult, appGroupId }) => ( + + {extensionResult && extensionResult.createdFiles.length > 0 && ( + + + {extensionResult.createdFiles.map((file) => ( + + • {file} + + ))} + + )} + + + + Step 1: Create Notification Service Extension Target in Xcode + + + + + 1. Open your project in Xcode + 2. File → New → Target... + 3. Select "Notification Service Extension" + + 4. Product Name: {extensionName} + + + 5. Bundle Identifier: {extensionBundleId} + + 6. Click "Finish" + + + + After creating the target: + • Delete the auto-generated NotificationService.swift + • Copy files from the created extension directory + + • Add App Group capability: {appGroupId} + + + + + Press Enter to continue... + + +); + +const BuildSettingsGuide: React.FC<{ + extensionName: string; + entitlementsPath: string; +}> = ({ extensionName, entitlementsPath }) => ( + + + + + + Step 2: Configure Build Settings + + + + + For the main app target: + 1. Select target → Signing & Capabilities + 2. Verify entitlements file is linked: + + {entitlementsPath} + + + + + For {extensionName}: + 1. Select extension target → Build Settings + + 2. Search for ENABLE_USER_SCRIPT_SANDBOXING + + + 3. Set to No (required for Xcode 15+) + + + + + + For React Native + Firebase projects: + + • Build Phases → Move "Embed Foundation Extensions" + above "[RNFB] Core Configuration" + + + + Press Enter to continue... + + +); + +const DependenciesGuide: React.FC<{ + extensionName: string; +}> = ({ extensionName }) => { + const podfileSnippet = generatePodfileSnippet(extensionName); + + return ( + + + + + + Step 3: Add Clix SDK to Extension Target + + + + + For CocoaPods projects: + Add to your Podfile: + + {podfileSnippet} + + + Then run: cd ios && pod install + + + + + For Swift Package Manager: + 1. Select the extension target in Xcode + 2. General → Frameworks, Libraries, and Embedded Content + 3. Click + and add the Clix package + + + + Press Enter to continue... + + + ); +}; + +const VerificationPhase: React.FC<{ + context: GuidedSetupContext; + extensionResult: ExtensionGeneratorResult | null; +}> = ({ context, extensionResult }) => { + const verification = verifyExtensionFiles(context.iosDir, context.appName); + + return ( + + + + + + Step 4: Verification + + + + + Extension Files: + {verification.complete ? ( + + ) : ( + + + {verification.missingFiles.map((file) => ( + + • {file} + + ))} + + )} + + + + Extension Directory: + {extensionResult?.extensionDir} + + + + Important Reminders: + + • Replace YOUR_PROJECT_ID in NotificationService.swift + + • Extension must share the same App Group as main app + • Regenerate provisioning profiles if needed + + + + Press Enter to finish... + + + ); +}; + +const CompletePhase: React.FC<{ + error: string | null; + extensionResult: ExtensionGeneratorResult | null; +}> = ({ error, extensionResult }) => ( + + {error ? ( + + ) : ( + + + + ✓ Extension setup guide complete! + + + {extensionResult && extensionResult.createdFiles.length > 0 && ( + + Created files: + {extensionResult.createdFiles.map((file) => ( + + • {file} + + ))} + + )} + + )} + +); diff --git a/src/ui/components/IosSetupFlow.tsx b/src/ui/components/IosSetupFlow.tsx new file mode 100644 index 0000000..34debd2 --- /dev/null +++ b/src/ui/components/IosSetupFlow.tsx @@ -0,0 +1,286 @@ +/** + * Integrated iOS Setup Flow for Interactive mode. + * Combines Phase 1 (IosSetupUI), Phase 2 (GuidedSetupWizard), and Phase 3 (PushSetupWizard). + */ +import { Box, Text, useInput } from 'ink'; +import type React from 'react'; +import { useCallback, useState } from 'react'; +import type { PushSetupResult } from '../../lib/push'; +import { type IosSetupResult, IosSetupUI } from '../IosSetupUI'; +import { + type GuidedSetupContext, + type GuidedSetupResult, + GuidedSetupWizard, +} from './GuidedSetupWizard'; +import { PushSetupWizard } from './PushSetupWizard'; + +type FlowPhase = + | 'intro' // Welcome screen + | 'direct_setup' // Phase 1: IosSetupUI + | 'guided_setup' // Phase 2: GuidedSetupWizard + | 'push_confirm' // Confirm push setup + | 'push_setup' // Phase 3: PushSetupWizard + | 'complete'; // All done + +export interface IosSetupFlowResult { + success: boolean; + message: string; + directResult?: IosSetupResult; + guidedResult?: GuidedSetupResult; + pushResult?: PushSetupResult | null; +} + +interface IosSetupFlowProps { + onComplete: (result: IosSetupFlowResult) => void; +} + +/** + * Intro screen component + */ +const IntroScreen: React.FC<{ + onContinue: () => void; + onCancel: () => void; +}> = ({ onContinue, onCancel }) => { + useInput((_input, key) => { + if (key.return) { + onContinue(); + } else if (key.escape) { + onCancel(); + } + }); + + return ( + + + iOS Push Notification Setup + + + This wizard will configure your iOS app for push notifications: + + + 1. Configure entitlements and capabilities + 2. Create Notification Service Extension files + 3. Set up APNS key for Firebase (optional) + + + Press Enter to continue, Esc to cancel + + + ); +}; + +/** + * Push setup confirmation component + */ +const PushSetupConfirmation: React.FC<{ + onYes: () => void; + onNo: () => void; +}> = ({ onYes, onNo }) => { + useInput((input, key) => { + if (input.toLowerCase() === 'y' || key.return) { + onYes(); + } else if (input.toLowerCase() === 'n' || key.escape) { + onNo(); + } + }); + + return ( + + + ✓ iOS setup completed! + + + + Set up APNS key for Firebase push notifications? [Y/n] + + + + ); +}; + +/** + * Integrated iOS Setup Flow component + */ +export const IosSetupFlow: React.FC = ({ onComplete }) => { + const [phase, setPhase] = useState('intro'); + const [directResult, setDirectResult] = useState(null); + const [guidedResult, setGuidedResult] = useState(null); + + // Intro handlers + const handleIntroContinue = useCallback(() => { + setPhase('direct_setup'); + }, []); + + const handleIntroCancel = useCallback(() => { + onComplete({ + success: false, + message: 'iOS setup cancelled', + }); + }, [onComplete]); + + // Phase 1 complete handler + const handleDirectSetupComplete = useCallback( + (result: IosSetupResult) => { + setDirectResult(result); + + if (!result.success) { + onComplete({ + success: false, + message: result.error || 'iOS setup failed', + directResult: result, + }); + return; + } + + // If there's agent context, proceed to guided setup + if (result.agentContext) { + setPhase('guided_setup'); + } else { + // No extension needed, go to push confirmation + setPhase('push_confirm'); + } + }, + [onComplete], + ); + + // Phase 2 complete handler + const handleGuidedSetupComplete = useCallback((result: GuidedSetupResult) => { + setGuidedResult(result); + // Always proceed to push confirmation (even if guided setup had issues) + setPhase('push_confirm'); + }, []); + + // Push confirmation handlers + const handlePushConfirmYes = useCallback(() => { + setPhase('push_setup'); + }, []); + + const handlePushConfirmNo = useCallback(() => { + // Complete without push setup + const message = buildCompletionMessage(directResult, guidedResult, null); + onComplete({ + success: true, + message, + directResult: directResult ?? undefined, + guidedResult: guidedResult ?? undefined, + pushResult: null, + }); + }, [directResult, guidedResult, onComplete]); + + // Phase 3 complete handler + const handlePushSetupComplete = useCallback( + (result: PushSetupResult) => { + const message = buildCompletionMessage(directResult, guidedResult, result); + onComplete({ + success: true, + message, + directResult: directResult ?? undefined, + guidedResult: guidedResult ?? undefined, + pushResult: result, + }); + }, + [directResult, guidedResult, onComplete], + ); + + // Push setup cancel handler + const handlePushSetupCancel = useCallback(() => { + const message = buildCompletionMessage(directResult, guidedResult, null); + onComplete({ + success: true, + message, + directResult: directResult ?? undefined, + guidedResult: guidedResult ?? undefined, + pushResult: null, + }); + }, [directResult, guidedResult, onComplete]); + + // Build guided setup context from direct result + const getGuidedSetupContext = (): GuidedSetupContext | null => { + if (!directResult?.agentContext) return null; + return { + bundleId: directResult.agentContext.bundleId, + appGroupId: directResult.agentContext.appGroupId, + appName: directResult.agentContext.appName, + iosDir: directResult.agentContext.iosDir, + entitlementsPath: directResult.agentContext.entitlementsPath, + }; + }; + + // Render based on current phase + switch (phase) { + case 'intro': + return ; + + case 'direct_setup': + return ; + + case 'guided_setup': { + const context = getGuidedSetupContext(); + if (!context) { + // Shouldn't happen, but handle gracefully + setPhase('push_confirm'); + return null; + } + return ; + } + + case 'push_confirm': + return ; + + case 'push_setup': + return ( + + ); + + case 'complete': + return null; + + default: + return null; + } +}; + +/** + * Build completion message summarizing all phases + */ +function buildCompletionMessage( + directResult: IosSetupResult | null, + guidedResult: GuidedSetupResult | null, + pushResult: PushSetupResult | null, +): string { + const lines: string[] = ['iOS Push Setup Complete!', '']; + + if (directResult?.success) { + lines.push('✓ Capabilities configured'); + lines.push('✓ Entitlements created'); + } + + if (guidedResult?.success) { + lines.push('✓ Extension files created'); + } + + if (pushResult?.success) { + lines.push('✓ APNS key registered with Firebase'); + } else if (pushResult === null) { + lines.push('○ APNS key setup skipped'); + } + + lines.push(''); + lines.push('Your iOS app is ready to receive push notifications!'); + + return lines.join('\n'); +} diff --git a/src/ui/components/PushSetupWizard.tsx b/src/ui/components/PushSetupWizard.tsx new file mode 100644 index 0000000..e36d854 --- /dev/null +++ b/src/ui/components/PushSetupWizard.tsx @@ -0,0 +1,1138 @@ +/** + * Push Notification setup wizard component. + * + * @module ui/components/PushSetupWizard + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Box, Text, useInput } from 'ink'; +import SelectInput from 'ink-select-input'; +import Spinner from 'ink-spinner'; +import TextInput from 'ink-text-input'; +import type React from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { openBrowser } from '@/lib/auth/browser'; +import { analyzeIosProject } from '@/lib/ios'; +import { + APNS_KEY_CREATION_STEPS, + FIREBASE_UPLOAD_STEPS, + getKeyIdError, + getTeamIdError, + PUSH_SETUP_URLS, + type PushSetupContext, + type PushSetupPhase, + type PushSetupResult, + validateP8File, +} from '@/lib/push'; +import { + FirebaseDownloader, + type FirebaseProject, + FirebaseService, + isOAuthConfigured, +} from '@/lib/services/firebase'; +import type { GoogleServiceInfoPlist, GoogleServicesJson } from '@/lib/services/firebase/types'; + +interface PushSetupWizardProps { + projectPath: string; + onComplete: (result: PushSetupResult) => void; + onCancel?: () => void; + /** Pre-detected Bundle ID from ios-setup */ + preDetectedBundleId?: string; + /** Pre-detected Firebase Project ID from ios-setup */ + preDetectedFirebaseProjectId?: string | null; + /** Pre-detected Team ID from Firebase config */ + preDetectedTeamId?: string | null; +} + +/** + * Detecting phase component. + */ +function DetectingPhase(): React.ReactElement { + return ( + + + Push Notification Setup + + + + + + Detecting project configuration... + + + ); +} + +/** + * Status phase component. + */ +function StatusPhase({ + context, + onContinue, + onCancel, +}: { + context: PushSetupContext; + onContinue: () => void; + onCancel: () => void; +}): React.ReactElement { + useInput((_input, key) => { + if (key.return) { + onContinue(); + } else if (key.escape) { + onCancel(); + } + }); + + return ( + + + Push Notification Setup + + + + Firebase Project: + {context.firebaseProjectId ? ( + {context.firebaseProjectId} + ) : ( + not configured + )} + + + Bundle ID: + {context.bundleId ? ( + {context.bundleId} + ) : ( + not detected + )} + + + + Press Enter to continue, Esc to cancel + + + ); +} + +/** + * Key source selection phase component. + */ +function KeySourcePhase({ + onHasKey, + onNoKey, + onCancel, +}: { + onHasKey: () => void; + onNoKey: () => void; + onCancel: () => void; +}): React.ReactElement { + const items = [ + { label: 'Yes, I have an APNS key (.p8 file)', value: 'has_key' }, + { label: 'No, I need to create one', value: 'no_key' }, + { label: 'Cancel', value: 'cancel' }, + ]; + + const handleSelect = (item: { value: string }) => { + switch (item.value) { + case 'has_key': + onHasKey(); + break; + case 'no_key': + onNoKey(); + break; + case 'cancel': + onCancel(); + break; + } + }; + + useInput((_input, key) => { + if (key.escape) { + onCancel(); + } + }); + + return ( + + + Do you have an existing APNS key? + + + + ↑↓ navigate · Enter select · Esc cancel + + + ); +} + +/** + * Apple guide phase component. + */ +function AppleGuidePhase({ + onContinue, + onCancel, +}: { + onContinue: () => void; + onCancel: () => void; +}): React.ReactElement { + const [browserOpened, setBrowserOpened] = useState(false); + + useEffect(() => { + if (!browserOpened) { + openBrowser(PUSH_SETUP_URLS.appleCreateKey); + setBrowserOpened(true); + } + }, [browserOpened]); + + useInput((_input, key) => { + if (key.return) { + onContinue(); + } else if (key.escape) { + onCancel(); + } + }); + + return ( + + + Create APNS Key in Apple Developer Portal + + + Browser opened to Apple Developer Portal + + + Steps: + {APNS_KEY_CREATION_STEPS.map((step) => ( + + {APNS_KEY_CREATION_STEPS.indexOf(step) + 1}. {step} + + ))} + + + Press Enter when you have copied the .p8 file to this directory + + + Esc to cancel + + + ); +} + +/** + * Find .p8 files in the current directory. + */ +function findP8Files(): string[] { + try { + const cwd = process.cwd(); + const files = fs.readdirSync(cwd); + return files + .filter((f) => f.endsWith('.p8')) + .map((f) => `./${f}`) + .sort(); + } catch { + return []; + } +} + +/** + * P8 input phase component. + */ +function P8InputPhase({ + suggestedKeyId, + suggestedTeamId, + onSubmit, + onCancel, +}: { + suggestedKeyId?: string; + suggestedTeamId?: string; + onSubmit: (p8Path: string, keyId: string, teamId: string) => void; + onCancel: () => void; +}): React.ReactElement { + const [stage, setStage] = useState<'p8_select' | 'p8_path' | 'key_id' | 'team_id'>('p8_select'); + const [foundFiles, setFoundFiles] = useState([]); + const [p8Path, setP8Path] = useState(''); + const [keyId, setKeyId] = useState(suggestedKeyId || ''); + const [teamId, setTeamId] = useState(suggestedTeamId || ''); + const [error, setError] = useState(null); + const [extractedKeyId, setExtractedKeyId] = useState(null); + const [prefilledTeamId] = useState(suggestedTeamId || null); + + // Search for .p8 files on mount + useEffect(() => { + const files = findP8Files(); + setFoundFiles(files); + // If no files found, go directly to manual input + if (files.length === 0) { + setStage('p8_path'); + } + }, []); + + useInput((input, key) => { + if (key.escape) { + onCancel(); + } + // Open Team ID page when 't' is pressed in team_id stage + if (stage === 'team_id' && input === 't') { + openBrowser(PUSH_SETUP_URLS.appleTeamId); + } + }); + + const handleFileSelect = useCallback((item: { label: string; value: string }) => { + if (item.value === 'manual') { + setStage('p8_path'); + return; + } + + const result = validateP8File(item.value); + if (!result.valid) { + setError(result.error || 'Invalid P8 file'); + return; + } + + setP8Path(item.value); + setError(null); + if (result.suggestedKeyId) { + setExtractedKeyId(result.suggestedKeyId); + setKeyId(result.suggestedKeyId); + } + setStage('key_id'); + }, []); + + const handleP8PathSubmit = useCallback(() => { + if (!p8Path.trim()) { + setError('P8 file path is required'); + return; + } + + const result = validateP8File(p8Path.trim()); + if (!result.valid) { + setError(result.error || 'Invalid P8 file'); + return; + } + + setError(null); + if (result.suggestedKeyId) { + setExtractedKeyId(result.suggestedKeyId); + setKeyId(result.suggestedKeyId); + } + setStage('key_id'); + }, [p8Path]); + + const handleKeyIdSubmit = useCallback(() => { + const keyIdError = getKeyIdError(keyId.trim()); + if (keyIdError) { + setError(keyIdError); + return; + } + setError(null); + setStage('team_id'); + }, [keyId]); + + const handleTeamIdSubmit = useCallback(() => { + const teamIdError = getTeamIdError(teamId.trim()); + if (teamIdError) { + setError(teamIdError); + return; + } + setError(null); + onSubmit(p8Path.trim(), keyId.trim().toUpperCase(), teamId.trim().toUpperCase()); + }, [p8Path, keyId, teamId, onSubmit]); + + // Build selection items for found files + const fileItems = [ + ...foundFiles.map((f) => ({ + label: `${path.basename(f)}`, + value: f, + })), + { label: 'Enter path manually...', value: 'manual' }, + ]; + + return ( + + + Enter APNS Key Information + + + {error && ( + + ✗ {error} + + )} + + {stage === 'p8_select' && foundFiles.length > 0 && ( + <> + + + Found {foundFiles.length} P8 file(s) in current directory: + + + + + )} + + {stage === 'p8_path' && ( + <> + + + Path to P8 file: (e.g., ./AuthKey_XXXXXXXXXX.p8) + + + + Tip: Copy the file to this directory to avoid permission issues + + + {'> '} + + + + )} + + {stage === 'key_id' && ( + <> + + P8 file: {p8Path} + + + + Key ID: (10 characters, e.g., ABCD123456) + + + {extractedKeyId && ( + + ✓ Extracted from filename: {extractedKeyId} + + )} + + {'> '} + + + + )} + + {stage === 'team_id' && ( + <> + + P8 file: {p8Path} + + + Key ID: {keyId} + + + + Apple Team ID: (10 characters) + + + {prefilledTeamId && ( + + ✓ Detected from Xcode project: {prefilledTeamId} + + )} + {!prefilledTeamId && ( + + + Find your Team ID at:{' '} + + developer.apple.com/account + + + → Look for "Team ID" in the "Membership details" card + + )} + + {'> '} + + + {!prefilledTeamId && ( + + Press 't' to open Team ID page in browser + + )} + + )} + + + + {stage === 'p8_select' + ? '↑↓ navigate · Enter select · Esc cancel' + : 'Enter to continue · Esc to cancel'} + + + + ); +} + +/** + * Firebase authentication phase component. + */ +function FirebaseAuthPhase({ onCancel }: { onCancel: () => void }): React.ReactElement { + useInput((_input, key) => { + const isCtrlC = (_input === 'c' && key.ctrl) || _input === '\x03'; + if (key.escape || isCtrlC) { + onCancel(); + } + }); + + return ( + + + Firebase Authentication + + + + + + Authenticating with Firebase... + + + A browser window will open for authentication + + + Esc to cancel + + + ); +} + +/** + * Firebase project selection phase component. + */ +function FirebaseProjectsPhase({ + projects, + onSelect, + onCancel, +}: { + projects: FirebaseProject[]; + onSelect: (project: FirebaseProject) => void; + onCancel: () => void; +}): React.ReactElement { + const items = projects.map((p) => ({ + label: p.displayName || p.projectId, + value: p.projectId, + })); + + const handleSelect = useCallback( + (item: { label: string; value: string }) => { + const project = projects.find((p) => p.projectId === item.value); + if (project) { + onSelect(project); + } + }, + [onSelect, projects], + ); + + useInput((_input, key) => { + if (key.escape) { + onCancel(); + } + }); + + return ( + + + Select Firebase Project + + + Choose the project to upload APNS key: + + + + Esc to cancel + + + ); +} + +/** + * Firebase upload phase component. + */ +function FirebaseUploadPhase({ + context, + selectedProject, + onComplete, + onCancel, +}: { + context: PushSetupContext; + selectedProject: FirebaseProject | null; + onComplete: () => void; + onCancel: () => void; +}): React.ReactElement { + const [browserOpened, setBrowserOpened] = useState(false); + + useEffect(() => { + if (!browserOpened && selectedProject) { + const url = PUSH_SETUP_URLS.firebaseConsole(selectedProject.projectId); + openBrowser(url); + setBrowserOpened(true); + } + }, [browserOpened, selectedProject]); + + useInput((_input, key) => { + if (key.return) { + onComplete(); + } else if (key.escape) { + onCancel(); + } + }); + + return ( + + + Upload APNS Key to Firebase + + + + Project:{' '} + {selectedProject?.displayName || selectedProject?.projectId} + + + + Browser opened to Firebase Console → Cloud Messaging + + + + + Information to enter in Firebase: + + + Key ID: + + {context.pushKey?.apnsKeyId || 'N/A'} + + + + Team ID: + + {context.pushKey?.teamId || 'N/A'} + + + + P8 File: + {context.p8FilePath || 'N/A'} + + + + + Steps: + {FIREBASE_UPLOAD_STEPS.map((step) => ( + + {FIREBASE_UPLOAD_STEPS.indexOf(step) + 1}. {step} + + ))} + + + + Press Enter when upload is complete + + + Esc to cancel + + + ); +} + +/** + * Complete phase component. + */ +function CompletePhase({ + context, + cancelled, +}: { + context: PushSetupContext; + cancelled: boolean; +}): React.ReactElement { + if (cancelled) { + return ( + + + + ! Push notification setup cancelled + + + + You can run /push-setup later to configure push notifications. + + + ); + } + + return ( + + + + ✓ Push Notification Setup Complete + + + + + Key ID: + {context.pushKey?.apnsKeyId || 'N/A'} + + + Team ID: + {context.pushKey?.teamId || 'N/A'} + + + + Your iOS app is now configured to receive push notifications. + + + ); +} + +/** + * Error phase component. + */ +function ErrorPhase({ + error, + onRetry, + onCancel, +}: { + error: string; + onRetry: () => void; + onCancel: () => void; +}): React.ReactElement { + useInput((_input, key) => { + if (key.return) { + onRetry(); + } else if (key.escape) { + onCancel(); + } + }); + + return ( + + + + Push Notification Setup Error + + + + ✗ {error} + + + Press Enter to retry, Esc to cancel + + + ); +} + +/** + * Detection result from project analysis. + */ +interface DetectionResult { + firebaseProjectId: string | null; + bundleId: string | null; + teamId: string | null; +} + +/** + * Detect project configuration from Firebase config. + */ +async function detectFromFirebase(projectPath: string): Promise { + const result: DetectionResult = { + firebaseProjectId: null, + bundleId: null, + teamId: null, + }; + + try { + const firebaseService = new FirebaseService(projectPath); + const detection = await firebaseService.detect(); + + const iosContent = detection.ios?.content as GoogleServiceInfoPlist | undefined; + const androidContent = detection.android?.content as GoogleServicesJson | undefined; + + result.firebaseProjectId = + iosContent?.PROJECT_ID || androidContent?.project_info?.project_id || null; + result.bundleId = iosContent?.BUNDLE_ID || null; + result.teamId = iosContent?.TEAM_ID || null; + } catch { + // Firebase detection failed + } + + return result; +} + +/** + * Detect Team ID and Bundle ID from Xcode project. + */ +async function detectFromXcodeProject( + projectPath: string, +): Promise<{ teamId: string | null; bundleId: string | null }> { + try { + const analysis = await analyzeIosProject(projectPath); + if (analysis.success && analysis.project) { + return { + teamId: analysis.project.teamId || null, + bundleId: analysis.project.bundleId || null, + }; + } + } catch { + // iOS project analysis failed + } + return { teamId: null, bundleId: null }; +} + +/** + * Push notification setup wizard component. + */ +export const PushSetupWizard: React.FC = ({ + projectPath, + onComplete, + onCancel, + preDetectedBundleId, + preDetectedFirebaseProjectId, + preDetectedTeamId, +}) => { + const [phase, setPhase] = useState('detecting'); + const [context, setContext] = useState({ + bundleId: null, + firebaseProjectId: null, + pushKey: null, + p8FilePath: null, + }); + const [error, setError] = useState(null); + const [cancelled, setCancelled] = useState(false); + const [detectedTeamId, setDetectedTeamId] = useState(preDetectedTeamId ?? null); + + // Firebase OAuth state + const downloaderRef = useRef(null); + const [projects, setProjects] = useState([]); + const [selectedProject, setSelectedProject] = useState(null); + + // Initial detection + useEffect(() => { + const detect = async () => { + // Use pre-detected values if available (from ios-setup integration) + if (preDetectedBundleId !== undefined || preDetectedFirebaseProjectId !== undefined) { + setContext((prev) => ({ + ...prev, + firebaseProjectId: preDetectedFirebaseProjectId ?? null, + bundleId: preDetectedBundleId ?? null, + })); + if (preDetectedTeamId) { + setDetectedTeamId(preDetectedTeamId); + } + setPhase('status'); + return; + } + + // Detect from Firebase config + const firebaseResult = await detectFromFirebase(projectPath); + let { firebaseProjectId, bundleId, teamId } = firebaseResult; + + // Try Xcode project if Team ID not found in Firebase config + if (!teamId) { + const xcodeResult = await detectFromXcodeProject(projectPath); + teamId = xcodeResult.teamId; + if (!bundleId) { + bundleId = xcodeResult.bundleId; + } + } + + if (teamId) { + setDetectedTeamId(teamId); + } + + setContext((prev) => ({ + ...prev, + firebaseProjectId, + bundleId, + })); + setPhase('status'); + }; + + if (phase === 'detecting') { + detect(); + } + }, [phase, projectPath, preDetectedBundleId, preDetectedFirebaseProjectId, preDetectedTeamId]); + + // Firebase authentication effect + useEffect(() => { + const authenticateAndFetchProjects = async () => { + try { + // Create downloader if not exists + if (!downloaderRef.current) { + downloaderRef.current = new FirebaseDownloader(); + } + const downloader = downloaderRef.current; + + // Authenticate with Firebase + await downloader.authenticate(openBrowser); + + // Fetch projects + const fetchedProjects = await downloader.listProjects(); + setProjects(fetchedProjects); + + // If pre-detected project exists, try to find and auto-select it + if (context.firebaseProjectId) { + const matchingProject = fetchedProjects.find( + (p) => p.projectId === context.firebaseProjectId, + ); + if (matchingProject) { + setSelectedProject(matchingProject); + setPhase('firebase_upload'); + return; + } + } + + // Otherwise, show project selection + if (fetchedProjects.length === 1) { + // Auto-select if only one project + setSelectedProject(fetchedProjects[0]); + setPhase('firebase_upload'); + } else if (fetchedProjects.length > 1) { + setPhase('firebase_projects'); + } else { + setError('No Firebase projects found'); + setPhase('error'); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Firebase authentication failed'; + setError(message); + setPhase('error'); + } + }; + + if (phase === 'firebase_auth') { + authenticateAndFetchProjects(); + } + }, [phase, context.firebaseProjectId]); + + const handleContinue = useCallback(() => { + setPhase('key_source'); + }, []); + + const handleCancel = useCallback(() => { + setCancelled(true); + setPhase('complete'); + if (onCancel) { + onCancel(); + } else { + onComplete({ + success: false, + message: 'Push notification setup cancelled', + }); + } + }, [onCancel, onComplete]); + + const handleHasKey = useCallback(() => { + setPhase('p8_input'); + }, []); + + const handleNoKey = useCallback(() => { + setPhase('apple_guide'); + }, []); + + const handleAppleGuideComplete = useCallback(() => { + setPhase('p8_input'); + }, []); + + const handleP8Submit = useCallback((p8Path: string, keyId: string, teamId: string) => { + const result = validateP8File(p8Path); + if (!result.valid || !result.content) { + setError(result.error || 'Invalid P8 file'); + setPhase('error'); + return; + } + + // Store content in a variable to ensure TypeScript type narrowing + const p8Content = result.content; + + setContext((prev) => ({ + ...prev, + pushKey: { + apnsKeyP8: p8Content, + apnsKeyId: keyId, + teamId, + }, + p8FilePath: p8Path, + })); + // Check if Firebase OAuth is configured + if (isOAuthConfigured()) { + setPhase('firebase_auth'); + } else { + // Fallback: open Firebase console directly with detected project + setPhase('firebase_upload'); + } + }, []); + + const handleProjectSelect = useCallback((project: FirebaseProject) => { + setSelectedProject(project); + setPhase('firebase_upload'); + }, []); + + const handleFirebaseComplete = useCallback(() => { + setPhase('complete'); + onComplete({ + success: true, + message: 'Push notification setup complete', + context, + }); + }, [context, onComplete]); + + const handleRetry = useCallback(() => { + setError(null); + setPhase('status'); + }, []); + + switch (phase) { + case 'detecting': + return ; + + case 'status': + return ; + + case 'key_source': + return ( + + ); + + case 'apple_guide': + return ; + + case 'p8_input': + return ( + + ); + + case 'firebase_auth': + return ; + + case 'firebase_projects': + return ( + + ); + + case 'firebase_upload': + return ( + + ); + + case 'complete': + return ; + + case 'error': + return ( + + ); + + default: + return ; + } +}; From 7b2691b811c0f230ebae87c6f7e2590be697e546 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Mon, 2 Feb 2026 18:11:45 +0900 Subject: [PATCH 2/9] refactor(skills): remove agent-based ios-setup skill Replace AI agent-driven ios-setup with React UI wizard: - Remove ios-setup SKILL.md and embedded skill definition - Update documentation for new /ios-setup and /push-setup commands - Simplify skills registry by removing ios-setup from embedded skills --- README.md | 33 ++- llms.txt | 88 ++++--- src/cli.tsx | 4 +- src/lib/commands/skills.ts | 9 +- src/lib/embedded-skills.ts | 371 --------------------------- src/lib/skills.ts | 31 +-- src/lib/skills/ios-setup/SKILL.md | 370 -------------------------- src/ui/components/FirebaseWizard.tsx | 27 +- 8 files changed, 83 insertions(+), 850 deletions(-) delete mode 100644 src/lib/skills/ios-setup/SKILL.md diff --git a/README.md b/README.md index 5d64713..138a028 100644 --- a/README.md +++ b/README.md @@ -162,24 +162,33 @@ clix doctor ### `clix ios-setup` -Configure iOS capabilities and Notification Service Extension (NSE) for the Clix SDK. +Configure iOS capabilities, Notification Service Extension (NSE), and APNS key for the Clix SDK. ```bash clix ios-setup ``` **What it does:** -1. Analyzes your iOS project structure -2. Checks current capabilities status (Push Notifications, App Groups) -3. Creates/modifies entitlements files -4. Guides NSE setup for rich push notifications: - - Creates `{AppName}NotificationServiceExtension` target - - Implements `NotificationService.swift` with `ClixNotificationServiceExtension` - - Configures CocoaPods/SPM dependencies for extension target - - Sets build settings (`ENABLE_USER_SCRIPT_SANDBOXING` for Xcode 15+) -5. Guides you through Xcode and Apple Developer Portal configuration - -**Note:** Some steps require manual action in Xcode and Apple Developer Portal. +1. **Phase 1 - Capabilities & Entitlements (Automatic):** + - Analyzes your iOS project structure + - Syncs capabilities with Apple Developer Portal (Push Notifications, App Groups) + - Creates/modifies entitlements files + +2. **Phase 2 - Extension Setup (Guided):** + - Auto-generates NSE files: + - `NotificationService.swift` with `ClixNotificationServiceExtension` + - `Info.plist` for extension + - Extension entitlements file + - Step-by-step guide for Xcode configuration: + - Creating extension target in Xcode + - Configuring build settings (`ENABLE_USER_SCRIPT_SANDBOXING` for Xcode 15+) + - Adding CocoaPods/SPM dependencies + +3. **Phase 3 - APNS Key Setup (Optional):** + - APNS authentication key (.p8 file) creation guide + - Firebase Console upload for push notification delivery + +**Note:** Phase 2 & 3 require manual action in Xcode, Apple Developer Portal, and Firebase Console. ### `clix update` diff --git a/llms.txt b/llms.txt index eeb5edc..891a04b 100644 --- a/llms.txt +++ b/llms.txt @@ -9,7 +9,7 @@ Clix CLI is an interactive command-line tool that provides a chat interface with **Core Features:** - Interactive chat interface as the primary interaction mode - Support for 6 AI agents: Claude, Codex, Gemini, OpenCode, Cursor, and GitHub Copilot -- 19 slash commands for quick actions +- 20 slash commands for quick actions - Skills system with 5 interactive skills + 4 autonomous commands - Interactive debug assistant for problem diagnosis - Session transfer to native agent CLIs @@ -70,14 +70,14 @@ clix **Features:** - Natural language conversation with AI - Real-time streaming responses -- 19 slash commands (type `/` to see menu) +- 20 slash commands (type `/` to see menu) - Context usage tracking (200K token window for Claude Sonnet) - History navigation with ↑/↓ arrow keys - Press Escape to cancel streaming requests - Automatic history compaction at 90% context threshold **Available within chat:** -- All 19 slash commands (4 autonomous + 5 skills + 10 system) +- All 20 slash commands (4 autonomous + 5 skills + 11 system) - Natural language queries - File exploration and code analysis by agent - Real-time tool execution visibility @@ -232,7 +232,7 @@ The interactive chat (`clix` command) is the primary way to interact with Clix C ### Slash Commands -Type `/` in the chat to see the autocomplete menu. All 19 slash commands are organized into three categories: +Type `/` in the chat to see the autocomplete menu. All 20 slash commands are organized into three categories: #### Autonomous Commands (4 commands) @@ -253,7 +253,7 @@ These execute pre-built workflows from the `@clix-so/clix-agent-skills` package - `/personalization` - Personalization template creation and debugging - `/api-triggered-campaigns` - API-triggered campaign setup -#### System Commands (10 commands) +#### System Commands (11 commands) These are built-in commands for chat management and tools: @@ -380,13 +380,13 @@ Describe the problem: Events not appearing in Clix dashboard [Provides fix with exact file location] ``` -#### `/ios-setup` - iOS Setup, Capabilities & NSE Configuration +#### `/ios-setup` - iOS Setup, Capabilities, NSE & APNS Key Configuration **Category:** Autonomous Command **Aliases:** `/capabilities`, `/ios-capabilities` -**What it does:** Configures iOS capabilities and Notification Service Extension (NSE) required for the Clix SDK. +**What it does:** Configures iOS capabilities, Notification Service Extension (NSE), and optionally sets up APNS authentication key for Firebase push notifications. **Capabilities configured:** - **Push Notifications** - Enables APNs communication (entitlement: `aps-environment`) @@ -399,21 +399,38 @@ Describe the problem: Events not appearing in Clix dashboard - **Build settings:** `ENABLE_USER_SCRIPT_SANDBOXING = No` for Xcode 15+ - **React Native + Firebase:** Move "Embed Foundation Extensions" above "[RNFB] Core Configuration" -**Workflow:** -1. Analyzes iOS project structure (finds .xcodeproj/.xcworkspace) -2. Detects Bundle ID and checks current capabilities status -3. Creates/modifies entitlements files for main app and extension -4. Guides NSE target creation and NotificationService.swift implementation -5. Provides CocoaPods/SPM setup instructions for extension target -6. Provides step-by-step instructions for Xcode configuration -7. Guides through Apple Developer Portal setup -8. Outputs verification report - -**What can be automated:** +**APNS Key Setup (Phase 3 - Optional):** +After iOS capabilities setup completes, prompts user to configure APNS authentication key: +- **APNS Key Creation:** Opens Apple Developer Portal to create/download .p8 key file +- **Key Validation:** Validates P8 file format, extracts Key ID from filename +- **Firebase Upload:** Opens Firebase Console and displays Key ID, Team ID for manual upload + +**Workflow (3 Phases):** +1. **Phase 1 - Direct Setup (Automatic):** + - Analyzes iOS project structure (finds .xcodeproj/.xcworkspace) + - Detects Bundle ID and checks current capabilities status + - Syncs capabilities with Apple Developer Portal (if credentials provided) + - Creates/modifies entitlements files for main app +2. **Phase 2 - Guided Setup (Static file generation + UI guide):** + - Auto-generates NSE files: + - `NotificationService.swift` with `ClixNotificationServiceExtension` + - `Info.plist` for extension + - Extension entitlements file + - Step-by-step Xcode configuration guide: + - Creating NSE target in Xcode + - Configuring build settings (`ENABLE_USER_SCRIPT_SANDBOXING` for Xcode 15+) + - Adding CocoaPods/SPM dependencies +3. **Phase 3 - Push Setup (Optional):** + - Prompts: "Set up APNS key for Firebase push notifications? [Y/n]" + - If yes, opens PushSetupWizard for APNS key creation and Firebase upload + +**What is automated:** - Creating/modifying entitlements files - Reading project configuration +- Generating NSE source files (NotificationService.swift, Info.plist) +- P8 file format validation -**What requires manual action:** +**What requires manual action (guided by UI):** - Creating NSE target in Xcode (File > New > Target > Notification Service Extension) - Adding capabilities in Xcode UI (Signing & Capabilities) - Adding Clix SDK to extension target (Podfile or SPM) @@ -421,27 +438,36 @@ Describe the problem: Events not appearing in Clix dashboard - Enabling capabilities in Apple Developer Portal - Registering App Group IDs - Regenerating provisioning profiles +- Creating APNS key in Apple Developer Portal +- Uploading P8 file to Firebase Console **Example:** ``` > /ios-setup Analyzing iOS project... -Bundle ID: com.example.myapp -Push Notifications: not configured -App Groups: not configured +✓ Found: MyApp.xcodeproj +✓ Bundle ID: com.example.myapp Creating entitlements files... ✓ Created MyApp.entitlements -✓ Created MyAppNotificationServiceExtension.entitlements -Manual steps required: -1. Create NSE target: MyAppNotificationServiceExtension -2. Implement NotificationService.swift with ClixNotificationServiceExtension -3. Add Clix SDK to extension (Podfile or SPM) -4. Add Push Notifications and App Groups capabilities -5. Set ENABLE_USER_SCRIPT_SANDBOXING to No (Xcode 15+) +Creating Notification Service Extension files... +✓ Created MyAppNotificationServiceExtension/NotificationService.swift +✓ Created MyAppNotificationServiceExtension/Info.plist +✓ Created MyAppNotificationServiceExtension/MyAppNotificationServiceExtension.entitlements -{verification report JSON} +Step 1: Create Notification Service Extension Target in Xcode +[Enter to continue...] + +Step 2: Configure Build Settings +[Enter to continue...] + +Step 3: Add Clix SDK to Extension Target +[Enter to continue...] + +✓ Extension setup guide complete! + +Set up APNS key for Firebase push notifications? [Y/n] ``` ### Interactive Skills @@ -1249,7 +1275,7 @@ When helping users with Clix CLI, keep these points in mind: 1. **Primary command is `clix`** - This launches interactive chat, not just a welcome screen 2. **Interactive > Commands** - The tool is primarily interactive, not command-based 3. **6 supported agents** - Gemini, Copilot, OpenCode, Cursor, Claude, Codex (recommend starting with Gemini or Copilot for free tiers) -4. **19 slash commands** - 4 autonomous commands + 5 interactive skills + 10 system commands +4. **20 slash commands** - 4 autonomous commands + 5 interactive skills + 11 system commands 5. **Autonomous vs Interactive** - Autonomous commands (`/install`, `/doctor`, `/debug`, `/ios-setup`) can run from CLI, Interactive skills (`/integration`, `/event-tracking`, etc.) require chat mode 6. **Skills from package** - Interactive skills from @clix-so/clix-agent-skills package, Autonomous commands are local 7. **/install vs /integration** - `/install` makes changes autonomously, `/integration` provides guided steps diff --git a/src/cli.tsx b/src/cli.tsx index 1aec137..cf0f292 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -4,7 +4,7 @@ import { chatCommand } from './commands/chat'; import { debugCommand } from './commands/debug'; import { firebaseCommand } from './commands/firebase'; import { installMCPCommand } from './commands/install-mcp'; -import { iosSetupCommand } from './commands/ios-setup/index'; +import { runIosSetupCommand } from './commands/ios-setup/index'; import { loginCommand } from './commands/login'; import { logoutCommand } from './commands/logout'; import { resumeCommand } from './commands/resume'; @@ -210,7 +210,7 @@ async function main() { process.exit(1); } const pushEnv = pushEnvRaw as 'development' | 'production' | undefined; - await iosSetupCommand({ + await runIosSetupCommand({ apiKeyPath: cli.flags.apiKey, keyId: cli.flags.keyId, issuerId: cli.flags.issuerId, diff --git a/src/lib/commands/skills.ts b/src/lib/commands/skills.ts index e239983..da79f0f 100644 --- a/src/lib/commands/skills.ts +++ b/src/lib/commands/skills.ts @@ -81,14 +81,7 @@ function generateSkillCommands(): Command[] { ), ); commands.push(createLocalSkillCommand('doctor', 'Check SDK integration status', 'doctor')); - commands.push( - createLocalSkillCommand( - 'ios-setup', - 'Configure iOS capabilities for push notifications and app groups', - 'ios-setup', - ['capabilities', 'ios-capabilities'], - ), - ); + // NOTE: ios-setup is now a LocalJSXCommand in registry.ts, not a skill return commands; } diff --git a/src/lib/embedded-skills.ts b/src/lib/embedded-skills.ts index f7c8328..877b0aa 100644 --- a/src/lib/embedded-skills.ts +++ b/src/lib/embedded-skills.ts @@ -1385,377 +1385,6 @@ Analyze the project and output a diagnostic JSON report: Output the JSON diagnostic, then provide a brief summary with actionable recommendations. Use \`/firebase\` command to interactively check and configure Firebase credentials. -`, - 'local-ios-setup': `# iOS Capabilities Configuration - -You are an AI agent that configures iOS capabilities required for the Clix SDK. - -## Core Directive - -**GUIDE USERS** through iOS capability configuration for push notifications and data sharing. For file modifications, use Edit/Write tools when possible. For Xcode-only steps, provide clear step-by-step instructions. - -## Required Capabilities for Clix iOS SDK - -### 1. Push Notifications - -- **Purpose:** Enable APNs (Apple Push Notification service) communication -- **Entitlement Key:** \`aps-environment\` -- **Values:** \`development\` (debug builds) or \`production\` (release builds) -- **Xcode Capability:** Push Notifications - -### 2. App Groups - -- **Purpose:** Share data between main app and Notification Service Extension using MMKV -- **Entitlement Key:** \`com.apple.security.application-groups\` -- **ID Format:** \`group.clix.{BUNDLE_ID}\` (e.g., \`group.clix.com.example.myapp\`) -- **Xcode Capability:** App Groups -- **Important:** Must be configured for BOTH main app AND Notification Service Extension targets - -## Workflow - -### Phase 1: Project Analysis - -1. **Detect iOS Project** - - Search for \`*.xcodeproj\` or \`*.xcworkspace\` files - - Identify the main app target name - - Check if this is a native iOS, React Native, or Flutter project - -2. **Find Bundle Identifier** - - Check \`Info.plist\` for \`CFBundleIdentifier\` - - Or parse \`project.pbxproj\` for \`PRODUCT_BUNDLE_IDENTIFIER\` - -3. **Check Current Capabilities Status** - - Search for existing \`*.entitlements\` files - - Check for \`aps-environment\` entitlement (Push Notifications configured) - - Check for \`com.apple.security.application-groups\` (App Groups configured) - - Check \`project.pbxproj\` for \`SystemCapabilities\` section - -4. **Report Current State** - Output findings: - \`\`\`text - Project: {project_name} - Bundle ID: {bundle_id} - Push Notifications: {configured/not configured} - App Groups: {configured/not configured} - Existing entitlements files: {list} - \`\`\` - -### Phase 2: Xcode Configuration (Manual Steps) - -Provide clear instructions for adding capabilities in Xcode. These steps CANNOT be automated and require user action in Xcode IDE. - -**Add Push Notifications:** -\`\`\`text -1. Open your project in Xcode -2. Select your main app target in the Navigator (left sidebar) -3. Go to the "Signing & Capabilities" tab -4. Click the "+ Capability" button -5. Search for and select "Push Notifications" -6. Xcode will automatically create an entitlements file if one doesn't exist -\`\`\` - -**Add Background Modes (Recommended):** -\`\`\`text -1. In "Signing & Capabilities", click "+ Capability" -2. Select "Background Modes" -3. Enable "Remote notifications" checkbox - - This allows the app to process push notifications in the background -\`\`\` - -**Add App Groups:** -\`\`\`text -1. Click "+ Capability" -2. Select "App Groups" -3. Click the "+" button under App Groups -4. Enter the App Group ID: group.clix.{BUNDLE_ID} - Example: group.clix.com.example.myapp -5. Click OK to create the group - -IMPORTANT: Repeat steps 1-5 for the Notification Service Extension target: -1. Select the extension target (usually named "{AppName}NotificationServiceExtension") -2. Go to "Signing & Capabilities" -3. Add "App Groups" capability -4. Select the SAME App Group ID you created above -\`\`\` - -### Phase 3: Entitlements Files - -Create or modify entitlements files. Use Write/Edit tools for these operations. - -**Main App Entitlements** (\`{AppName}.entitlements\` or \`{AppName}/{AppName}.entitlements\`): - -\`\`\`xml - - - - - aps-environment - development - com.apple.security.application-groups - - group.clix.{BUNDLE_ID} - - - -\`\`\` - -**Notification Service Extension Entitlements** (\`{ExtensionName}/{ExtensionName}.entitlements\`): - -\`\`\`xml - - - - - com.apple.security.application-groups - - group.clix.{BUNDLE_ID} - - - -\`\`\` - -**Note:** Replace \`{BUNDLE_ID}\` with the actual bundle identifier (e.g., \`com.example.myapp\`). - -### Phase 3.5: Notification Service Extension Setup - -Create a Notification Service Extension for rich push notifications (images, buttons, etc.). - -**Create Extension Target in Xcode:** -\`\`\`text -1. File > New > Target -2. Select "Notification Service Extension" -3. Name it "{AppName}NotificationServiceExtension" (e.g., "MyAppNotificationServiceExtension") -4. Click "Finish" (Cancel the "Activate scheme" dialog) -5. Note: Use this exact name consistently in Podfile, entitlements path, and SPM setup -\`\`\` - -**Implement NotificationService.swift:** - -\`\`\`swift -import UserNotifications -import Clix - -class NotificationService: ClixNotificationServiceExtension { - override func didReceive( - _ request: UNNotificationRequest, - withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void - ) { - register(projectId: "YOUR_PROJECT_ID") - super.didReceive(request, withContentHandler: contentHandler) - } -} -\`\`\` - -**Note:** Replace \`YOUR_PROJECT_ID\` with your actual Clix project ID from - -**Add Clix SDK to Extension Target:** - -For CocoaPods projects, add to Podfile: -\`\`\`ruby -target '{AppName}NotificationServiceExtension' do - pod 'Clix' -end -\`\`\` -Then run: \`cd ios && pod install\` - -For SPM projects in Xcode: -1. Select the extension target -2. Go to General > Frameworks, Libraries, and Embedded Content -3. Click + and add the Clix package - -**Configure Build Settings (Xcode 15+):** - -For the extension target: -- Set \`ENABLE_USER_SCRIPT_SANDBOXING\` to "No" in Build Settings - -For React Native projects with Firebase: -- In Build Phases, move "Embed Foundation Extensions" above "[RNFB] Core Configuration" - -### Phase 4: Apple Developer Portal Configuration - -Guide user through manual portal configuration. These steps CANNOT be automated. - -**Enable Capabilities on App ID:** -\`\`\`text -1. Go to https://developer.apple.com/account -2. Navigate to "Certificates, Identifiers & Profiles" -3. Select "Identifiers" from the sidebar -4. Find and click your App ID (Bundle ID) -5. Scroll down to "Capabilities" section -6. Enable "Push Notifications" - - You may need to configure certificates (Development/Production) -7. Enable "App Groups" -8. Click "Save" -\`\`\` - -**Register App Group ID:** -\`\`\`text -1. In the sidebar, select "Identifiers" -2. Click the "+" button -3. Select "App Groups" and click "Continue" -4. Enter: - - Description: Clix SDK App Group for {App Name} - - Identifier: group.clix.{BUNDLE_ID} -5. Click "Continue" then "Register" -6. Go back to your App ID and associate the App Group: - - Edit your App ID - - Under "App Groups", click "Configure" - - Select the App Group you just created - - Click "Save" -\`\`\` - -**Regenerate Provisioning Profile:** -\`\`\`text -After enabling capabilities, your provisioning profiles become invalid. - -1. Navigate to "Profiles" in the sidebar -2. Find your Development and/or Distribution profile -3. Click on the profile -4. Click "Edit" or delete and recreate the profile -5. Ensure the updated App ID is selected -6. Download the new profile - -In Xcode: -1. Go to Xcode > Settings (or Preferences) > Accounts -2. Select your Apple ID -3. Click "Download Manual Profiles" - Or: Delete old profiles and let Xcode auto-manage -\`\`\` - -### Phase 5: Verification - -After configuration, verify the setup and output a report. - -**Check Entitlements Files:** -- Main app entitlements contains \`aps-environment\` -- Main app entitlements contains \`com.apple.security.application-groups\` -- Extension entitlements contains matching App Group ID - -**Check project.pbxproj (if accessible):** -- Look for \`SystemCapabilities\` dictionary -- Verify \`com.apple.Push\` is enabled -- Verify \`com.apple.ApplicationGroups.iOS\` is enabled - -**Output Verification Report:** - -\`\`\`json -{ - "project": "{project_name}", - "bundleId": "{bundle_id}", - "capabilities": { - "pushNotifications": { - "entitlementFile": true, - "environment": "development", - "xcodeCapability": "verify manually in Xcode", - "developerPortal": "verify manually at developer.apple.com" - }, - "appGroups": { - "groupId": "group.clix.{bundle_id}", - "mainAppEntitlement": true, - "extensionEntitlement": true, - "developerPortal": "verify manually at developer.apple.com" - } - }, - "nextSteps": [ - "Verify capabilities are added in Xcode Signing & Capabilities", - "Confirm App Group ID is registered in Apple Developer Portal", - "Regenerate provisioning profiles if needed", - "Build and run to verify no signing errors" - ] -} -\`\`\` - -## Common Issues and Solutions - -### Missing Entitlements File - -**Symptom:** No \`.entitlements\` file exists in the project. - -**Solution:** -- Xcode automatically creates one when you add your first capability -- Or create manually and link in Build Settings: - 1. Create \`{AppName}.entitlements\` file - 2. In Xcode, select target > Build Settings - 3. Search for "Code Signing Entitlements" - 4. Set the path to your entitlements file - -### App Group ID Mismatch - -**Symptom:** Data not shared between app and extension. - -**Solution:** -- Verify the App Group ID is EXACTLY the same in both targets -- Format must be: \`group.clix.{BUNDLE_ID}\` -- Check both entitlements files have identical values - -### Provisioning Profile Invalid - -**Symptom:** "Provisioning profile doesn't include the X capability" error. - -**Solution:** -1. Go to Apple Developer Portal -2. Delete the old provisioning profile -3. Create a new one with the updated App ID -4. Download and install in Xcode -5. Or enable "Automatically manage signing" in Xcode - -### Push Notifications Not Working - -**Symptom:** Push notifications not received. - -**Checklist:** -- [ ] Push Notifications capability added in Xcode -- [ ] \`aps-environment\` in entitlements (check value matches build config) -- [ ] Push Notifications enabled on App ID in Developer Portal -- [ ] APNs certificate or key configured in Clix console -- [ ] Provisioning profile regenerated after enabling capability -- [ ] Physical device used (simulator doesn't receive push) - -### App Group Data Not Shared - -**Symptom:** MMKV data not accessible from extension. - -**Checklist:** -- [ ] App Groups capability added to BOTH main app AND extension -- [ ] Same App Group ID in both targets' entitlements -- [ ] App Group ID registered in Developer Portal -- [ ] App Group associated with App ID in Developer Portal - -## Automation Rules - -**CAN automate (use Write/Edit tools):** -- Creating entitlements files -- Modifying existing entitlements files -- Reading project configuration files -- Detecting current capabilities status - -**CANNOT automate (provide instructions only):** -- Adding capabilities in Xcode UI (Signing & Capabilities tab) -- Enabling capabilities in Apple Developer Portal -- Registering App Group IDs in Developer Portal -- Generating/downloading provisioning profiles -- Associating App Groups with App IDs - -For manual steps, provide clear instructions and proceed without waiting for confirmation. - -## Output Format - -After completing the workflow, summarize: - -1. **Files Created/Modified** - - List all entitlements files with full paths - - Show what was added or changed - -2. **Manual Steps Required** - - Xcode capability additions - - Developer Portal configurations - -3. **Verification Checklist** - - JSON report with status of each component - - Next steps for user to complete - -4. **Troubleshooting Tips** - - Common issues to watch for based on project state `, }; diff --git a/src/lib/skills.ts b/src/lib/skills.ts index d6a6c25..d39a0c0 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -58,12 +58,7 @@ const LOCAL_SKILLS: SkillInfo[] = [ description: 'Interactive debugging assistant', isLocal: true, }, - { - type: 'ios-setup', - name: 'iOS Setup', - description: 'Configure iOS capabilities for push notifications and app groups', - isLocal: true, - }, + // NOTE: ios-setup is now a LocalJSXCommand in registry.ts, not a skill ]; /** @@ -321,8 +316,6 @@ async function getLocalSkillPrompt(skillType: SkillType, options?: SkillOptions) projectPath: options?.projectPath ?? process.cwd(), oneShot: options?.oneShot, }); - case 'ios-setup': - return getIosSetupPrompt(options); default: throw new Error(`Unknown local skill: ${skillType}`); } @@ -350,28 +343,6 @@ function getDoctorPrompt(options?: SkillOptions): string { return prompt; } -/** - * Get prompt for the ios-setup skill. - * Configures iOS capabilities for push notifications and app groups. - * Prompt is loaded from src/lib/skills/ios-setup/SKILL.md - */ -function getIosSetupPrompt(options?: SkillOptions): string { - const projectPath = options?.projectPath ?? process.cwd(); - - let prompt = `Project path: ${projectPath}\n\n`; - - // Add one-shot instruction for autonomous execution - if (options?.oneShot) { - prompt += `${ONE_SHOT_INSTRUCTION}\n\n`; - } - - // Load the ios-setup prompt from external file - const iosSetupPrompt = readLocalSkillPrompt('ios-setup'); - prompt += iosSetupPrompt; - - return prompt; -} - export async function* executeSkill( skillType: SkillType, executor: AgentExecutor, diff --git a/src/lib/skills/ios-setup/SKILL.md b/src/lib/skills/ios-setup/SKILL.md deleted file mode 100644 index f42786f..0000000 --- a/src/lib/skills/ios-setup/SKILL.md +++ /dev/null @@ -1,370 +0,0 @@ -# iOS Capabilities Configuration - -You are an AI agent that configures iOS capabilities required for the Clix SDK. - -## Core Directive - -**GUIDE USERS** through iOS capability configuration for push notifications and data sharing. For file modifications, use Edit/Write tools when possible. For Xcode-only steps, provide clear step-by-step instructions. - -## Required Capabilities for Clix iOS SDK - -### 1. Push Notifications - -- **Purpose:** Enable APNs (Apple Push Notification service) communication -- **Entitlement Key:** `aps-environment` -- **Values:** `development` (debug builds) or `production` (release builds) -- **Xcode Capability:** Push Notifications - -### 2. App Groups - -- **Purpose:** Share data between main app and Notification Service Extension using MMKV -- **Entitlement Key:** `com.apple.security.application-groups` -- **ID Format:** `group.clix.{BUNDLE_ID}` (e.g., `group.clix.com.example.myapp`) -- **Xcode Capability:** App Groups -- **Important:** Must be configured for BOTH main app AND Notification Service Extension targets - -## Workflow - -### Phase 1: Project Analysis - -1. **Detect iOS Project** - - Search for `*.xcodeproj` or `*.xcworkspace` files - - Identify the main app target name - - Check if this is a native iOS, React Native, or Flutter project - -2. **Find Bundle Identifier** - - Check `Info.plist` for `CFBundleIdentifier` - - Or parse `project.pbxproj` for `PRODUCT_BUNDLE_IDENTIFIER` - -3. **Check Current Capabilities Status** - - Search for existing `*.entitlements` files - - Check for `aps-environment` entitlement (Push Notifications configured) - - Check for `com.apple.security.application-groups` (App Groups configured) - - Check `project.pbxproj` for `SystemCapabilities` section - -4. **Report Current State** - Output findings: - ```text - Project: {project_name} - Bundle ID: {bundle_id} - Push Notifications: {configured/not configured} - App Groups: {configured/not configured} - Existing entitlements files: {list} - ``` - -### Phase 2: Xcode Configuration (Manual Steps) - -Provide clear instructions for adding capabilities in Xcode. These steps CANNOT be automated and require user action in Xcode IDE. - -**Add Push Notifications:** -```text -1. Open your project in Xcode -2. Select your main app target in the Navigator (left sidebar) -3. Go to the "Signing & Capabilities" tab -4. Click the "+ Capability" button -5. Search for and select "Push Notifications" -6. Xcode will automatically create an entitlements file if one doesn't exist -``` - -**Add Background Modes (Recommended):** -```text -1. In "Signing & Capabilities", click "+ Capability" -2. Select "Background Modes" -3. Enable "Remote notifications" checkbox - - This allows the app to process push notifications in the background -``` - -**Add App Groups:** -```text -1. Click "+ Capability" -2. Select "App Groups" -3. Click the "+" button under App Groups -4. Enter the App Group ID: group.clix.{BUNDLE_ID} - Example: group.clix.com.example.myapp -5. Click OK to create the group - -IMPORTANT: Repeat steps 1-5 for the Notification Service Extension target: -1. Select the extension target (usually named "{AppName}NotificationServiceExtension") -2. Go to "Signing & Capabilities" -3. Add "App Groups" capability -4. Select the SAME App Group ID you created above -``` - -### Phase 3: Entitlements Files - -Create or modify entitlements files. Use Write/Edit tools for these operations. - -**Main App Entitlements** (`{AppName}.entitlements` or `{AppName}/{AppName}.entitlements`): - -```xml - - - - - aps-environment - development - com.apple.security.application-groups - - group.clix.{BUNDLE_ID} - - - -``` - -**Notification Service Extension Entitlements** (`{ExtensionName}/{ExtensionName}.entitlements`): - -```xml - - - - - com.apple.security.application-groups - - group.clix.{BUNDLE_ID} - - - -``` - -**Note:** Replace `{BUNDLE_ID}` with the actual bundle identifier (e.g., `com.example.myapp`). - -### Phase 3.5: Notification Service Extension Setup - -Create a Notification Service Extension for rich push notifications (images, buttons, etc.). - -**Create Extension Target in Xcode:** -```text -1. File > New > Target -2. Select "Notification Service Extension" -3. Name it "{AppName}NotificationServiceExtension" (e.g., "MyAppNotificationServiceExtension") -4. Click "Finish" (Cancel the "Activate scheme" dialog) -5. Note: Use this exact name consistently in Podfile, entitlements path, and SPM setup -``` - -**Implement NotificationService.swift:** - -```swift -import UserNotifications -import Clix - -class NotificationService: ClixNotificationServiceExtension { - override func didReceive( - _ request: UNNotificationRequest, - withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void - ) { - register(projectId: "YOUR_PROJECT_ID") - super.didReceive(request, withContentHandler: contentHandler) - } -} -``` - -**Note:** Replace `YOUR_PROJECT_ID` with your actual Clix project ID from - -**Add Clix SDK to Extension Target:** - -For CocoaPods projects, add to Podfile: -```ruby -target '{AppName}NotificationServiceExtension' do - pod 'Clix' -end -``` -Then run: `cd ios && pod install` - -For SPM projects in Xcode: -1. Select the extension target -2. Go to General > Frameworks, Libraries, and Embedded Content -3. Click + and add the Clix package - -**Configure Build Settings (Xcode 15+):** - -For the extension target: -- Set `ENABLE_USER_SCRIPT_SANDBOXING` to "No" in Build Settings - -For React Native projects with Firebase: -- In Build Phases, move "Embed Foundation Extensions" above "[RNFB] Core Configuration" - -### Phase 4: Apple Developer Portal Configuration - -Guide user through manual portal configuration. These steps CANNOT be automated. - -**Enable Capabilities on App ID:** -```text -1. Go to https://developer.apple.com/account -2. Navigate to "Certificates, Identifiers & Profiles" -3. Select "Identifiers" from the sidebar -4. Find and click your App ID (Bundle ID) -5. Scroll down to "Capabilities" section -6. Enable "Push Notifications" - - You may need to configure certificates (Development/Production) -7. Enable "App Groups" -8. Click "Save" -``` - -**Register App Group ID:** -```text -1. In the sidebar, select "Identifiers" -2. Click the "+" button -3. Select "App Groups" and click "Continue" -4. Enter: - - Description: Clix SDK App Group for {App Name} - - Identifier: group.clix.{BUNDLE_ID} -5. Click "Continue" then "Register" -6. Go back to your App ID and associate the App Group: - - Edit your App ID - - Under "App Groups", click "Configure" - - Select the App Group you just created - - Click "Save" -``` - -**Regenerate Provisioning Profile:** -```text -After enabling capabilities, your provisioning profiles become invalid. - -1. Navigate to "Profiles" in the sidebar -2. Find your Development and/or Distribution profile -3. Click on the profile -4. Click "Edit" or delete and recreate the profile -5. Ensure the updated App ID is selected -6. Download the new profile - -In Xcode: -1. Go to Xcode > Settings (or Preferences) > Accounts -2. Select your Apple ID -3. Click "Download Manual Profiles" - Or: Delete old profiles and let Xcode auto-manage -``` - -### Phase 5: Verification - -After configuration, verify the setup and output a report. - -**Check Entitlements Files:** -- Main app entitlements contains `aps-environment` -- Main app entitlements contains `com.apple.security.application-groups` -- Extension entitlements contains matching App Group ID - -**Check project.pbxproj (if accessible):** -- Look for `SystemCapabilities` dictionary -- Verify `com.apple.Push` is enabled -- Verify `com.apple.ApplicationGroups.iOS` is enabled - -**Output Verification Report:** - -```json -{ - "project": "{project_name}", - "bundleId": "{bundle_id}", - "capabilities": { - "pushNotifications": { - "entitlementFile": true, - "environment": "development", - "xcodeCapability": "verify manually in Xcode", - "developerPortal": "verify manually at developer.apple.com" - }, - "appGroups": { - "groupId": "group.clix.{bundle_id}", - "mainAppEntitlement": true, - "extensionEntitlement": true, - "developerPortal": "verify manually at developer.apple.com" - } - }, - "nextSteps": [ - "Verify capabilities are added in Xcode Signing & Capabilities", - "Confirm App Group ID is registered in Apple Developer Portal", - "Regenerate provisioning profiles if needed", - "Build and run to verify no signing errors" - ] -} -``` - -## Common Issues and Solutions - -### Missing Entitlements File - -**Symptom:** No `.entitlements` file exists in the project. - -**Solution:** -- Xcode automatically creates one when you add your first capability -- Or create manually and link in Build Settings: - 1. Create `{AppName}.entitlements` file - 2. In Xcode, select target > Build Settings - 3. Search for "Code Signing Entitlements" - 4. Set the path to your entitlements file - -### App Group ID Mismatch - -**Symptom:** Data not shared between app and extension. - -**Solution:** -- Verify the App Group ID is EXACTLY the same in both targets -- Format must be: `group.clix.{BUNDLE_ID}` -- Check both entitlements files have identical values - -### Provisioning Profile Invalid - -**Symptom:** "Provisioning profile doesn't include the X capability" error. - -**Solution:** -1. Go to Apple Developer Portal -2. Delete the old provisioning profile -3. Create a new one with the updated App ID -4. Download and install in Xcode -5. Or enable "Automatically manage signing" in Xcode - -### Push Notifications Not Working - -**Symptom:** Push notifications not received. - -**Checklist:** -- [ ] Push Notifications capability added in Xcode -- [ ] `aps-environment` in entitlements (check value matches build config) -- [ ] Push Notifications enabled on App ID in Developer Portal -- [ ] APNs certificate or key configured in Clix console -- [ ] Provisioning profile regenerated after enabling capability -- [ ] Physical device used (simulator doesn't receive push) - -### App Group Data Not Shared - -**Symptom:** MMKV data not accessible from extension. - -**Checklist:** -- [ ] App Groups capability added to BOTH main app AND extension -- [ ] Same App Group ID in both targets' entitlements -- [ ] App Group ID registered in Developer Portal -- [ ] App Group associated with App ID in Developer Portal - -## Automation Rules - -**CAN automate (use Write/Edit tools):** -- Creating entitlements files -- Modifying existing entitlements files -- Reading project configuration files -- Detecting current capabilities status - -**CANNOT automate (provide instructions only):** -- Adding capabilities in Xcode UI (Signing & Capabilities tab) -- Enabling capabilities in Apple Developer Portal -- Registering App Group IDs in Developer Portal -- Generating/downloading provisioning profiles -- Associating App Groups with App IDs - -For manual steps, provide clear instructions and proceed without waiting for confirmation. - -## Output Format - -After completing the workflow, summarize: - -1. **Files Created/Modified** - - List all entitlements files with full paths - - Show what was added or changed - -2. **Manual Steps Required** - - Xcode capability additions - - Developer Portal configurations - -3. **Verification Checklist** - - JSON report with status of each component - - Next steps for user to complete - -4. **Troubleshooting Tips** - - Common issues to watch for based on project state diff --git a/src/ui/components/FirebaseWizard.tsx b/src/ui/components/FirebaseWizard.tsx index 1c5943f..742d3cf 100644 --- a/src/ui/components/FirebaseWizard.tsx +++ b/src/ui/components/FirebaseWizard.tsx @@ -1,10 +1,10 @@ -import { spawn } from 'node:child_process'; import { Box, Text, useInput } from 'ink'; import SelectInput from 'ink-select-input'; import Spinner from 'ink-spinner'; import TextInput from 'ink-text-input'; import type React from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { openBrowser } from '@/lib/auth/browser'; import { type AndroidApp, type CredentialAction, @@ -172,31 +172,6 @@ function buildMenuItems(result: FirebaseDetectionResult): MenuAction[] { return items; } -/** - * Open URL in default browser. - * Uses spawn with argument array to prevent shell injection. - */ -function openBrowser(url: string): void { - const platform = process.platform; - - let command: string; - let args: string[]; - - if (platform === 'darwin') { - command = 'open'; - args = [url]; - } else if (platform === 'win32') { - // Windows 'start' requires empty title as first arg for URLs - command = 'cmd'; - args = ['/c', 'start', '""', url]; - } else { - command = 'xdg-open'; - args = [url]; - } - - spawn(command, args, { detached: true, stdio: 'ignore' }).unref(); -} - /** * Authenticating phase component. */ From 369dcc5b7ad570aad6be56ac6599f2ab4050a5bc Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Mon, 2 Feb 2026 19:29:33 +0900 Subject: [PATCH 3/9] feat(ios): add Apple account login for APNS key management - Add Apple account authentication with session restoration - Skip password if already authenticated - Use @expo/apple-utils for session management - Store credentials securely in macOS Keychain - Add APNS key management features - Create new keys via Apple account - List existing keys with download status - Download keys (if canDownload is true) - Save P8 files to project directory - Add pbxproj and Podfile manipulation modules - pbxproj-modifier for NSE target management - podfile-modifier for CocoaPods integration - Fix bundling issues - Upgrade simple-plist to 1.4.0 (ESM compatible) - Add package overrides for transitive deps - Update NotificationService template to use init() pattern --- bun.lock | 24 +- package.json | 8 +- scripts/build.ts | 2 + src/commands/ios-setup/index.tsx | 329 ++++++++++++-- src/lib/embedded-skills.ts | 10 +- src/lib/ios/agent-prompt-generator.ts | 10 +- src/lib/ios/apple-auth.ts | 430 ++++++++++++++++++ src/lib/ios/extension-templates.ts | 16 +- src/lib/ios/index.ts | 63 +++ src/lib/ios/keychain.ts | 123 +++++ src/lib/ios/pbxproj-modifier.ts | 232 ++++++++++ src/lib/ios/podfile-modifier.ts | 166 +++++++ src/lib/ios/push-key.ts | 176 ++++++++ src/lib/push/types.ts | 3 +- src/lib/skills/install/SKILL.md | 10 +- src/types/keychain.d.ts | 25 + src/types/xcode.d.ts | 59 +++ src/ui/components/AppleLoginUI.tsx | 627 ++++++++++++++++++++++++++ src/ui/components/PushSetupWizard.tsx | 73 ++- 19 files changed, 2330 insertions(+), 56 deletions(-) create mode 100644 src/lib/ios/apple-auth.ts create mode 100644 src/lib/ios/keychain.ts create mode 100644 src/lib/ios/pbxproj-modifier.ts create mode 100644 src/lib/ios/podfile-modifier.ts create mode 100644 src/lib/ios/push-key.ts create mode 100644 src/types/keychain.d.ts create mode 100644 src/types/xcode.d.ts create mode 100644 src/ui/components/AppleLoginUI.tsx diff --git a/bun.lock b/bun.lock index 1975ec9..b310a85 100644 --- a/bun.lock +++ b/bun.lock @@ -14,11 +14,14 @@ "ink-select-input": "^6.2.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", + "keychain": "^1.5.0", "meow": "^14.0.0", "open": "^11.0.0", "picocolors": "^1.1.1", "plist": "^3.1.0", "react": "^19.2.3", + "simple-plist": "1.4.0", + "xcode": "^3.0.1", "xdg-app-paths": "^8.3.0", "zod": "^4.3.5", }, @@ -34,6 +37,9 @@ }, }, }, + "overrides": { + "simple-plist": "1.4.0", + }, "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ=="], @@ -179,12 +185,18 @@ "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "bmp-js": ["bmp-js@0.1.0", "", {}, "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="], + "bplist-creator": ["bplist-creator@0.1.1", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-Ese7052fdWrxp/vqSJkydgx/1MdBnNOCV2XVfbmdGWD2H6EYza+Q4pyYSuVSnCUD22hfI/BFI4jHaC3NLXLlJQ=="], + + "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], + "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -441,6 +453,8 @@ "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "keychain": ["keychain@1.5.0", "", {}, "sha512-liyp4r+93RI7EB2jhwaRd4MWfdgHH6shuldkaPMkELCJjMFvOOVXuTvw1pGqFfhsrgA6OqfykWWPQgBjQakVag=="], + "kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], "lint-staged": ["lint-staged@16.2.7", "", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="], @@ -587,12 +601,16 @@ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "simple-plist": ["simple-plist@1.4.0", "", { "dependencies": { "bplist-creator": "0.1.1", "bplist-parser": "0.3.2", "plist": "^3.0.5" } }, "sha512-Emr2CR0T6cfQlbXxk7KtpU183WpJXWdl9c7D8uTtduX7bzVO1A6yTO6BauGzbWQhdOfpggcc9s0PN8+JyG/2gQ=="], + "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="], "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "stream-buffers": ["stream-buffers@2.2.0", "", {}, "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg=="], + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], "string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], @@ -651,7 +669,7 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "uuid": ["uuid@3.4.0", "", { "bin": { "uuid": "./bin/uuid" } }, "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="], + "uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="], "verror": ["verror@1.10.0", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw=="], @@ -679,6 +697,8 @@ "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="], + "xdg-app-paths": ["xdg-app-paths@8.3.0", "", { "dependencies": { "xdg-portable": "^10.6.0" }, "optionalDependencies": { "fsevents": "*" } }, "sha512-mgxlWVZw0TNWHoGmXq+NC3uhCIc55dDpAlDkMQUaIAcQzysb0kxctwv//fvuW61/nAAeUBJMQ8mnZjMmuYwOcQ=="], "xdg-portable": ["xdg-portable@10.6.0", "", { "dependencies": { "os-paths": "^7.4.0" }, "optionalDependencies": { "fsevents": "*" } }, "sha512-xrcqhWDvtZ7WLmt8G4f3hHy37iK7D2idtosRgkeiSPZEPmBShp0VfmRBLWAPC6zLF48APJ21yfea+RfQMF4/Aw=="], @@ -743,6 +763,8 @@ "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "request/uuid": ["uuid@3.4.0", "", { "bin": { "uuid": "./bin/uuid" } }, "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="], + "string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "string-width-cjs/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], diff --git a/package.json b/package.json index 6214054..766bf24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@clix-so/clix-cli", - "version": "1.1.2-beta.1", + "version": "1.1.2-beta.2", "description": "A CLI tool for integrating and managing the Clix SDK in your mobile projects", "type": "module", "bin": { @@ -55,11 +55,14 @@ "ink-select-input": "^6.2.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", + "keychain": "^1.5.0", "meow": "^14.0.0", "open": "^11.0.0", "picocolors": "^1.1.1", "plist": "^3.1.0", "react": "^19.2.3", + "simple-plist": "1.4.0", + "xcode": "^3.0.1", "xdg-app-paths": "^8.3.0", "zod": "^4.3.5" }, @@ -75,5 +78,8 @@ }, "engines": { "node": ">=20.0.0" + }, + "overrides": { + "simple-plist": "1.4.0" } } diff --git a/scripts/build.ts b/scripts/build.ts index a0e5c1a..2f82c07 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -53,6 +53,8 @@ async function build() { 'ws', 'bufferutil', 'utf-8-validate', + // @expo/plist uses relative requires - must be external + '@expo/plist', ], define: { // Disable dev mode to prevent react-devtools-core import diff --git a/src/commands/ios-setup/index.tsx b/src/commands/ios-setup/index.tsx index 3024b8e..9af52c1 100644 --- a/src/commands/ios-setup/index.tsx +++ b/src/commands/ios-setup/index.tsx @@ -1,5 +1,17 @@ import { Box, render, Text, useInput } from 'ink'; +import Spinner from 'ink-spinner'; import type React from 'react'; +import { + addClixToExtensionTarget, + addNotificationServiceExtension, + backupPodfile, + backupProject, + createExtensionFiles, + type ExtensionContext, + getExtensionBundleId, + getExtensionName, + hasPodfile, +} from '../../lib/ios'; import type { PushSetupResult } from '../../lib/push'; import { type GuidedSetupContext, @@ -25,6 +37,21 @@ export interface IosSetupCommandOptions { pushEnvironment?: 'development' | 'production'; } +/** + * Result of automated project modification phase. + */ +interface ProjectModificationResult { + success: boolean; + extensionFilesCreated: boolean; + pbxprojModified: boolean; + podfileModified: boolean; + createdFiles: string[]; + warnings: string[]; + /** If true, fall back to guided wizard for manual steps */ + requiresManualSteps: boolean; + error?: string; +} + function toDirectSetupOutput(result: IosSetupResult): FinalOutputResult { if (result.success) { const details: string[] = []; @@ -91,9 +118,189 @@ async function runDirectSetup(options: IosSetupCommandOptions): Promise + + + + + {status} + + {warnings.map((warning) => ( + + ⚠ {warning} + + ))} + + ); +} + +/** + * Print the result of project modification. + */ +function printModificationResult(result: ProjectModificationResult): void { + if (!result.extensionFilesCreated && !result.pbxprojModified && !result.podfileModified) { + return; + } + + console.log(''); + if (result.extensionFilesCreated) { + console.log('✓ Extension files created'); + for (const file of result.createdFiles) { + console.log(` • ${file}`); + } + } + if (result.pbxprojModified) { + console.log('✓ Xcode project updated (NSE target added)'); + } + if (result.podfileModified) { + console.log('✓ Podfile updated (extension target added)'); + console.log(' Run: cd ios && pod install'); + } + if (result.warnings.length > 0) { + console.log(''); + for (const warning of result.warnings) { + console.log(`⚠ ${warning}`); + } + } +} + +/** + * Execute the project modification steps. + */ +async function executeProjectModification( + directResult: IosSetupResult, + result: ProjectModificationResult, + updateStatus: (status: string) => void, +): Promise { + const { agentContext } = directResult; + if (!agentContext) return; + + const extensionName = getExtensionName(agentContext.appName); + const extensionBundleId = getExtensionBundleId(agentContext.bundleId, agentContext.appName); + const extensionDir = `${agentContext.iosDir}/${extensionName}`; + + // 1. Create extension files + const extContext: ExtensionContext = { + appName: agentContext.appName, + bundleId: agentContext.bundleId, + iosDir: agentContext.iosDir, + pushEnvironment: 'development', + }; + + const extResult = await createExtensionFiles(extContext); + if (extResult.success) { + result.extensionFilesCreated = true; + result.createdFiles.push(...extResult.createdFiles); + } else { + result.warnings.push(extResult.error || 'Failed to create extension files'); + } + + // 2. Modify pbxproj + updateStatus('Modifying Xcode project...'); + backupProject(agentContext.projectPath); + + const pbxResult = await addNotificationServiceExtension({ + projectPath: agentContext.projectPath, + extensionName, + extensionBundleId, + extensionDir, + appGroupId: agentContext.appGroupId, + teamId: directResult.projectInfo?.teamId, + deploymentTarget: '14.0', + }); + + if (pbxResult.success) { + result.pbxprojModified = pbxResult.targetAdded; + result.warnings.push(...pbxResult.warnings); + } else { + result.warnings.push(pbxResult.error || 'Failed to modify pbxproj'); + result.requiresManualSteps = true; + } + + // 3. Modify Podfile (if exists) + if (hasPodfile(agentContext.iosDir)) { + updateStatus('Updating Podfile...'); + backupPodfile(agentContext.iosDir); + + const podResult = await addClixToExtensionTarget({ + iosDir: agentContext.iosDir, + extensionName, + }); + + if (podResult.success) { + result.podfileModified = podResult.modified; + } else { + result.warnings.push(podResult.error || 'Failed to modify Podfile'); + } + } + + result.success = true; +} + +/** + * Run automated project modification phase. + * Creates extension files and modifies pbxproj/Podfile programmatically. + */ +async function runProjectModification( + directResult: IosSetupResult, +): Promise { + const result: ProjectModificationResult = { + success: false, + extensionFilesCreated: false, + pbxprojModified: false, + podfileModified: false, + createdFiles: [], + warnings: [], + requiresManualSteps: false, + }; + + if (!directResult.agentContext) { + result.success = true; + return result; + } + + // Render progress UI + const displayWarnings: string[] = []; + let currentStatus = 'Creating extension files...'; + + const { unmount, rerender } = render( + , + { incrementalRendering: true }, + ); + + const updateStatus = (status: string) => { + currentStatus = status; + displayWarnings.push(...result.warnings.filter((w) => !displayWarnings.includes(w))); + rerender(); + }; + + try { + await executeProjectModification(directResult, result, updateStatus); + } catch (error) { + result.error = error instanceof Error ? error.message : String(error); + result.requiresManualSteps = true; + } finally { + unmount(); + } + + printModificationResult(result); + return result; +} + /** * Run the guided setup phase (Extension file generation + Xcode configuration guide) - * Replaces the agent-based approach with static file generation and step-by-step guide + * Used as fallback when automated modification fails or for manual verification. */ async function runGuidedSetup( directResult: IosSetupResult, @@ -203,11 +410,66 @@ async function runPushSetup(directResult: IosSetupResult): Promise { + // Phase 1: Direct implementation (Portal sync + Entitlements) + const directResult = await runDirectSetup(options); + + if (!directResult.success) { + printFinalOutput(toDirectSetupOutput(directResult)); + return; + } + + // Show direct setup completion + printFinalOutput(toDirectSetupOutput(directResult)); + + // Phase 2: Automated project modification (pbxproj + Podfile) + let modificationResult: ProjectModificationResult | undefined; + let guidedResult: GuidedSetupResult | undefined; + + if (directResult.agentContext) { + console.log('\n'); // Add spacing before modification phase + modificationResult = await runProjectModification(directResult); + + // Fall back to guided setup if automated modification failed or requires manual steps + if (modificationResult.requiresManualSteps) { + console.log('\n'); // Add spacing before guided setup + console.log('Some steps require manual configuration. Starting guided setup...'); + console.log(''); + guidedResult = await runGuidedSetup(directResult); + } + } + + // Phase 3: Push setup (optional - APNS key + Firebase) + // Only ask if Phase 1 & 2 were successful + const phase2Success = + modificationResult?.success && !modificationResult.requiresManualSteps + ? true + : (guidedResult?.success ?? true); + + if (directResult.success && phase2Success) { + console.log('\n'); // Add spacing before push setup prompt + const shouldSetupPush = await askPushSetupConfirmation(); + + if (shouldSetupPush) { + const pushResult = await runPushSetup(directResult); + printConsolidatedOutputWithModification( + directResult, + modificationResult, + guidedResult, + pushResult, + ); + } else { + printConsolidatedOutputWithModification(directResult, modificationResult, guidedResult, null); + } + } +} + /** - * Print consolidated output for all phases + * Print consolidated output including modification results. */ -function printConsolidatedOutput( +function printConsolidatedOutputWithModification( directResult: IosSetupResult, + modificationResult: ProjectModificationResult | undefined, guidedResult: GuidedSetupResult | undefined, pushResult: PushSetupResult | null, ): void { @@ -223,14 +485,18 @@ function printConsolidatedOutput( console.log('✓ Entitlements created'); } - // Phase 2 summary - if (guidedResult?.success) { + // Phase 2 summary (modification or guided) + if (modificationResult?.extensionFilesCreated) { console.log('✓ Extension files created'); - if (guidedResult.createdFiles.length > 0) { - for (const file of guidedResult.createdFiles) { - console.log(` • ${file}`); - } - } + } + if (modificationResult?.pbxprojModified) { + console.log('✓ Xcode project updated'); + } + if (modificationResult?.podfileModified) { + console.log('✓ Podfile updated'); + } + if (guidedResult?.success) { + console.log('✓ Guided setup completed'); } // Phase 3 summary @@ -244,41 +510,16 @@ function printConsolidatedOutput( console.log('○ APNS key setup skipped'); } + // Warnings + if (modificationResult?.warnings && modificationResult.warnings.length > 0) { + console.log(''); + console.log('Warnings:'); + for (const warning of modificationResult.warnings) { + console.log(` ⚠ ${warning}`); + } + } + console.log(''); console.log('Your iOS app is ready to receive push notifications!'); console.log(''); } - -export async function runIosSetupCommand(options: IosSetupCommandOptions): Promise { - // Phase 1: Direct implementation (Portal sync + Entitlements) - const directResult = await runDirectSetup(options); - - if (!directResult.success) { - printFinalOutput(toDirectSetupOutput(directResult)); - return; - } - - // Show direct setup completion - printFinalOutput(toDirectSetupOutput(directResult)); - - // Phase 2: Guided setup (Extension file generation + Xcode configuration guide) - let guidedResult: GuidedSetupResult | undefined; - if (directResult.agentContext) { - console.log('\n'); // Add spacing before guided setup phase - guidedResult = await runGuidedSetup(directResult); - } - - // Phase 3: Push setup (optional - APNS key + Firebase) - // Only ask if Phase 1 & 2 were successful - if (directResult.success && (!guidedResult || guidedResult.success)) { - console.log('\n'); // Add spacing before push setup prompt - const shouldSetupPush = await askPushSetupConfirmation(); - - if (shouldSetupPush) { - const pushResult = await runPushSetup(directResult); - printConsolidatedOutput(directResult, guidedResult, pushResult); - } else { - printConsolidatedOutput(directResult, guidedResult, null); - } - } -} diff --git a/src/lib/embedded-skills.ts b/src/lib/embedded-skills.ts index 877b0aa..8d3286f 100644 --- a/src/lib/embedded-skills.ts +++ b/src/lib/embedded-skills.ts @@ -1264,11 +1264,19 @@ For rich push notifications (images, buttons), create a Notification Service Ext import Clix class NotificationService: ClixNotificationServiceExtension { + override init() { + super.init() + register(projectId: "YOUR_PROJECT_ID") + } + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - register(projectId: "YOUR_PROJECT_ID") super.didReceive(request, withContentHandler: contentHandler) } + + override func serviceExtensionTimeWillExpire() { + super.serviceExtensionTimeWillExpire() + } } \`\`\` 4. Add App Groups capability to both main app and extension (same group ID: \`group.clix.{BUNDLE_ID}\`) diff --git a/src/lib/ios/agent-prompt-generator.ts b/src/lib/ios/agent-prompt-generator.ts index 32f2f16..5f8652b 100644 --- a/src/lib/ios/agent-prompt-generator.ts +++ b/src/lib/ios/agent-prompt-generator.ts @@ -56,13 +56,21 @@ import UserNotifications import Clix class NotificationService: ClixNotificationServiceExtension { + override init() { + super.init() + register(projectId: "YOUR_PROJECT_ID") + } + override func didReceive( _ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void ) { - register(projectId: "YOUR_PROJECT_ID") super.didReceive(request, withContentHandler: contentHandler) } + + override func serviceExtensionTimeWillExpire() { + super.serviceExtensionTimeWillExpire() + } } \`\`\` diff --git a/src/lib/ios/apple-auth.ts b/src/lib/ios/apple-auth.ts new file mode 100644 index 0000000..324e18a --- /dev/null +++ b/src/lib/ios/apple-auth.ts @@ -0,0 +1,430 @@ +/** + * Apple account authentication for iOS setup. + * Supports both API Key and User (Apple ID/Password) authentication. + * Based on EAS CLI implementation using @expo/apple-utils. + * + * @module ios/apple-auth + */ + +import * as fs from 'node:fs'; +import { + Auth, + InvalidUserCredentialsError, + JsonFileCache, + type RequestContext, + Session, + Teams, + Token, +} from '@expo/apple-utils'; + +import { + CLIX_NO_KEYCHAIN, + deletePasswordAsync, + getAppleKeychainServiceName, + getPasswordAsync, + setPasswordAsync, +} from './keychain'; + +/** + * Authentication modes supported by the Apple APIs. + */ +export enum AuthenticationMode { + /** App Store Connect API Key (JWT-based, CI-friendly, no 2FA) */ + API_KEY = 'API_KEY', + /** User credentials (cookie-based, more features, requires 2FA) */ + USER = 'USER', +} + +/** + * Apple team information. + */ +export interface AppleTeam { + id: string; + name?: string; + inHouse: boolean; +} + +/** + * API Key authentication configuration. + */ +export interface ApiKeyAuthConfig { + keyId: string; + issuerId: string; + keyP8: string; +} + +/** + * User authentication context (Apple ID/Password). + */ +export interface UserAuthContext { + appleId: string; + appleIdPassword?: string; + team: AppleTeam; + authState: Session.AuthState; + fastlaneSession?: string; +} + +/** + * API Key authentication context. + */ +export interface ApiKeyAuthContext { + team: AppleTeam; + authState: { + context: RequestContext; + }; + ascApiKey: ApiKeyAuthConfig; +} + +/** + * Combined authentication context. + */ +export type AuthContext = UserAuthContext | ApiKeyAuthContext; + +/** + * Authentication options. + */ +export interface AuthOptions { + appleId?: string; + teamId?: string; + teamName?: string; + ascApiKey?: ApiKeyAuthConfig; + cookies?: Session.AuthState['cookies']; + mode?: AuthenticationMode; +} + +/** + * Check if auth context is user-based. + */ +export function isUserAuthContext(authCtx: AuthContext | undefined): authCtx is UserAuthContext { + return !!authCtx && typeof (authCtx as UserAuthContext).appleId === 'string'; +} + +/** + * Get request context from auth context. + */ +export function getRequestContext(authCtx: AuthContext): RequestContext { + if (isUserAuthContext(authCtx)) { + if (!authCtx.authState?.context) { + throw new Error('Apple request context must be defined'); + } + return authCtx.authState.context; + } + return authCtx.authState.context; +} + +/** + * Prompt for Apple ID (username). + */ +export async function promptAppleIdAsync( + promptFn: (message: string, defaultValue?: string) => Promise, +): Promise { + const lastAppleId = await getCachedUsernameAsync(); + + console.log('› Log in to your Apple Developer account to continue'); + + let username = await promptFn('Apple ID:', lastAppleId ?? undefined); + + // Remove any unprintable control characters (ASCII 0-31) + username = removeControlCharacters(username); + + if (username && username !== lastAppleId) { + await cacheUsernameAsync(username); + } + + return username; +} + +/** + * Prompt for Apple ID password. + */ +export async function promptPasswordAsync( + username: string, + promptFn: (message: string) => Promise, +): Promise { + const cachedPassword = await getCachedPasswordAsync(username); + + if (cachedPassword) { + console.log(`› Using password for ${username} from your local Keychain`); + return cachedPassword; + } + + console.log('› The password is only used to authenticate with Apple and never stored on servers'); + + const password = await promptFn(`Password (for ${username}):`); + + await cachePasswordAsync(username, password); + return password; +} + +/** + * Cache username to file. + */ +async function cacheUsernameAsync(username: string): Promise { + if (!CLIX_NO_KEYCHAIN && username) { + const cachedPath = JsonFileCache.usernameCachePath(); + await JsonFileCache.cacheAsync(cachedPath, { username }); + } +} + +/** + * Get cached username from file. + */ +async function getCachedUsernameAsync(): Promise { + if (CLIX_NO_KEYCHAIN) { + try { + await fs.promises.unlink(JsonFileCache.usernameCachePath()); + } catch { + // File may not exist + } + return null; + } + + const cached = await JsonFileCache.getCacheAsync(JsonFileCache.usernameCachePath()); + const lastAppleId = cached?.username ?? null; + return typeof lastAppleId === 'string' ? lastAppleId : null; +} + +/** + * Cache password to Keychain. + */ +async function cachePasswordAsync(username: string, password: string): Promise { + if (CLIX_NO_KEYCHAIN) { + console.log('› Skip storing Apple ID password in the local Keychain.'); + return false; + } + + console.log('› Saving Apple ID password to the local Keychain'); + const serviceName = getAppleKeychainServiceName(username); + return setPasswordAsync({ username, password, serviceName }); +} + +/** + * Get cached password from Keychain. + */ +async function getCachedPasswordAsync(username: string): Promise { + if (CLIX_NO_KEYCHAIN) { + await deletePasswordAsync({ username, serviceName: getAppleKeychainServiceName(username) }); + return null; + } + + const serviceName = getAppleKeychainServiceName(username); + return getPasswordAsync({ username, serviceName }); +} + +/** + * Delete cached password from Keychain. + */ +export async function deleteCachedPasswordAsync(username: string): Promise { + const serviceName = getAppleKeychainServiceName(username); + const success = await deletePasswordAsync({ username, serviceName }); + if (success) { + console.log('› Removed Apple ID password from the native Keychain'); + } + return success; +} + +/** + * Login with Apple ID credentials. + * Handles session restoration, 2FA prompts (via @expo/apple-utils), and password caching. + */ +export async function loginWithUserCredentialsAsync( + promptAppleId: (message: string, defaultValue?: string) => Promise, + promptPassword: (message: string) => Promise, + promptConfirm: (message: string) => Promise, + options: { + cookies?: Session.AuthState['cookies']; + teamId?: string; + providerId?: number; + } = {}, +): Promise { + // Try login with cookies first + if (options.cookies) { + const session = await Auth.loginWithCookiesAsync({ cookies: options.cookies }); + if (session) { + return await buildUserAuthContext(session); + } + } + + // Get username + const username = await promptAppleIdAsync(promptAppleId); + + // Clear in-memory data + Auth.resetInMemoryData(); + + try { + // Try restoring session + const restoredSession = await Auth.tryRestoringAuthStateFromUserCredentialsAsync( + { + username, + providerId: options.providerId, + teamId: options.teamId, + }, + { autoResolveProvider: true }, + ); + + if (restoredSession) { + return await buildUserAuthContext({ ...restoredSession }); + } + + // Full login with password + const password = await promptPasswordAsync(username, promptPassword); + const newSession = await Auth.loginWithUserCredentialsAsync( + { + username, + password, + providerId: options.providerId, + teamId: options.teamId, + }, + { autoResolveProvider: true }, + ); + + if (!newSession) { + throw new Error('An unexpected error occurred while completing authentication'); + } + + return await buildUserAuthContext({ password, ...newSession }); + } catch (error) { + if (error instanceof InvalidUserCredentialsError) { + console.error(error.message); + await deleteCachedPasswordAsync(username); + + const retry = await promptConfirm('Would you like to try again?'); + if (retry) { + return loginWithUserCredentialsAsync(promptAppleId, promptPassword, promptConfirm, { + teamId: options.teamId, + providerId: options.providerId, + }); + } + throw new Error('ABORTED'); + } + throw error; + } +} + +/** + * Build UserAuthContext from session. + */ +async function buildUserAuthContext(authState: Session.AuthState): Promise { + const teamId = authState.context.teamId; + + if (!teamId) { + throw new Error('Team ID not found in authentication state'); + } + + // Get all teams to resolve user data + const teams = await Teams.getTeamsAsync(); + const team = teams.find((t) => t.teamId === teamId); + + if (!team) { + throw new Error(`Your account is not associated with Apple Team with ID: ${teamId}`); + } + + const fastlaneSession = Session.getSessionAsYAML(); + + return { + appleId: authState.username, + appleIdPassword: authState.password, + team: { + id: team.teamId, + name: `${team.name} (${team.type})`, + inHouse: team.type.toLowerCase() === 'in-house', + }, + authState, + fastlaneSession, + }; +} + +/** + * Authenticate with API Key. + */ +export async function authenticateWithApiKeyAsync( + apiKey: ApiKeyAuthConfig, + teamId?: string, +): Promise { + const token = new Token({ + key: apiKey.keyP8, + issuerId: apiKey.issuerId, + keyId: apiKey.keyId, + duration: 1200, // 20 minutes + }); + + return { + team: { + id: teamId || '', + inHouse: false, + }, + authState: { + context: { token }, + }, + ascApiKey: apiKey, + }; +} + +/** + * Check if API Key environment variables are set. + */ +export function hasApiKeyEnvVars(): boolean { + return !!( + process.env.EXPO_ASC_API_KEY_PATH || + process.env.EXPO_ASC_KEY_ID || + process.env.EXPO_ASC_ISSUER_ID || + process.env.CLIX_ASC_API_KEY_PATH || + process.env.CLIX_ASC_KEY_ID || + process.env.CLIX_ASC_ISSUER_ID + ); +} + +/** + * Check if Apple ID environment variables are set. + */ +export function hasAppleIdEnvVars(): boolean { + return !!(process.env.EXPO_APPLE_ID || process.env.CLIX_APPLE_ID); +} + +/** + * Get API Key from environment variables. + */ +export async function getApiKeyFromEnvAsync(): Promise { + const keyPath = process.env.EXPO_ASC_API_KEY_PATH || process.env.CLIX_ASC_API_KEY_PATH; + const keyId = process.env.EXPO_ASC_KEY_ID || process.env.CLIX_ASC_KEY_ID; + const issuerId = process.env.EXPO_ASC_ISSUER_ID || process.env.CLIX_ASC_ISSUER_ID; + + if (!keyPath && !keyId && !issuerId) { + return null; + } + + if (!keyPath || !keyId || !issuerId) { + throw new Error( + 'Incomplete API Key configuration. Please provide all of: API Key Path, Key ID, and Issuer ID.', + ); + } + + if (!fs.existsSync(keyPath)) { + throw new Error(`API Key file not found: ${keyPath}`); + } + + const keyP8 = fs.readFileSync(keyPath, 'utf-8'); + return { keyId, issuerId, keyP8 }; +} + +/** + * Get Apple ID from environment variables. + */ +export function getAppleIdFromEnv(): string | null { + return process.env.EXPO_APPLE_ID || process.env.CLIX_APPLE_ID || null; +} + +/** + * Remove control characters (ASCII 0-31) from a string. + * Used to sanitize user input that may contain unprintable characters. + */ +function removeControlCharacters(str: string): string { + let result = ''; + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code >= 32) { + result += str[i]; + } + } + return result; +} diff --git a/src/lib/ios/extension-templates.ts b/src/lib/ios/extension-templates.ts index 81ac90b..9be4cb1 100644 --- a/src/lib/ios/extension-templates.ts +++ b/src/lib/ios/extension-templates.ts @@ -3,21 +3,29 @@ */ /** - * NotificationService.swift template for Clix SDK integration + * NotificationService.swift template for Clix SDK integration. + * Based on https://docs.clix.so/sdk-ios-nse */ export const NOTIFICATION_SERVICE_TEMPLATE = `import UserNotifications import Clix class NotificationService: ClixNotificationServiceExtension { + override init() { + super.init() + // Register with Clix (replace with your project ID from https://console.clix.so/) + register(projectId: "YOUR_PROJECT_ID") + } + override func didReceive( _ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void ) { - // Register with Clix (replace with your project ID from https://console.clix.so/) - register(projectId: "YOUR_PROJECT_ID") - super.didReceive(request, withContentHandler: contentHandler) } + + override func serviceExtensionTimeWillExpire() { + super.serviceExtensionTimeWillExpire() + } } `; diff --git a/src/lib/ios/index.ts b/src/lib/ios/index.ts index 74c9671..4c2349a 100644 --- a/src/lib/ios/index.ts +++ b/src/lib/ios/index.ts @@ -3,6 +3,27 @@ // Agent context (used to pass setup context between phases) export { type AgentContext, buildAgentContext } from './agent-prompt-generator'; +// Apple account authentication (supports both API Key and User login) +export { + type ApiKeyAuthConfig as ApiKeyAuthConfigNew, + type ApiKeyAuthContext, + type AppleTeam, + type AuthContext, + AuthenticationMode, + type AuthOptions, + authenticateWithApiKeyAsync, + deleteCachedPasswordAsync, + getApiKeyFromEnvAsync, + getAppleIdFromEnv, + getRequestContext, + hasApiKeyEnvVars, + hasAppleIdEnvVars, + isUserAuthContext, + loginWithUserCredentialsAsync, + promptAppleIdAsync, + promptPasswordAsync, + type UserAuthContext, +} from './apple-auth'; // Apple Developer Portal integration export { type ApiKeyAuthConfig, @@ -40,6 +61,37 @@ export { verifyExtensionFiles, } from './extension-generator'; export { generatePodfileSnippet } from './extension-templates'; +// Keychain integration +export { + CLIX_NO_KEYCHAIN, + deletePasswordAsync as deleteKeychainPasswordAsync, + getAppleKeychainServiceName, + getPasswordAsync as getKeychainPasswordAsync, + isKeychainAvailable, + type KeychainCredentials, + setPasswordAsync as setKeychainPasswordAsync, +} from './keychain'; +// pbxproj manipulation +export { + addNotificationServiceExtension, + backupProject, + getProjectTargets, + hasNotificationServiceExtension, + type PbxprojModificationResult, + type PbxprojModifierOptions, + restoreProject, +} from './pbxproj-modifier'; +// Podfile manipulation +export { + addClixToExtensionTarget, + backupPodfile, + getPodfileTargets, + hasExtensionTarget, + hasPodfile, + type PodfileModificationResult, + type PodfileModifierOptions, + restorePodfile, +} from './podfile-modifier'; export { analyzeIosProject, findEntitlementsFiles, @@ -47,3 +99,14 @@ export { type IosProjectInfo, type ProjectAnalysisResult, } from './project-analyzer'; +// Push Key management +export { + APPLE_KEYS_TOO_MANY_ERROR, + createPushKeyAsync, + downloadPushKeyAsync, + isPushKeyValid, + listPushKeysAsync, + type PushKey, + type PushKeyStoreInfo, + revokePushKeysAsync, +} from './push-key'; diff --git a/src/lib/ios/keychain.ts b/src/lib/ios/keychain.ts new file mode 100644 index 0000000..b3fec02 --- /dev/null +++ b/src/lib/ios/keychain.ts @@ -0,0 +1,123 @@ +/** + * macOS Keychain integration for secure credential storage. + * Based on EAS CLI implementation. + * + * @module ios/keychain + */ + +import keychain from 'keychain'; + +const KEYCHAIN_TYPE = 'internet'; +const NO_PASSWORD_REGEX = /Could not find password/; +const IS_MAC = process.platform === 'darwin'; + +/** + * Environment variable to disable keychain functionality. + * When set, passwords will be skipped and existing ones deleted. + */ +export const CLIX_NO_KEYCHAIN = process.env.CLIX_NO_KEYCHAIN; + +export interface KeychainCredentials { + serviceName: string; + username: string; + password: string; +} + +/** + * Delete a password from the macOS Keychain. + */ +export async function deletePasswordAsync({ + username, + serviceName, +}: Pick): Promise { + if (!IS_MAC) { + return Promise.resolve(false); + } + + return new Promise((resolve, reject) => { + keychain.deletePassword( + { account: username, service: serviceName, type: KEYCHAIN_TYPE }, + (error: Error) => { + if (error) { + if (NO_PASSWORD_REGEX.test(error.message)) { + resolve(false); + return; + } + reject(error); + } else { + resolve(true); + } + }, + ); + }); +} + +/** + * Get a password from the macOS Keychain. + */ +export async function getPasswordAsync({ + username, + serviceName, +}: Pick): Promise { + if (!IS_MAC) { + return null; + } + + return new Promise((resolve, reject) => { + keychain.getPassword( + { account: username, service: serviceName, type: KEYCHAIN_TYPE }, + (error: Error, password?: string) => { + if (error) { + if (NO_PASSWORD_REGEX.test(error.message)) { + resolve(null); + return; + } + reject(error); + } else { + resolve(password ?? null); + } + }, + ); + }); +} + +/** + * Store a password in the macOS Keychain. + */ +export async function setPasswordAsync({ + serviceName, + username, + password, +}: KeychainCredentials): Promise { + if (!IS_MAC) { + return Promise.resolve(false); + } + + return new Promise((resolve, reject) => { + keychain.setPassword( + { account: username, service: serviceName, password, type: KEYCHAIN_TYPE }, + (error: Error) => { + if (error) { + reject(error); + } else { + resolve(true); + } + }, + ); + }); +} + +/** + * Get the keychain service name for Apple ID credentials. + * Uses the same format as Fastlane for potential interoperability. + */ +export function getAppleKeychainServiceName(appleId: string): string { + return `deliver.${appleId}`; +} + +/** + * Check if keychain functionality is available. + */ +export function isKeychainAvailable(): boolean { + return IS_MAC && !CLIX_NO_KEYCHAIN; +} diff --git a/src/lib/ios/pbxproj-modifier.ts b/src/lib/ios/pbxproj-modifier.ts new file mode 100644 index 0000000..366200c --- /dev/null +++ b/src/lib/ios/pbxproj-modifier.ts @@ -0,0 +1,232 @@ +/** + * Xcode project (.pbxproj) modifier for iOS setup automation. + * Uses the 'xcode' npm package to programmatically modify Xcode projects. + * + * @module ios/pbxproj-modifier + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import xcode from 'xcode'; + +const NSE_PRODUCT_TYPE = 'com.apple.product-type.app-extension'; + +/** + * Options for pbxproj modification operations. + */ +export interface PbxprojModifierOptions { + /** Path to .xcodeproj directory */ + projectPath: string; + /** Extension target name (e.g., "AppNotificationServiceExtension") */ + extensionName: string; + /** Extension bundle ID (e.g., "com.example.app.AppNotificationServiceExtension") */ + extensionBundleId: string; + /** Path where extension files are located */ + extensionDir: string; + /** App group for shared data */ + appGroupId: string; + /** Development team ID */ + teamId?: string; + /** iOS deployment target (default: "14.0") */ + deploymentTarget?: string; +} + +/** + * Result of pbxproj modification operations. + */ +export interface PbxprojModificationResult { + success: boolean; + targetAdded: boolean; + filesLinked: string[]; + buildSettingsApplied: string[]; + warnings: string[]; + error?: string; +} + +/** + * Check if NSE target already exists in the project. + */ +export function hasNotificationServiceExtension( + projectPath: string, + extensionName: string, +): boolean { + const pbxprojPath = path.join(projectPath, 'project.pbxproj'); + if (!fs.existsSync(pbxprojPath)) { + return false; + } + + try { + const project = xcode.project(pbxprojPath); + project.parseSync(); + + const targets = project.pbxNativeTargetSection(); + if (!targets) return false; + + for (const key of Object.keys(targets)) { + const target = targets[key] as { name?: string } | undefined; + if (target && typeof target === 'object' && target.name === extensionName) { + return true; + } + } + return false; + } catch { + return false; + } +} + +/** + * Create backup of the Xcode project file. + */ +export function backupProject(projectPath: string): string { + const pbxprojPath = path.join(projectPath, 'project.pbxproj'); + const backupPath = `${pbxprojPath}.backup.${Date.now()}`; + fs.copyFileSync(pbxprojPath, backupPath); + return backupPath; +} + +/** + * Restore project from backup. + */ +export function restoreProject(backupPath: string, projectPath: string): void { + const pbxprojPath = path.join(projectPath, 'project.pbxproj'); + fs.copyFileSync(backupPath, pbxprojPath); +} + +/** + * Add Notification Service Extension target to Xcode project. + */ +export async function addNotificationServiceExtension( + options: PbxprojModifierOptions, +): Promise { + const result: PbxprojModificationResult = { + success: false, + targetAdded: false, + filesLinked: [], + buildSettingsApplied: [], + warnings: [], + }; + + const pbxprojPath = path.join(options.projectPath, 'project.pbxproj'); + + if (!fs.existsSync(pbxprojPath)) { + result.error = `Project file not found: ${pbxprojPath}`; + return result; + } + + try { + // 1. Parse project + const project = xcode.project(pbxprojPath); + project.parseSync(); + + // 2. Check if target already exists + if (hasNotificationServiceExtension(options.projectPath, options.extensionName)) { + result.warnings.push('NSE target already exists, skipping target creation'); + result.success = true; + return result; + } + + // 3. Add NSE target + const target = project.addTarget( + options.extensionName, + NSE_PRODUCT_TYPE, + options.extensionName, + options.extensionBundleId, + ); + + if (!target) { + result.error = 'Failed to add target to project'; + return result; + } + + result.targetAdded = true; + + // 4. Add source file (NotificationService.swift) + const swiftFile = path.join(options.extensionDir, 'NotificationService.swift'); + if (fs.existsSync(swiftFile)) { + const groupKey = project.findPBXGroupKey({ name: options.extensionName }); + if (groupKey) { + project.addSourceFile('NotificationService.swift', { target: target.uuid }, groupKey); + result.filesLinked.push('NotificationService.swift'); + } else { + result.warnings.push('Could not find group for extension, files may need manual linking'); + } + } + + // 5. Set build settings for the extension target + const deploymentTarget = options.deploymentTarget || '14.0'; + const buildSettings: Record = { + CODE_SIGN_ENTITLEMENTS: `${options.extensionName}/${options.extensionName}.entitlements`, + ENABLE_USER_SCRIPT_SANDBOXING: 'NO', + INFOPLIST_FILE: `${options.extensionName}/Info.plist`, + PRODUCT_BUNDLE_IDENTIFIER: options.extensionBundleId, + IPHONEOS_DEPLOYMENT_TARGET: deploymentTarget, + SWIFT_VERSION: '5.0', + TARGETED_DEVICE_FAMILY: '1,2', + GENERATE_INFOPLIST_FILE: 'NO', + }; + + if (options.teamId) { + buildSettings.DEVELOPMENT_TEAM = options.teamId; + } + + // Apply build settings to both Debug and Release configurations + for (const [setting, value] of Object.entries(buildSettings)) { + try { + project.updateBuildProperty(setting, value, null, options.extensionName); + result.buildSettingsApplied.push(setting); + } catch { + result.warnings.push(`Could not set ${setting}, may need manual configuration`); + } + } + + // 6. Add target dependency to main app (embed extension) + const firstTarget = project.getFirstTarget(); + if (firstTarget?.uuid) { + try { + project.addTargetDependency(firstTarget.uuid, [target.uuid]); + } catch { + result.warnings.push( + 'Could not add target dependency, extension may need manual embedding', + ); + } + } + + // 7. Write changes to project file + fs.writeFileSync(pbxprojPath, project.writeSync()); + + result.success = true; + } catch (error) { + result.error = error instanceof Error ? error.message : String(error); + } + + return result; +} + +/** + * Get all native targets in the project. + */ +export function getProjectTargets(projectPath: string): string[] { + const pbxprojPath = path.join(projectPath, 'project.pbxproj'); + if (!fs.existsSync(pbxprojPath)) { + return []; + } + + try { + const project = xcode.project(pbxprojPath); + project.parseSync(); + + const targets = project.pbxNativeTargetSection(); + if (!targets) return []; + + const targetNames: string[] = []; + for (const key of Object.keys(targets)) { + const target = targets[key] as { name?: string } | undefined; + if (target && typeof target === 'object' && target.name) { + targetNames.push(target.name); + } + } + return targetNames; + } catch { + return []; + } +} diff --git a/src/lib/ios/podfile-modifier.ts b/src/lib/ios/podfile-modifier.ts new file mode 100644 index 0000000..0dfaf46 --- /dev/null +++ b/src/lib/ios/podfile-modifier.ts @@ -0,0 +1,166 @@ +/** + * Podfile modifier for iOS setup automation. + * Adds Clix SDK to extension targets in CocoaPods projects. + * + * @module ios/podfile-modifier + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * Options for Podfile modification operations. + */ +export interface PodfileModifierOptions { + /** iOS project directory containing Podfile */ + iosDir: string; + /** NSE target name */ + extensionName: string; + /** Custom pod spec (default: 'Clix') */ + clixPodSpec?: string; +} + +/** + * Result of Podfile modification operations. + */ +export interface PodfileModificationResult { + success: boolean; + modified: boolean; + podfileExists: boolean; + targetAdded: boolean; + error?: string; +} + +/** + * Check if Podfile exists in the iOS directory. + */ +export function hasPodfile(iosDir: string): boolean { + return fs.existsSync(path.join(iosDir, 'Podfile')); +} + +/** + * Check if extension target already exists in Podfile. + */ +export function hasExtensionTarget(iosDir: string, extensionName: string): boolean { + const podfilePath = path.join(iosDir, 'Podfile'); + if (!fs.existsSync(podfilePath)) return false; + + const content = fs.readFileSync(podfilePath, 'utf-8'); + const targetRegex = new RegExp(`target\\s+['"]${escapeRegex(extensionName)}['"]\\s+do`, 'i'); + return targetRegex.test(content); +} + +/** + * Create backup of Podfile before modification. + */ +export function backupPodfile(iosDir: string): string { + const podfilePath = path.join(iosDir, 'Podfile'); + const backupPath = `${podfilePath}.backup.${Date.now()}`; + fs.copyFileSync(podfilePath, backupPath); + return backupPath; +} + +/** + * Restore Podfile from backup. + */ +export function restorePodfile(backupPath: string, iosDir: string): void { + const podfilePath = path.join(iosDir, 'Podfile'); + fs.copyFileSync(backupPath, podfilePath); +} + +/** + * Add Clix SDK to extension target in Podfile. + */ +export async function addClixToExtensionTarget( + options: PodfileModifierOptions, +): Promise { + const result: PodfileModificationResult = { + success: false, + modified: false, + podfileExists: false, + targetAdded: false, + }; + + const podfilePath = path.join(options.iosDir, 'Podfile'); + + if (!fs.existsSync(podfilePath)) { + // Not an error - just skip for non-CocoaPods projects + result.success = true; + return result; + } + + result.podfileExists = true; + + try { + let content = fs.readFileSync(podfilePath, 'utf-8'); + + // Check if target already exists + if (hasExtensionTarget(options.iosDir, options.extensionName)) { + result.success = true; + return result; + } + + // Generate target block + const targetBlock = generateTargetBlock(options.extensionName, options.clixPodSpec); + + // Find insertion point + // Strategy: Insert before post_install hook, or at the end of the file + const postInstallMatch = content.match(/^post_install\s+do/m); + + if (postInstallMatch && postInstallMatch.index !== undefined) { + // Insert before post_install + content = + content.slice(0, postInstallMatch.index) + + targetBlock + + '\n\n' + + content.slice(postInstallMatch.index); + } else { + // Append at the end + content = `${content.trimEnd()}\n\n${targetBlock}\n`; + } + + fs.writeFileSync(podfilePath, content); + + result.success = true; + result.modified = true; + result.targetAdded = true; + } catch (error) { + result.error = error instanceof Error ? error.message : String(error); + } + + return result; +} + +/** + * Generate Podfile target block for extension. + */ +function generateTargetBlock(extensionName: string, clixPodSpec?: string): string { + const podSpec = clixPodSpec || 'Clix'; + return `target '${extensionName}' do + pod '${podSpec}' +end`; +} + +/** + * Escape special regex characters in a string. + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Get all target names from Podfile. + */ +export function getPodfileTargets(iosDir: string): string[] { + const podfilePath = path.join(iosDir, 'Podfile'); + if (!fs.existsSync(podfilePath)) return []; + + try { + const content = fs.readFileSync(podfilePath, 'utf-8'); + const targetRegex = /target\s+['"]([^'"]+)['"]\s+do/gi; + const matches = content.matchAll(targetRegex); + return Array.from(matches, (m) => m[1]); + } catch { + return []; + } +} diff --git a/src/lib/ios/push-key.ts b/src/lib/ios/push-key.ts new file mode 100644 index 0000000..b3cdf09 --- /dev/null +++ b/src/lib/ios/push-key.ts @@ -0,0 +1,176 @@ +/** + * Apple Push Notification Service (APNS) Key management. + * Uses @expo/apple-utils Keys module for creating and managing push keys. + * + * Note: Push Key operations require USER authentication (Apple ID/Password), + * not API Key authentication. This is a limitation of Apple's API. + * + * @module ios/push-key + */ + +import { Keys } from '@expo/apple-utils'; + +import { getRequestContext, type UserAuthContext } from './apple-auth'; + +/** + * Push Key information. + */ +export interface PushKey { + /** Key ID from Apple Developer Portal */ + apnsKeyId: string; + /** Key content (.p8 format) */ + apnsKeyP8: string; + /** Apple Developer Team ID */ + teamId: string; + /** Apple Developer Team Name */ + teamName?: string; +} + +/** + * Push Key store information (from Apple's listing). + */ +export interface PushKeyStoreInfo { + id: string; + name: string; + canDownload: boolean; + canRevoke: boolean; +} + +/** + * Error message when maximum keys are reached. + */ +export const APPLE_KEYS_TOO_MANY_ERROR = ` +You can have only two Apple Keys generated on your Apple Developer account. +Revoke the old ones or reuse existing from your other apps. +Remember that Apple Keys are not application specific! +`; + +const { MaxKeysCreatedError } = Keys; + +/** + * Format current date for key naming. + */ +function formatDateForKeyName(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + return `${year}${month}${day}${hours}${minutes}${seconds}`; +} + +/** + * List all existing push keys on Apple servers. + * + * **Requires USER authentication (Apple ID/Password), not API Key.** + */ +export async function listPushKeysAsync(userAuthCtx: UserAuthContext): Promise { + const context = getRequestContext(userAuthCtx); + const keys = await Keys.getKeysAsync(context); + return keys.map((key) => ({ + id: key.id, + name: key.name, + canDownload: key.canDownload, + canRevoke: key.canRevoke, + })); +} + +/** + * Create a new push key on Apple servers. + * + * **Requires USER authentication (Apple ID/Password), not API Key.** + * + * @param userAuthCtx User authentication context + * @param name Optional custom name for the key (defaults to "Clix Push Notifications Key {timestamp}") + */ +export async function createPushKeyAsync( + userAuthCtx: UserAuthContext, + name?: string, +): Promise { + const keyName = name || `Clix Push Notifications Key ${formatDateForKeyName()}`; + + try { + const context = getRequestContext(userAuthCtx); + + // Create the key with APNS capability + const key = await Keys.createKeyAsync(context, { + name: keyName, + isApns: true, + }); + + // Download the key content (.p8) + const apnsKeyP8 = await Keys.downloadKeyAsync(context, { id: key.id }); + + return { + apnsKeyId: key.id, + apnsKeyP8, + teamId: userAuthCtx.team.id, + teamName: userAuthCtx.team.name, + }; + } catch (err: unknown) { + const error = err as { rawDump?: { resultString?: string } }; + const resultString = error.rawDump?.resultString; + + if ( + err instanceof MaxKeysCreatedError || + (typeof resultString === 'string' && resultString.includes('maximum allowed number of Keys')) + ) { + throw new Error(APPLE_KEYS_TOO_MANY_ERROR); + } + throw err; + } +} + +/** + * Revoke existing push keys on Apple servers. + * + * **Requires USER authentication (Apple ID/Password), not API Key.** + * + * @param userAuthCtx User authentication context + * @param ids Key IDs to revoke + */ +export async function revokePushKeysAsync( + userAuthCtx: UserAuthContext, + ids: string[], +): Promise { + const context = getRequestContext(userAuthCtx); + await Promise.all(ids.map((id) => Keys.revokeKeyAsync(context, { id }))); +} + +/** + * Download an existing push key from Apple servers. + * + * **Requires USER authentication (Apple ID/Password), not API Key.** + * **Note: Keys can only be downloaded once. If canDownload is false, this will fail.** + * + * @param userAuthCtx User authentication context + * @param keyId The key ID to download + */ +export async function downloadPushKeyAsync( + userAuthCtx: UserAuthContext, + keyId: string, +): Promise { + const context = getRequestContext(userAuthCtx); + const apnsKeyP8 = await Keys.downloadKeyAsync(context, { id: keyId }); + + return { + apnsKeyId: keyId, + apnsKeyP8, + teamId: userAuthCtx.team.id, + teamName: userAuthCtx.team.name, + }; +} + +/** + * Check if a push key is available for APNS. + */ +export function isPushKeyValid(key: PushKey): boolean { + return !!( + key.apnsKeyId && + key.apnsKeyP8 && + key.apnsKeyP8.includes('-----BEGIN PRIVATE KEY-----') && + key.teamId + ); +} diff --git a/src/lib/push/types.ts b/src/lib/push/types.ts index 9c36647..a90230a 100644 --- a/src/lib/push/types.ts +++ b/src/lib/push/types.ts @@ -36,7 +36,8 @@ export type PushSetupPhase = | 'detecting' // Analyzing project | 'status' // Showing current status | 'key_source' // Asking if user has existing key - | 'apple_guide' // Apple Portal guide (optional) + | 'apple_login' // Apple account login for auto key creation + | 'apple_guide' // Apple Portal guide (manual) | 'p8_input' // P8 + Key ID + Team ID input | 'validation' // Validating inputs | 'firebase_auth' // Authenticating with Firebase diff --git a/src/lib/skills/install/SKILL.md b/src/lib/skills/install/SKILL.md index 810806a..0d718ad 100644 --- a/src/lib/skills/install/SKILL.md +++ b/src/lib/skills/install/SKILL.md @@ -177,11 +177,19 @@ For rich push notifications (images, buttons), create a Notification Service Ext import Clix class NotificationService: ClixNotificationServiceExtension { + override init() { + super.init() + register(projectId: "YOUR_PROJECT_ID") + } + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - register(projectId: "YOUR_PROJECT_ID") super.didReceive(request, withContentHandler: contentHandler) } + + override func serviceExtensionTimeWillExpire() { + super.serviceExtensionTimeWillExpire() + } } ``` 4. Add App Groups capability to both main app and extension (same group ID: `group.clix.{BUNDLE_ID}`) diff --git a/src/types/keychain.d.ts b/src/types/keychain.d.ts new file mode 100644 index 0000000..51f8a04 --- /dev/null +++ b/src/types/keychain.d.ts @@ -0,0 +1,25 @@ +/** + * Type declarations for the 'keychain' npm package. + * @see https://github.com/nicksrandall/keychain + */ + +declare module 'keychain' { + interface KeychainOptions { + account: string; + service: string; + password?: string; + type?: 'generic' | 'internet'; + } + + type Callback = (error: Error, password?: string) => void; + + function getPassword(options: KeychainOptions, callback: Callback): void; + function setPassword(options: KeychainOptions, callback: (error: Error) => void): void; + function deletePassword(options: KeychainOptions, callback: (error: Error) => void): void; + + export = { + getPassword, + setPassword, + deletePassword, + }; +} diff --git a/src/types/xcode.d.ts b/src/types/xcode.d.ts new file mode 100644 index 0000000..4ec7b56 --- /dev/null +++ b/src/types/xcode.d.ts @@ -0,0 +1,59 @@ +/** + * Type declarations for the 'xcode' npm package. + * @see https://github.com/nicksrandall/xcode + */ + +declare module 'xcode' { + interface PBXTarget { + uuid: string; + pbxNativeTarget: { + name: string; + productType: string; + }; + } + + interface PBXProject { + parseSync(): void; + writeSync(): string; + + // Target operations + addTarget( + name: string, + productType: string, + subfolder: string, + bundleId: string, + ): PBXTarget | null; + getFirstTarget(): PBXTarget | null; + pbxNativeTargetSection(): Record | null; + addTargetDependency(target: string, dependencies: string[]): void; + + // File operations + addSourceFile(path: string, options: { target?: string }, group?: string): void; + addResourceFile(path: string, options?: { target?: string }): void; + + // Group operations + findPBXGroupKey(criteria: { name?: string; path?: string }): string | null; + addPbxGroup(files: string[], name: string, path: string): { uuid: string }; + + // Build settings + updateBuildProperty( + key: string, + value: string, + buildConfig: string | null, + targetName?: string, + ): void; + + // Build phases + addBuildPhase( + files: string[], + buildPhaseType: string, + comment: string, + target: string, + optionAlias?: string, + ): void; + } + + function project(projectPath: string): PBXProject; + + export = { project }; +} diff --git a/src/ui/components/AppleLoginUI.tsx b/src/ui/components/AppleLoginUI.tsx new file mode 100644 index 0000000..072b0dc --- /dev/null +++ b/src/ui/components/AppleLoginUI.tsx @@ -0,0 +1,627 @@ +/** + * Apple Account login UI component for Ink. + * + * Supports: + * - Session restoration (skip password if already authenticated) + * - Existing key selection with download option + * - New key creation + * + * @module ui/components/AppleLoginUI + */ + +import { Auth } from '@expo/apple-utils'; +import { Box, Text, useInput } from 'ink'; +import SelectInput from 'ink-select-input'; +import Spinner from 'ink-spinner'; +import TextInput from 'ink-text-input'; +import type React from 'react'; +import { useCallback, useState } from 'react'; +import { + createPushKeyAsync, + downloadPushKeyAsync, + listPushKeysAsync, + loginWithUserCredentialsAsync, + type PushKey, + type PushKeyStoreInfo, + type UserAuthContext, +} from '@/lib/ios'; + +type AppleLoginPhase = + | 'prompt_login' + | 'apple_id_input' + | 'restoring_session' + | 'password_input' + | 'logging_in' + | 'loading_keys' + | 'key_selection' + | 'creating_key' + | 'downloading_key' + | 'success' + | 'error'; + +interface AppleLoginUIProps { + onSuccess: (result: { authContext: UserAuthContext; pushKey: PushKey }) => void; + onCancel: () => void; + onFallback: () => void; +} + +/** + * Interactive Apple login UI that prompts for credentials and creates push key. + */ +export const AppleLoginUI: React.FC = ({ onSuccess, onCancel, onFallback }) => { + const [phase, setPhase] = useState('prompt_login'); + const [appleId, setAppleId] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [statusMessage, setStatusMessage] = useState(''); + + const [authContext, setAuthContext] = useState(null); + const [existingKeys, setExistingKeys] = useState([]); + + useInput((_input, key) => { + if (key.escape) { + onCancel(); + } + }); + + const handleStartLogin = useCallback(() => { + setPhase('apple_id_input'); + }, []); + + const handleManualSetup = useCallback(() => { + onFallback(); + }, [onFallback]); + + // Try to restore session after Apple ID is submitted + const handleAppleIdSubmit = useCallback(async () => { + if (!appleId.trim()) { + setError('Apple ID is required'); + return; + } + setError(null); + setPhase('restoring_session'); + setStatusMessage('Checking existing session...'); + + // Clear in-memory data + Auth.resetInMemoryData(); + + try { + // Try restoring session without password + const restoredSession = await Auth.tryRestoringAuthStateFromUserCredentialsAsync( + { username: appleId }, + { autoResolveProvider: true }, + ); + + if (restoredSession?.context.teamId) { + // Session restored! Build auth context and load keys + const ctx = await buildAuthContextFromSession(restoredSession, appleId); + setAuthContext(ctx); + setPhase('loading_keys'); + setStatusMessage('Loading existing keys...'); + + const keys = await listPushKeysAsync(ctx); + setExistingKeys(keys); + setPhase('key_selection'); + } else { + // No valid session, need password + setPhase('password_input'); + } + } catch { + // Session restoration failed, need password + setPhase('password_input'); + } + }, [appleId]); + + const handlePasswordSubmit = useCallback(async () => { + if (!password) { + setError('Password is required'); + return; + } + setError(null); + setPhase('logging_in'); + setStatusMessage('Authenticating with Apple...'); + + try { + // Create simple prompt functions for the login + const promptAppleIdFn = async () => appleId; + const promptPasswordFn = async () => password; + const promptConfirmFn = async () => false; + + const ctx = await loginWithUserCredentialsAsync( + promptAppleIdFn, + promptPasswordFn, + promptConfirmFn, + {}, + ); + + setAuthContext(ctx); + setPhase('loading_keys'); + setStatusMessage('Loading existing keys...'); + + const keys = await listPushKeysAsync(ctx); + setExistingKeys(keys); + setPhase('key_selection'); + } catch (err) { + const message = err instanceof Error ? err.message : 'Authentication failed'; + if (message === 'ABORTED') { + onCancel(); + return; + } + setError(message); + setPhase('error'); + } + }, [appleId, password, onCancel]); + + const handleCreateNewKey = useCallback(async () => { + if (!authContext) return; + + setPhase('creating_key'); + setStatusMessage('Creating new APNS key...'); + + try { + const pushKey = await createPushKeyAsync(authContext); + setPhase('success'); + onSuccess({ authContext, pushKey }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create key'; + setError(message); + setPhase('error'); + } + }, [authContext, onSuccess]); + + const handleDownloadKey = useCallback( + async (keyId: string) => { + if (!authContext) return; + + setPhase('downloading_key'); + setStatusMessage('Downloading key...'); + + try { + const pushKey = await downloadPushKeyAsync(authContext, keyId); + setPhase('success'); + onSuccess({ authContext, pushKey }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to download key'; + setError(message); + setPhase('error'); + } + }, + [authContext, onSuccess], + ); + + const handleRetry = useCallback(() => { + setError(null); + setPassword(''); + setPhase('apple_id_input'); + }, []); + + if (phase === 'prompt_login') { + return ( + + + Create APNS Key with Apple Account + + + + You can create an APNS key automatically by logging in with your Apple Developer + account. + + + + • Two-factor authentication may be required + + + + • Your password is only used for authentication and stored locally in Keychain + + + + + {'[L]'} + Log in with Apple Account + + + {'[M]'} + Manual setup (create key in browser) + + + {'[Esc]'} + Cancel + + + + + ); + } + + if (phase === 'apple_id_input') { + return ( + + + Apple Developer Account Login + + {error && ( + + ✗ {error} + + )} + + Apple ID (email): + + + {'> '} + + + + Enter to continue · Esc to cancel + + + ); + } + + if (phase === 'restoring_session') { + return ( + + + Apple Developer Account + + + + + + {statusMessage} + + + ); + } + + if (phase === 'password_input') { + return ( + + + Apple Developer Account Login + + + Apple ID: {appleId} + + {error && ( + + ✗ {error} + + )} + + Password: + + + {'> '} + + + + Your password is stored securely in your local Keychain + + + Enter to continue · Esc to cancel + + + ); + } + + if (phase === 'logging_in' || phase === 'loading_keys') { + return ( + + + Apple Developer Account + + + + + + {statusMessage} + + {phase === 'logging_in' && ( + + If prompted, check your device for 2FA code + + )} + + ); + } + + if (phase === 'key_selection' && authContext) { + return ( + + ); + } + + if (phase === 'creating_key' || phase === 'downloading_key') { + return ( + + + Apple Developer Account + + + + + + {statusMessage} + + + ); + } + + if (phase === 'success' && authContext) { + return ( + + + + ✓ APNS Key Ready + + + + Team: + {authContext.team.name || authContext.team.id} + + + ); + } + + if (phase === 'error') { + return ( + + + + Apple Login Error + + + + ✗ {error} + + + + {'[R]'} + Retry login + + + {'[M]'} + Manual setup instead + + + {'[Esc]'} + Cancel + + + + + ); + } + + return null; +}; + +/** + * Key selection phase component. + */ +const KeySelectionPhase: React.FC<{ + authContext: UserAuthContext; + existingKeys: PushKeyStoreInfo[]; + onCreateNew: () => void; + onDownload: (keyId: string) => void; + onManual: () => void; +}> = ({ authContext, existingKeys, onCreateNew, onDownload, onManual }) => { + const downloadableKeys = existingKeys.filter((k) => k.canDownload); + const hasDownloadableKeys = downloadableKeys.length > 0; + + // Build selection items + const items: Array<{ label: string; value: string }> = [ + { label: 'Create new APNS key', value: 'create_new' }, + ]; + + // Add downloadable keys + for (const key of downloadableKeys) { + items.push({ + label: `Download existing: ${key.name} (${key.id})`, + value: `download:${key.id}`, + }); + } + + items.push({ label: 'Manual setup (create key in browser)', value: 'manual' }); + + const handleSelect = (item: { value: string }) => { + if (item.value === 'create_new') { + onCreateNew(); + } else if (item.value === 'manual') { + onManual(); + } else if (item.value.startsWith('download:')) { + const keyId = item.value.replace('download:', ''); + onDownload(keyId); + } + }; + + return ( + + + + ✓ Logged in as {authContext.appleId} + + + + Team: {authContext.team.name || authContext.team.id} + + + {existingKeys.length > 0 && ( + + + Found {existingKeys.length} existing key(s) + {hasDownloadableKeys + ? ` (${downloadableKeys.length} downloadable)` + : ' (none downloadable)'} + + {!hasDownloadableKeys && existingKeys.length > 0 && ( + + Note: P8 keys can only be downloaded once when created + + )} + + )} + + + Select an option: + + + + + + Esc to cancel + + + ); +}; + +/** + * Build UserAuthContext from restored session. + */ +async function buildAuthContextFromSession( + authState: { context: { teamId?: string }; username: string }, + appleId: string, +): Promise { + const { Teams, Session } = await import('@expo/apple-utils'); + + const teamId = authState.context.teamId; + if (!teamId) { + throw new Error('Team ID not found in authentication state'); + } + + const teams = await Teams.getTeamsAsync(); + const team = teams.find((t) => t.teamId === teamId); + + if (!team) { + throw new Error(`Your account is not associated with Apple Team with ID: ${teamId}`); + } + + const fastlaneSession = Session.getSessionAsYAML(); + + return { + appleId: authState.username || appleId, + team: { + id: team.teamId, + name: `${team.name} (${team.type})`, + inHouse: team.type.toLowerCase() === 'in-house', + }, + authState: authState as UserAuthContext['authState'], + fastlaneSession, + }; +} + +/** + * Helper component for prompt_login phase input handling. + */ +const AppleLoginPromptInput: React.FC<{ + onLogin: () => void; + onManual: () => void; +}> = ({ onLogin, onManual }) => { + useInput((input) => { + if (input.toLowerCase() === 'l') { + onLogin(); + } else if (input.toLowerCase() === 'm') { + onManual(); + } + }); + return null; +}; + +/** + * Helper component for error phase input handling. + */ +const AppleLoginErrorInput: React.FC<{ + onRetry: () => void; + onManual: () => void; +}> = ({ onRetry, onManual }) => { + useInput((input) => { + if (input.toLowerCase() === 'r') { + onRetry(); + } else if (input.toLowerCase() === 'm') { + onManual(); + } + }); + return null; +}; diff --git a/src/ui/components/PushSetupWizard.tsx b/src/ui/components/PushSetupWizard.tsx index e36d854..751bfb1 100644 --- a/src/ui/components/PushSetupWizard.tsx +++ b/src/ui/components/PushSetupWizard.tsx @@ -32,6 +32,7 @@ import { isOAuthConfigured, } from '@/lib/services/firebase'; import type { GoogleServiceInfoPlist, GoogleServicesJson } from '@/lib/services/firebase/types'; +import { AppleLoginUI } from './AppleLoginUI'; interface PushSetupWizardProps { projectPath: string; @@ -134,15 +135,18 @@ function StatusPhase({ function KeySourcePhase({ onHasKey, onNoKey, + onAppleLogin, onCancel, }: { onHasKey: () => void; onNoKey: () => void; + onAppleLogin: () => void; onCancel: () => void; }): React.ReactElement { const items = [ { label: 'Yes, I have an APNS key (.p8 file)', value: 'has_key' }, - { label: 'No, I need to create one', value: 'no_key' }, + { label: 'Create with Apple Account (auto)', value: 'apple_login' }, + { label: 'Create manually in browser', value: 'no_key' }, { label: 'Cancel', value: 'cancel' }, ]; @@ -151,6 +155,9 @@ function KeySourcePhase({ case 'has_key': onHasKey(); break; + case 'apple_login': + onAppleLogin(); + break; case 'no_key': onNoKey(); break; @@ -1022,6 +1029,54 @@ export const PushSetupWizard: React.FC = ({ setPhase('apple_guide'); }, []); + const handleAppleLogin = useCallback(() => { + setPhase('apple_login'); + }, []); + + const handleAppleLoginSuccess = useCallback( + (result: { + pushKey: { apnsKeyId: string; apnsKeyP8: string; teamId: string; teamName?: string }; + }) => { + // Save P8 content to a file in the project directory + const p8FileName = `AuthKey_${result.pushKey.apnsKeyId}.p8`; + const p8FilePath = path.join(projectPath, p8FileName); + + try { + fs.writeFileSync(p8FilePath, result.pushKey.apnsKeyP8, 'utf-8'); + } catch { + // If we can't write to project dir, try current dir + const fallbackPath = path.join(process.cwd(), p8FileName); + fs.writeFileSync(fallbackPath, result.pushKey.apnsKeyP8, 'utf-8'); + } + + const savedPath = fs.existsSync(p8FilePath) + ? p8FilePath + : path.join(process.cwd(), p8FileName); + + setContext((prev) => ({ + ...prev, + pushKey: { + apnsKeyP8: result.pushKey.apnsKeyP8, + apnsKeyId: result.pushKey.apnsKeyId, + teamId: result.pushKey.teamId, + }, + p8FilePath: savedPath, + })); + // Proceed to Firebase upload + if (isOAuthConfigured()) { + setPhase('firebase_auth'); + } else { + setPhase('firebase_upload'); + } + }, + [projectPath], + ); + + const handleAppleLoginFallback = useCallback(() => { + // Fall back to manual browser-based key creation + setPhase('apple_guide'); + }, []); + const handleAppleGuideComplete = useCallback(() => { setPhase('p8_input'); }, []); @@ -1083,7 +1138,21 @@ export const PushSetupWizard: React.FC = ({ case 'key_source': return ( - + + ); + + case 'apple_login': + return ( + ); case 'apple_guide': From ed120af5d8d50122fc9742d2447be0938d06556b Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Tue, 3 Feb 2026 13:21:34 +0900 Subject: [PATCH 4/9] refactor: remove unused code and consolidate duplicates - Remove unused generateAgentPrompt() function (118 lines) - Remove unused ApiKeyAuthConfigNew alias - Remove unused validatePackageNameMatch/validateBundleIdMatch exports - Remove unused _projectPath parameter from getExpectedPaths() - Consolidate duplicate ApiKeyAuthConfig interface definition - Extract common stdout/stderr handler in bash-service.ts Co-Authored-By: Claude (global.anthropic.claude-opus-4-5-20251101-v1:0) --- src/lib/ios/agent-prompt-generator.ts | 127 ------------------ src/lib/ios/apple-auth.ts | 11 +- src/lib/ios/index.ts | 1 - src/lib/services/bash-service.ts | 37 +++-- src/lib/services/firebase/detector.ts | 4 +- src/lib/services/firebase/downloader.ts | 2 +- src/lib/services/firebase/firebase-service.ts | 2 +- src/lib/services/firebase/index.ts | 2 - src/lib/services/firebase/validator.ts | 58 -------- 9 files changed, 23 insertions(+), 221 deletions(-) diff --git a/src/lib/ios/agent-prompt-generator.ts b/src/lib/ios/agent-prompt-generator.ts index 5f8652b..0a6317a 100644 --- a/src/lib/ios/agent-prompt-generator.ts +++ b/src/lib/ios/agent-prompt-generator.ts @@ -12,133 +12,6 @@ export interface AgentContext { iosDir: string; } -/** - * Generate agent prompt for completing iOS setup - * The agent will handle tasks that cannot be done programmatically: - * - Xcode project file modifications (.pbxproj) - * - Notification Service Extension creation - * - Extension code generation - */ -export function generateAgentPrompt(context: AgentContext): string { - return `## iOS Setup - Remaining Tasks - -The following has been completed automatically: -- Apple Developer Portal capabilities synced (Push Notifications, App Groups) -- Entitlements file created: ${context.entitlementsPath} -- App Group ID: ${context.appGroupId} - -### Project Information -- App Name: ${context.appName} -- Bundle ID: ${context.bundleId} -- iOS Directory: ${context.iosDir} -- Project Path: ${context.projectPath} - -### Tasks to Complete - -#### 1. Link Entitlements in Xcode Project [Required] -Modify the Xcode project file (.pbxproj) to link the entitlements file: -- Find the main app target -- Add CODE_SIGN_ENTITLEMENTS build setting pointing to the entitlements file -- Ensure both Debug and Release configurations are updated - -#### 2. Create Notification Service Extension [Required] -Create a new Notification Service Extension target for rich push notifications: -- Target name: \`${context.appName}NotificationServiceExtension\` -- Bundle ID: \`${context.bundleId}.${context.appName}NotificationServiceExtension\` -- Deployment target: Same as main app or iOS 14.0+ -- Create necessary files in the extension directory - -#### 3. Implement NotificationService.swift [Required] -Create the NotificationService.swift file with Clix SDK integration: - -\`\`\`swift -import UserNotifications -import Clix - -class NotificationService: ClixNotificationServiceExtension { - override init() { - super.init() - register(projectId: "YOUR_PROJECT_ID") - } - - override func didReceive( - _ request: UNNotificationRequest, - withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void - ) { - super.didReceive(request, withContentHandler: contentHandler) - } - - override func serviceExtensionTimeWillExpire() { - super.serviceExtensionTimeWillExpire() - } -} -\`\`\` - -**Note:** Replace \`YOUR_PROJECT_ID\` with your actual Clix project ID from https://console.clix.so/ - -#### 4. Add Clix SDK to Extension Target [Required] - -**For CocoaPods projects:** -Add to your Podfile: -\`\`\`ruby -target '${context.appName}NotificationServiceExtension' do - pod 'Clix' -end -\`\`\` -Then run: \`cd ios && pod install\` - -**For SPM projects:** -In Xcode: -1. Select the extension target -2. Go to General > Frameworks, Libraries, and Embedded Content -3. Click + and add the Clix package - -#### 5. Create Extension Entitlements [Required] -Create entitlements file for the extension at \`${context.iosDir}/${context.appName}NotificationServiceExtension/${context.appName}NotificationServiceExtension.entitlements\`: - -\`\`\`xml - - - - - com.apple.security.application-groups - - ${context.appGroupId} - - - -\`\`\` - -#### 6. Update Extension Info.plist [Required] -Ensure the extension's Info.plist has the correct NSExtension configuration: - -\`\`\`xml -NSExtension - - NSExtensionPointIdentifier - com.apple.usernotifications.service - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).NotificationService - -\`\`\` - -#### 7. Configure Build Settings [Required - Xcode 15+] - -For the extension target in Xcode: -- Set \`ENABLE_USER_SCRIPT_SANDBOXING\` to "No" in Build Settings - -For React Native projects with Firebase: -- In Build Phases, move "Embed Foundation Extensions" above "[RNFB] Core Configuration" - -### Important Notes -- The extension must share the same App Group as the main app -- The extension's bundle ID must be a child of the main app's bundle ID -- Ensure the extension is added to the app's "Embed App Extensions" build phase -- Replace \`YOUR_PROJECT_ID\` with your actual Clix project ID - -Please complete these tasks by modifying the Xcode project files directly.`; -} - /** * Build agent context from iOS setup results */ diff --git a/src/lib/ios/apple-auth.ts b/src/lib/ios/apple-auth.ts index 324e18a..cfe3e52 100644 --- a/src/lib/ios/apple-auth.ts +++ b/src/lib/ios/apple-auth.ts @@ -17,6 +17,7 @@ import { Token, } from '@expo/apple-utils'; +import type { ApiKeyAuthConfig } from './apple-portal'; import { CLIX_NO_KEYCHAIN, deletePasswordAsync, @@ -44,14 +45,8 @@ export interface AppleTeam { inHouse: boolean; } -/** - * API Key authentication configuration. - */ -export interface ApiKeyAuthConfig { - keyId: string; - issuerId: string; - keyP8: string; -} +// Re-export ApiKeyAuthConfig from apple-portal for backwards compatibility +export type { ApiKeyAuthConfig }; /** * User authentication context (Apple ID/Password). diff --git a/src/lib/ios/index.ts b/src/lib/ios/index.ts index 4c2349a..b378bd7 100644 --- a/src/lib/ios/index.ts +++ b/src/lib/ios/index.ts @@ -5,7 +5,6 @@ export { type AgentContext, buildAgentContext } from './agent-prompt-generator'; // Apple account authentication (supports both API Key and User login) export { - type ApiKeyAuthConfig as ApiKeyAuthConfigNew, type ApiKeyAuthContext, type AppleTeam, type AuthContext, diff --git a/src/lib/services/bash-service.ts b/src/lib/services/bash-service.ts index 0c844a0..3f9ab8e 100644 --- a/src/lib/services/bash-service.ts +++ b/src/lib/services/bash-service.ts @@ -97,39 +97,34 @@ export async function executeBash( }); }; - // Handle stdout (use Buffer.length for byte-accurate counting) - child.stdout?.on('data', (data: Buffer) => { + // Create data handler for stdout/stderr with byte-accurate truncation + const createDataHandler = (appendTo: 'stdout' | 'stderr') => (data: Buffer) => { const chunkSize = data.length; totalOutputSize += chunkSize; if (totalOutputSize <= maxOutput) { - stdout += data.toString(); - } else if (!truncated) { - // Truncate at byte boundary - const remaining = maxOutput - (totalOutputSize - chunkSize); - if (remaining > 0) { - stdout += data.subarray(0, remaining).toString(); + if (appendTo === 'stdout') { + stdout += data.toString(); + } else { + stderr += data.toString(); } - truncated = true; - } - }); - - // Handle stderr (use Buffer.length for byte-accurate counting) - child.stderr?.on('data', (data: Buffer) => { - const chunkSize = data.length; - totalOutputSize += chunkSize; - - if (totalOutputSize <= maxOutput) { - stderr += data.toString(); } else if (!truncated) { // Truncate at byte boundary const remaining = maxOutput - (totalOutputSize - chunkSize); if (remaining > 0) { - stderr += data.subarray(0, remaining).toString(); + const truncatedData = data.subarray(0, remaining).toString(); + if (appendTo === 'stdout') { + stdout += truncatedData; + } else { + stderr += truncatedData; + } } truncated = true; } - }); + }; + + child.stdout?.on('data', createDataHandler('stdout')); + child.stderr?.on('data', createDataHandler('stderr')); // Handle process exit child.on('close', (code) => { diff --git a/src/lib/services/firebase/detector.ts b/src/lib/services/firebase/detector.ts index 251925e..c175ff1 100644 --- a/src/lib/services/firebase/detector.ts +++ b/src/lib/services/firebase/detector.ts @@ -181,7 +181,7 @@ export async function detectPlatform(projectPath: string): Promise { /** * Get expected credential file paths based on platform. */ -export function getExpectedPaths(platform: Platform, _projectPath: string): ExpectedPaths { +export function getExpectedPaths(platform: Platform): ExpectedPaths { const androidPaths: string[] = []; const iosPaths: string[] = []; @@ -641,7 +641,7 @@ function generateIssues( */ export async function detectFirebaseConfig(projectPath: string): Promise { const platform = await detectPlatform(projectPath); - const expectedPaths = getExpectedPaths(platform, projectPath); + const expectedPaths = getExpectedPaths(platform); const android = await detectAndroidCredential(projectPath, expectedPaths.android); const ios = await detectIosCredential(projectPath, expectedPaths.ios); diff --git a/src/lib/services/firebase/downloader.ts b/src/lib/services/firebase/downloader.ts index f69fce1..8a34a4a 100644 --- a/src/lib/services/firebase/downloader.ts +++ b/src/lib/services/firebase/downloader.ts @@ -228,7 +228,7 @@ export class FirebaseDownloader { projectPath: string, ): Promise<{ android: string | null; ios: string | null; platform: Platform }> { const platform = await detectPlatform(projectPath); - const paths = getExpectedPaths(platform, projectPath); + const paths = getExpectedPaths(platform); // For unknown platform, assume both platforms are needed const needsAndroid = platformNeedsAndroid(platform) || platform === 'unknown'; diff --git a/src/lib/services/firebase/firebase-service.ts b/src/lib/services/firebase/firebase-service.ts index 15f92bd..3e87b3c 100644 --- a/src/lib/services/firebase/firebase-service.ts +++ b/src/lib/services/firebase/firebase-service.ts @@ -152,7 +152,7 @@ export class FirebaseService { */ async getExpectedPath(platform: 'android' | 'ios'): Promise { const detectedPlatform = await detectPlatform(this.projectPath); - const paths = getExpectedPaths(detectedPlatform, this.projectPath); + const paths = getExpectedPaths(detectedPlatform); const platformPaths = platform === 'android' ? paths.android : paths.ios; return ( platformPaths[0] || diff --git a/src/lib/services/firebase/index.ts b/src/lib/services/firebase/index.ts index 59b8816..20beb15 100644 --- a/src/lib/services/firebase/index.ts +++ b/src/lib/services/firebase/index.ts @@ -30,9 +30,7 @@ export * from './types'; export { extractProjectId, extractProjectIdFromPlist, - validateBundleIdMatch, validateGoogleServiceInfoPlist, validateGoogleServicesJson, - validatePackageNameMatch, validateProjectIdMatch, } from './validator'; diff --git a/src/lib/services/firebase/validator.ts b/src/lib/services/firebase/validator.ts index 86f284e..70e9596 100644 --- a/src/lib/services/firebase/validator.ts +++ b/src/lib/services/firebase/validator.ts @@ -164,64 +164,6 @@ export function validateGoogleServiceInfoPlist(content: unknown): ValidationResu }; } -/** - * Validate that the package name in google-services.json matches the expected package. - * - * @param googleServices - Validated google-services.json content - * @param expectedPackageName - Expected Android package name - * @returns Validation result - */ -export function validatePackageNameMatch( - googleServices: GoogleServicesJson, - expectedPackageName: string, -): ValidationResult { - const packageNames = googleServices.client.map( - (client) => client.client_info.android_client_info.package_name, - ); - - if (packageNames.includes(expectedPackageName)) { - return { valid: true, errors: [] }; - } - - return { - valid: false, - errors: [ - { - path: 'client.client_info.android_client_info.package_name', - message: `Package name mismatch. Expected "${expectedPackageName}", found: ${packageNames.join(', ')}`, - code: 'PACKAGE_MISMATCH', - }, - ], - }; -} - -/** - * Validate that the bundle ID in GoogleService-Info.plist matches the expected bundle ID. - * - * @param serviceInfo - Validated GoogleService-Info.plist content - * @param expectedBundleId - Expected iOS bundle ID - * @returns Validation result - */ -export function validateBundleIdMatch( - serviceInfo: GoogleServiceInfoPlist, - expectedBundleId: string, -): ValidationResult { - if (serviceInfo.BUNDLE_ID === expectedBundleId) { - return { valid: true, errors: [] }; - } - - return { - valid: false, - errors: [ - { - path: 'BUNDLE_ID', - message: `Bundle ID mismatch. Expected "${expectedBundleId}", found: "${serviceInfo.BUNDLE_ID}"`, - code: 'BUNDLE_MISMATCH', - }, - ], - }; -} - /** * Extract project ID from google-services.json. * From 4affc0d23cb8ddac0e77d3e0a2e7294d18111429 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Tue, 3 Feb 2026 17:52:57 +0900 Subject: [PATCH 5/9] fix(ios): address PR review feedback - Propagate pushEnvironment to extension entitlements (fix production APNS) - Add early return when extension file creation fails - Revoke APNS key on download failure to avoid wasting limited slots - Respect CLIX_NO_KEYCHAIN env var in getPasswordAsync/setPasswordAsync - Handle P8 file write failures gracefully with error phase - Add try-catch error handling to ios-setup command - Escape apostrophes in Podfile extensionName for Ruby compatibility - Compute relative paths from extensionDir in pbxproj build settings - Filter app target by productType instead of getFirstTarget() - Fix Firebase upload phase when selectedProject is null - Move setPhase call to useEffect to avoid React anti-pattern - Fix invalid /push-setup command reference to /ios-setup Co-Authored-By: Claude (global.anthropic.claude-opus-4-5-20251101-v1:0) --- src/commands/ios-setup/index.tsx | 10 +++++--- src/lib/commands/ios-setup.tsx | 16 +++++++----- src/lib/ios/extension-templates.ts | 4 ++- src/lib/ios/keychain.ts | 4 +-- src/lib/ios/pbxproj-modifier.ts | 37 +++++++++++++++++++++++---- src/lib/ios/push-key.ts | 13 +++++++++- src/ui/components/IosSetupFlow.tsx | 12 ++++++--- src/ui/components/PushSetupWizard.tsx | 33 +++++++++++++++--------- 8 files changed, 96 insertions(+), 33 deletions(-) diff --git a/src/commands/ios-setup/index.tsx b/src/commands/ios-setup/index.tsx index 9af52c1..74bb4be 100644 --- a/src/commands/ios-setup/index.tsx +++ b/src/commands/ios-setup/index.tsx @@ -182,6 +182,7 @@ async function executeProjectModification( directResult: IosSetupResult, result: ProjectModificationResult, updateStatus: (status: string) => void, + pushEnvironment?: 'development' | 'production', ): Promise { const { agentContext } = directResult; if (!agentContext) return; @@ -195,7 +196,7 @@ async function executeProjectModification( appName: agentContext.appName, bundleId: agentContext.bundleId, iosDir: agentContext.iosDir, - pushEnvironment: 'development', + pushEnvironment: pushEnvironment ?? 'development', }; const extResult = await createExtensionFiles(extContext); @@ -204,6 +205,8 @@ async function executeProjectModification( result.createdFiles.push(...extResult.createdFiles); } else { result.warnings.push(extResult.error || 'Failed to create extension files'); + result.requiresManualSteps = true; + return; } // 2. Modify pbxproj @@ -254,6 +257,7 @@ async function executeProjectModification( */ async function runProjectModification( directResult: IosSetupResult, + pushEnvironment?: 'development' | 'production', ): Promise { const result: ProjectModificationResult = { success: false, @@ -286,7 +290,7 @@ async function runProjectModification( }; try { - await executeProjectModification(directResult, result, updateStatus); + await executeProjectModification(directResult, result, updateStatus, pushEnvironment); } catch (error) { result.error = error instanceof Error ? error.message : String(error); result.requiresManualSteps = true; @@ -428,7 +432,7 @@ export async function runIosSetupCommand(options: IosSetupCommandOptions): Promi if (directResult.agentContext) { console.log('\n'); // Add spacing before modification phase - modificationResult = await runProjectModification(directResult); + modificationResult = await runProjectModification(directResult, options.pushEnvironment); // Fall back to guided setup if automated modification failed or requires manual steps if (modificationResult.requiresManualSteps) { diff --git a/src/lib/commands/ios-setup.tsx b/src/lib/commands/ios-setup.tsx index 15d16f0..ef72cb4 100644 --- a/src/lib/commands/ios-setup.tsx +++ b/src/lib/commands/ios-setup.tsx @@ -25,12 +25,16 @@ export const iosSetupCommand: Command = { }, async call(onDone: CommandDoneCallback): Promise { - // Run the iOS setup command (skipPortal=true by default for chat mode) - await runIosSetupCommand({ - skipPortal: true, - }); - - onDone?.('iOS setup complete'); + try { + // Run the iOS setup command (skipPortal=true by default for chat mode) + await runIosSetupCommand({ + skipPortal: true, + }); + onDone?.('iOS setup complete'); + } catch (error) { + const message = error instanceof Error ? error.message : 'iOS setup failed'; + onDone?.(message); + } return null; }, }; diff --git a/src/lib/ios/extension-templates.ts b/src/lib/ios/extension-templates.ts index 9be4cb1..d3dbea3 100644 --- a/src/lib/ios/extension-templates.ts +++ b/src/lib/ios/extension-templates.ts @@ -51,7 +51,9 @@ export const EXTENSION_INFO_PLIST_TEMPLATE = `): Promise { - if (!IS_MAC) { + if (!IS_MAC || CLIX_NO_KEYCHAIN) { return null; } @@ -89,7 +89,7 @@ export async function setPasswordAsync({ username, password, }: KeychainCredentials): Promise { - if (!IS_MAC) { + if (!IS_MAC || CLIX_NO_KEYCHAIN) { return Promise.resolve(false); } diff --git a/src/lib/ios/pbxproj-modifier.ts b/src/lib/ios/pbxproj-modifier.ts index 366200c..aa6a896 100644 --- a/src/lib/ios/pbxproj-modifier.ts +++ b/src/lib/ios/pbxproj-modifier.ts @@ -10,6 +10,7 @@ import * as path from 'node:path'; import xcode from 'xcode'; const NSE_PRODUCT_TYPE = 'com.apple.product-type.app-extension'; +const APP_PRODUCT_TYPE = 'com.apple.product-type.application'; /** * Options for pbxproj modification operations. @@ -92,6 +93,24 @@ export function restoreProject(backupPath: string, projectPath: string): void { fs.copyFileSync(backupPath, pbxprojPath); } +/** + * Find the main app target UUID by productType. + */ +function findAppTargetUuid(project: { + pbxNativeTargetSection: () => Record | null; +}): string | null { + const targets = project.pbxNativeTargetSection(); + if (!targets) return null; + + for (const key of Object.keys(targets)) { + const t = targets[key] as { productType?: string } | undefined; + if (t && typeof t === 'object' && t.productType === APP_PRODUCT_TYPE) { + return key; + } + } + return null; +} + /** * Add Notification Service Extension target to Xcode project. */ @@ -154,10 +173,15 @@ export async function addNotificationServiceExtension( // 5. Set build settings for the extension target const deploymentTarget = options.deploymentTarget || '14.0'; + // Compute project-relative paths from extensionDir + const projectDir = path.dirname(options.projectPath); + const extensionRelDir = path.relative(projectDir, options.extensionDir); + const entitlementsPath = path.join(extensionRelDir, `${options.extensionName}.entitlements`); + const infoPlistPath = path.join(extensionRelDir, 'Info.plist'); const buildSettings: Record = { - CODE_SIGN_ENTITLEMENTS: `${options.extensionName}/${options.extensionName}.entitlements`, + CODE_SIGN_ENTITLEMENTS: entitlementsPath, ENABLE_USER_SCRIPT_SANDBOXING: 'NO', - INFOPLIST_FILE: `${options.extensionName}/Info.plist`, + INFOPLIST_FILE: infoPlistPath, PRODUCT_BUNDLE_IDENTIFIER: options.extensionBundleId, IPHONEOS_DEPLOYMENT_TARGET: deploymentTarget, SWIFT_VERSION: '5.0', @@ -180,15 +204,18 @@ export async function addNotificationServiceExtension( } // 6. Add target dependency to main app (embed extension) - const firstTarget = project.getFirstTarget(); - if (firstTarget?.uuid) { + // Find the main app target by productType instead of using getFirstTarget() + const appTargetUuid = findAppTargetUuid(project); + if (appTargetUuid) { try { - project.addTargetDependency(firstTarget.uuid, [target.uuid]); + project.addTargetDependency(appTargetUuid, [target.uuid]); } catch { result.warnings.push( 'Could not add target dependency, extension may need manual embedding', ); } + } else { + result.warnings.push('Could not find main app target, extension may need manual embedding'); } // 7. Write changes to project file diff --git a/src/lib/ios/push-key.ts b/src/lib/ios/push-key.ts index b3cdf09..744bb71 100644 --- a/src/lib/ios/push-key.ts +++ b/src/lib/ios/push-key.ts @@ -101,7 +101,18 @@ export async function createPushKeyAsync( }); // Download the key content (.p8) - const apnsKeyP8 = await Keys.downloadKeyAsync(context, { id: key.id }); + let apnsKeyP8: string; + try { + apnsKeyP8 = await Keys.downloadKeyAsync(context, { id: key.id }); + } catch (downloadErr) { + // Best-effort cleanup to avoid leaking a limited key slot (max 2 APNS keys per account) + try { + await Keys.revokeKeyAsync(context, { id: key.id }); + } catch { + // Swallow revoke failure; original error is more relevant + } + throw downloadErr; + } return { apnsKeyId: key.id, diff --git a/src/ui/components/IosSetupFlow.tsx b/src/ui/components/IosSetupFlow.tsx index 34debd2..b60d8e9 100644 --- a/src/ui/components/IosSetupFlow.tsx +++ b/src/ui/components/IosSetupFlow.tsx @@ -4,7 +4,7 @@ */ import { Box, Text, useInput } from 'ink'; import type React from 'react'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { PushSetupResult } from '../../lib/push'; import { type IosSetupResult, IosSetupUI } from '../IosSetupUI'; import { @@ -213,6 +213,13 @@ export const IosSetupFlow: React.FC = ({ onComplete }) => { }; }; + // Handle edge case where guided_setup has no context (avoid setState during render) + useEffect(() => { + if (phase === 'guided_setup' && !directResult?.agentContext) { + setPhase('push_confirm'); + } + }, [phase, directResult?.agentContext]); + // Render based on current phase switch (phase) { case 'intro': @@ -224,8 +231,7 @@ export const IosSetupFlow: React.FC = ({ onComplete }) => { case 'guided_setup': { const context = getGuidedSetupContext(); if (!context) { - // Shouldn't happen, but handle gracefully - setPhase('push_confirm'); + // Will be handled by useEffect above return null; } return ; diff --git a/src/ui/components/PushSetupWizard.tsx b/src/ui/components/PushSetupWizard.tsx index 751bfb1..8a5eb58 100644 --- a/src/ui/components/PushSetupWizard.tsx +++ b/src/ui/components/PushSetupWizard.tsx @@ -630,12 +630,16 @@ function FirebaseUploadPhase({ const [browserOpened, setBrowserOpened] = useState(false); useEffect(() => { - if (!browserOpened && selectedProject) { - const url = PUSH_SETUP_URLS.firebaseConsole(selectedProject.projectId); - openBrowser(url); - setBrowserOpened(true); + if (!browserOpened) { + // Use selectedProject if available, otherwise fall back to context.firebaseProjectId + const projectId = selectedProject?.projectId ?? context.firebaseProjectId; + if (projectId) { + const url = PUSH_SETUP_URLS.firebaseConsole(projectId); + openBrowser(url); + setBrowserOpened(true); + } } - }, [browserOpened, selectedProject]); + }, [browserOpened, selectedProject, context.firebaseProjectId]); useInput((_input, key) => { if (key.return) { @@ -740,7 +744,7 @@ function CompletePhase({ - You can run /push-setup later to configure push notifications. + You can run /ios-setup later to configure push notifications. ); @@ -1041,18 +1045,23 @@ export const PushSetupWizard: React.FC = ({ const p8FileName = `AuthKey_${result.pushKey.apnsKeyId}.p8`; const p8FilePath = path.join(projectPath, p8FileName); + let savedPath: string | null = null; try { fs.writeFileSync(p8FilePath, result.pushKey.apnsKeyP8, 'utf-8'); + savedPath = p8FilePath; } catch { // If we can't write to project dir, try current dir - const fallbackPath = path.join(process.cwd(), p8FileName); - fs.writeFileSync(fallbackPath, result.pushKey.apnsKeyP8, 'utf-8'); + try { + const fallbackPath = path.join(process.cwd(), p8FileName); + fs.writeFileSync(fallbackPath, result.pushKey.apnsKeyP8, 'utf-8'); + savedPath = fallbackPath; + } catch { + setError('Failed to write APNS key file. Check directory permissions and try again.'); + setPhase('error'); + return; + } } - const savedPath = fs.existsSync(p8FilePath) - ? p8FilePath - : path.join(process.cwd(), p8FileName); - setContext((prev) => ({ ...prev, pushKey: { From 0b6555b63f72336a6cb6792d6f2fd46fa15d0de6 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Tue, 3 Feb 2026 18:03:53 +0900 Subject: [PATCH 6/9] fix(push): guard Firebase auth effect against cancellation Add cancelled flag to Firebase authentication useEffect to prevent stale state mutations when user cancels during async operation. Extracted handleProjectSelection helper to reduce complexity. Co-Authored-By: Claude (global.anthropic.claude-opus-4-5-20251101-v1:0) --- src/ui/components/PushSetupWizard.tsx | 63 +++++++++++++++------------ 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/src/ui/components/PushSetupWizard.tsx b/src/ui/components/PushSetupWizard.tsx index 8a5eb58..4edc34a 100644 --- a/src/ui/components/PushSetupWizard.tsx +++ b/src/ui/components/PushSetupWizard.tsx @@ -958,45 +958,50 @@ export const PushSetupWizard: React.FC = ({ // Firebase authentication effect useEffect(() => { + let cancelled = false; + + const handleProjectSelection = (fetchedProjects: FirebaseProject[]) => { + // If pre-detected project exists, try to find and auto-select it + if (context.firebaseProjectId) { + const matchingProject = fetchedProjects.find( + (p) => p.projectId === context.firebaseProjectId, + ); + if (matchingProject) { + setSelectedProject(matchingProject); + setPhase('firebase_upload'); + return true; + } + } + + // Otherwise, show project selection based on count + if (fetchedProjects.length === 1) { + setSelectedProject(fetchedProjects[0]); + setPhase('firebase_upload'); + } else if (fetchedProjects.length > 1) { + setPhase('firebase_projects'); + } else { + setError('No Firebase projects found'); + setPhase('error'); + } + return false; + }; + const authenticateAndFetchProjects = async () => { try { - // Create downloader if not exists if (!downloaderRef.current) { downloaderRef.current = new FirebaseDownloader(); } const downloader = downloaderRef.current; - // Authenticate with Firebase await downloader.authenticate(openBrowser); - // Fetch projects const fetchedProjects = await downloader.listProjects(); - setProjects(fetchedProjects); - - // If pre-detected project exists, try to find and auto-select it - if (context.firebaseProjectId) { - const matchingProject = fetchedProjects.find( - (p) => p.projectId === context.firebaseProjectId, - ); - if (matchingProject) { - setSelectedProject(matchingProject); - setPhase('firebase_upload'); - return; - } - } + if (cancelled) return; - // Otherwise, show project selection - if (fetchedProjects.length === 1) { - // Auto-select if only one project - setSelectedProject(fetchedProjects[0]); - setPhase('firebase_upload'); - } else if (fetchedProjects.length > 1) { - setPhase('firebase_projects'); - } else { - setError('No Firebase projects found'); - setPhase('error'); - } + setProjects(fetchedProjects); + handleProjectSelection(fetchedProjects); } catch (err) { + if (cancelled) return; const message = err instanceof Error ? err.message : 'Firebase authentication failed'; setError(message); setPhase('error'); @@ -1006,6 +1011,10 @@ export const PushSetupWizard: React.FC = ({ if (phase === 'firebase_auth') { authenticateAndFetchProjects(); } + + return () => { + cancelled = true; + }; }, [phase, context.firebaseProjectId]); const handleContinue = useCallback(() => { From f21a68c478fd44686b85bd557c69563218c85115 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Tue, 3 Feb 2026 18:11:33 +0900 Subject: [PATCH 7/9] fix(push): handle null project ID in FirebaseUploadPhase Open generic Firebase console when no project ID is known and display "Unknown" label instead of empty text. Co-Authored-By: Claude (global.anthropic.claude-opus-4-5-20251101-v1:0) --- src/ui/components/PushSetupWizard.tsx | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/ui/components/PushSetupWizard.tsx b/src/ui/components/PushSetupWizard.tsx index 4edc34a..3d7aa30 100644 --- a/src/ui/components/PushSetupWizard.tsx +++ b/src/ui/components/PushSetupWizard.tsx @@ -629,15 +629,23 @@ function FirebaseUploadPhase({ }): React.ReactElement { const [browserOpened, setBrowserOpened] = useState(false); + // Compute project label with fallback + const projectLabel = + selectedProject?.displayName || + selectedProject?.projectId || + context.firebaseProjectId || + 'Unknown'; + useEffect(() => { if (!browserOpened) { // Use selectedProject if available, otherwise fall back to context.firebaseProjectId const projectId = selectedProject?.projectId ?? context.firebaseProjectId; - if (projectId) { - const url = PUSH_SETUP_URLS.firebaseConsole(projectId); - openBrowser(url); - setBrowserOpened(true); - } + // If no project ID is known, open the generic Firebase console + const url = projectId + ? PUSH_SETUP_URLS.firebaseConsole(projectId) + : PUSH_SETUP_URLS.firebaseConsoleGeneric; + openBrowser(url); + setBrowserOpened(true); } }, [browserOpened, selectedProject, context.firebaseProjectId]); @@ -663,8 +671,7 @@ function FirebaseUploadPhase({ - Project:{' '} - {selectedProject?.displayName || selectedProject?.projectId} + Project: {projectLabel} From facd859b001c28f8ce8b070a30359474cf7ac8a1 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Tue, 3 Feb 2026 18:17:55 +0900 Subject: [PATCH 8/9] fix(push): restrict APNS key file permissions to owner only Use mode 0o600 when writing .p8 files to prevent world-readable sensitive cryptographic credentials. Co-Authored-By: Claude (global.anthropic.claude-opus-4-5-20251101-v1:0) --- src/ui/components/PushSetupWizard.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ui/components/PushSetupWizard.tsx b/src/ui/components/PushSetupWizard.tsx index 3d7aa30..cc8b3c6 100644 --- a/src/ui/components/PushSetupWizard.tsx +++ b/src/ui/components/PushSetupWizard.tsx @@ -1063,13 +1063,17 @@ export const PushSetupWizard: React.FC = ({ let savedPath: string | null = null; try { - fs.writeFileSync(p8FilePath, result.pushKey.apnsKeyP8, 'utf-8'); + // Use mode 0o600 to restrict APNS key file to current user only + fs.writeFileSync(p8FilePath, result.pushKey.apnsKeyP8, { encoding: 'utf-8', mode: 0o600 }); savedPath = p8FilePath; } catch { // If we can't write to project dir, try current dir try { const fallbackPath = path.join(process.cwd(), p8FileName); - fs.writeFileSync(fallbackPath, result.pushKey.apnsKeyP8, 'utf-8'); + fs.writeFileSync(fallbackPath, result.pushKey.apnsKeyP8, { + encoding: 'utf-8', + mode: 0o600, + }); savedPath = fallbackPath; } catch { setError('Failed to write APNS key file. Check directory permissions and try again.'); From 2c0201ef90483122200746a6300fa99522e94be4 Mon Sep 17 00:00:00 2001 From: Minkyu Cho Date: Wed, 4 Feb 2026 13:20:36 +0900 Subject: [PATCH 9/9] fix(push): add cancellation guard to initial detection effect Add cancelled flag with cleanup function to prevent state updates after component unmounts or phase changes during async detection. Co-Authored-By: Claude (global.anthropic.claude-opus-4-5-20251101-v1:0) --- src/ui/components/PushSetupWizard.tsx | 63 ++++++++++++++++++--------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/src/ui/components/PushSetupWizard.tsx b/src/ui/components/PushSetupWizard.tsx index cc8b3c6..fe0248a 100644 --- a/src/ui/components/PushSetupWizard.tsx +++ b/src/ui/components/PushSetupWizard.tsx @@ -916,52 +916,73 @@ export const PushSetupWizard: React.FC = ({ const [projects, setProjects] = useState([]); const [selectedProject, setSelectedProject] = useState(null); + // Helper to apply detection results to state + const applyDetectionResult = useCallback( + (result: { + firebaseProjectId: string | null; + bundleId: string | null; + teamId: string | null; + }) => { + if (result.teamId) { + setDetectedTeamId(result.teamId); + } + setContext((prev) => ({ + ...prev, + firebaseProjectId: result.firebaseProjectId, + bundleId: result.bundleId, + })); + setPhase('status'); + }, + [], + ); + // Initial detection useEffect(() => { + let cancelled = false; + const detect = async () => { // Use pre-detected values if available (from ios-setup integration) if (preDetectedBundleId !== undefined || preDetectedFirebaseProjectId !== undefined) { - setContext((prev) => ({ - ...prev, + if (cancelled) return; + applyDetectionResult({ firebaseProjectId: preDetectedFirebaseProjectId ?? null, bundleId: preDetectedBundleId ?? null, - })); - if (preDetectedTeamId) { - setDetectedTeamId(preDetectedTeamId); - } - setPhase('status'); + teamId: preDetectedTeamId ?? null, + }); return; } // Detect from Firebase config const firebaseResult = await detectFromFirebase(projectPath); + if (cancelled) return; let { firebaseProjectId, bundleId, teamId } = firebaseResult; // Try Xcode project if Team ID not found in Firebase config if (!teamId) { const xcodeResult = await detectFromXcodeProject(projectPath); + if (cancelled) return; teamId = xcodeResult.teamId; - if (!bundleId) { - bundleId = xcodeResult.bundleId; - } + bundleId = bundleId || xcodeResult.bundleId; } - if (teamId) { - setDetectedTeamId(teamId); - } - - setContext((prev) => ({ - ...prev, - firebaseProjectId, - bundleId, - })); - setPhase('status'); + applyDetectionResult({ firebaseProjectId, bundleId, teamId }); }; if (phase === 'detecting') { detect(); } - }, [phase, projectPath, preDetectedBundleId, preDetectedFirebaseProjectId, preDetectedTeamId]); + + return () => { + cancelled = true; + }; + }, [ + phase, + projectPath, + preDetectedBundleId, + preDetectedFirebaseProjectId, + preDetectedTeamId, + applyDetectionResult, + ]); // Firebase authentication effect useEffect(() => {