diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index 4e6b543..8b85e90 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -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; diff --git a/Neon Vision Editor/AI/AIModelClient.swift b/Neon Vision Editor/AI/AIModelClient.swift index 7811b66..2107fe9 100644 --- a/Neon Vision Editor/AI/AIModelClient.swift +++ b/Neon Vision Editor/AI/AIModelClient.swift @@ -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 { guard let baseURL = URL(string: baseURLString) else { return AsyncStream { continuation in diff --git a/Neon Vision Editor/App/NeonVisionEditorApp.swift b/Neon Vision Editor/App/NeonVisionEditorApp.swift index 69deb0f..6c9aa56 100644 --- a/Neon Vision Editor/App/NeonVisionEditorApp.swift +++ b/Neon Vision Editor/App/NeonVisionEditorApp.swift @@ -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() } diff --git a/Neon Vision Editor/Core/AppUpdateManager.swift b/Neon Vision Editor/Core/AppUpdateManager.swift new file mode 100644 index 0000000..9cdb20a --- /dev/null +++ b/Neon Vision Editor/Core/AppUpdateManager.swift @@ -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? + 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[.. 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.. 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" + } +} diff --git a/Neon Vision Editor/Core/AppleFMHelper.swift b/Neon Vision Editor/Core/AppleFMHelper.swift index ba01a0d..35a823d 100644 --- a/Neon Vision Editor/Core/AppleFMHelper.swift +++ b/Neon Vision Editor/Core/AppleFMHelper.swift @@ -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 { 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 { return AsyncStream { continuation in continuation.finish() diff --git a/Neon Vision Editor/Core/ReleaseRuntimePolicy.swift b/Neon Vision Editor/Core/ReleaseRuntimePolicy.swift index 94d63c6..9f5d4fd 100644 --- a/Neon Vision Editor/Core/ReleaseRuntimePolicy.swift +++ b/Neon Vision Editor/Core/ReleaseRuntimePolicy.swift @@ -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 diff --git a/Neon Vision Editor/Data/EditorViewModel.swift b/Neon Vision Editor/Data/EditorViewModel.swift index a5591b1..646b632 100644 --- a/Neon Vision Editor/Data/EditorViewModel.swift +++ b/Neon Vision Editor/Data/EditorViewModel.swift @@ -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() diff --git a/Neon Vision Editor/Data/SecureTokenStore.swift b/Neon Vision Editor/Data/SecureTokenStore.swift index 1fa2c80..22c0783 100644 --- a/Neon Vision Editor/Data/SecureTokenStore.swift +++ b/Neon Vision Editor/Data/SecureTokenStore.swift @@ -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)") } } diff --git a/Neon Vision Editor/Data/SupportPurchaseManager.swift b/Neon Vision Editor/Data/SupportPurchaseManager.swift index 32aec1c..80712a1 100644 --- a/Neon Vision Editor/Data/SupportPurchaseManager.swift +++ b/Neon Vision Editor/Data/SupportPurchaseManager.swift @@ -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? 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 { 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(_ result: VerificationResult) throws -> T { switch result { case .verified(let safe): @@ -194,6 +207,7 @@ final class SupportPurchaseManager: ObservableObject { } } +///MARK: - StoreKit Errors enum SupportPurchaseError: LocalizedError { case failedVerification diff --git a/Neon Vision Editor/Models/AIModel.swift b/Neon Vision Editor/Models/AIModel.swift index af303b6..8b21f85 100644 --- a/Neon Vision Editor/Models/AIModel.swift +++ b/Neon Vision Editor/Models/AIModel.swift @@ -1,9 +1,6 @@ -// // AIModel.swift // Neon Vision Editor -// // Created by Hilthart Pedersen on 06.02.26. -// import Foundation diff --git a/Neon Vision Editor/UI/AppUpdaterDialog.swift b/Neon Vision Editor/UI/AppUpdaterDialog.swift new file mode 100644 index 0000000..7d30c8f --- /dev/null +++ b/Neon Vision Editor/UI/AppUpdaterDialog.swift @@ -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("You’re 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) + } + } + } +} diff --git a/Neon Vision Editor/UI/ContentView+Actions.swift b/Neon Vision Editor/UI/ContentView+Actions.swift index 2bdf22b..84ff0d6 100644 --- a/Neon Vision Editor/UI/ContentView+Actions.swift +++ b/Neon Vision Editor/UI/ContentView+Actions.swift @@ -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) diff --git a/Neon Vision Editor/UI/ContentView+Toolbar.swift b/Neon Vision Editor/UI/ContentView+Toolbar.swift index 8b6887c..73b95b1 100644 --- a/Neon Vision Editor/UI/ContentView+Toolbar.swift +++ b/Neon Vision Editor/UI/ContentView+Toolbar.swift @@ -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") } diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index 80e767a..3ef51d3 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -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) { diff --git a/Neon Vision Editor/UI/EditorTextView.swift b/Neon Vision Editor/UI/EditorTextView.swift index 864ad49..3d81e5f 100644 --- a/Neon Vision Editor/UI/EditorTextView.swift +++ b/Neon Vision Editor/UI/EditorTextView.swift @@ -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 } diff --git a/Neon Vision Editor/UI/NeonSettingsView.swift b/Neon Vision Editor/UI/NeonSettingsView.swift index 8a4daf5..a2191be 100644 --- a/Neon Vision Editor/UI/NeonSettingsView.swift +++ b/Neon Vision Editor/UI/NeonSettingsView.swift @@ -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(maxWidth: CGFloat = 560, @ViewBuilder _ content: () -> Content) -> some View { ScrollView { VStack(alignment: isCompactSettingsLayout ? .leading : .center, spacing: UI.space20) { diff --git a/Neon Vision EditorTests/AppUpdateManagerTests.swift b/Neon Vision EditorTests/AppUpdateManagerTests.swift new file mode 100644 index 0000000..949a60e --- /dev/null +++ b/Neon Vision EditorTests/AppUpdateManagerTests.swift @@ -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) + } +} diff --git a/docs/notarized-github-runner-workflow.md b/docs/notarized-github-runner-workflow.md index d17b710..07ccf94 100644 --- a/docs/notarized-github-runner-workflow.md +++ b/docs/notarized-github-runner-workflow.md @@ -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 +tar xzf actions-runner-osx-arm64.tar.gz +./config.sh --url https://github.com/h3pdesign/Neon-Vision-Editor --token --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 --exit-status +``` + ## 7) Monitor and Inspect ```bash diff --git a/scripts/run_selfhosted_notarized_release.sh b/scripts/run_selfhosted_notarized_release.sh new file mode 100755 index 0000000..a840bc1 --- /dev/null +++ b/scripts/run_selfhosted_notarized_release.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat < + +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}."