Prepare App Store security and distribution for v0.4.1-beta

This commit is contained in:
h3p 2026-02-07 23:56:52 +01:00
parent 6fc11927f2
commit db43757005
10 changed files with 251 additions and 42 deletions

View file

@ -270,7 +270,7 @@
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
@ -285,6 +285,7 @@
ENABLE_USER_SELECTED_FILES = readwrite; ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Neon Vision Editor"; INFOPLIST_KEY_CFBundleDisplayName = "Neon Vision Editor";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
@ -305,7 +306,7 @@
PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor"; PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = NO;
RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO;
RUNTIME_EXCEPTION_ALLOW_JIT = NO; RUNTIME_EXCEPTION_ALLOW_JIT = NO;
RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO;
@ -341,7 +342,7 @@
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
@ -356,6 +357,7 @@
ENABLE_USER_SELECTED_FILES = readwrite; ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Neon Vision Editor"; INFOPLIST_KEY_CFBundleDisplayName = "Neon Vision Editor";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
@ -376,7 +378,7 @@
PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor"; PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = NO;
RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO;
RUNTIME_EXCEPTION_ALLOW_JIT = NO; RUNTIME_EXCEPTION_ALLOW_JIT = NO;
RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO;

View file

@ -99,10 +99,10 @@ final class GeminiAIClient: AIClient {
return AsyncStream { continuation in return AsyncStream { continuation in
Task { Task {
do { do {
var comps = URLComponents(string: "https://generativelanguage.googleapis.com/v1beta/models/\(model):generateContent")! let url = URL(string: "https://generativelanguage.googleapis.com/v1beta/models/\(model):generateContent")!
comps.queryItems = [URLQueryItem(name: "key", value: apiKey)] var request = URLRequest(url: url)
var request = URLRequest(url: comps.url!)
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue(apiKey, forHTTPHeaderField: "x-goog-api-key")
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any] = [ let body: [String: Any] = [
"contents": [[ "contents": [[

View file

@ -51,10 +51,10 @@ struct ContentView: View {
@State var lastProviderUsed: String = "Apple" @State var lastProviderUsed: String = "Apple"
// Persisted API tokens for external providers // Persisted API tokens for external providers
@State var grokAPIToken: String = UserDefaults.standard.string(forKey: "GrokAPIToken") ?? "" @State var grokAPIToken: String = SecureTokenStore.token(for: .grok)
@State var openAIAPIToken: String = UserDefaults.standard.string(forKey: "OpenAIAPIToken") ?? "" @State var openAIAPIToken: String = SecureTokenStore.token(for: .openAI)
@State var geminiAPIToken: String = UserDefaults.standard.string(forKey: "GeminiAPIToken") ?? "" @State var geminiAPIToken: String = SecureTokenStore.token(for: .gemini)
@State var anthropicAPIToken: String = UserDefaults.standard.string(forKey: "AnthropicAPIToken") ?? "" @State var anthropicAPIToken: String = SecureTokenStore.token(for: .anthropic)
// Debounce handle for inline completion // Debounce handle for inline completion
@State var lastCompletionWorkItem: DispatchWorkItem? @State var lastCompletionWorkItem: DispatchWorkItem?
@ -91,7 +91,7 @@ struct ContentView: View {
var activeProviderName: String { lastProviderUsed } 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. /// Returns true if a token is present/was saved; false if cancelled or empty.
private func promptForGrokTokenIfNeeded() -> Bool { private func promptForGrokTokenIfNeeded() -> Bool {
if !grokAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true } if !grokAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true }
@ -110,14 +110,14 @@ struct ContentView: View {
let token = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) let token = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty { return false } if token.isEmpty { return false }
grokAPIToken = token grokAPIToken = token
UserDefaults.standard.set(token, forKey: "GrokAPIToken") SecureTokenStore.setToken(token, for: .grok)
return true return true
} }
#endif #endif
return false 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. /// Returns true if a token is present/was saved; false if cancelled or empty.
private func promptForOpenAITokenIfNeeded() -> Bool { private func promptForOpenAITokenIfNeeded() -> Bool {
if !openAIAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true } if !openAIAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true }
@ -136,14 +136,14 @@ struct ContentView: View {
let token = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) let token = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty { return false } if token.isEmpty { return false }
openAIAPIToken = token openAIAPIToken = token
UserDefaults.standard.set(token, forKey: "OpenAIAPIToken") SecureTokenStore.setToken(token, for: .openAI)
return true return true
} }
#endif #endif
return false 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. /// Returns true if a token is present/was saved; false if cancelled or empty.
private func promptForGeminiTokenIfNeeded() -> Bool { private func promptForGeminiTokenIfNeeded() -> Bool {
if !geminiAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true } if !geminiAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true }
@ -162,14 +162,14 @@ struct ContentView: View {
let token = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) let token = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty { return false } if token.isEmpty { return false }
geminiAPIToken = token geminiAPIToken = token
UserDefaults.standard.set(token, forKey: "GeminiAPIToken") SecureTokenStore.setToken(token, for: .gemini)
return true return true
} }
#endif #endif
return false 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. /// Returns true if a token is present/was saved; false if cancelled or empty.
private func promptForAnthropicTokenIfNeeded() -> Bool { private func promptForAnthropicTokenIfNeeded() -> Bool {
if !anthropicAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true } if !anthropicAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true }
@ -188,7 +188,7 @@ struct ContentView: View {
let token = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) let token = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty { return false } if token.isEmpty { return false }
anthropicAPIToken = token anthropicAPIToken = token
UserDefaults.standard.set(token, forKey: "AnthropicAPIToken") SecureTokenStore.setToken(token, for: .anthropic)
return true return true
} }
#endif #endif
@ -320,7 +320,9 @@ struct ContentView: View {
let content = message["content"] as? String { let content = message["content"] as? String {
return sanitizeCompletion(content) return sanitizeCompletion(content)
} }
} catch { print("[Completion][Fallback][Grok] error: \(error)") } } catch {
debugLog("[Completion][Fallback][Grok] request failed")
}
} }
// Try OpenAI // Try OpenAI
if !openAIAPIToken.isEmpty { if !openAIAPIToken.isEmpty {
@ -353,16 +355,19 @@ struct ContentView: View {
let content = message["content"] as? String { let content = message["content"] as? String {
return sanitizeCompletion(content) return sanitizeCompletion(content)
} }
} catch { print("[Completion][Fallback][OpenAI] error: \(error)") } } catch {
debugLog("[Completion][Fallback][OpenAI] request failed")
}
} }
// Try Gemini // Try Gemini
if !geminiAPIToken.isEmpty { if !geminiAPIToken.isEmpty {
do { do {
let model = "gemini-1.5-flash-latest" 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 "" } guard let url = URL(string: endpoint) else { return "" }
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue(geminiAPIToken, forHTTPHeaderField: "x-goog-api-key")
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let prompt = """ let prompt = """
Continue the following \(language) code snippet with a few lines or tokens of code only. Do not add prose or explanations. 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 { let text = parts.first?["text"] as? String {
return sanitizeCompletion(text) return sanitizeCompletion(text)
} }
} catch { print("[Completion][Fallback][Gemini] error: \(error)") } } catch {
debugLog("[Completion][Fallback][Gemini] request failed")
}
} }
// Try Anthropic // Try Anthropic
if !anthropicAPIToken.isEmpty { if !anthropicAPIToken.isEmpty {
@ -424,7 +431,9 @@ struct ContentView: View {
let text = first["text"] as? String { let text = first["text"] as? String {
return sanitizeCompletion(text) return sanitizeCompletion(text)
} }
} catch { print("[Completion][Fallback][Anthropic] error: \(error)") } } catch {
debugLog("[Completion][Fallback][Anthropic] request failed")
}
} }
return "" return ""
} }
@ -491,7 +500,7 @@ struct ContentView: View {
await MainActor.run { lastProviderUsed = "Grok (fallback to Apple)" } await MainActor.run { lastProviderUsed = "Grok (fallback to Apple)" }
return res return res
} catch { } catch {
print("[Completion][Grok] request failed: \(error)") debugLog("[Completion][Grok] request failed")
let res = await appleModelCompletion(prefix: prefix, language: language) let res = await appleModelCompletion(prefix: prefix, language: language)
await MainActor.run { lastProviderUsed = "Grok (fallback to Apple)" } await MainActor.run { lastProviderUsed = "Grok (fallback to Apple)" }
return res return res
@ -536,7 +545,7 @@ struct ContentView: View {
await MainActor.run { lastProviderUsed = "OpenAI (fallback to Apple)" } await MainActor.run { lastProviderUsed = "OpenAI (fallback to Apple)" }
return res return res
} catch { } catch {
print("[Completion][OpenAI] request failed: \(error)") debugLog("[Completion][OpenAI] request failed")
let res = await appleModelCompletion(prefix: prefix, language: language) let res = await appleModelCompletion(prefix: prefix, language: language)
await MainActor.run { lastProviderUsed = "OpenAI (fallback to Apple)" } await MainActor.run { lastProviderUsed = "OpenAI (fallback to Apple)" }
return res return res
@ -549,7 +558,7 @@ struct ContentView: View {
} }
do { do {
let model = "gemini-1.5-flash-latest" 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 { guard let url = URL(string: endpoint) else {
let res = await appleModelCompletion(prefix: prefix, language: language) let res = await appleModelCompletion(prefix: prefix, language: language)
await MainActor.run { lastProviderUsed = "Gemini (fallback to Apple)" } await MainActor.run { lastProviderUsed = "Gemini (fallback to Apple)" }
@ -557,6 +566,7 @@ struct ContentView: View {
} }
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue(geminiAPIToken, forHTTPHeaderField: "x-goog-api-key")
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let prompt = """ let prompt = """
Continue the following \(language) code snippet with a few lines or tokens of code only. Do not add prose or explanations. 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)" } await MainActor.run { lastProviderUsed = "Gemini (fallback to Apple)" }
return res return res
} catch { } catch {
print("[Completion][Gemini] request failed: \(error)") debugLog("[Completion][Gemini] request failed")
let res = await appleModelCompletion(prefix: prefix, language: language) let res = await appleModelCompletion(prefix: prefix, language: language)
await MainActor.run { lastProviderUsed = "Gemini (fallback to Apple)" } await MainActor.run { lastProviderUsed = "Gemini (fallback to Apple)" }
return res return res
@ -636,7 +646,7 @@ struct ContentView: View {
await MainActor.run { lastProviderUsed = "Anthropic (fallback to Apple)" } await MainActor.run { lastProviderUsed = "Anthropic (fallback to Apple)" }
return res return res
} catch { } catch {
print("[Completion][Anthropic] request failed: \(error)") debugLog("[Completion][Anthropic] request failed")
let res = await appleModelCompletion(prefix: prefix, language: language) let res = await appleModelCompletion(prefix: prefix, language: language)
await MainActor.run { lastProviderUsed = "Anthropic (fallback to Apple)" } await MainActor.run { lastProviderUsed = "Anthropic (fallback to Apple)" }
return res return res
@ -669,6 +679,12 @@ struct ContentView: View {
return result return result
} }
private func debugLog(_ message: String) {
#if DEBUG
print(message)
#endif
}
@ViewBuilder @ViewBuilder
private var platformLayout: some View { private var platformLayout: some View {
#if os(macOS) #if os(macOS)

View file

@ -196,7 +196,7 @@ class EditorViewModel: ObservableObject {
try tabs[index].content.write(to: url, atomically: true, encoding: .utf8) try tabs[index].content.write(to: url, atomically: true, encoding: .utf8)
tabs[index].isDirty = false tabs[index].isDirty = false
} catch { } catch {
print("Error saving file: \(error)") debugLog("Failed to save file.")
} }
} else { } else {
saveFileAs(tab: tab) saveFileAs(tab: tab)
@ -228,13 +228,13 @@ class EditorViewModel: ObservableObject {
tabs[index].name = url.lastPathComponent tabs[index].name = url.lastPathComponent
tabs[index].isDirty = false tabs[index].isDirty = false
} catch { } catch {
print("Error saving file: \(error)") debugLog("Failed to save file.")
} }
} }
#else #else
// iOS/iPadOS: explicit Save As panel is not available here yet. // iOS/iPadOS: explicit Save As panel is not available here yet.
// Keep document dirty so user can export/share via future document APIs. // 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 #endif
} }
@ -262,12 +262,12 @@ class EditorViewModel: ObservableObject {
tabs.append(newTab) tabs.append(newTab)
selectedTabID = newTab.id selectedTabID = newTab.id
} catch { } catch {
print("Error opening file: \(error)") debugLog("Failed to open file.")
} }
} }
#else #else
// iOS/iPadOS: document picker flow can be added here. // 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 #endif
} }
@ -285,7 +285,7 @@ class EditorViewModel: ObservableObject {
tabs.append(newTab) tabs.append(newTab)
selectedTabID = newTab.id selectedTabID = newTab.id
} catch { } catch {
print("Error opening file: \(error)") debugLog("Failed to open file.")
} }
} }
@ -302,4 +302,10 @@ class EditorViewModel: ObservableObject {
text.components(separatedBy: .whitespacesAndNewlines) text.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }.count .filter { !$0.isEmpty }.count
} }
private func debugLog(_ message: String) {
#if DEBUG
print(message)
#endif
}
} }

