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_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;
|
||||||
|
|
|
||||||
|
|
@ -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": [[
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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