mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
Prepare App Store security and distribution for v0.4.1-beta
This commit is contained in:
parent
6fc11927f2
commit
db43757005
10 changed files with 251 additions and 42 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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": [[
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
Neon Vision Editor/PrivacyInfo.xcprivacy
Normal file
23
Neon Vision Editor/PrivacyInfo.xcprivacy
Normal 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>
|
||||
89
Neon Vision Editor/SecureTokenStore.swift
Normal file
89
Neon Vision Editor/SecureTokenStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
31
release/App-Store-Readiness.md
Normal file
31
release/App-Store-Readiness.md
Normal 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.
|
||||
38
release/Apple-Store-Distribution-Checklist.md
Normal file
38
release/Apple-Store-Distribution-Checklist.md
Normal 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 / 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.
|
||||
Loading…
Reference in a new issue