View file

@ -48,6 +48,10 @@ struct NeonVisionEditorApp: App {
@State private var showGrokError: Bool = false @State private var showGrokError: Bool = false
@State private var grokErrorMessage: String = "" @State private var grokErrorMessage: String = ""
init() {
SecureTokenStore.migrateLegacyUserDefaultsTokens()
}
var body: some Scene { var body: some Scene {
#if os(macOS) #if os(macOS)
WindowGroup { WindowGroup {
@ -230,9 +234,9 @@ struct NeonVisionEditorApp: App {
let contentPrefix = String(tab.content.prefix(1000)) let contentPrefix = String(tab.content.prefix(1000))
let prompt = "Suggest improvements for this \(tab.language) code: \(contentPrefix)" let prompt = "Suggest improvements for this \(tab.language) code: \(contentPrefix)"
let grokToken = UserDefaults.standard.string(forKey: "GrokAPIToken") ?? "" let grokToken = SecureTokenStore.token(for: .grok)
let openAIToken = UserDefaults.standard.string(forKey: "OpenAIAPIToken") ?? "" let openAIToken = SecureTokenStore.token(for: .openAI)
let geminiToken = UserDefaults.standard.string(forKey: "GeminiAPIToken") ?? "" let geminiToken = SecureTokenStore.token(for: .gemini)
let client: AIClient? = { let client: AIClient? = {
#if USE_FOUNDATION_MODELS #if USE_FOUNDATION_MODELS

View file

@ -43,7 +43,7 @@ struct APISupportSettingsView: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.onChange(of: grokAPIToken) { _, new in .onChange(of: grokAPIToken) { _, new in
UserDefaults.standard.set(new, forKey: "GrokAPIToken") SecureTokenStore.setToken(new, for: .grok)
} }
} }
LabeledContent("OpenAI") { LabeledContent("OpenAI") {
@ -51,7 +51,7 @@ struct APISupportSettingsView: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.onChange(of: openAIAPIToken) { _, new in .onChange(of: openAIAPIToken) { _, new in
UserDefaults.standard.set(new, forKey: "OpenAIAPIToken") SecureTokenStore.setToken(new, for: .openAI)
} }
} }
LabeledContent("Gemini") { LabeledContent("Gemini") {
@ -59,7 +59,7 @@ struct APISupportSettingsView: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.onChange(of: geminiAPIToken) { _, new in .onChange(of: geminiAPIToken) { _, new in
UserDefaults.standard.set(new, forKey: "GeminiAPIToken") SecureTokenStore.setToken(new, for: .gemini)
} }
} }
LabeledContent("Anthropic") { LabeledContent("Anthropic") {
@ -67,7 +67,7 @@ struct APISupportSettingsView: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.onChange(of: anthropicAPIToken) { _, new in .onChange(of: anthropicAPIToken) { _, new in
UserDefaults.standard.set(new, forKey: "AnthropicAPIToken") SecureTokenStore.setToken(new, for: .anthropic)
} }
} }
} }

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -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)
}
}

View file

@ -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.

View file

@ -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 / Whats 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.