feat: updater hardening and release readiness for v0.4.13

This commit is contained in:
h3p 2026-02-14 14:24:01 +01:00
parent 6d7e7f66a3
commit 09d1096941
19 changed files with 1745 additions and 217 deletions

View file

@ -358,7 +358,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 220;
CURRENT_PROJECT_VERSION = 221;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;
@ -438,7 +438,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 220;
CURRENT_PROJECT_VERSION = 221;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;

View file

@ -8,7 +8,7 @@ public final class AIModelClient {
self.apiKey = apiKey
}
// MARK: - Non-streaming text generation
///MARK: - Non-streaming text generation
public func generateText(prompt: String, model: String = "grok-3-beta", maxTokens: Int = 500) async throws -> String {
guard let baseURL = URL(string: baseURLString) else {
throw NSError(
@ -49,7 +49,7 @@ public final class AIModelClient {
return decoded.choices.first?.message.content ?? ""
}
// MARK: - Streaming suggestions (SSE)
///MARK: - Streaming suggestions (SSE)
public func streamSuggestions(prompt: String, model: String = "grok-code-fast-1") -> AsyncStream<String> {
guard let baseURL = URL(string: baseURLString) else {
return AsyncStream { continuation in

View file

@ -58,6 +58,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private struct DetachedWindowContentView: View {
@StateObject private var viewModel = EditorViewModel()
@ObservedObject var supportPurchaseManager: SupportPurchaseManager
@ObservedObject var appUpdateManager: AppUpdateManager
@Binding var showGrokError: Bool
@Binding var grokErrorMessage: String
@ -65,6 +66,7 @@ private struct DetachedWindowContentView: View {
ContentView()
.environmentObject(viewModel)
.environmentObject(supportPurchaseManager)
.environmentObject(appUpdateManager)
.environment(\.showGrokError, $showGrokError)
.environment(\.grokErrorMessage, $grokErrorMessage)
.frame(minWidth: 600, minHeight: 400)
@ -76,6 +78,7 @@ private struct DetachedWindowContentView: View {
struct NeonVisionEditorApp: App {
@StateObject private var viewModel = EditorViewModel()
@StateObject private var supportPurchaseManager = SupportPurchaseManager()
@StateObject private var appUpdateManager = AppUpdateManager()
@AppStorage("SettingsAppearance") private var appearance: String = "system"
@AppStorage("SettingsOpenInTabs") private var openInTabs: String = "system"
#if os(macOS)
@ -173,7 +176,6 @@ struct NeonVisionEditorApp: App {
init() {
let defaults = UserDefaults.standard
SecureTokenStore.migrateLegacyUserDefaultsTokens()
// Safety reset: avoid stale NORMAL-mode state making editor appear non-editable.
defaults.set(false, forKey: "EditorVimModeEnabled")
// Force-disable invisible/control character rendering.
@ -205,7 +207,10 @@ struct NeonVisionEditorApp: App {
"SettingsOpenWithBlankDocument": true,
"SettingsDefaultNewFileLanguage": "plain",
"SettingsConfirmCloseDirtyTab": true,
"SettingsConfirmClearEditor": true
"SettingsConfirmClearEditor": true,
"SettingsAutoCheckForUpdates": true,
"SettingsUpdateCheckInterval": AppUpdateCheckInterval.daily.rawValue,
"SettingsAutoDownloadUpdates": false
])
let whitespaceMigrationKey = "SettingsMigrationWhitespaceGlyphResetV1"
if !defaults.bool(forKey: whitespaceMigrationKey) {
@ -244,6 +249,7 @@ struct NeonVisionEditorApp: App {
ContentView()
.environmentObject(viewModel)
.environmentObject(supportPurchaseManager)
.environmentObject(appUpdateManager)
.onAppear { appDelegate.viewModel = viewModel }
.onAppear { applyGlobalAppearanceOverride() }
.onAppear { applyOpenInTabsPreference() }
@ -254,6 +260,9 @@ struct NeonVisionEditorApp: App {
.preferredColorScheme(preferredAppearance)
.frame(minWidth: 600, minHeight: 400)
.task {
if ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution {
appUpdateManager.startAutomaticChecks()
}
#if USE_FOUNDATION_MODELS && canImport(FoundationModels)
do {
let start = Date()
@ -276,6 +285,7 @@ struct NeonVisionEditorApp: App {
WindowGroup("New Window", id: "blank-window") {
DetachedWindowContentView(
supportPurchaseManager: supportPurchaseManager,
appUpdateManager: appUpdateManager,
showGrokError: $showGrokError,
grokErrorMessage: $grokErrorMessage
)
@ -291,6 +301,7 @@ struct NeonVisionEditorApp: App {
Settings {
NeonSettingsView()
.environmentObject(supportPurchaseManager)
.environmentObject(appUpdateManager)
.onAppear { applyGlobalAppearanceOverride() }
.onAppear { applyOpenInTabsPreference() }
.onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() }
@ -300,6 +311,14 @@ struct NeonVisionEditorApp: App {
.commands {
CommandGroup(replacing: .appSettings) {
if ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution {
Button("Check for Updates…") {
postWindowCommand(.showUpdaterRequested, object: true)
}
}
Divider()
Button("Settings…") {
showSettingsWindow()
}
@ -544,6 +563,7 @@ struct NeonVisionEditorApp: App {
ContentView()
.environmentObject(viewModel)
.environmentObject(supportPurchaseManager)
.environmentObject(appUpdateManager)
.environment(\.showGrokError, $showGrokError)
.environment(\.grokErrorMessage, $grokErrorMessage)
.onAppear { applyIOSAppearanceOverride() }

View file

@ -0,0 +1,859 @@
import Foundation
import SwiftUI
import Combine
import CryptoKit
#if canImport(Security)
import Security
#endif
#if canImport(AppKit)
import AppKit
#endif
#if canImport(UIKit)
import UIKit
#endif
enum AppUpdateCheckInterval: String, CaseIterable, Identifiable {
case hourly = "hourly"
case daily = "daily"
case weekly = "weekly"
var id: String { rawValue }
var title: String {
switch self {
case .hourly: return "Hourly"
case .daily: return "Daily"
case .weekly: return "Weekly"
}
}
var seconds: TimeInterval {
switch self {
case .hourly: return 3600
case .daily: return 86400
case .weekly: return 604800
}
}
}
@MainActor
final class AppUpdateManager: ObservableObject {
enum CheckSource {
case automatic
case manual
}
enum Status {
case idle
case checking
case updateAvailable
case upToDate
case failed
}
struct ReleaseInfo: Codable, Equatable {
let version: String
let title: String
let notes: String
let publishedAt: Date?
let releaseURL: URL
let downloadURL: URL?
let assetName: String?
}
private enum UpdateError: LocalizedError {
case invalidReleaseSource
case prereleaseRejected
case draftRejected
case rateLimited(until: Date?)
case missingCachedRelease
case installUnsupported(String)
case checksumMissing(String)
case checksumMismatch
case invalidCodeSignature
case noDownloadAsset
var errorDescription: String? {
switch self {
case .invalidReleaseSource:
return "Release source validation failed."
case .prereleaseRejected:
return "Latest GitHub release is marked as prerelease and was skipped."
case .draftRejected:
return "Latest GitHub release is a draft and was skipped."
case .rateLimited(let until):
if let until {
return "GitHub API rate limit reached. Retry after \(until.formatted(date: .abbreviated, time: .shortened))."
}
return "GitHub API rate limit reached."
case .missingCachedRelease:
return "No cached release metadata found for ETag response."
case .installUnsupported(let reason):
return reason
case .checksumMissing(let asset):
return "Checksum missing for \(asset)."
case .checksumMismatch:
return "Downloaded update checksum does not match release metadata."
case .invalidCodeSignature:
return "Downloaded app signature validation failed."
case .noDownloadAsset:
return "No downloadable ZIP asset found for this release."
}
}
}
@Published private(set) var status: Status = .idle
@Published private(set) var latestRelease: ReleaseInfo?
@Published private(set) var errorMessage: String?
@Published private(set) var lastCheckedAt: Date?
@Published private(set) var automaticPromptToken: Int = 0
@Published private(set) var isInstalling: Bool = false
@Published private(set) var installMessage: String?
@Published private(set) var lastCheckResultSummary: String = "Never checked"
private let owner: String
private let repo: String
private let defaults: UserDefaults
private let session: URLSession
private let appLaunchDate: Date
private var automaticTask: Task<Void, Never>?
private var pendingAutomaticPrompt: Bool = false
let currentVersion: String
static let autoCheckEnabledKey = "SettingsAutoCheckForUpdates"
static let updateIntervalKey = "SettingsUpdateCheckInterval"
static let autoDownloadEnabledKey = "SettingsAutoDownloadUpdates"
static let skippedVersionKey = "SettingsSkippedUpdateVersion"
static let lastCheckedAtKey = "SettingsLastUpdateCheckAt"
static let remindUntilKey = "SettingsUpdateRemindUntil"
static let etagKey = "SettingsUpdateETag"
static let cachedReleaseKey = "SettingsCachedReleaseInfo"
static let consecutiveFailuresKey = "SettingsUpdateConsecutiveFailures"
static let pauseUntilKey = "SettingsUpdatePauseUntil"
static let lastCheckSummaryKey = "SettingsUpdateLastCheckSummary"
private static let minAutoPromptUptime: TimeInterval = 90
private static let circuitBreakerThreshold = 3
private static let circuitBreakerPause: TimeInterval = 3600
init(
owner: String = "h3pdesign",
repo: String = "Neon-Vision-Editor",
defaults: UserDefaults = .standard,
session: URLSession = .shared
) {
self.owner = owner
self.repo = repo
self.defaults = defaults
self.session = session
self.appLaunchDate = Date()
self.currentVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0"
if let timestamp = defaults.object(forKey: Self.lastCheckedAtKey) as? TimeInterval {
self.lastCheckedAt = Date(timeIntervalSince1970: timestamp)
}
if let summary = defaults.string(forKey: Self.lastCheckSummaryKey), !summary.isEmpty {
self.lastCheckResultSummary = summary
}
if Self.isDevelopmentRuntime {
// Prevent persisted settings from triggering relaunch/install loops during local debugging.
defaults.set(false, forKey: Self.autoDownloadEnabledKey)
}
}
var autoCheckEnabled: Bool {
defaults.object(forKey: Self.autoCheckEnabledKey) as? Bool ?? true
}
var autoDownloadEnabled: Bool {
defaults.object(forKey: Self.autoDownloadEnabledKey) as? Bool ?? false
}
var updateInterval: AppUpdateCheckInterval {
let raw = defaults.string(forKey: Self.updateIntervalKey) ?? AppUpdateCheckInterval.daily.rawValue
return AppUpdateCheckInterval(rawValue: raw) ?? .daily
}
var pausedUntil: Date? {
guard let ts = defaults.object(forKey: Self.pauseUntilKey) as? TimeInterval else { return nil }
return Date(timeIntervalSince1970: ts)
}
var consecutiveFailureCount: Int {
defaults.object(forKey: Self.consecutiveFailuresKey) as? Int ?? 0
}
func setAutoCheckEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: Self.autoCheckEnabledKey)
rescheduleAutomaticChecks()
}
func setAutoDownloadEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: Self.autoDownloadEnabledKey)
}
func setUpdateInterval(_ interval: AppUpdateCheckInterval) {
defaults.set(interval.rawValue, forKey: Self.updateIntervalKey)
rescheduleAutomaticChecks()
}
func startAutomaticChecks() {
guard ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution else { return }
rescheduleAutomaticChecks()
guard autoCheckEnabled else { return }
if shouldRunInitialCheckNow() {
Task { await checkForUpdates(source: .automatic) }
}
}
func checkForUpdates(source: CheckSource) async {
guard ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution else {
status = .idle
errorMessage = "Updater is disabled for this distribution channel."
return
}
guard status != .checking else { return }
if source == .automatic,
let pausedUntil,
pausedUntil > Date() {
// Respect circuit-breaker/rate-limit pause windows for background checks.
updateLastSummary("Auto-check paused until \(pausedUntil.formatted(date: .abbreviated, time: .shortened))")
return
}
status = .checking
errorMessage = nil
do {
let release = try await fetchLatestRelease()
let now = Date()
lastCheckedAt = now
defaults.set(now.timeIntervalSince1970, forKey: Self.lastCheckedAtKey)
defaults.set(0, forKey: Self.consecutiveFailuresKey)
defaults.removeObject(forKey: Self.pauseUntilKey)
if Self.compareVersions(release.version, currentVersion) == .orderedDescending {
latestRelease = release
status = .updateAvailable
installMessage = nil
updateLastSummary("Update available: \(release.version)")
if source == .automatic,
shouldAutoPrompt(for: release.version) {
if autoDownloadEnabled {
if Self.isDevelopmentRuntime {
// Avoid relaunch loops while running from Xcode/DerivedData.
pendingAutomaticPrompt = true
automaticPromptToken &+= 1
installMessage = "Automatic install is disabled while running from Xcode/DerivedData."
} else {
await attemptAutoInstall()
}
} else {
pendingAutomaticPrompt = true
automaticPromptToken &+= 1
}
}
} else {
latestRelease = nil
status = .upToDate
updateLastSummary("Up to date")
}
} catch {
latestRelease = nil
status = .failed
errorMessage = error.localizedDescription
if case let UpdateError.rateLimited(until) = error, let until, until > Date() {
// Use GitHub-provided reset time when available.
defaults.set(until.timeIntervalSince1970, forKey: Self.pauseUntilKey)
updateLastSummary("Rate limited by GitHub. Auto-check paused until \(until.formatted(date: .abbreviated, time: .shortened)).")
} else {
let failures = (defaults.object(forKey: Self.consecutiveFailuresKey) as? Int ?? 0) + 1
defaults.set(failures, forKey: Self.consecutiveFailuresKey)
if failures >= Self.circuitBreakerThreshold {
let until = Date().addingTimeInterval(Self.circuitBreakerPause)
defaults.set(until.timeIntervalSince1970, forKey: Self.pauseUntilKey)
updateLastSummary("Checks paused after \(failures) failures (until \(until.formatted(date: .abbreviated, time: .shortened))).")
} else {
updateLastSummary("Update check failed: \(error.localizedDescription)")
}
}
}
}
func consumeAutomaticPromptIfNeeded() -> Bool {
guard pendingAutomaticPrompt else { return false }
pendingAutomaticPrompt = false
return true
}
func skipCurrentVersion() {
guard let version = latestRelease?.version else { return }
defaults.set(version, forKey: Self.skippedVersionKey)
}
func remindMeTomorrow() {
let until = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date().addingTimeInterval(86400)
defaults.set(until.timeIntervalSince1970, forKey: Self.remindUntilKey)
}
func clearSkippedVersion() {
defaults.removeObject(forKey: Self.skippedVersionKey)
}
func openDownloadPage() {
guard let release = latestRelease else { return }
openURL(release.downloadURL ?? release.releaseURL)
}
func openReleasePage() {
guard let release = latestRelease else { return }
openURL(release.releaseURL)
}
func clearInstallMessage() {
installMessage = nil
}
func installUpdateNow() async {
guard ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution else {
installMessage = "Updater is disabled for this distribution channel."
return
}
guard !Self.isDevelopmentRuntime else {
installMessage = "Install now is disabled while running from Xcode/DerivedData. Use Download Update instead."
return
}
await attemptAutoInstall()
}
private func shouldRunInitialCheckNow() -> Bool {
guard let lastCheckedAt else { return true }
return Date().timeIntervalSince(lastCheckedAt) >= updateInterval.seconds
}
private func shouldAutoPrompt(for version: String) -> Bool {
if defaults.string(forKey: Self.skippedVersionKey) == version { return false }
if let remindTS = defaults.object(forKey: Self.remindUntilKey) as? TimeInterval,
Date(timeIntervalSince1970: remindTS) > Date() {
return false
}
let uptime = Date().timeIntervalSince(appLaunchDate)
return uptime >= Self.minAutoPromptUptime
}
private func rescheduleAutomaticChecks() {
automaticTask?.cancel()
automaticTask = nil
guard autoCheckEnabled else { return }
let intervalSeconds = updateInterval.seconds
automaticTask = Task { [weak self] in
guard let self else { return }
while !Task.isCancelled {
let nanos = UInt64(intervalSeconds * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanos)
if Task.isCancelled { break }
await self.checkForUpdates(source: .automatic)
}
}
}
private func updateLastSummary(_ summary: String) {
lastCheckResultSummary = summary
defaults.set(summary, forKey: Self.lastCheckSummaryKey)
}
private func fetchLatestRelease() async throws -> ReleaseInfo {
let endpoint = "https://api.github.com/repos/\(owner)/\(repo)/releases/latest"
guard let url = URL(string: endpoint) else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.timeoutInterval = 20
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.setValue("NeonVisionEditorUpdater", forHTTPHeaderField: "User-Agent")
if let etag = defaults.string(forKey: Self.etagKey), !etag.isEmpty {
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
}
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
if http.statusCode == 304 {
// ETag hit: reuse previously cached release payload.
if let cached = cachedReleaseInfo() {
return cached
}
throw UpdateError.missingCachedRelease
}
if http.statusCode == 403,
(http.value(forHTTPHeaderField: "X-RateLimit-Remaining") ?? "") == "0" {
let until = Self.rateLimitResetDate(from: http)
throw UpdateError.rateLimited(until: until)
}
guard 200..<300 ~= http.statusCode else {
throw NSError(
domain: "AppUpdater",
code: http.statusCode,
userInfo: [NSLocalizedDescriptionKey: "GitHub update check failed (HTTP \(http.statusCode))."]
)
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let payload = try decoder.decode(GitHubReleasePayload.self, from: data)
// Enforce repository identity from API payload before using any release data.
if let apiURL = payload.apiURL,
!Self.matchesExpectedRepository(url: apiURL, expectedOwner: owner, expectedRepo: repo) {
throw UpdateError.invalidReleaseSource
}
guard !payload.draft else { throw UpdateError.draftRejected }
guard !payload.prerelease else { throw UpdateError.prereleaseRejected }
guard let releaseURL = URL(string: payload.htmlURL),
isTrustedGitHubURL(releaseURL),
Self.matchesExpectedRepository(url: releaseURL, expectedOwner: owner, expectedRepo: repo) else {
throw UpdateError.invalidReleaseSource
}
let selectedAssetURL = preferredAsset(from: payload.assets)
let selectedAssetName = selectedAssetName(from: payload.assets)
let release = ReleaseInfo(
version: Self.normalizedVersion(from: payload.tagName),
title: payload.name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
? (payload.name ?? payload.tagName)
: payload.tagName,
notes: payload.body ?? "",
publishedAt: payload.publishedAt,
releaseURL: releaseURL,
downloadURL: selectedAssetURL,
assetName: selectedAssetName
)
if let etag = http.value(forHTTPHeaderField: "ETag"), !etag.isEmpty {
defaults.set(etag, forKey: Self.etagKey)
}
persistCachedReleaseInfo(release)
return release
}
private func selectedAssetName(from assets: [GitHubAssetPayload]) -> String? {
let names = assets.map { $0.name }
return Self.selectPreferredAssetName(from: names)
}
private func preferredAsset(from assets: [GitHubAssetPayload]) -> URL? {
guard let selectedName = selectedAssetName(from: assets),
let asset = assets.first(where: { $0.name == selectedName }),
let url = URL(string: asset.browserDownloadURL),
isTrustedGitHubURL(url),
Self.matchesExpectedAssetURL(url: url, expectedOwner: owner, expectedRepo: repo) else {
return nil
}
return url
}
private func cachedReleaseInfo() -> ReleaseInfo? {
guard let data = defaults.data(forKey: Self.cachedReleaseKey) else { return nil }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try? decoder.decode(ReleaseInfo.self, from: data)
}
private func persistCachedReleaseInfo(_ release: ReleaseInfo) {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
if let data = try? encoder.encode(release) {
defaults.set(data, forKey: Self.cachedReleaseKey)
}
}
private func attemptAutoInstall() async {
#if os(macOS)
guard !isInstalling else { return }
guard let release = latestRelease else { return }
guard let downloadURL = release.downloadURL else {
installMessage = UpdateError.noDownloadAsset.localizedDescription
return
}
guard let assetName = release.assetName else {
installMessage = UpdateError.noDownloadAsset.localizedDescription
return
}
isInstalling = true
defer { isInstalling = false }
do {
// Defense-in-depth:
// 1) verify artifact checksum from release metadata
// 2) verify code signature validity + signing identity
let expectedHash = try Self.extractSHA256(from: release.notes, preferredAssetName: assetName)
let (tmpURL, response) = try await session.download(from: downloadURL)
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
throw URLError(.badServerResponse)
}
let actualHash = try Self.sha256Hex(of: tmpURL)
guard actualHash.caseInsensitiveCompare(expectedHash) == .orderedSame else {
throw UpdateError.checksumMismatch
}
let fileManager = FileManager.default
let workDir = fileManager.temporaryDirectory.appendingPathComponent("nve-update-\(UUID().uuidString)", isDirectory: true)
let unzipDir = workDir.appendingPathComponent("unzipped", isDirectory: true)
try fileManager.createDirectory(at: unzipDir, withIntermediateDirectories: true)
let downloadedZip = workDir.appendingPathComponent(assetName)
try fileManager.moveItem(at: tmpURL, to: downloadedZip)
let unzipStatus = try Self.unzip(zipURL: downloadedZip, to: unzipDir)
guard unzipStatus == 0 else {
throw UpdateError.installUnsupported("Failed to unpack update archive.")
}
guard let appBundle = Self.findFirstAppBundle(in: unzipDir) else {
throw UpdateError.installUnsupported("No .app bundle found in downloaded update.")
}
guard try Self.verifyCodeSignatureStrictCLI(of: appBundle) else {
throw UpdateError.invalidCodeSignature
}
// Require the downloaded app to match current Team ID and bundle identifier.
guard try Self.verifyCodeSignatureStrictCLI(of: Bundle.main.bundleURL) else {
throw UpdateError.installUnsupported("Current app signature is invalid. Reinstall the app manually before auto-install updates.")
}
guard let expectedTeamID = try Self.readTeamIdentifier(of: Bundle.main.bundleURL) else {
throw UpdateError.installUnsupported("Could not determine local signing team. Use Download Update for manual install.")
}
guard try Self.verifyCodeSignature(
of: appBundle,
expectedTeamID: expectedTeamID,
expectedBundleID: Bundle.main.bundleIdentifier
) else {
throw UpdateError.invalidCodeSignature
}
let currentApp = Bundle.main.bundleURL.standardizedFileURL
let targetDir = currentApp.deletingLastPathComponent()
let backupApp = targetDir.appendingPathComponent("\(currentApp.deletingPathExtension().lastPathComponent)-backup-\(Int(Date().timeIntervalSince1970)).app")
do {
try fileManager.moveItem(at: currentApp, to: backupApp)
try fileManager.moveItem(at: appBundle, to: currentApp)
} catch {
if fileManager.fileExists(atPath: backupApp.path), !fileManager.fileExists(atPath: currentApp.path) {
try? fileManager.moveItem(at: backupApp, to: currentApp)
}
throw UpdateError.installUnsupported("Automatic install failed due to file permissions. Use Download Update for manual install.")
}
installMessage = "Update installed. Relaunching…"
NSWorkspace.shared.openApplication(at: currentApp, configuration: NSWorkspace.OpenConfiguration(), completionHandler: nil)
NSApp.terminate(nil)
} catch {
installMessage = error.localizedDescription
if let release = latestRelease {
openURL(release.downloadURL ?? release.releaseURL)
}
}
#else
installMessage = "Automatic install is supported on macOS only."
#endif
}
#if os(macOS)
private nonisolated static func unzip(zipURL: URL, to destination: URL) throws -> Int32 {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
process.arguments = ["-xk", zipURL.path, destination.path]
try process.run()
process.waitUntilExit()
return process.terminationStatus
}
private nonisolated static func findFirstAppBundle(in directory: URL) -> URL? {
let fm = FileManager.default
guard let enumerator = fm.enumerator(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) else { return nil }
for case let url as URL in enumerator {
if url.pathExtension.lowercased() == "app" {
return url
}
}
return nil
}
#endif
private func openURL(_ url: URL) {
#if canImport(AppKit)
NSWorkspace.shared.open(url)
#elseif canImport(UIKit)
UIApplication.shared.open(url)
#endif
}
private func isTrustedGitHubURL(_ url: URL) -> Bool {
guard url.scheme == "https" else { return false }
return Self.isTrustedGitHubHost(url.host)
}
nonisolated static func isTrustedGitHubHost(_ host: String?) -> Bool {
guard let host = host?.lowercased() else { return false }
return host == "github.com"
|| host == "objects.githubusercontent.com"
|| host == "github-releases.githubusercontent.com"
}
nonisolated static func selectPreferredAssetName(from names: [String]) -> String? {
if let exact = names.first(where: { $0.caseInsensitiveCompare("Neon.Vision.Editor.app.zip") == .orderedSame }) {
return exact
}
if let appZip = names.first(where: { $0.lowercased().hasSuffix(".app.zip") }) {
return appZip
}
if let neonZip = names.first(where: { $0.lowercased().contains("neon") && $0.lowercased().hasSuffix(".zip") }) {
return neonZip
}
return names.first(where: { $0.lowercased().hasSuffix(".zip") })
}
nonisolated static func normalizedVersion(from tag: String) -> String {
var cleaned = tag.trimmingCharacters(in: .whitespacesAndNewlines)
if cleaned.hasPrefix("v") || cleaned.hasPrefix("V") {
cleaned.removeFirst()
}
if let dash = cleaned.firstIndex(of: "-") {
cleaned = String(cleaned[..<dash])
}
return cleaned
}
nonisolated static func compareVersions(_ lhs: String, _ rhs: String) -> ComparisonResult {
let leftParts = normalizedVersion(from: lhs).split(separator: ".").map { Int($0) ?? 0 }
let rightParts = normalizedVersion(from: rhs).split(separator: ".").map { Int($0) ?? 0 }
let maxCount = max(leftParts.count, rightParts.count)
for index in 0..<maxCount {
let l = index < leftParts.count ? leftParts[index] : 0
let r = index < rightParts.count ? rightParts[index] : 0
if l < r { return .orderedAscending }
if l > r { return .orderedDescending }
}
let leftIsPrerelease = isPrereleaseVersionTag(lhs)
let rightIsPrerelease = isPrereleaseVersionTag(rhs)
if leftIsPrerelease && !rightIsPrerelease { return .orderedAscending }
if !leftIsPrerelease && rightIsPrerelease { return .orderedDescending }
return .orderedSame
}
nonisolated static func isVersionSkipped(_ version: String, skippedValue: String?) -> Bool {
skippedValue == version
}
nonisolated private static func isPrereleaseVersionTag(_ value: String) -> Bool {
value.trimmingCharacters(in: .whitespacesAndNewlines).contains("-")
}
nonisolated private static func matchesExpectedRepository(url: URL, expectedOwner: String, expectedRepo: String) -> Bool {
let parts = url.pathComponents.filter { $0 != "/" }
guard parts.count >= 2 else { return false }
if parts[0].caseInsensitiveCompare(expectedOwner) == .orderedSame,
parts[1].caseInsensitiveCompare(expectedRepo) == .orderedSame {
return true
}
// GitHub REST API paths are /repos/{owner}/{repo}/...
if parts.count >= 3,
parts[0].caseInsensitiveCompare("repos") == .orderedSame,
parts[1].caseInsensitiveCompare(expectedOwner) == .orderedSame,
parts[2].caseInsensitiveCompare(expectedRepo) == .orderedSame {
return true
}
return false
}
nonisolated private static func matchesExpectedAssetURL(url: URL, expectedOwner: String, expectedRepo: String) -> Bool {
guard let host = url.host?.lowercased() else { return false }
if host == "github.com" {
let parts = url.pathComponents.filter { $0 != "/" }
guard parts.count >= 4 else { return false }
guard parts[0].caseInsensitiveCompare(expectedOwner) == .orderedSame,
parts[1].caseInsensitiveCompare(expectedRepo) == .orderedSame else {
return false
}
return parts[2].lowercased() == "releases" && parts[3].lowercased() == "download"
}
return host == "github-releases.githubusercontent.com"
|| host == "objects.githubusercontent.com"
}
nonisolated private static func rateLimitResetDate(from response: HTTPURLResponse) -> Date? {
guard let reset = response.value(forHTTPHeaderField: "X-RateLimit-Reset"),
let epoch = TimeInterval(reset) else { return nil }
return Date(timeIntervalSince1970: epoch)
}
nonisolated private static func extractSHA256(from notes: String, preferredAssetName: String) throws -> String {
let escapedAsset = NSRegularExpression.escapedPattern(for: preferredAssetName)
let exactAssetPattern = "(?im)\\b\(escapedAsset)\\b[^\\n]*?([A-Fa-f0-9]{64})"
if let hash = firstMatchGroup(in: notes, pattern: exactAssetPattern) {
return hash
}
let genericPattern = "(?im)sha[- ]?256[^A-Fa-f0-9]*([A-Fa-f0-9]{64})"
if let hash = firstMatchGroup(in: notes, pattern: genericPattern) {
return hash
}
throw UpdateError.checksumMissing(preferredAssetName)
}
nonisolated private static func firstMatchGroup(in text: String, pattern: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let ns = text as NSString
let range = NSRange(location: 0, length: ns.length)
guard let match = regex.firstMatch(in: text, options: [], range: range), match.numberOfRanges > 1 else { return nil }
let captured = ns.substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines)
return captured.isEmpty ? nil : captured
}
nonisolated private static func sha256Hex(of fileURL: URL) throws -> String {
// Stream hashing avoids loading large zip files fully into memory.
let handle = try FileHandle(forReadingFrom: fileURL)
defer { try? handle.close() }
var hasher = SHA256()
while true {
let chunk = handle.readData(ofLength: 64 * 1024)
if chunk.isEmpty { break }
hasher.update(data: chunk)
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
nonisolated private static var isDevelopmentRuntime: Bool {
#if DEBUG
return true
#else
let bundlePath = Bundle.main.bundleURL.path
if bundlePath.contains("/DerivedData/") { return true }
if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { return true }
return false
#endif
}
#if os(macOS)
nonisolated private static func verifyCodeSignatureStrictCLI(of appBundle: URL) throws -> Bool {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign")
process.arguments = ["--verify", "--deep", "--strict", appBundle.path]
let outputPipe = Pipe()
process.standardError = outputPipe
process.standardOutput = outputPipe
try process.run()
process.waitUntilExit()
return process.terminationStatus == 0
}
nonisolated private static func readTeamIdentifier(of appBundle: URL) throws -> String? {
#if canImport(Security)
var staticCode: SecStaticCode?
let createStatus = SecStaticCodeCreateWithPath(appBundle as CFURL, [], &staticCode)
guard createStatus == errSecSuccess, let staticCode else { return nil }
var signingInfoRef: CFDictionary?
let infoStatus = SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &signingInfoRef)
guard infoStatus == errSecSuccess,
let signingInfo = signingInfoRef as? [String: Any] else {
return nil
}
return signingInfo[kSecCodeInfoTeamIdentifier as String] as? String
#else
return nil
#endif
}
nonisolated private static func verifyCodeSignature(
of appBundle: URL,
expectedTeamID: String,
expectedBundleID: String?
) throws -> Bool {
#if canImport(Security)
var staticCode: SecStaticCode?
let createStatus = SecStaticCodeCreateWithPath(appBundle as CFURL, [], &staticCode)
guard createStatus == errSecSuccess, let staticCode else { return false }
let checkStatus = SecStaticCodeCheckValidity(staticCode, SecCSFlags(), nil)
guard checkStatus == errSecSuccess else { return false }
var signingInfoRef: CFDictionary?
let infoStatus = SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &signingInfoRef)
guard infoStatus == errSecSuccess,
let signingInfo = signingInfoRef as? [String: Any] else {
return false
}
guard let teamID = signingInfo[kSecCodeInfoTeamIdentifier as String] as? String,
teamID == expectedTeamID else {
return false
}
if let expectedBundleID, !expectedBundleID.isEmpty {
guard let actualBundleID = signingInfo[kSecCodeInfoIdentifier as String] as? String,
actualBundleID == expectedBundleID else {
return false
}
}
return true
#else
return false
#endif
}
#endif
}
private struct GitHubReleasePayload: Decodable {
let apiURL: URL?
let tagName: String
let name: String?
let body: String?
let htmlURL: String
let publishedAt: Date?
let draft: Bool
let prerelease: Bool
let assets: [GitHubAssetPayload]
enum CodingKeys: String, CodingKey {
case apiURL = "url"
case tagName = "tag_name"
case name
case body
case htmlURL = "html_url"
case publishedAt = "published_at"
case draft
case prerelease
case assets
}
}
private struct GitHubAssetPayload: Decodable {
let name: String
let browserDownloadURL: String
enum CodingKeys: String, CodingKey {
case name
case browserDownloadURL = "browser_download_url"
}
}

View file

@ -6,17 +6,12 @@ import FoundationModels
public struct GeneratedText { public var text: String }
public enum AppleFM {
/// Global toggle to enable Apple Foundation Models features at runtime.
/// Defaults to `false` so code completion/AI features are disabled by default.
public static var isEnabled: Bool = false
private static func featureDisabledError() -> NSError {
NSError(domain: "AppleFM", code: -10, userInfo: [NSLocalizedDescriptionKey: "Foundation Models feature is disabled by default. Enable via AppleFM.isEnabled = true."])
}
/// Perform a simple health check by requesting a short completion using the system model.
/// - Returns: A string indicating the model is responsive ("pong").
/// - Throws: Any error thrown by the Foundation Models API or availability checks.
public static func appleFMHealthCheck() async throws -> String {
if #available(iOS 18.0, macOS 15.0, *) {
guard isEnabled else {
@ -35,10 +30,6 @@ public enum AppleFM {
}
}
/// Generate a completion from the given prompt using the system language model.
/// - Parameter prompt: The prompt string to complete.
/// - Returns: The completion text from the model.
/// - Throws: Any error thrown by the Foundation Models API or availability checks.
public static func appleFMComplete(prompt: String) async throws -> String {
if #available(iOS 18.0, macOS 15.0, *) {
guard isEnabled else {
@ -56,9 +47,6 @@ public enum AppleFM {
}
}
/// Stream a completion from the given prompt, yielding partial updates as the model generates them.
/// - Parameter prompt: The prompt string to complete.
/// - Returns: An AsyncStream of incremental text deltas.
public static func appleFMStream(prompt: String) -> AsyncStream<String> {
if #available(iOS 18.0, macOS 15.0, *) {
guard isEnabled else {
@ -120,24 +108,16 @@ public enum AppleFM {
import Foundation
public enum AppleFM {
/// Global toggle to enable Apple Foundation Models features at runtime.
/// Defaults to `false` so code completion/AI features are disabled by default.
public static var isEnabled: Bool = false
/// Stub health check implementation when Foundation Models is not available.
/// - Throws: Always throws an error indicating the feature is unavailable.
public static func appleFMHealthCheck() async throws -> String {
throw NSError(domain: "AppleFM", code: -1, userInfo: [NSLocalizedDescriptionKey: "Foundation Models feature is not enabled."])
}
/// Stub completion implementation when Foundation Models is not available.
/// - Parameter prompt: The prompt string.
/// - Returns: Placeholder string indicating unavailable feature.
public static func appleFMComplete(prompt: String) async throws -> String {
return "Completion unavailable: Foundation Models feature not enabled."
}
/// Stub streaming implementation when Foundation Models is not available.
public static func appleFMStream(prompt: String) -> AsyncStream<String> {
return AsyncStream { continuation in
continuation.finish()

View file

@ -2,6 +2,24 @@ import Foundation
import SwiftUI
enum ReleaseRuntimePolicy {
static var isUpdaterEnabledForCurrentDistribution: Bool {
#if os(macOS)
return !isMacAppStoreDistribution
#else
return false
#endif
}
#if os(macOS)
static var isMacAppStoreDistribution: Bool {
let receiptURL = Bundle.main.bundleURL
.appendingPathComponent("Contents", isDirectory: true)
.appendingPathComponent("_MASReceipt", isDirectory: true)
.appendingPathComponent("receipt", isDirectory: false)
return FileManager.default.fileExists(atPath: receiptURL.path)
}
#endif
static func settingsTab(from requested: String?) -> String {
let trimmed = requested?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "general" : trimmed

View file

@ -6,7 +6,10 @@ import Foundation
import UIKit
#endif
///MARK: - Text Sanitization
// Normalizes pasted and loaded text before it reaches editor state.
enum EditorTextSanitizer {
// Converts control/marker glyphs into safe spaces/newlines and removes unsupported scalars.
static func sanitize(_ input: String) -> String {
// Normalize line endings first so CRLF does not become double newlines.
let normalized = input
@ -49,6 +52,8 @@ enum EditorTextSanitizer {
}
}
///MARK: - Tab Model
// Represents one editor tab and its mutable editing state.
struct TabData: Identifiable {
let id = UUID()
var name: String
@ -59,6 +64,8 @@ struct TabData: Identifiable {
var isDirty: Bool = false
}
///MARK: - Editor View Model
// Owns tab lifecycle, file IO, and language-detection behavior.
@MainActor
class EditorViewModel: ObservableObject {
@Published var tabs: [TabData] = []
@ -142,20 +149,23 @@ class EditorViewModel: ObservableObject {
init() {
addNewTab()
}
// Creates and selects a new untitled tab.
func addNewTab() {
// Keep language discovery active for new untitled tabs.
let newTab = TabData(name: "Untitled \(tabs.count + 1)", content: "", language: defaultNewTabLanguage(), fileURL: nil, languageLocked: false)
tabs.append(newTab)
selectedTabID = newTab.id
}
// Renames an existing tab.
func renameTab(tab: TabData, newName: String) {
if let index = tabs.firstIndex(where: { $0.id == tab.id }) {
tabs[index].name = newName
}
}
// Updates tab text and applies language detection/locking heuristics.
func updateTabContent(tab: TabData, content: String) {
if let index = tabs.firstIndex(where: { $0.id == tab.id }) {
let previous = tabs[index].content
@ -280,14 +290,16 @@ class EditorViewModel: ObservableObject {
}
}
}
// Manually sets language and locks automatic switching.
func updateTabLanguage(tab: TabData, language: String) {
if let index = tabs.firstIndex(where: { $0.id == tab.id }) {
tabs[index].language = language
tabs[index].languageLocked = true
}
}
// Closes a tab while guaranteeing one tab remains open.
func closeTab(tab: TabData) {
tabs.removeAll { $0.id == tab.id }
if tabs.isEmpty {
@ -296,7 +308,8 @@ class EditorViewModel: ObservableObject {
selectedTabID = tabs.first?.id
}
}
// Saves tab content to the existing file URL or falls back to Save As.
func saveFile(tab: TabData) {
guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return }
if let url = tabs[index].fileURL {
@ -312,7 +325,8 @@ class EditorViewModel: ObservableObject {
saveFileAs(tab: tab)
}
}
// Saves tab content to a user-selected path on macOS.
func saveFileAs(tab: TabData) {
guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return }
#if os(macOS)
@ -353,7 +367,8 @@ class EditorViewModel: ObservableObject {
debugLog("Save As is currently only available on macOS.")
#endif
}
// Opens file-picker UI on macOS.
func openFile() {
#if os(macOS)
let panel = NSOpenPanel()
@ -372,7 +387,8 @@ class EditorViewModel: ObservableObject {
debugLog("Open File panel is currently only available on macOS.")
#endif
}
// Loads a file into a new tab unless the file is already open.
func openFile(url: URL) {
if focusTabIfOpen(for: url) { return }
do {
@ -402,6 +418,7 @@ class EditorViewModel: ObservableObject {
indexOfOpenTab(for: url) != nil
}
// Focuses an existing tab for URL if present.
func focusTabIfOpen(for url: URL) -> Bool {
if let existingIndex = indexOfOpenTab(for: url) {
selectedTabID = tabs[existingIndex].id
@ -418,6 +435,7 @@ class EditorViewModel: ObservableObject {
}
}
// Marks a tab clean after successful save/export and updates URL-derived metadata.
func markTabSaved(tabID: UUID, fileURL: URL? = nil) {
guard let index = tabs.firstIndex(where: { $0.id == tabID }) else { return }
if let fileURL {
@ -430,7 +448,8 @@ class EditorViewModel: ObservableObject {
}
tabs[index].isDirty = false
}
// Returns whitespace-delimited word count for status display.
func wordCount(for text: String) -> Int {
text.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }.count
@ -442,6 +461,7 @@ class EditorViewModel: ObservableObject {
#endif
}
// Reads user preference for default language of newly created tabs.
private func defaultNewTabLanguage() -> String {
let stored = UserDefaults.standard.string(forKey: "SettingsDefaultNewFileLanguage") ?? "plain"
let trimmed = stored.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()

View file

@ -1,6 +1,8 @@
import Foundation
import Security
///MARK: - Token Keys
// Logical API-token keys mapped to Keychain account names.
enum APITokenKey: String, CaseIterable {
case grok
case openAI
@ -17,73 +19,143 @@ enum APITokenKey: String, CaseIterable {
}
}
///MARK: - Secure Token Store
// Keychain-backed storage for provider API tokens.
enum SecureTokenStore {
private static let service = "h3p.Neon-Vision-Editor.tokens"
private static let debugTokenPrefix = "DebugTokenStore."
// Returns UTF-8 token value or empty string when token is missing.
static func token(for key: APITokenKey) -> String {
#if DEBUG
let debugValue = UserDefaults.standard.string(forKey: debugTokenPrefix + key.account) ?? ""
return debugValue.trimmingCharacters(in: .whitespacesAndNewlines)
#else
guard let data = readData(for: key),
let value = String(data: data, encoding: .utf8) else {
return ""
}
return value
#endif
}
static func setToken(_ value: String, for key: APITokenKey) {
@discardableResult
// Writes token to Keychain or deletes entry when value is empty.
static func setToken(_ value: String, for key: APITokenKey) -> Bool {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
#if DEBUG
if trimmed.isEmpty {
deleteToken(for: key)
return
UserDefaults.standard.removeObject(forKey: debugTokenPrefix + key.account)
return true
}
guard let data = trimmed.data(using: .utf8) else { return }
UserDefaults.standard.set(trimmed, forKey: debugTokenPrefix + key.account)
return true
#else
if trimmed.isEmpty {
return deleteToken(for: key)
}
guard let data = trimmed.data(using: .utf8) else { return false }
let baseQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key.account
]
let baseQuery = baseQuery(for: key)
let updateAttributes: [CFString: Any] = [kSecValueData: data]
let updateStatus = SecItemUpdate(baseQuery as CFDictionary, updateAttributes as CFDictionary)
if updateStatus == errSecSuccess {
return true
}
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)
addQuery[kSecValueData] = data
// Keep secrets device-bound and unavailable while the device is locked.
addQuery[kSecAttrAccessible] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
if addStatus == errSecSuccess {
return true
}
logKeychainError(status: addStatus, context: "add token \(key.account)")
return false
}
logKeychainError(status: updateStatus, context: "update token \(key.account)")
return false
#endif
}
// Migrates legacy UserDefaults tokens into Keychain and cleans stale defaults.
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)
let trimmedDefaultsValue = defaultsValue.trimmingCharacters(in: .whitespacesAndNewlines)
let hasStoredValue = !token(for: key).isEmpty
if !hasStoredValue && !trimmedDefaultsValue.isEmpty {
let didStore = setToken(trimmedDefaultsValue, for: key)
if didStore {
UserDefaults.standard.removeObject(forKey: defaultsKey)
}
continue
}
if hasStoredValue || trimmedDefaultsValue.isEmpty {
UserDefaults.standard.removeObject(forKey: defaultsKey)
}
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 query = baseQuery(for: key)
query[kSecReturnData] = kCFBooleanTrue
query[kSecMatchLimit] = kSecMatchLimitOne
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess else { return nil }
return item as? Data
if status == errSecItemNotFound || isMissingDataStoreStatus(status) {
// Some environments report missing keychain backends with legacy CSSM errors.
// Treat them as "token not present" to keep app startup resilient.
return nil
}
guard status == errSecSuccess else {
logKeychainError(status: status, context: "read token \(key.account)")
return nil
}
guard let data = item as? Data else {
logKeychainError(status: errSecInternalError, context: "read token \(key.account) returned non-data payload")
return nil
}
return 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
@discardableResult
// Deletes a token entry from Keychain.
private static func deleteToken(for key: APITokenKey) -> Bool {
let query = baseQuery(for: key)
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound {
return true
}
logKeychainError(status: status, context: "delete token \(key.account)")
return false
}
// Builds a strongly-typed keychain query to avoid CF bridging issues at runtime.
private static func baseQuery(for key: APITokenKey) -> [CFString: Any] {
[
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key.account
]
_ = SecItemDelete(query as CFDictionary)
}
// Maps legacy CSSM keychain "store missing" errors to a benign "not found" condition.
private static func isMissingDataStoreStatus(_ status: OSStatus) -> Bool {
status == errSecNoSuchKeychain || status == errSecNotAvailable || status == errSecInteractionNotAllowed || status == -2147413737
}
// Emits consistent Keychain error diagnostics for support/debugging.
private static func logKeychainError(status: OSStatus, context: String) {
let message = SecCopyErrorMessageString(status, nil) as String? ?? "Unknown OSStatus"
NSLog("SecureTokenStore error (\(context)): \(status) - \(message)")
}
}

View file

@ -2,6 +2,8 @@ import Foundation
import Combine
import StoreKit
///MARK: - Support Purchase Manager
// Handles optional support purchase and entitlement state via StoreKit.
@MainActor
final class SupportPurchaseManager: ObservableObject {
static let supportProductID = "h3p.neon-vision-editor.support.optional"
@ -17,6 +19,7 @@ final class SupportPurchaseManager: ObservableObject {
private var transactionUpdatesTask: Task<Void, Never>?
private let bypassDefaultsKey = "SupportPurchaseBypassEnabled"
// Allows bypass in simulator/debug environments for testing purchase-gated UI.
private func shouldAllowTestingBypass(environment: AppStore.Environment) -> Bool {
#if targetEnvironment(simulator)
return true
@ -47,12 +50,14 @@ final class SupportPurchaseManager: ObservableObject {
allowsTestingBypass
}
// Refreshes StoreKit capability, product metadata, and entitlement state.
func refreshStoreState() async {
await refreshBypassEligibility()
await refreshProducts(showStatusOnFailure: false)
await refreshSupportEntitlement()
}
// Enables testing bypass where allowed.
func bypassForTesting() {
guard canBypassInCurrentBuild else { return }
UserDefaults.standard.set(true, forKey: bypassDefaultsKey)
@ -60,11 +65,13 @@ final class SupportPurchaseManager: ObservableObject {
statusMessage = "Support purchase bypass enabled for TestFlight/Sandbox testing."
}
// Clears testing bypass and re-evaluates current entitlement.
func clearBypassForTesting() {
UserDefaults.standard.removeObject(forKey: bypassDefaultsKey)
Task { await refreshSupportEntitlement() }
}
// Loads support product metadata from App Store.
func refreshProducts(showStatusOnFailure: Bool = true) async {
guard canUseInAppPurchases else {
supportProduct = nil
@ -86,6 +93,7 @@ final class SupportPurchaseManager: ObservableObject {
}
}
// Starts purchase flow for the optional support product.
func purchaseSupport() async {
guard canUseInAppPurchases else {
statusMessage = "In-app purchase is only available in App Store/TestFlight builds."
@ -119,6 +127,7 @@ final class SupportPurchaseManager: ObservableObject {
}
}
// Triggers App Store restore flow and refreshes entitlement state.
func restorePurchases() async {
guard canUseInAppPurchases else {
statusMessage = "Restore is only available in App Store/TestFlight builds."
@ -134,6 +143,7 @@ final class SupportPurchaseManager: ObservableObject {
}
}
// Recomputes support entitlement from current verified transactions.
private func refreshSupportEntitlement() async {
if canBypassInCurrentBuild && UserDefaults.standard.bool(forKey: bypassDefaultsKey) {
hasSupported = true
@ -150,6 +160,7 @@ final class SupportPurchaseManager: ObservableObject {
hasSupported = supported
}
// Detects whether this build/environment can use in-app purchases.
private func refreshBypassEligibility() async {
do {
let appTransactionResult = try await AppTransaction.shared
@ -167,6 +178,7 @@ final class SupportPurchaseManager: ObservableObject {
}
}
// Listens for transaction updates and applies verified changes.
private func observeTransactionUpdates() -> Task<Void, Never> {
Task { [weak self] in
guard let self else { return }
@ -184,6 +196,7 @@ final class SupportPurchaseManager: ObservableObject {
}
}
// Enforces StoreKit verification before using transaction payloads.
private func verify<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .verified(let safe):
@ -194,6 +207,7 @@ final class SupportPurchaseManager: ObservableObject {
}
}
///MARK: - StoreKit Errors
enum SupportPurchaseError: LocalizedError {
case failedVerification

View file

@ -1,9 +1,6 @@
//
// AIModel.swift
// Neon Vision Editor
//
// Created by Hilthart Pedersen on 06.02.26.
//
import Foundation

View file

@ -0,0 +1,209 @@
import SwiftUI
struct AppUpdaterDialog: View {
@EnvironmentObject private var appUpdateManager: AppUpdateManager
@Binding var isPresented: Bool
private var releaseTitle: String {
appUpdateManager.latestRelease?.title ?? "Latest Release"
}
var body: some View {
VStack(spacing: 16) {
header
bodyContent
actionRow
}
.padding(20)
.frame(minWidth: 520, idealWidth: 560)
}
private var header: some View {
HStack(spacing: 14) {
Image(systemName: "arrow.triangle.2.circlepath.circle.fill")
.font(.system(size: 38, weight: .semibold))
.symbolRenderingMode(.palette)
.foregroundStyle(
LinearGradient(colors: [Color.blue, Color.cyan], startPoint: .topLeading, endPoint: .bottomTrailing),
Color.white.opacity(0.9)
)
VStack(alignment: .leading, spacing: 4) {
Text("Software Update")
.font(.title3.weight(.semibold))
Text("Checks GitHub releases for Neon Vision Editor updates.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.ultraThinMaterial)
)
}
@ViewBuilder
private var bodyContent: some View {
switch appUpdateManager.status {
case .idle, .checking:
VStack(alignment: .leading, spacing: 12) {
ProgressView()
Text("Checking for updates…")
.font(.headline)
Text("Current version: \(appUpdateManager.currentVersion)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
case .upToDate:
VStack(alignment: .leading, spacing: 10) {
Label("Youre up to date.", systemImage: "checkmark.circle.fill")
.font(.headline)
.foregroundStyle(.green)
Text("Current version: \(appUpdateManager.currentVersion)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
case .failed:
VStack(alignment: .leading, spacing: 10) {
Label("Update check failed", systemImage: "exclamationmark.triangle.fill")
.font(.headline)
.foregroundStyle(.orange)
Text(appUpdateManager.errorMessage ?? "Unknown error")
.font(.subheadline)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
case .updateAvailable:
VStack(alignment: .leading, spacing: 12) {
Label("\(releaseTitle) is available", systemImage: "sparkles")
.font(.headline)
Text("Current version: \(appUpdateManager.currentVersion) • New version: \(appUpdateManager.latestRelease?.version ?? "-")")
.font(.subheadline)
.foregroundStyle(.secondary)
if let date = appUpdateManager.latestRelease?.publishedAt {
Text("Published: \(date.formatted(date: .abbreviated, time: .omitted))")
.font(.caption)
.foregroundStyle(.secondary)
}
if let notes = appUpdateManager.latestRelease?.notes,
!notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
ScrollView {
Text(notes)
.frame(maxWidth: .infinity, alignment: .leading)
.font(.footnote)
.textSelection(.enabled)
}
.frame(maxHeight: 180)
.padding(10)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.primary.opacity(0.05))
)
}
Text("Updates are delivered from GitHub release assets, not App Store updates.")
.font(.caption)
.foregroundStyle(.secondary)
if appUpdateManager.isInstalling {
ProgressView("Installing update…")
.font(.caption)
}
if let installMessage = appUpdateManager.installMessage {
Text(installMessage)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
}
}
@ViewBuilder
private var actionRow: some View {
HStack {
switch appUpdateManager.status {
case .updateAvailable:
Button("Skip This Version") {
appUpdateManager.skipCurrentVersion()
isPresented = false
}
Button("Remind Me Tomorrow") {
appUpdateManager.remindMeTomorrow()
isPresented = false
}
Spacer()
Button("Download Update") {
appUpdateManager.openDownloadPage()
isPresented = false
}
Button("Install Now") {
Task {
await appUpdateManager.installUpdateNow()
}
}
.buttonStyle(.borderedProminent)
.disabled(appUpdateManager.isInstalling)
case .failed:
Button("Close") {
isPresented = false
}
Spacer()
Button("Try Again") {
Task {
await appUpdateManager.checkForUpdates(source: .manual)
}
}
.buttonStyle(.borderedProminent)
case .upToDate:
Button("Close") {
isPresented = false
}
Spacer()
Button("View Releases") {
appUpdateManager.openReleasePage()
}
Button("Check Again") {
Task {
await appUpdateManager.checkForUpdates(source: .manual)
}
}
.buttonStyle(.borderedProminent)
case .idle, .checking:
Spacer()
Button("Close") {
isPresented = false
}
.disabled(appUpdateManager.status == .checking)
}
}
}
}

View file

@ -7,6 +7,18 @@ import UIKit
#endif
extension ContentView {
func showUpdaterDialog(checkNow: Bool = true) {
#if os(macOS)
guard ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution else { return }
showUpdateDialog = true
if checkNow {
Task {
await appUpdateManager.checkForUpdates(source: .manual)
}
}
#endif
}
func openSettings(tab: String? = nil) {
settingsActiveTab = ReleaseRuntimePolicy.settingsTab(from: tab)
#if os(macOS)

View file

@ -330,6 +330,15 @@ extension ContentView {
}
.help("Settings")
if ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution {
Button(action: {
showUpdaterDialog(checkNow: true)
}) {
Image(systemName: "arrow.triangle.2.circlepath.circle")
}
.help("Check for Updates")
}
Button(action: { adjustEditorFontSize(-1) }) {
Image(systemName: "textformat.size.smaller")
}

View file

@ -2,7 +2,7 @@
// Main SwiftUI container for Neon Vision Editor. Hosts the single-document editor UI,
// toolbar actions, AI integration, syntax highlighting, line numbers, and sidebar TOC.
// MARK: - Imports
///MARK: - Imports
import SwiftUI
import Foundation
import UniformTypeIdentifiers
@ -27,12 +27,13 @@ extension String {
#endif
}
// MARK: - Root view for the editor.
///MARK: - Root View
//Manages the editor area, toolbar, popovers, and bridges to the view model for file I/O and metrics.
struct ContentView: View {
// Environment-provided view model and theme/error bindings
@EnvironmentObject var viewModel: EditorViewModel
@EnvironmentObject private var supportPurchaseManager: SupportPurchaseManager
@EnvironmentObject var appUpdateManager: AppUpdateManager
@Environment(\.colorScheme) var colorScheme
#if os(iOS)
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@ -78,10 +79,10 @@ struct ContentView: View {
@State private var highlightRefreshToken: Int = 0
// Persisted API tokens for external providers
@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)
@State var grokAPIToken: String = ""
@State var openAIAPIToken: String = ""
@State var geminiAPIToken: String = ""
@State var anthropicAPIToken: String = ""
// Debounce handle for inline completion
@State var lastCompletionWorkItem: DispatchWorkItem?
@ -90,6 +91,7 @@ struct ContentView: View {
@State var showFindReplace: Bool = false
@State var showSettingsSheet: Bool = false
@State var showUpdateDialog: Bool = false
@State var findQuery: String = ""
@State var replaceQuery: String = ""
@State var findUsesRegex: Bool = false
@ -145,8 +147,6 @@ struct ContentView: View {
set { selectedModelRaw = newValue.rawValue }
}
/// 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 }
#if os(macOS)
@ -171,8 +171,6 @@ struct ContentView: View {
return false
}
/// 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 }
#if os(macOS)
@ -197,8 +195,6 @@ struct ContentView: View {
return false
}
/// 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.isEmpty { return true }
#if os(macOS)
@ -223,8 +219,6 @@ struct ContentView: View {
return false
}
/// 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 }
#if os(macOS)
@ -1018,6 +1012,11 @@ struct ContentView: View {
guard matchesCurrentWindow(notif) else { return }
openAPISettings()
}
.onReceive(NotificationCenter.default.publisher(for: .showUpdaterRequested)) { notif in
guard matchesCurrentWindow(notif) else { return }
let shouldCheckNow = (notif.object as? Bool) ?? true
showUpdaterDialog(checkNow: shouldCheckNow)
}
.onReceive(NotificationCenter.default.publisher(for: .selectAIModelRequested)) { notif in
guard matchesCurrentWindow(notif) else { return }
guard let modelRawValue = notif.object as? String,
@ -1084,7 +1083,7 @@ struct ContentView: View {
// Layout: NavigationSplitView with optional sidebar and the primary code editor.
var body: some View {
platformLayout
AnyView(platformLayout)
.alert("AI Error", isPresented: showGrokError) {
Button("OK") { }
} message: {
@ -1128,6 +1127,11 @@ struct ContentView: View {
settingsLineWrapEnabled = enabled
}
}
.onChange(of: appUpdateManager.automaticPromptToken) { _, _ in
if appUpdateManager.consumeAutomaticPromptIfNeeded() {
showUpdaterDialog(checkNow: false)
}
}
.onChange(of: settingsThemeName) { _, _ in
highlightRefreshToken += 1
}
@ -1146,123 +1150,7 @@ struct ContentView: View {
.onReceive(viewModel.$tabs) { _ in
persistSessionIfReady()
}
.sheet(isPresented: $showFindReplace) {
FindReplacePanel(
findQuery: $findQuery,
replaceQuery: $replaceQuery,
useRegex: $findUsesRegex,
caseSensitive: $findCaseSensitive,
statusMessage: $findStatusMessage,
onFindNext: { findNext() },
onReplace: { replaceSelection() },
onReplaceAll: { replaceAll() }
)
#if canImport(UIKit)
.frame(maxWidth: 420)
#if os(iOS)
.presentationDetents([.height(280), .medium])
.presentationDragIndicator(.visible)
.presentationContentInteraction(.scrolls)
#endif
#else
.frame(width: 420)
#endif
}
#if canImport(UIKit)
.sheet(isPresented: $showSettingsSheet) {
NeonSettingsView(
supportsOpenInTabs: false,
supportsTranslucency: false
)
.environmentObject(supportPurchaseManager)
#if os(iOS)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
.presentationContentInteraction(.scrolls)
#endif
}
#endif
#if os(iOS)
.sheet(isPresented: $showCompactSidebarSheet) {
NavigationStack {
SidebarView(content: currentContent, language: currentLanguage)
.navigationTitle("Sidebar")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
showCompactSidebarSheet = false
}
}
}
}
.presentationDetents([.medium, .large])
}
#endif
#if canImport(UIKit)
.sheet(isPresented: $showProjectFolderPicker) {
ProjectFolderPicker(
onPick: { url in
setProjectFolder(url)
showProjectFolderPicker = false
},
onCancel: { showProjectFolderPicker = false }
)
}
#endif
.sheet(isPresented: $showQuickSwitcher) {
QuickFileSwitcherPanel(
query: $quickSwitcherQuery,
items: quickSwitcherItems,
onSelect: { selectQuickSwitcherItem($0) }
)
}
.sheet(isPresented: $showLanguageSetupPrompt) {
languageSetupSheet
}
.sheet(isPresented: $showWelcomeTour) {
WelcomeTourView {
hasSeenWelcomeTourV1 = true
welcomeTourSeenRelease = WelcomeTourView.releaseID
showWelcomeTour = false
}
}
.confirmationDialog("Save changes before closing?", isPresented: $showUnsavedCloseDialog, titleVisibility: .visible) {
Button("Save") { saveAndClosePendingTab() }
Button("Don't Save", role: .destructive) { discardAndClosePendingTab() }
Button("Cancel", role: .cancel) {
pendingCloseTabID = nil
}
} message: {
if let pendingCloseTabID,
let tab = viewModel.tabs.first(where: { $0.id == pendingCloseTabID }) {
Text("\"\(tab.name)\" has unsaved changes.")
} else {
Text("This file has unsaved changes.")
}
}
.confirmationDialog("Clear editor content?", isPresented: $showClearEditorConfirmDialog, titleVisibility: .visible) {
Button("Clear", role: .destructive) { clearEditorContent() }
Button("Cancel", role: .cancel) {}
} message: {
Text("This will remove all text in the current editor.")
}
#if canImport(UIKit)
.fileImporter(
isPresented: $showIOSFileImporter,
allowedContentTypes: [.text, .plainText, .sourceCode, .json, .xml, .yaml],
allowsMultipleSelection: false
) { result in
handleIOSImportResult(result)
}
.fileExporter(
isPresented: $showIOSFileExporter,
document: iosExportDocument,
contentType: .plainText,
defaultFilename: iosExportFilename
) { result in
handleIOSExportResult(result)
}
#endif
.modifier(ModalPresentationModifier(contentView: self))
.onAppear {
// Start with sidebar collapsed by default
viewModel.showSidebar = false
@ -1297,6 +1185,135 @@ struct ContentView: View {
#endif
}
private struct ModalPresentationModifier: ViewModifier {
let contentView: ContentView
func body(content: Content) -> some View {
content
.sheet(isPresented: contentView.$showFindReplace) {
FindReplacePanel(
findQuery: contentView.$findQuery,
replaceQuery: contentView.$replaceQuery,
useRegex: contentView.$findUsesRegex,
caseSensitive: contentView.$findCaseSensitive,
statusMessage: contentView.$findStatusMessage,
onFindNext: { contentView.findNext() },
onReplace: { contentView.replaceSelection() },
onReplaceAll: { contentView.replaceAll() }
)
#if canImport(UIKit)
.frame(maxWidth: 420)
#if os(iOS)
.presentationDetents([.height(280), .medium])
.presentationDragIndicator(.visible)
.presentationContentInteraction(.scrolls)
#endif
#else
.frame(width: 420)
#endif
}
#if canImport(UIKit)
.sheet(isPresented: contentView.$showSettingsSheet) {
NeonSettingsView(
supportsOpenInTabs: false,
supportsTranslucency: false
)
.environmentObject(contentView.supportPurchaseManager)
#if os(iOS)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
.presentationContentInteraction(.scrolls)
#endif
}
#endif
#if os(iOS)
.sheet(isPresented: contentView.$showCompactSidebarSheet) {
NavigationStack {
SidebarView(content: contentView.currentContent, language: contentView.currentLanguage)
.navigationTitle("Sidebar")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
contentView.$showCompactSidebarSheet.wrappedValue = false
}
}
}
}
.presentationDetents([.medium, .large])
}
#endif
#if canImport(UIKit)
.sheet(isPresented: contentView.$showProjectFolderPicker) {
ProjectFolderPicker(
onPick: { url in
contentView.setProjectFolder(url)
contentView.$showProjectFolderPicker.wrappedValue = false
},
onCancel: { contentView.$showProjectFolderPicker.wrappedValue = false }
)
}
#endif
.sheet(isPresented: contentView.$showQuickSwitcher) {
QuickFileSwitcherPanel(
query: contentView.$quickSwitcherQuery,
items: contentView.quickSwitcherItems,
onSelect: { contentView.selectQuickSwitcherItem($0) }
)
}
.sheet(isPresented: contentView.$showLanguageSetupPrompt) {
contentView.languageSetupSheet
}
.sheet(isPresented: contentView.$showWelcomeTour) {
WelcomeTourView {
contentView.$hasSeenWelcomeTourV1.wrappedValue = true
contentView.$welcomeTourSeenRelease.wrappedValue = WelcomeTourView.releaseID
contentView.$showWelcomeTour.wrappedValue = false
}
}
.sheet(isPresented: contentView.$showUpdateDialog) {
AppUpdaterDialog(isPresented: contentView.$showUpdateDialog)
.environmentObject(contentView.appUpdateManager)
}
.confirmationDialog("Save changes before closing?", isPresented: contentView.$showUnsavedCloseDialog, titleVisibility: .visible) {
Button("Save") { contentView.saveAndClosePendingTab() }
Button("Don't Save", role: .destructive) { contentView.discardAndClosePendingTab() }
Button("Cancel", role: .cancel) {
contentView.$pendingCloseTabID.wrappedValue = nil
}
} message: {
if let pendingCloseTabID = contentView.pendingCloseTabID,
let tab = contentView.viewModel.tabs.first(where: { $0.id == pendingCloseTabID }) {
Text("\"\(tab.name)\" has unsaved changes.")
} else {
Text("This file has unsaved changes.")
}
}
.confirmationDialog("Clear editor content?", isPresented: contentView.$showClearEditorConfirmDialog, titleVisibility: .visible) {
Button("Clear", role: .destructive) { contentView.clearEditorContent() }
Button("Cancel", role: .cancel) {}
} message: {
Text("This will remove all text in the current editor.")
}
#if canImport(UIKit)
.fileImporter(
isPresented: contentView.$showIOSFileImporter,
allowedContentTypes: [.text, .plainText, .sourceCode, .json, .xml, .yaml],
allowsMultipleSelection: false
) { result in
contentView.handleIOSImportResult(result)
}
.fileExporter(
isPresented: contentView.$showIOSFileExporter,
document: contentView.iosExportDocument,
contentType: .plainText,
defaultFilename: contentView.iosExportFilename
) { result in
contentView.handleIOSExportResult(result)
}
#endif
}
}
private var shouldUseSplitView: Bool {
#if os(macOS)
return viewModel.showSidebar && !viewModel.isBrainDumpMode
@ -1639,8 +1656,6 @@ struct ContentView: View {
}
}
/// Detects language using Apple Foundation Models when available, with a heuristic fallback.
/// Returns a supported language string used by syntax highlighting and the language picker.
private func detectLanguageWithAppleIntelligence(_ text: String) async -> String {
// Supported languages in our picker
let supported = ["swift", "python", "javascript", "typescript", "php", "java", "kotlin", "go", "ruby", "rust", "cobol", "dotenv", "proto", "graphql", "rst", "nginx", "sql", "html", "expressionengine", "css", "c", "cpp", "objective-c", "csharp", "json", "xml", "yaml", "toml", "csv", "ini", "vim", "log", "ipynb", "markdown", "bash", "zsh", "powershell", "standard", "plain"]
@ -1793,7 +1808,7 @@ struct ContentView: View {
return "standard"
}
// MARK: Main editor stack: hosts the NSTextView-backed editor, status line, and toolbar.
///MARK: - Main Editor Stack
var editorView: some View {
let content = HStack(spacing: 0) {
VStack(spacing: 0) {

View file

@ -1,10 +1,13 @@
import SwiftUI
import Foundation
///MARK: - Paste Notifications
extension Notification.Name {
static let pastedFileURL = Notification.Name("pastedFileURL")
}
///MARK: - Scope Match Models
// Bracket-based scope data used for highlighting and guide rendering.
private struct BracketScopeMatch {
let openRange: NSRange
let closeRange: NSRange
@ -12,11 +15,13 @@ private struct BracketScopeMatch {
let guideMarkerRanges: [NSRange]
}
// Indentation-based scope data used for Python/YAML style highlighting.
private struct IndentationScopeMatch {
let scopeRange: NSRange
let guideMarkerRanges: [NSRange]
}
///MARK: - Bracket/Indent Scope Helpers
private func matchingOpeningBracket(for closing: unichar) -> unichar? {
switch UnicodeScalar(closing) {
case "}": return unichar(UnicodeScalar("{").value)
@ -442,7 +447,7 @@ final class AcceptingTextView: NSTextView {
forceDisableInvisibleGlyphRendering()
}
// MARK: - Drag & Drop: insert file contents instead of file path
///MARK: - Drag and Drop
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
let pb = sender.draggingPasteboard
let canReadFileURL = pb.canReadObject(forClasses: [NSURL.self], options: [
@ -699,7 +704,7 @@ final class AcceptingTextView: NSTextView {
return Self.sanitizePlainText(String(decoding: data, as: UTF8.self))
}
// MARK: - Typing helpers (existing behavior)
///MARK: - Typing Helpers
override func insertText(_ insertString: Any, replacementRange: NSRange) {
if !isApplyingInlineSuggestion {
clearInlineSuggestion()
@ -740,8 +745,8 @@ final class AcceptingTextView: NSTextView {
super.insertText(sanitized, replacementRange: replacementRange)
}
/// Remove control/format characters that render as visible placeholders and normalize NBSP/CR to safe whitespace.
static func sanitizePlainText(_ input: String) -> String {
// Reuse model-level sanitizer to keep all text paths consistent.
EditorTextSanitizer.sanitize(input)
}
@ -1714,8 +1719,6 @@ struct CustomTextEditor: NSViewRepresentable {
lastSelectionLocation = -1
}
/// Schedules highlighting if text/language/theme changed. Skips very large documents
/// and defers when a modal sheet is presented.
func scheduleHighlightIfNeeded(currentText: String? = nil, immediate: Bool = false) {
guard textView != nil else { return }
@ -1803,7 +1806,6 @@ struct CustomTextEditor: NSViewRepresentable {
rehighlight(token: token, generation: generation, immediate: shouldRunImmediate)
}
/// Perform regex-based token coloring off-main, then apply attributes on the main thread.
func rehighlight(token: Int, generation: Int, immediate: Bool = false) {
guard let textView = textView else { return }
// Snapshot current state
@ -2009,7 +2011,6 @@ struct CustomTextEditor: NSViewRepresentable {
scheduleHighlightIfNeeded(currentText: tv.string, immediate: true)
}
/// Move caret to a 1-based line number, clamping to bounds, and emphasize the line.
@objc func moveToLine(_ notification: Notification) {
guard let lineOneBased = notification.object as? Int,
let textView = textView else { return }

View file

@ -8,6 +8,7 @@ struct NeonSettingsView: View {
let supportsOpenInTabs: Bool
let supportsTranslucency: Bool
@EnvironmentObject private var supportPurchaseManager: SupportPurchaseManager
@EnvironmentObject private var appUpdateManager: AppUpdateManager
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@AppStorage("SettingsOpenInTabs") private var openInTabs: String = "system"
@AppStorage("SettingsEditorFontName") private var editorFontName: String = ""
@ -21,6 +22,9 @@ struct NeonSettingsView: View {
@AppStorage("SettingsDefaultNewFileLanguage") private var defaultNewFileLanguage: String = "plain"
@AppStorage("SettingsConfirmCloseDirtyTab") private var confirmCloseDirtyTab: Bool = true
@AppStorage("SettingsConfirmClearEditor") private var confirmClearEditor: Bool = true
@AppStorage(AppUpdateManager.autoCheckEnabledKey) private var autoCheckForUpdates: Bool = true
@AppStorage(AppUpdateManager.updateIntervalKey) private var updateCheckIntervalRaw: String = AppUpdateCheckInterval.daily.rawValue
@AppStorage(AppUpdateManager.autoDownloadEnabledKey) private var autoDownloadUpdates: Bool = false
@AppStorage("SettingsShowLineNumbers") private var showLineNumbers: Bool = true
@AppStorage("SettingsHighlightCurrentLine") private var highlightCurrentLine: Bool = false
@ -45,10 +49,10 @@ struct NeonSettingsView: View {
@State private var fontPicker = FontPickerController()
#endif
@State private var grokAPIToken: String = SecureTokenStore.token(for: .grok)
@State private var openAIAPIToken: String = SecureTokenStore.token(for: .openAI)
@State private var geminiAPIToken: String = SecureTokenStore.token(for: .gemini)
@State private var anthropicAPIToken: String = SecureTokenStore.token(for: .anthropic)
@State private var grokAPIToken: String = ""
@State private var openAIAPIToken: String = ""
@State private var geminiAPIToken: String = ""
@State private var anthropicAPIToken: String = ""
@State private var showSupportPurchaseDialog: Bool = false
@State private var availableEditorFonts: [String] = []
private let privacyPolicyURL = URL(string: "https://github.com/h3pdesign/Neon-Vision-Editor/blob/main/PRIVACY.md")
@ -151,6 +155,13 @@ struct NeonSettingsView: View {
supportTab
.tabItem { Label("Support", systemImage: "heart") }
.tag("support")
#if os(macOS)
if ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution {
updatesTab
.tabItem { Label("Updates", systemImage: "arrow.triangle.2.circlepath.circle") }
.tag("updates")
}
#endif
}
#if os(macOS)
.frame(minWidth: 900, idealWidth: 980, minHeight: 820, idealHeight: 880)
@ -168,6 +179,9 @@ struct NeonSettingsView: View {
if supportPurchaseManager.supportProduct == nil {
Task { await supportPurchaseManager.refreshStoreState() }
}
appUpdateManager.setAutoCheckEnabled(autoCheckForUpdates)
appUpdateManager.setUpdateInterval(selectedUpdateInterval)
appUpdateManager.setAutoDownloadEnabled(autoDownloadUpdates)
#if os(macOS)
fontPicker.onChange = { selected in
useSystemFont = false
@ -198,6 +212,20 @@ struct NeonSettingsView: View {
highlightScopeBackground = false
}
}
.onChange(of: autoCheckForUpdates) { _, enabled in
appUpdateManager.setAutoCheckEnabled(enabled)
}
.onChange(of: updateCheckIntervalRaw) { _, _ in
appUpdateManager.setUpdateInterval(selectedUpdateInterval)
}
.onChange(of: autoDownloadUpdates) { _, enabled in
appUpdateManager.setAutoDownloadEnabled(enabled)
}
.onChange(of: settingsActiveTab) { _, newValue in
if newValue == "ai" {
loadAPITokensIfNeeded()
}
}
.confirmationDialog("Support Neon Vision Editor", isPresented: $showSupportPurchaseDialog, titleVisibility: .visible) {
Button("Support \(supportPurchaseManager.supportPriceLabel)") {
Task { await supportPurchaseManager.purchaseSupport() }
@ -222,6 +250,13 @@ struct NeonSettingsView: View {
}
}
private func loadAPITokensIfNeeded() {
if grokAPIToken.isEmpty { grokAPIToken = SecureTokenStore.token(for: .grok) }
if openAIAPIToken.isEmpty { openAIAPIToken = SecureTokenStore.token(for: .openAI) }
if geminiAPIToken.isEmpty { geminiAPIToken = SecureTokenStore.token(for: .gemini) }
if anthropicAPIToken.isEmpty { anthropicAPIToken = SecureTokenStore.token(for: .anthropic) }
}
private var preferredColorSchemeOverride: ColorScheme? {
ReleaseRuntimePolicy.preferredColorScheme(for: appearance)
}
@ -449,6 +484,10 @@ struct NeonSettingsView: View {
selectedFontValue = useSystemFont ? systemFontSentinel : (editorFontName.isEmpty ? systemFontSentinel : editorFontName)
}
private var selectedUpdateInterval: AppUpdateCheckInterval {
AppUpdateCheckInterval(rawValue: updateCheckIntervalRaw) ?? .daily
}
private var editorTab: some View {
settingsContainer(maxWidth: 760) {
GroupBox("Editor") {
@ -768,6 +807,63 @@ struct NeonSettingsView: View {
}
}
#if os(macOS)
private var updatesTab: some View {
settingsContainer(maxWidth: 620) {
GroupBox("GitHub Release Updates") {
VStack(alignment: .leading, spacing: UI.space12) {
Toggle("Automatically check for updates", isOn: $autoCheckForUpdates)
HStack(alignment: .center, spacing: UI.space12) {
Text("Check Interval")
.frame(width: isCompactSettingsLayout ? nil : 140, alignment: .leading)
Picker("", selection: $updateCheckIntervalRaw) {
ForEach(AppUpdateCheckInterval.allCases) { interval in
Text(interval.title).tag(interval.rawValue)
}
}
.pickerStyle(.menu)
.frame(maxWidth: isCompactSettingsLayout ? .infinity : 220, alignment: .leading)
}
.disabled(!autoCheckForUpdates)
Toggle("Automatically install updates when available", isOn: $autoDownloadUpdates)
.disabled(!autoCheckForUpdates)
HStack(spacing: UI.space8) {
Button("Check Now") {
Task { await appUpdateManager.checkForUpdates(source: .manual) }
}
.buttonStyle(.borderedProminent)
if let checkedAt = appUpdateManager.lastCheckedAt {
Text("Last checked: \(checkedAt.formatted(date: .abbreviated, time: .shortened))")
.font(Typography.footnote)
.foregroundStyle(.secondary)
}
}
VStack(alignment: .leading, spacing: UI.space6) {
Text("Last check result: \(appUpdateManager.lastCheckResultSummary)")
.font(Typography.footnote)
.foregroundStyle(.secondary)
if let pausedUntil = appUpdateManager.pausedUntil, pausedUntil > Date() {
Text("Auto-check pause active until \(pausedUntil.formatted(date: .abbreviated, time: .shortened)) (\(appUpdateManager.consecutiveFailureCount) consecutive failures).")
.font(Typography.footnote)
.foregroundStyle(.orange)
}
}
Text("Uses GitHub release assets only. App Store Connect releases are not used by this updater.")
.font(Typography.footnote)
.foregroundStyle(.secondary)
}
.padding(UI.groupPadding)
}
}
}
#endif
private func settingsContainer<Content: View>(maxWidth: CGFloat = 560, @ViewBuilder _ content: () -> Content) -> some View {
ScrollView {
VStack(alignment: isCompactSettingsLayout ? .leading : .center, spacing: UI.space20) {

View file

@ -0,0 +1,63 @@
import XCTest
@testable import Neon_Vision_Editor
final class AppUpdateManagerTests: XCTestCase {
func testHostAllowlistBehavior() {
XCTAssertTrue(AppUpdateManager.isTrustedGitHubHost("github.com"))
XCTAssertTrue(AppUpdateManager.isTrustedGitHubHost("objects.githubusercontent.com"))
XCTAssertTrue(AppUpdateManager.isTrustedGitHubHost("github-releases.githubusercontent.com"))
XCTAssertFalse(AppUpdateManager.isTrustedGitHubHost("api.github.com"))
XCTAssertFalse(AppUpdateManager.isTrustedGitHubHost("github.com.evil.example"))
XCTAssertFalse(AppUpdateManager.isTrustedGitHubHost(nil))
}
func testAssetChooserPrecedence() {
let names = [
"Neon-Vision-Editor-macOS.zip",
"Neon.Vision.Editor.app.zip",
"Neon-App-Preview.zip"
]
XCTAssertEqual(AppUpdateManager.selectPreferredAssetName(from: names), "Neon.Vision.Editor.app.zip")
let appZipFallback = [
"NeonVisionEditor.app.zip",
"Neon-Vision-Editor-macOS.zip"
]
XCTAssertEqual(AppUpdateManager.selectPreferredAssetName(from: appZipFallback), "NeonVisionEditor.app.zip")
let neonZipFallback = [
"Neon-Vision-Editor-macOS.zip",
"SomethingElse.zip"
]
XCTAssertEqual(AppUpdateManager.selectPreferredAssetName(from: neonZipFallback), "Neon-Vision-Editor-macOS.zip")
}
func testSkipVersionBehavior() {
XCTAssertTrue(AppUpdateManager.isVersionSkipped("1.2.3", skippedValue: "1.2.3"))
XCTAssertFalse(AppUpdateManager.isVersionSkipped("1.2.3", skippedValue: nil))
XCTAssertFalse(AppUpdateManager.isVersionSkipped("1.2.3", skippedValue: "1.2.4"))
XCTAssertFalse(AppUpdateManager.isVersionSkipped("1.2.3", skippedValue: "v1.2.3"))
}
func testNormalizeVersionStripsPrefixAndPrerelease() {
XCTAssertEqual(AppUpdateManager.normalizedVersion(from: "v1.2.3"), "1.2.3")
XCTAssertEqual(AppUpdateManager.normalizedVersion(from: "V2.0.0-beta.1"), "2.0.0")
}
func testVersionComparison() {
XCTAssertEqual(AppUpdateManager.compareVersions("1.2.0", "1.1.9"), .orderedDescending)
XCTAssertEqual(AppUpdateManager.compareVersions("1.2", "1.2.0"), .orderedSame)
XCTAssertEqual(AppUpdateManager.compareVersions("1.2.0", "1.2.1"), .orderedAscending)
}
func testStableIsNewerThanPrereleaseWithSameCoreVersion() {
XCTAssertEqual(AppUpdateManager.compareVersions("1.2.0-beta.1", "1.2.0"), .orderedAscending)
XCTAssertEqual(AppUpdateManager.compareVersions("1.2.0", "1.2.0-beta.1"), .orderedDescending)
}
func testPrereleaseVsStableEdgeCases() {
XCTAssertEqual(AppUpdateManager.compareVersions("1.10.0-beta.1", "1.9.9"), .orderedDescending)
XCTAssertEqual(AppUpdateManager.compareVersions("v1.2.0", "1.2.0-rc.1"), .orderedDescending)
XCTAssertEqual(AppUpdateManager.compareVersions("1.2.0-rc.1", "1.2.0-beta.4"), .orderedSame)
}
}

View file

@ -82,6 +82,59 @@ The workflow performs:
5. Staple ticket
6. Zip app and upload to GitHub release asset (`--clobber`)
## 6b) Self-Hosted Runner Setup (Required when hosted runner lacks Xcode 17+)
Use this when GitHub-hosted runners only provide Xcode 16.x and release icon requirements need Xcode 17+.
Where to run:
- Run on the physical Mac that will be your runner, in Terminal.
- Use a dedicated directory (recommended): `~/actions-runner`.
- Do not run the runner from the app repository folder.
Get the correct token:
- Open: `https://github.com/h3pdesign/Neon-Vision-Editor/settings/actions/runners/new`
- Use the short-lived **runner registration token** shown on that page.
- Do not use a Personal Access Token for `./config.sh --token`.
Install and configure:
```bash
mkdir -p ~/actions-runner
cd ~/actions-runner
curl -o actions-runner-osx-arm64.tar.gz -L <github-runner-download-url-from-page>
tar xzf actions-runner-osx-arm64.tar.gz
./config.sh --url https://github.com/h3pdesign/Neon-Vision-Editor --token <runner-token-from-page> --labels self-hosted,macOS
```
Start as a service:
```bash
sudo ./svc.sh install
sudo ./svc.sh start
```
Verify prerequisites on runner:
```bash
xcodebuild -version
xcode-select -p
```
Expected:
- Xcode major version `17` or higher.
- Runner appears online in repo settings with labels: `self-hosted`, `macOS`.
Trigger self-hosted notarized release:
```bash
gh workflow run release-notarized-selfhosted.yml -f tag=v0.4.12 -f use_self_hosted=true
gh run list --workflow release-notarized-selfhosted.yml --limit 5
gh run watch <RUN_ID> --exit-status
```
## 7) Monitor and Inspect
```bash

View file

@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<EOF
Run notarized release using the self-hosted macOS workflow.
Usage:
scripts/run_selfhosted_notarized_release.sh <tag>
Examples:
scripts/run_selfhosted_notarized_release.sh v0.4.12
scripts/run_selfhosted_notarized_release.sh 0.4.12
EOF
}
if [[ "${1:-}" == "" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
TAG="$1"
if [[ "$TAG" != v* ]]; then
TAG="v$TAG"
fi
if ! command -v gh >/dev/null 2>&1; then
echo "gh CLI is required." >&2
exit 1
fi
if ! gh auth status >/dev/null 2>&1; then
echo "gh is not authenticated. Run: gh auth login" >&2
exit 1
fi
REPO_SLUG="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')"
if [[ -z "$REPO_SLUG" ]]; then
echo "Could not resolve repository slug from gh." >&2
exit 1
fi
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag ${TAG} does not exist locally. Create/push tag first." >&2
exit 1
fi
if ! git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
echo "Tag ${TAG} not found on origin. Push tag first: git push origin ${TAG}" >&2
exit 1
fi
RUNNER_LINE="$(
gh api "repos/${REPO_SLUG}/actions/runners" \
--jq '.runners[] | select(.status == "online") | [.name, ([.labels[].name] | join(","))] | @tsv' \
| awk -F '\t' 'index($2, "self-hosted") && index($2, "macOS") { print; exit }'
)"
if [[ -z "$RUNNER_LINE" ]]; then
echo "No online self-hosted macOS runner found for ${REPO_SLUG}." >&2
echo "Check: https://github.com/${REPO_SLUG}/settings/actions/runners" >&2
exit 1
fi
RUNNER_NAME="$(echo "$RUNNER_LINE" | awk -F '\t' '{print $1}')"
echo "Using online runner: ${RUNNER_NAME}"
echo "Triggering self-hosted notarized workflow for ${TAG}..."
gh workflow run release-notarized-selfhosted.yml -f tag="$TAG" -f use_self_hosted=true
sleep 6
RUN_ID="$(
gh run list --workflow release-notarized-selfhosted.yml --limit 20 \
--json databaseId,displayTitle \
--jq ".[] | select(.displayTitle | contains(\"${TAG}\")) | .databaseId" \
| head -n1
)"
if [[ -z "$RUN_ID" ]]; then
echo "Could not find self-hosted workflow run for ${TAG}." >&2
exit 1
fi
echo "Watching run ${RUN_ID}..."
gh run watch "$RUN_ID" --exit-status
echo "Verifying published release asset payload..."
scripts/ci/verify_release_asset.sh "$TAG"
echo "Self-hosted notarized release completed for ${TAG}."