From db437570053c729b317278124e4c88ab42b9a35a Mon Sep 17 00:00:00 2001 From: h3p Date: Sat, 7 Feb 2026 23:56:52 +0100 Subject: [PATCH] Prepare App Store security and distribution for v0.4.1-beta --- Neon Vision Editor.xcodeproj/project.pbxproj | 10 ++- Neon Vision Editor/AIClient.swift | 6 +- Neon Vision Editor/ContentView.swift | 60 ++++++++----- Neon Vision Editor/EditorViewModel.swift | 18 ++-- Neon Vision Editor/NeonVisionEditorApp.swift | 10 ++- Neon Vision Editor/PanelsAndHelpers.swift | 8 +- Neon Vision Editor/PrivacyInfo.xcprivacy | 23 +++++ Neon Vision Editor/SecureTokenStore.swift | 89 +++++++++++++++++++ release/App-Store-Readiness.md | 31 +++++++ release/Apple-Store-Distribution-Checklist.md | 38 ++++++++ 10 files changed, 251 insertions(+), 42 deletions(-) create mode 100644 Neon Vision Editor/PrivacyInfo.xcprivacy create mode 100644 Neon Vision Editor/SecureTokenStore.swift create mode 100644 release/App-Store-Readiness.md create mode 100644 release/Apple-Store-Distribution-Checklist.md diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index 0f7ccb5..856e843 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -270,7 +270,7 @@ ENABLE_APP_SANDBOX = YES; ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; - ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; @@ -285,6 +285,7 @@ ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Neon Vision Editor"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; @@ -305,7 +306,7 @@ PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - REGISTER_APP_GROUPS = YES; + REGISTER_APP_GROUPS = NO; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; RUNTIME_EXCEPTION_ALLOW_JIT = NO; RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; @@ -341,7 +342,7 @@ ENABLE_APP_SANDBOX = YES; ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; - ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; @@ -356,6 +357,7 @@ ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Neon Vision Editor"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; @@ -376,7 +378,7 @@ PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - REGISTER_APP_GROUPS = YES; + REGISTER_APP_GROUPS = NO; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; RUNTIME_EXCEPTION_ALLOW_JIT = NO; RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; diff --git a/Neon Vision Editor/AIClient.swift b/Neon Vision Editor/AIClient.swift index f614cbd..dc3a334 100644 --- a/Neon Vision Editor/AIClient.swift +++ b/Neon Vision Editor/AIClient.swift @@ -99,10 +99,10 @@ final class GeminiAIClient: AIClient { return AsyncStream { continuation in Task { do { - var comps = URLComponents(string: "https://generativelanguage.googleapis.com/v1beta/models/\(model):generateContent")! - comps.queryItems = [URLQueryItem(name: "key", value: apiKey)] - var request = URLRequest(url: comps.url!) + let url = URL(string: "https://generativelanguage.googleapis.com/v1beta/models/\(model):generateContent")! + var request = URLRequest(url: url) request.httpMethod = "POST" + request.setValue(apiKey, forHTTPHeaderField: "x-goog-api-key") request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body: [String: Any] = [ "contents": [[ diff --git a/Neon Vision Editor/ContentView.swift b/Neon Vision Editor/ContentView.swift index d61c623..5c06612 100644 --- a/Neon Vision Editor/ContentView.swift +++ b/Neon Vision Editor/ContentView.swift @@ -51,10 +51,10 @@ struct ContentView: View { @State var lastProviderUsed: String = "Apple" // Persisted API tokens for external providers - @State var grokAPIToken: String = UserDefaults.standard.string(forKey: "GrokAPIToken") ?? "" - @State var openAIAPIToken: String = UserDefaults.standard.string(forKey: "OpenAIAPIToken") ?? "" - @State var geminiAPIToken: String = UserDefaults.standard.string(forKey: "GeminiAPIToken") ?? "" - @State var anthropicAPIToken: String = UserDefaults.standard.string(forKey: "AnthropicAPIToken") ?? "" + @State var grokAPIToken: String = SecureTokenStore.token(for: .grok) + @State var openAIAPIToken: String = SecureTokenStore.token(for: .openAI) + @State var geminiAPIToken: String = SecureTokenStore.token(for: .gemini) + @State var anthropicAPIToken: String = SecureTokenStore.token(for: .anthropic) // Debounce handle for inline completion @State var lastCompletionWorkItem: DispatchWorkItem? @@ -91,7 +91,7 @@ struct ContentView: View { var activeProviderName: String { lastProviderUsed } - /// Prompts the user for a Grok token if none is saved. Persists to UserDefaults. + /// Prompts the user for a Grok token if none is saved. Persists to Keychain. /// Returns true if a token is present/was saved; false if cancelled or empty. private func promptForGrokTokenIfNeeded() -> Bool { if !grokAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true } @@ -110,14 +110,14 @@ struct ContentView: View { let token = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) if token.isEmpty { return false } grokAPIToken = token - UserDefaults.standard.set(token, forKey: "GrokAPIToken") + SecureTokenStore.setToken(token, for: .grok) return true } #endif return false } - /// Prompts the user for an OpenAI token if none is saved. Persists to UserDefaults. + /// Prompts the user for an OpenAI token if none is saved. Persists to Keychain. /// Returns true if a token is present/was saved; false if cancelled or empty. private func promptForOpenAITokenIfNeeded() -> Bool { if !openAIAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true } @@ -136,14 +136,14 @@ struct ContentView: View { let token = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) if token.isEmpty { return false } openAIAPIToken = token - UserDefaults.standard.set(token, forKey: "OpenAIAPIToken") + SecureTokenStore.setToken(token, for: .openAI) return true } #endif return false } - /// Prompts the user for a Gemini token if none is saved. Persists to UserDefaults. + /// Prompts the user for a Gemini token if none is saved. Persists to Keychain. /// Returns true if a token is present/was saved; false if cancelled or empty. private func promptForGeminiTokenIfNeeded() -> Bool { if !geminiAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true } @@ -162,14 +162,14 @@ struct ContentView: View { let token = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) if token.isEmpty { return false } geminiAPIToken = token - UserDefaults.standard.set(token, forKey: "GeminiAPIToken") + SecureTokenStore.setToken(token, for: .gemini) return true } #endif return false } - /// Prompts the user for an Anthropic API token if none is saved. Persists to UserDefaults. + /// Prompts the user for an Anthropic API token if none is saved. Persists to Keychain. /// Returns true if a token is present/was saved; false if cancelled or empty. private func promptForAnthropicTokenIfNeeded() -> Bool { if !anthropicAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true } @@ -188,7 +188,7 @@ struct ContentView: View { let token = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) if token.isEmpty { return false } anthropicAPIToken = token - UserDefaults.standard.set(token, forKey: "AnthropicAPIToken") + SecureTokenStore.setToken(token, for: .anthropic) return true } #endif @@ -320,7 +320,9 @@ struct ContentView: View { let content = message["content"] as? String { return sanitizeCompletion(content) } - } catch { print("[Completion][Fallback][Grok] error: \(error)") } + } catch { + debugLog("[Completion][Fallback][Grok] request failed") + } } // Try OpenAI if !openAIAPIToken.isEmpty { @@ -353,16 +355,19 @@ struct ContentView: View { let content = message["content"] as? String { return sanitizeCompletion(content) } - } catch { print("[Completion][Fallback][OpenAI] error: \(error)") } + } catch { + debugLog("[Completion][Fallback][OpenAI] request failed") + } } // Try Gemini if !geminiAPIToken.isEmpty { do { let model = "gemini-1.5-flash-latest" - let endpoint = "https://generativelanguage.googleapis.com/v1beta/models/\(model):generateContent?key=\(geminiAPIToken)" + let endpoint = "https://generativelanguage.googleapis.com/v1beta/models/\(model):generateContent" guard let url = URL(string: endpoint) else { return "" } var request = URLRequest(url: url) request.httpMethod = "POST" + request.setValue(geminiAPIToken, forHTTPHeaderField: "x-goog-api-key") request.setValue("application/json", forHTTPHeaderField: "Content-Type") let prompt = """ Continue the following \(language) code snippet with a few lines or tokens of code only. Do not add prose or explanations. @@ -385,7 +390,9 @@ struct ContentView: View { let text = parts.first?["text"] as? String { return sanitizeCompletion(text) } - } catch { print("[Completion][Fallback][Gemini] error: \(error)") } + } catch { + debugLog("[Completion][Fallback][Gemini] request failed") + } } // Try Anthropic if !anthropicAPIToken.isEmpty { @@ -424,7 +431,9 @@ struct ContentView: View { let text = first["text"] as? String { return sanitizeCompletion(text) } - } catch { print("[Completion][Fallback][Anthropic] error: \(error)") } + } catch { + debugLog("[Completion][Fallback][Anthropic] request failed") + } } return "" } @@ -491,7 +500,7 @@ struct ContentView: View { await MainActor.run { lastProviderUsed = "Grok (fallback to Apple)" } return res } catch { - print("[Completion][Grok] request failed: \(error)") + debugLog("[Completion][Grok] request failed") let res = await appleModelCompletion(prefix: prefix, language: language) await MainActor.run { lastProviderUsed = "Grok (fallback to Apple)" } return res @@ -536,7 +545,7 @@ struct ContentView: View { await MainActor.run { lastProviderUsed = "OpenAI (fallback to Apple)" } return res } catch { - print("[Completion][OpenAI] request failed: \(error)") + debugLog("[Completion][OpenAI] request failed") let res = await appleModelCompletion(prefix: prefix, language: language) await MainActor.run { lastProviderUsed = "OpenAI (fallback to Apple)" } return res @@ -549,7 +558,7 @@ struct ContentView: View { } do { let model = "gemini-1.5-flash-latest" - let endpoint = "https://generativelanguage.googleapis.com/v1beta/models/\(model):generateContent?key=\(geminiAPIToken)" + let endpoint = "https://generativelanguage.googleapis.com/v1beta/models/\(model):generateContent" guard let url = URL(string: endpoint) else { let res = await appleModelCompletion(prefix: prefix, language: language) await MainActor.run { lastProviderUsed = "Gemini (fallback to Apple)" } @@ -557,6 +566,7 @@ struct ContentView: View { } var request = URLRequest(url: url) request.httpMethod = "POST" + request.setValue(geminiAPIToken, forHTTPHeaderField: "x-goog-api-key") request.setValue("application/json", forHTTPHeaderField: "Content-Type") let prompt = """ Continue the following \(language) code snippet with a few lines or tokens of code only. Do not add prose or explanations. @@ -584,7 +594,7 @@ struct ContentView: View { await MainActor.run { lastProviderUsed = "Gemini (fallback to Apple)" } return res } catch { - print("[Completion][Gemini] request failed: \(error)") + debugLog("[Completion][Gemini] request failed") let res = await appleModelCompletion(prefix: prefix, language: language) await MainActor.run { lastProviderUsed = "Gemini (fallback to Apple)" } return res @@ -636,7 +646,7 @@ struct ContentView: View { await MainActor.run { lastProviderUsed = "Anthropic (fallback to Apple)" } return res } catch { - print("[Completion][Anthropic] request failed: \(error)") + debugLog("[Completion][Anthropic] request failed") let res = await appleModelCompletion(prefix: prefix, language: language) await MainActor.run { lastProviderUsed = "Anthropic (fallback to Apple)" } return res @@ -669,6 +679,12 @@ struct ContentView: View { return result } + private func debugLog(_ message: String) { +#if DEBUG + print(message) +#endif + } + @ViewBuilder private var platformLayout: some View { #if os(macOS) diff --git a/Neon Vision Editor/EditorViewModel.swift b/Neon Vision Editor/EditorViewModel.swift index 0987682..499640a 100644 --- a/Neon Vision Editor/EditorViewModel.swift +++ b/Neon Vision Editor/EditorViewModel.swift @@ -196,7 +196,7 @@ class EditorViewModel: ObservableObject { try tabs[index].content.write(to: url, atomically: true, encoding: .utf8) tabs[index].isDirty = false } catch { - print("Error saving file: \(error)") + debugLog("Failed to save file.") } } else { saveFileAs(tab: tab) @@ -228,13 +228,13 @@ class EditorViewModel: ObservableObject { tabs[index].name = url.lastPathComponent tabs[index].isDirty = false } catch { - print("Error saving file: \(error)") + debugLog("Failed to save file.") } } #else // iOS/iPadOS: explicit Save As panel is not available here yet. // Keep document dirty so user can export/share via future document APIs. - print("Save As is currently only available on macOS.") + debugLog("Save As is currently only available on macOS.") #endif } @@ -262,12 +262,12 @@ class EditorViewModel: ObservableObject { tabs.append(newTab) selectedTabID = newTab.id } catch { - print("Error opening file: \(error)") + debugLog("Failed to open file.") } } #else // iOS/iPadOS: document picker flow can be added here. - print("Open File panel is currently only available on macOS.") + debugLog("Open File panel is currently only available on macOS.") #endif } @@ -285,7 +285,7 @@ class EditorViewModel: ObservableObject { tabs.append(newTab) selectedTabID = newTab.id } catch { - print("Error opening file: \(error)") + debugLog("Failed to open file.") } } @@ -302,4 +302,10 @@ class EditorViewModel: ObservableObject { text.components(separatedBy: .whitespacesAndNewlines) .filter { !$0.isEmpty }.count } + + private func debugLog(_ message: String) { +#if DEBUG + print(message) +#endif + } } diff --git a/Neon Vision Editor/NeonVisionEditorApp.swift b/Neon Vision Editor/NeonVisionEditorApp.swift index 3b53ad0..bb92b35 100644 --- a/Neon Vision Editor/NeonVisionEditorApp.swift +++ b/Neon Vision Editor/NeonVisionEditorApp.swift @@ -48,6 +48,10 @@ struct NeonVisionEditorApp: App { @State private var showGrokError: Bool = false @State private var grokErrorMessage: String = "" + init() { + SecureTokenStore.migrateLegacyUserDefaultsTokens() + } + var body: some Scene { #if os(macOS) WindowGroup { @@ -230,9 +234,9 @@ struct NeonVisionEditorApp: App { let contentPrefix = String(tab.content.prefix(1000)) let prompt = "Suggest improvements for this \(tab.language) code: \(contentPrefix)" - let grokToken = UserDefaults.standard.string(forKey: "GrokAPIToken") ?? "" - let openAIToken = UserDefaults.standard.string(forKey: "OpenAIAPIToken") ?? "" - let geminiToken = UserDefaults.standard.string(forKey: "GeminiAPIToken") ?? "" + let grokToken = SecureTokenStore.token(for: .grok) + let openAIToken = SecureTokenStore.token(for: .openAI) + let geminiToken = SecureTokenStore.token(for: .gemini) let client: AIClient? = { #if USE_FOUNDATION_MODELS diff --git a/Neon Vision Editor/PanelsAndHelpers.swift b/Neon Vision Editor/PanelsAndHelpers.swift index c60947f..a12e202 100644 --- a/Neon Vision Editor/PanelsAndHelpers.swift +++ b/Neon Vision Editor/PanelsAndHelpers.swift @@ -43,7 +43,7 @@ struct APISupportSettingsView: View { .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) .onChange(of: grokAPIToken) { _, new in - UserDefaults.standard.set(new, forKey: "GrokAPIToken") + SecureTokenStore.setToken(new, for: .grok) } } LabeledContent("OpenAI") { @@ -51,7 +51,7 @@ struct APISupportSettingsView: View { .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) .onChange(of: openAIAPIToken) { _, new in - UserDefaults.standard.set(new, forKey: "OpenAIAPIToken") + SecureTokenStore.setToken(new, for: .openAI) } } LabeledContent("Gemini") { @@ -59,7 +59,7 @@ struct APISupportSettingsView: View { .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) .onChange(of: geminiAPIToken) { _, new in - UserDefaults.standard.set(new, forKey: "GeminiAPIToken") + SecureTokenStore.setToken(new, for: .gemini) } } LabeledContent("Anthropic") { @@ -67,7 +67,7 @@ struct APISupportSettingsView: View { .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) .onChange(of: anthropicAPIToken) { _, new in - UserDefaults.standard.set(new, forKey: "AnthropicAPIToken") + SecureTokenStore.setToken(new, for: .anthropic) } } } diff --git a/Neon Vision Editor/PrivacyInfo.xcprivacy b/Neon Vision Editor/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..5704bed --- /dev/null +++ b/Neon Vision Editor/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/Neon Vision Editor/SecureTokenStore.swift b/Neon Vision Editor/SecureTokenStore.swift new file mode 100644 index 0000000..1fa2c80 --- /dev/null +++ b/Neon Vision Editor/SecureTokenStore.swift @@ -0,0 +1,89 @@ +import Foundation +import Security + +enum APITokenKey: String, CaseIterable { + case grok + case openAI + case gemini + case anthropic + + var account: String { + switch self { + case .grok: return "GrokAPIToken" + case .openAI: return "OpenAIAPIToken" + case .gemini: return "GeminiAPIToken" + case .anthropic: return "AnthropicAPIToken" + } + } +} + +enum SecureTokenStore { + private static let service = "h3p.Neon-Vision-Editor.tokens" + + static func token(for key: APITokenKey) -> String { + guard let data = readData(for: key), + let value = String(data: data, encoding: .utf8) else { + return "" + } + return value + } + + static func setToken(_ value: String, for key: APITokenKey) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + deleteToken(for: key) + return + } + guard let data = trimmed.data(using: .utf8) else { return } + + let baseQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.account + ] + + let updateStatus = SecItemUpdate(baseQuery as CFDictionary, [kSecValueData as String: data] as CFDictionary) + if updateStatus == errSecItemNotFound { + var addQuery = baseQuery + addQuery[kSecValueData as String] = data + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + _ = SecItemAdd(addQuery as CFDictionary, nil) + } + } + + static func migrateLegacyUserDefaultsTokens() { + for key in APITokenKey.allCases { + let defaultsKey = key.account + let defaultsValue = UserDefaults.standard.string(forKey: defaultsKey) ?? "" + let hasKeychainValue = !token(for: key).isEmpty + if !hasKeychainValue && !defaultsValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + setToken(defaultsValue, for: key) + } + UserDefaults.standard.removeObject(forKey: defaultsKey) + } + } + + private static func readData(for key: APITokenKey) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess else { return nil } + return item as? Data + } + + private static func deleteToken(for key: APITokenKey) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.account + ] + _ = SecItemDelete(query as CFDictionary) + } +} diff --git a/release/App-Store-Readiness.md b/release/App-Store-Readiness.md new file mode 100644 index 0000000..4676993 --- /dev/null +++ b/release/App-Store-Readiness.md @@ -0,0 +1,31 @@ +# App Store Readiness (Checked: 2026-02-07) + +## Completed in codebase +- API tokens moved from `UserDefaults` to Keychain (`SecureTokenStore`). +- Added privacy manifest: `Neon Vision Editor/PrivacyInfo.xcprivacy`. +- Disabled unnecessary incoming-network sandbox entitlement. +- Disabled app-group registration entitlement (not used by app). +- Added `ITSAppUsesNonExemptEncryption = NO` in generated Info.plist settings. +- Moved Gemini API key transport from URL query parameters to request headers. +- Reduced production log verbosity for network failures (debug-only logs). + +## Still required in App Store Connect / release process +- Add/update Privacy Policy URL and support URL in app metadata. +- Complete App Privacy questionnaire to match real behavior (AI prompts/source code sent to providers). +- Verify age rating and regional availability. +- Provide reviewer notes that explain AI provider setup (bring-your-own API key flow). +- Upload final screenshots for all device classes enabled by target (`iPhone` + `iPad` and macOS listing if shipping macOS on App Store). +- Ensure valid Apple signing certificates/profiles exist on the build machine for both iOS and macOS distribution. + +## High-risk rejection pitfalls to avoid +- Hidden paywalls or paid features without In-App Purchase. +- Missing disclosure for data sent to third-party AI endpoints. +- Non-functional “Sign in with Apple” parity if any third-party login is added later. +- Broken document open/save flows, especially around permission prompts and sandboxed file access. +- Misleading marketing text (claims not supported by in-app functionality). + +## Pre-submit validation +- `xcodebuild` simulator build passes. +- Archive with Release config and upload through Organizer or `scripts/archive_testflight.sh`. +- Run through first-launch flow with no API key configured. +- Verify all AI providers fail gracefully and show user-facing errors. diff --git a/release/Apple-Store-Distribution-Checklist.md b/release/Apple-Store-Distribution-Checklist.md new file mode 100644 index 0000000..7ec0979 --- /dev/null +++ b/release/Apple-Store-Distribution-Checklist.md @@ -0,0 +1,38 @@ +# Apple Store Distribution Checklist (iOS + macOS) + +Checked on: 2026-02-07 + +## Required app-side files/settings +- `PrivacyInfo.xcprivacy` included in app bundle. +- App icon assets present and generated for target platforms. +- `ITSAppUsesNonExemptEncryption` set (currently `NO`). +- App Sandbox enabled for macOS target. +- Only required entitlements enabled (incoming network disabled, app groups disabled). + +## Required App Store Connect metadata +- Privacy Policy URL +- Support URL +- App description, keywords, and category +- Age rating questionnaire +- App Privacy data collection answers +- Export compliance answers (encryption) +- Reviewer notes (include AI provider behavior and user-supplied API key flow) + +## Required media/assets in App Store Connect +- iPhone screenshots +- iPad screenshots +- macOS screenshots (if distributing the macOS build via App Store) +- Promotional text / What’s New + +## Security checks before submit +- No hardcoded secrets or API tokens in source. +- API tokens stored in Keychain, not `UserDefaults`. +- No tokens embedded in URL query strings. +- Production logs do not include provider tokens or sensitive payloads. +- Network traffic uses HTTPS only. + +## Signing/distribution prerequisites +- Valid team provisioning access in Xcode (`CS727NF72U`). +- Valid iOS distribution signing assets on the release machine. +- Valid macOS distribution signing assets on the release machine. +- Archive from `Release` configuration and upload via Organizer/TestFlight flow.