mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
feat: updater hardening and release readiness for v0.4.13
This commit is contained in:
parent
6d7e7f66a3
commit
09d1096941
19 changed files with 1745 additions and 217 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ public final class AIModelClient {
|
|||
self.apiKey = apiKey
|
||||
}
|
||||
|
||||
// MARK: - Non-streaming text generation
|
||||
///MARK: - Non-streaming text generation
|
||||
public func generateText(prompt: String, model: String = "grok-3-beta", maxTokens: Int = 500) async throws -> String {
|
||||
guard let baseURL = URL(string: baseURLString) else {
|
||||
throw NSError(
|
||||
|
|
@ -49,7 +49,7 @@ public final class AIModelClient {
|
|||
return decoded.choices.first?.message.content ?? ""
|
||||
}
|
||||
|
||||
// MARK: - Streaming suggestions (SSE)
|
||||
///MARK: - Streaming suggestions (SSE)
|
||||
public func streamSuggestions(prompt: String, model: String = "grok-code-fast-1") -> AsyncStream<String> {
|
||||
guard let baseURL = URL(string: baseURLString) else {
|
||||
return AsyncStream { continuation in
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
859
Neon Vision Editor/Core/AppUpdateManager.swift
Normal file
859
Neon Vision Editor/Core/AppUpdateManager.swift
Normal file
|
|
@ -0,0 +1,859 @@
|
|||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import CryptoKit
|
||||
#if canImport(Security)
|
||||
import Security
|
||||
#endif
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
enum AppUpdateCheckInterval: String, CaseIterable, Identifiable {
|
||||
case hourly = "hourly"
|
||||
case daily = "daily"
|
||||
case weekly = "weekly"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .hourly: return "Hourly"
|
||||
case .daily: return "Daily"
|
||||
case .weekly: return "Weekly"
|
||||
}
|
||||
}
|
||||
|
||||
var seconds: TimeInterval {
|
||||
switch self {
|
||||
case .hourly: return 3600
|
||||
case .daily: return 86400
|
||||
case .weekly: return 604800
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppUpdateManager: ObservableObject {
|
||||
enum CheckSource {
|
||||
case automatic
|
||||
case manual
|
||||
}
|
||||
|
||||
enum Status {
|
||||
case idle
|
||||
case checking
|
||||
case updateAvailable
|
||||
case upToDate
|
||||
case failed
|
||||
}
|
||||
|
||||
struct ReleaseInfo: Codable, Equatable {
|
||||
let version: String
|
||||
let title: String
|
||||
let notes: String
|
||||
let publishedAt: Date?
|
||||
let releaseURL: URL
|
||||
let downloadURL: URL?
|
||||
let assetName: String?
|
||||
}
|
||||
|
||||
private enum UpdateError: LocalizedError {
|
||||
case invalidReleaseSource
|
||||
case prereleaseRejected
|
||||
case draftRejected
|
||||
case rateLimited(until: Date?)
|
||||
case missingCachedRelease
|
||||
case installUnsupported(String)
|
||||
case checksumMissing(String)
|
||||
case checksumMismatch
|
||||
case invalidCodeSignature
|
||||
case noDownloadAsset
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidReleaseSource:
|
||||
return "Release source validation failed."
|
||||
case .prereleaseRejected:
|
||||
return "Latest GitHub release is marked as prerelease and was skipped."
|
||||
case .draftRejected:
|
||||
return "Latest GitHub release is a draft and was skipped."
|
||||
case .rateLimited(let until):
|
||||
if let until {
|
||||
return "GitHub API rate limit reached. Retry after \(until.formatted(date: .abbreviated, time: .shortened))."
|
||||
}
|
||||
return "GitHub API rate limit reached."
|
||||
case .missingCachedRelease:
|
||||
return "No cached release metadata found for ETag response."
|
||||
case .installUnsupported(let reason):
|
||||
return reason
|
||||
case .checksumMissing(let asset):
|
||||
return "Checksum missing for \(asset)."
|
||||
case .checksumMismatch:
|
||||
return "Downloaded update checksum does not match release metadata."
|
||||
case .invalidCodeSignature:
|
||||
return "Downloaded app signature validation failed."
|
||||
case .noDownloadAsset:
|
||||
return "No downloadable ZIP asset found for this release."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published private(set) var status: Status = .idle
|
||||
@Published private(set) var latestRelease: ReleaseInfo?
|
||||
@Published private(set) var errorMessage: String?
|
||||
@Published private(set) var lastCheckedAt: Date?
|
||||
@Published private(set) var automaticPromptToken: Int = 0
|
||||
@Published private(set) var isInstalling: Bool = false
|
||||
@Published private(set) var installMessage: String?
|
||||
@Published private(set) var lastCheckResultSummary: String = "Never checked"
|
||||
|
||||
private let owner: String
|
||||
private let repo: String
|
||||
private let defaults: UserDefaults
|
||||
private let session: URLSession
|
||||
private let appLaunchDate: Date
|
||||
private var automaticTask: Task<Void, Never>?
|
||||
private var pendingAutomaticPrompt: Bool = false
|
||||
|
||||
let currentVersion: String
|
||||
|
||||
static let autoCheckEnabledKey = "SettingsAutoCheckForUpdates"
|
||||
static let updateIntervalKey = "SettingsUpdateCheckInterval"
|
||||
static let autoDownloadEnabledKey = "SettingsAutoDownloadUpdates"
|
||||
static let skippedVersionKey = "SettingsSkippedUpdateVersion"
|
||||
static let lastCheckedAtKey = "SettingsLastUpdateCheckAt"
|
||||
static let remindUntilKey = "SettingsUpdateRemindUntil"
|
||||
static let etagKey = "SettingsUpdateETag"
|
||||
static let cachedReleaseKey = "SettingsCachedReleaseInfo"
|
||||
static let consecutiveFailuresKey = "SettingsUpdateConsecutiveFailures"
|
||||
static let pauseUntilKey = "SettingsUpdatePauseUntil"
|
||||
static let lastCheckSummaryKey = "SettingsUpdateLastCheckSummary"
|
||||
|
||||
private static let minAutoPromptUptime: TimeInterval = 90
|
||||
private static let circuitBreakerThreshold = 3
|
||||
private static let circuitBreakerPause: TimeInterval = 3600
|
||||
|
||||
init(
|
||||
owner: String = "h3pdesign",
|
||||
repo: String = "Neon-Vision-Editor",
|
||||
defaults: UserDefaults = .standard,
|
||||
session: URLSession = .shared
|
||||
) {
|
||||
self.owner = owner
|
||||
self.repo = repo
|
||||
self.defaults = defaults
|
||||
self.session = session
|
||||
self.appLaunchDate = Date()
|
||||
self.currentVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0"
|
||||
|
||||
if let timestamp = defaults.object(forKey: Self.lastCheckedAtKey) as? TimeInterval {
|
||||
self.lastCheckedAt = Date(timeIntervalSince1970: timestamp)
|
||||
}
|
||||
if let summary = defaults.string(forKey: Self.lastCheckSummaryKey), !summary.isEmpty {
|
||||
self.lastCheckResultSummary = summary
|
||||
}
|
||||
if Self.isDevelopmentRuntime {
|
||||
// Prevent persisted settings from triggering relaunch/install loops during local debugging.
|
||||
defaults.set(false, forKey: Self.autoDownloadEnabledKey)
|
||||
}
|
||||
}
|
||||
|
||||
var autoCheckEnabled: Bool {
|
||||
defaults.object(forKey: Self.autoCheckEnabledKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
var autoDownloadEnabled: Bool {
|
||||
defaults.object(forKey: Self.autoDownloadEnabledKey) as? Bool ?? false
|
||||
}
|
||||
|
||||
var updateInterval: AppUpdateCheckInterval {
|
||||
let raw = defaults.string(forKey: Self.updateIntervalKey) ?? AppUpdateCheckInterval.daily.rawValue
|
||||
return AppUpdateCheckInterval(rawValue: raw) ?? .daily
|
||||
}
|
||||
|
||||
var pausedUntil: Date? {
|
||||
guard let ts = defaults.object(forKey: Self.pauseUntilKey) as? TimeInterval else { return nil }
|
||||
return Date(timeIntervalSince1970: ts)
|
||||
}
|
||||
|
||||
var consecutiveFailureCount: Int {
|
||||
defaults.object(forKey: Self.consecutiveFailuresKey) as? Int ?? 0
|
||||
}
|
||||
|
||||
func setAutoCheckEnabled(_ enabled: Bool) {
|
||||
defaults.set(enabled, forKey: Self.autoCheckEnabledKey)
|
||||
rescheduleAutomaticChecks()
|
||||
}
|
||||
|
||||
func setAutoDownloadEnabled(_ enabled: Bool) {
|
||||
defaults.set(enabled, forKey: Self.autoDownloadEnabledKey)
|
||||
}
|
||||
|
||||
func setUpdateInterval(_ interval: AppUpdateCheckInterval) {
|
||||
defaults.set(interval.rawValue, forKey: Self.updateIntervalKey)
|
||||
rescheduleAutomaticChecks()
|
||||
}
|
||||
|
||||
func startAutomaticChecks() {
|
||||
guard ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution else { return }
|
||||
rescheduleAutomaticChecks()
|
||||
|
||||
guard autoCheckEnabled else { return }
|
||||
if shouldRunInitialCheckNow() {
|
||||
Task { await checkForUpdates(source: .automatic) }
|
||||
}
|
||||
}
|
||||
|
||||
func checkForUpdates(source: CheckSource) async {
|
||||
guard ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution else {
|
||||
status = .idle
|
||||
errorMessage = "Updater is disabled for this distribution channel."
|
||||
return
|
||||
}
|
||||
guard status != .checking else { return }
|
||||
|
||||
if source == .automatic,
|
||||
let pausedUntil,
|
||||
pausedUntil > Date() {
|
||||
// Respect circuit-breaker/rate-limit pause windows for background checks.
|
||||
updateLastSummary("Auto-check paused until \(pausedUntil.formatted(date: .abbreviated, time: .shortened))")
|
||||
return
|
||||
}
|
||||
|
||||
status = .checking
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let release = try await fetchLatestRelease()
|
||||
let now = Date()
|
||||
lastCheckedAt = now
|
||||
defaults.set(now.timeIntervalSince1970, forKey: Self.lastCheckedAtKey)
|
||||
defaults.set(0, forKey: Self.consecutiveFailuresKey)
|
||||
defaults.removeObject(forKey: Self.pauseUntilKey)
|
||||
|
||||
if Self.compareVersions(release.version, currentVersion) == .orderedDescending {
|
||||
latestRelease = release
|
||||
status = .updateAvailable
|
||||
installMessage = nil
|
||||
updateLastSummary("Update available: \(release.version)")
|
||||
|
||||
if source == .automatic,
|
||||
shouldAutoPrompt(for: release.version) {
|
||||
if autoDownloadEnabled {
|
||||
if Self.isDevelopmentRuntime {
|
||||
// Avoid relaunch loops while running from Xcode/DerivedData.
|
||||
pendingAutomaticPrompt = true
|
||||
automaticPromptToken &+= 1
|
||||
installMessage = "Automatic install is disabled while running from Xcode/DerivedData."
|
||||
} else {
|
||||
await attemptAutoInstall()
|
||||
}
|
||||
} else {
|
||||
pendingAutomaticPrompt = true
|
||||
automaticPromptToken &+= 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
latestRelease = nil
|
||||
status = .upToDate
|
||||
updateLastSummary("Up to date")
|
||||
}
|
||||
} catch {
|
||||
latestRelease = nil
|
||||
status = .failed
|
||||
errorMessage = error.localizedDescription
|
||||
if case let UpdateError.rateLimited(until) = error, let until, until > Date() {
|
||||
// Use GitHub-provided reset time when available.
|
||||
defaults.set(until.timeIntervalSince1970, forKey: Self.pauseUntilKey)
|
||||
updateLastSummary("Rate limited by GitHub. Auto-check paused until \(until.formatted(date: .abbreviated, time: .shortened)).")
|
||||
} else {
|
||||
let failures = (defaults.object(forKey: Self.consecutiveFailuresKey) as? Int ?? 0) + 1
|
||||
defaults.set(failures, forKey: Self.consecutiveFailuresKey)
|
||||
|
||||
if failures >= Self.circuitBreakerThreshold {
|
||||
let until = Date().addingTimeInterval(Self.circuitBreakerPause)
|
||||
defaults.set(until.timeIntervalSince1970, forKey: Self.pauseUntilKey)
|
||||
updateLastSummary("Checks paused after \(failures) failures (until \(until.formatted(date: .abbreviated, time: .shortened))).")
|
||||
} else {
|
||||
updateLastSummary("Update check failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func consumeAutomaticPromptIfNeeded() -> Bool {
|
||||
guard pendingAutomaticPrompt else { return false }
|
||||
pendingAutomaticPrompt = false
|
||||
return true
|
||||
}
|
||||
|
||||
func skipCurrentVersion() {
|
||||
guard let version = latestRelease?.version else { return }
|
||||
defaults.set(version, forKey: Self.skippedVersionKey)
|
||||
}
|
||||
|
||||
func remindMeTomorrow() {
|
||||
let until = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date().addingTimeInterval(86400)
|
||||
defaults.set(until.timeIntervalSince1970, forKey: Self.remindUntilKey)
|
||||
}
|
||||
|
||||
func clearSkippedVersion() {
|
||||
defaults.removeObject(forKey: Self.skippedVersionKey)
|
||||
}
|
||||
|
||||
func openDownloadPage() {
|
||||
guard let release = latestRelease else { return }
|
||||
openURL(release.downloadURL ?? release.releaseURL)
|
||||
}
|
||||
|
||||
func openReleasePage() {
|
||||
guard let release = latestRelease else { return }
|
||||
openURL(release.releaseURL)
|
||||
}
|
||||
|
||||
func clearInstallMessage() {
|
||||
installMessage = nil
|
||||
}
|
||||
|
||||
func installUpdateNow() async {
|
||||
guard ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution else {
|
||||
installMessage = "Updater is disabled for this distribution channel."
|
||||
return
|
||||
}
|
||||
guard !Self.isDevelopmentRuntime else {
|
||||
installMessage = "Install now is disabled while running from Xcode/DerivedData. Use Download Update instead."
|
||||
return
|
||||
}
|
||||
await attemptAutoInstall()
|
||||
}
|
||||
|
||||
private func shouldRunInitialCheckNow() -> Bool {
|
||||
guard let lastCheckedAt else { return true }
|
||||
return Date().timeIntervalSince(lastCheckedAt) >= updateInterval.seconds
|
||||
}
|
||||
|
||||
private func shouldAutoPrompt(for version: String) -> Bool {
|
||||
if defaults.string(forKey: Self.skippedVersionKey) == version { return false }
|
||||
if let remindTS = defaults.object(forKey: Self.remindUntilKey) as? TimeInterval,
|
||||
Date(timeIntervalSince1970: remindTS) > Date() {
|
||||
return false
|
||||
}
|
||||
let uptime = Date().timeIntervalSince(appLaunchDate)
|
||||
return uptime >= Self.minAutoPromptUptime
|
||||
}
|
||||
|
||||
private func rescheduleAutomaticChecks() {
|
||||
automaticTask?.cancel()
|
||||
automaticTask = nil
|
||||
|
||||
guard autoCheckEnabled else { return }
|
||||
let intervalSeconds = updateInterval.seconds
|
||||
|
||||
automaticTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
while !Task.isCancelled {
|
||||
let nanos = UInt64(intervalSeconds * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanos)
|
||||
if Task.isCancelled { break }
|
||||
await self.checkForUpdates(source: .automatic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateLastSummary(_ summary: String) {
|
||||
lastCheckResultSummary = summary
|
||||
defaults.set(summary, forKey: Self.lastCheckSummaryKey)
|
||||
}
|
||||
|
||||
private func fetchLatestRelease() async throws -> ReleaseInfo {
|
||||
let endpoint = "https://api.github.com/repos/\(owner)/\(repo)/releases/latest"
|
||||
guard let url = URL(string: endpoint) else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 20
|
||||
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("NeonVisionEditorUpdater", forHTTPHeaderField: "User-Agent")
|
||||
if let etag = defaults.string(forKey: Self.etagKey), !etag.isEmpty {
|
||||
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
|
||||
}
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
if http.statusCode == 304 {
|
||||
// ETag hit: reuse previously cached release payload.
|
||||
if let cached = cachedReleaseInfo() {
|
||||
return cached
|
||||
}
|
||||
throw UpdateError.missingCachedRelease
|
||||
}
|
||||
|
||||
if http.statusCode == 403,
|
||||
(http.value(forHTTPHeaderField: "X-RateLimit-Remaining") ?? "") == "0" {
|
||||
let until = Self.rateLimitResetDate(from: http)
|
||||
throw UpdateError.rateLimited(until: until)
|
||||
}
|
||||
|
||||
guard 200..<300 ~= http.statusCode else {
|
||||
throw NSError(
|
||||
domain: "AppUpdater",
|
||||
code: http.statusCode,
|
||||
userInfo: [NSLocalizedDescriptionKey: "GitHub update check failed (HTTP \(http.statusCode))."]
|
||||
)
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let payload = try decoder.decode(GitHubReleasePayload.self, from: data)
|
||||
|
||||
// Enforce repository identity from API payload before using any release data.
|
||||
if let apiURL = payload.apiURL,
|
||||
!Self.matchesExpectedRepository(url: apiURL, expectedOwner: owner, expectedRepo: repo) {
|
||||
throw UpdateError.invalidReleaseSource
|
||||
}
|
||||
guard !payload.draft else { throw UpdateError.draftRejected }
|
||||
guard !payload.prerelease else { throw UpdateError.prereleaseRejected }
|
||||
|
||||
guard let releaseURL = URL(string: payload.htmlURL),
|
||||
isTrustedGitHubURL(releaseURL),
|
||||
Self.matchesExpectedRepository(url: releaseURL, expectedOwner: owner, expectedRepo: repo) else {
|
||||
throw UpdateError.invalidReleaseSource
|
||||
}
|
||||
|
||||
let selectedAssetURL = preferredAsset(from: payload.assets)
|
||||
let selectedAssetName = selectedAssetName(from: payload.assets)
|
||||
|
||||
let release = ReleaseInfo(
|
||||
version: Self.normalizedVersion(from: payload.tagName),
|
||||
title: payload.name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
? (payload.name ?? payload.tagName)
|
||||
: payload.tagName,
|
||||
notes: payload.body ?? "",
|
||||
publishedAt: payload.publishedAt,
|
||||
releaseURL: releaseURL,
|
||||
downloadURL: selectedAssetURL,
|
||||
assetName: selectedAssetName
|
||||
)
|
||||
|
||||
if let etag = http.value(forHTTPHeaderField: "ETag"), !etag.isEmpty {
|
||||
defaults.set(etag, forKey: Self.etagKey)
|
||||
}
|
||||
persistCachedReleaseInfo(release)
|
||||
|
||||
return release
|
||||
}
|
||||
|
||||
private func selectedAssetName(from assets: [GitHubAssetPayload]) -> String? {
|
||||
let names = assets.map { $0.name }
|
||||
return Self.selectPreferredAssetName(from: names)
|
||||
}
|
||||
|
||||
private func preferredAsset(from assets: [GitHubAssetPayload]) -> URL? {
|
||||
guard let selectedName = selectedAssetName(from: assets),
|
||||
let asset = assets.first(where: { $0.name == selectedName }),
|
||||
let url = URL(string: asset.browserDownloadURL),
|
||||
isTrustedGitHubURL(url),
|
||||
Self.matchesExpectedAssetURL(url: url, expectedOwner: owner, expectedRepo: repo) else {
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private func cachedReleaseInfo() -> ReleaseInfo? {
|
||||
guard let data = defaults.data(forKey: Self.cachedReleaseKey) else { return nil }
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return try? decoder.decode(ReleaseInfo.self, from: data)
|
||||
}
|
||||
|
||||
private func persistCachedReleaseInfo(_ release: ReleaseInfo) {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
if let data = try? encoder.encode(release) {
|
||||
defaults.set(data, forKey: Self.cachedReleaseKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func attemptAutoInstall() async {
|
||||
#if os(macOS)
|
||||
guard !isInstalling else { return }
|
||||
guard let release = latestRelease else { return }
|
||||
guard let downloadURL = release.downloadURL else {
|
||||
installMessage = UpdateError.noDownloadAsset.localizedDescription
|
||||
return
|
||||
}
|
||||
guard let assetName = release.assetName else {
|
||||
installMessage = UpdateError.noDownloadAsset.localizedDescription
|
||||
return
|
||||
}
|
||||
|
||||
isInstalling = true
|
||||
defer { isInstalling = false }
|
||||
|
||||
do {
|
||||
// Defense-in-depth:
|
||||
// 1) verify artifact checksum from release metadata
|
||||
// 2) verify code signature validity + signing identity
|
||||
let expectedHash = try Self.extractSHA256(from: release.notes, preferredAssetName: assetName)
|
||||
let (tmpURL, response) = try await session.download(from: downloadURL)
|
||||
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
let actualHash = try Self.sha256Hex(of: tmpURL)
|
||||
guard actualHash.caseInsensitiveCompare(expectedHash) == .orderedSame else {
|
||||
throw UpdateError.checksumMismatch
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let workDir = fileManager.temporaryDirectory.appendingPathComponent("nve-update-\(UUID().uuidString)", isDirectory: true)
|
||||
let unzipDir = workDir.appendingPathComponent("unzipped", isDirectory: true)
|
||||
try fileManager.createDirectory(at: unzipDir, withIntermediateDirectories: true)
|
||||
|
||||
let downloadedZip = workDir.appendingPathComponent(assetName)
|
||||
try fileManager.moveItem(at: tmpURL, to: downloadedZip)
|
||||
|
||||
let unzipStatus = try Self.unzip(zipURL: downloadedZip, to: unzipDir)
|
||||
guard unzipStatus == 0 else {
|
||||
throw UpdateError.installUnsupported("Failed to unpack update archive.")
|
||||
}
|
||||
|
||||
guard let appBundle = Self.findFirstAppBundle(in: unzipDir) else {
|
||||
throw UpdateError.installUnsupported("No .app bundle found in downloaded update.")
|
||||
}
|
||||
guard try Self.verifyCodeSignatureStrictCLI(of: appBundle) else {
|
||||
throw UpdateError.invalidCodeSignature
|
||||
}
|
||||
// Require the downloaded app to match current Team ID and bundle identifier.
|
||||
guard try Self.verifyCodeSignatureStrictCLI(of: Bundle.main.bundleURL) else {
|
||||
throw UpdateError.installUnsupported("Current app signature is invalid. Reinstall the app manually before auto-install updates.")
|
||||
}
|
||||
guard let expectedTeamID = try Self.readTeamIdentifier(of: Bundle.main.bundleURL) else {
|
||||
throw UpdateError.installUnsupported("Could not determine local signing team. Use Download Update for manual install.")
|
||||
}
|
||||
guard try Self.verifyCodeSignature(
|
||||
of: appBundle,
|
||||
expectedTeamID: expectedTeamID,
|
||||
expectedBundleID: Bundle.main.bundleIdentifier
|
||||
) else {
|
||||
throw UpdateError.invalidCodeSignature
|
||||
}
|
||||
|
||||
let currentApp = Bundle.main.bundleURL.standardizedFileURL
|
||||
let targetDir = currentApp.deletingLastPathComponent()
|
||||
let backupApp = targetDir.appendingPathComponent("\(currentApp.deletingPathExtension().lastPathComponent)-backup-\(Int(Date().timeIntervalSince1970)).app")
|
||||
|
||||
do {
|
||||
try fileManager.moveItem(at: currentApp, to: backupApp)
|
||||
try fileManager.moveItem(at: appBundle, to: currentApp)
|
||||
} catch {
|
||||
if fileManager.fileExists(atPath: backupApp.path), !fileManager.fileExists(atPath: currentApp.path) {
|
||||
try? fileManager.moveItem(at: backupApp, to: currentApp)
|
||||
}
|
||||
throw UpdateError.installUnsupported("Automatic install failed due to file permissions. Use Download Update for manual install.")
|
||||
}
|
||||
|
||||
installMessage = "Update installed. Relaunching…"
|
||||
NSWorkspace.shared.openApplication(at: currentApp, configuration: NSWorkspace.OpenConfiguration(), completionHandler: nil)
|
||||
NSApp.terminate(nil)
|
||||
} catch {
|
||||
installMessage = error.localizedDescription
|
||||
if let release = latestRelease {
|
||||
openURL(release.downloadURL ?? release.releaseURL)
|
||||
}
|
||||
}
|
||||
#else
|
||||
installMessage = "Automatic install is supported on macOS only."
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private nonisolated static func unzip(zipURL: URL, to destination: URL) throws -> Int32 {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
|
||||
process.arguments = ["-xk", zipURL.path, destination.path]
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus
|
||||
}
|
||||
|
||||
private nonisolated static func findFirstAppBundle(in directory: URL) -> URL? {
|
||||
let fm = FileManager.default
|
||||
guard let enumerator = fm.enumerator(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) else { return nil }
|
||||
for case let url as URL in enumerator {
|
||||
if url.pathExtension.lowercased() == "app" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
|
||||
private func openURL(_ url: URL) {
|
||||
#if canImport(AppKit)
|
||||
NSWorkspace.shared.open(url)
|
||||
#elseif canImport(UIKit)
|
||||
UIApplication.shared.open(url)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func isTrustedGitHubURL(_ url: URL) -> Bool {
|
||||
guard url.scheme == "https" else { return false }
|
||||
return Self.isTrustedGitHubHost(url.host)
|
||||
}
|
||||
|
||||
nonisolated static func isTrustedGitHubHost(_ host: String?) -> Bool {
|
||||
guard let host = host?.lowercased() else { return false }
|
||||
return host == "github.com"
|
||||
|| host == "objects.githubusercontent.com"
|
||||
|| host == "github-releases.githubusercontent.com"
|
||||
}
|
||||
|
||||
nonisolated static func selectPreferredAssetName(from names: [String]) -> String? {
|
||||
if let exact = names.first(where: { $0.caseInsensitiveCompare("Neon.Vision.Editor.app.zip") == .orderedSame }) {
|
||||
return exact
|
||||
}
|
||||
if let appZip = names.first(where: { $0.lowercased().hasSuffix(".app.zip") }) {
|
||||
return appZip
|
||||
}
|
||||
if let neonZip = names.first(where: { $0.lowercased().contains("neon") && $0.lowercased().hasSuffix(".zip") }) {
|
||||
return neonZip
|
||||
}
|
||||
return names.first(where: { $0.lowercased().hasSuffix(".zip") })
|
||||
}
|
||||
|
||||
nonisolated static func normalizedVersion(from tag: String) -> String {
|
||||
var cleaned = tag.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if cleaned.hasPrefix("v") || cleaned.hasPrefix("V") {
|
||||
cleaned.removeFirst()
|
||||
}
|
||||
if let dash = cleaned.firstIndex(of: "-") {
|
||||
cleaned = String(cleaned[..<dash])
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
nonisolated static func compareVersions(_ lhs: String, _ rhs: String) -> ComparisonResult {
|
||||
let leftParts = normalizedVersion(from: lhs).split(separator: ".").map { Int($0) ?? 0 }
|
||||
let rightParts = normalizedVersion(from: rhs).split(separator: ".").map { Int($0) ?? 0 }
|
||||
let maxCount = max(leftParts.count, rightParts.count)
|
||||
|
||||
for index in 0..<maxCount {
|
||||
let l = index < leftParts.count ? leftParts[index] : 0
|
||||
let r = index < rightParts.count ? rightParts[index] : 0
|
||||
if l < r { return .orderedAscending }
|
||||
if l > r { return .orderedDescending }
|
||||
}
|
||||
|
||||
let leftIsPrerelease = isPrereleaseVersionTag(lhs)
|
||||
let rightIsPrerelease = isPrereleaseVersionTag(rhs)
|
||||
if leftIsPrerelease && !rightIsPrerelease { return .orderedAscending }
|
||||
if !leftIsPrerelease && rightIsPrerelease { return .orderedDescending }
|
||||
return .orderedSame
|
||||
}
|
||||
|
||||
nonisolated static func isVersionSkipped(_ version: String, skippedValue: String?) -> Bool {
|
||||
skippedValue == version
|
||||
}
|
||||
|
||||
nonisolated private static func isPrereleaseVersionTag(_ value: String) -> Bool {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).contains("-")
|
||||
}
|
||||
|
||||
nonisolated private static func matchesExpectedRepository(url: URL, expectedOwner: String, expectedRepo: String) -> Bool {
|
||||
let parts = url.pathComponents.filter { $0 != "/" }
|
||||
guard parts.count >= 2 else { return false }
|
||||
if parts[0].caseInsensitiveCompare(expectedOwner) == .orderedSame,
|
||||
parts[1].caseInsensitiveCompare(expectedRepo) == .orderedSame {
|
||||
return true
|
||||
}
|
||||
// GitHub REST API paths are /repos/{owner}/{repo}/...
|
||||
if parts.count >= 3,
|
||||
parts[0].caseInsensitiveCompare("repos") == .orderedSame,
|
||||
parts[1].caseInsensitiveCompare(expectedOwner) == .orderedSame,
|
||||
parts[2].caseInsensitiveCompare(expectedRepo) == .orderedSame {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
nonisolated private static func matchesExpectedAssetURL(url: URL, expectedOwner: String, expectedRepo: String) -> Bool {
|
||||
guard let host = url.host?.lowercased() else { return false }
|
||||
if host == "github.com" {
|
||||
let parts = url.pathComponents.filter { $0 != "/" }
|
||||
guard parts.count >= 4 else { return false }
|
||||
guard parts[0].caseInsensitiveCompare(expectedOwner) == .orderedSame,
|
||||
parts[1].caseInsensitiveCompare(expectedRepo) == .orderedSame else {
|
||||
return false
|
||||
}
|
||||
return parts[2].lowercased() == "releases" && parts[3].lowercased() == "download"
|
||||
}
|
||||
return host == "github-releases.githubusercontent.com"
|
||||
|| host == "objects.githubusercontent.com"
|
||||
}
|
||||
|
||||
nonisolated private static func rateLimitResetDate(from response: HTTPURLResponse) -> Date? {
|
||||
guard let reset = response.value(forHTTPHeaderField: "X-RateLimit-Reset"),
|
||||
let epoch = TimeInterval(reset) else { return nil }
|
||||
return Date(timeIntervalSince1970: epoch)
|
||||
}
|
||||
|
||||
nonisolated private static func extractSHA256(from notes: String, preferredAssetName: String) throws -> String {
|
||||
let escapedAsset = NSRegularExpression.escapedPattern(for: preferredAssetName)
|
||||
let exactAssetPattern = "(?im)\\b\(escapedAsset)\\b[^\\n]*?([A-Fa-f0-9]{64})"
|
||||
if let hash = firstMatchGroup(in: notes, pattern: exactAssetPattern) {
|
||||
return hash
|
||||
}
|
||||
|
||||
let genericPattern = "(?im)sha[- ]?256[^A-Fa-f0-9]*([A-Fa-f0-9]{64})"
|
||||
if let hash = firstMatchGroup(in: notes, pattern: genericPattern) {
|
||||
return hash
|
||||
}
|
||||
|
||||
throw UpdateError.checksumMissing(preferredAssetName)
|
||||
}
|
||||
|
||||
nonisolated private static func firstMatchGroup(in text: String, pattern: String) -> String? {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let ns = text as NSString
|
||||
let range = NSRange(location: 0, length: ns.length)
|
||||
guard let match = regex.firstMatch(in: text, options: [], range: range), match.numberOfRanges > 1 else { return nil }
|
||||
let captured = ns.substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return captured.isEmpty ? nil : captured
|
||||
}
|
||||
|
||||
nonisolated private static func sha256Hex(of fileURL: URL) throws -> String {
|
||||
// Stream hashing avoids loading large zip files fully into memory.
|
||||
let handle = try FileHandle(forReadingFrom: fileURL)
|
||||
defer { try? handle.close() }
|
||||
|
||||
var hasher = SHA256()
|
||||
while true {
|
||||
let chunk = handle.readData(ofLength: 64 * 1024)
|
||||
if chunk.isEmpty { break }
|
||||
hasher.update(data: chunk)
|
||||
}
|
||||
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
nonisolated private static var isDevelopmentRuntime: Bool {
|
||||
#if DEBUG
|
||||
return true
|
||||
#else
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
if bundlePath.contains("/DerivedData/") { return true }
|
||||
if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { return true }
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
nonisolated private static func verifyCodeSignatureStrictCLI(of appBundle: URL) throws -> Bool {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign")
|
||||
process.arguments = ["--verify", "--deep", "--strict", appBundle.path]
|
||||
let outputPipe = Pipe()
|
||||
process.standardError = outputPipe
|
||||
process.standardOutput = outputPipe
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
}
|
||||
|
||||
nonisolated private static func readTeamIdentifier(of appBundle: URL) throws -> String? {
|
||||
#if canImport(Security)
|
||||
var staticCode: SecStaticCode?
|
||||
let createStatus = SecStaticCodeCreateWithPath(appBundle as CFURL, [], &staticCode)
|
||||
guard createStatus == errSecSuccess, let staticCode else { return nil }
|
||||
|
||||
var signingInfoRef: CFDictionary?
|
||||
let infoStatus = SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &signingInfoRef)
|
||||
guard infoStatus == errSecSuccess,
|
||||
let signingInfo = signingInfoRef as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return signingInfo[kSecCodeInfoTeamIdentifier as String] as? String
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
nonisolated private static func verifyCodeSignature(
|
||||
of appBundle: URL,
|
||||
expectedTeamID: String,
|
||||
expectedBundleID: String?
|
||||
) throws -> Bool {
|
||||
#if canImport(Security)
|
||||
var staticCode: SecStaticCode?
|
||||
let createStatus = SecStaticCodeCreateWithPath(appBundle as CFURL, [], &staticCode)
|
||||
guard createStatus == errSecSuccess, let staticCode else { return false }
|
||||
let checkStatus = SecStaticCodeCheckValidity(staticCode, SecCSFlags(), nil)
|
||||
guard checkStatus == errSecSuccess else { return false }
|
||||
|
||||
var signingInfoRef: CFDictionary?
|
||||
let infoStatus = SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &signingInfoRef)
|
||||
guard infoStatus == errSecSuccess,
|
||||
let signingInfo = signingInfoRef as? [String: Any] else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let teamID = signingInfo[kSecCodeInfoTeamIdentifier as String] as? String,
|
||||
teamID == expectedTeamID else {
|
||||
return false
|
||||
}
|
||||
|
||||
if let expectedBundleID, !expectedBundleID.isEmpty {
|
||||
guard let actualBundleID = signingInfo[kSecCodeInfoIdentifier as String] as? String,
|
||||
actualBundleID == expectedBundleID else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private struct GitHubReleasePayload: Decodable {
|
||||
let apiURL: URL?
|
||||
let tagName: String
|
||||
let name: String?
|
||||
let body: String?
|
||||
let htmlURL: String
|
||||
let publishedAt: Date?
|
||||
let draft: Bool
|
||||
let prerelease: Bool
|
||||
let assets: [GitHubAssetPayload]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case apiURL = "url"
|
||||
case tagName = "tag_name"
|
||||
case name
|
||||
case body
|
||||
case htmlURL = "html_url"
|
||||
case publishedAt = "published_at"
|
||||
case draft
|
||||
case prerelease
|
||||
case assets
|
||||
}
|
||||
}
|
||||
|
||||
private struct GitHubAssetPayload: Decodable {
|
||||
let name: String
|
||||
let browserDownloadURL: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case browserDownloadURL = "browser_download_url"
|
||||
}
|
||||
}
|
||||
|
|
@ -6,17 +6,12 @@ import FoundationModels
|
|||
public struct GeneratedText { public var text: String }
|
||||
|
||||
public enum AppleFM {
|
||||
/// Global toggle to enable Apple Foundation Models features at runtime.
|
||||
/// Defaults to `false` so code completion/AI features are disabled by default.
|
||||
public static var isEnabled: Bool = false
|
||||
|
||||
private static func featureDisabledError() -> NSError {
|
||||
NSError(domain: "AppleFM", code: -10, userInfo: [NSLocalizedDescriptionKey: "Foundation Models feature is disabled by default. Enable via AppleFM.isEnabled = true."])
|
||||
}
|
||||
|
||||
/// Perform a simple health check by requesting a short completion using the system model.
|
||||
/// - Returns: A string indicating the model is responsive ("pong").
|
||||
/// - Throws: Any error thrown by the Foundation Models API or availability checks.
|
||||
public static func appleFMHealthCheck() async throws -> String {
|
||||
if #available(iOS 18.0, macOS 15.0, *) {
|
||||
guard isEnabled else {
|
||||
|
|
@ -35,10 +30,6 @@ public enum AppleFM {
|
|||
}
|
||||
}
|
||||
|
||||
/// Generate a completion from the given prompt using the system language model.
|
||||
/// - Parameter prompt: The prompt string to complete.
|
||||
/// - Returns: The completion text from the model.
|
||||
/// - Throws: Any error thrown by the Foundation Models API or availability checks.
|
||||
public static func appleFMComplete(prompt: String) async throws -> String {
|
||||
if #available(iOS 18.0, macOS 15.0, *) {
|
||||
guard isEnabled else {
|
||||
|
|
@ -56,9 +47,6 @@ public enum AppleFM {
|
|||
}
|
||||
}
|
||||
|
||||
/// Stream a completion from the given prompt, yielding partial updates as the model generates them.
|
||||
/// - Parameter prompt: The prompt string to complete.
|
||||
/// - Returns: An AsyncStream of incremental text deltas.
|
||||
public static func appleFMStream(prompt: String) -> AsyncStream<String> {
|
||||
if #available(iOS 18.0, macOS 15.0, *) {
|
||||
guard isEnabled else {
|
||||
|
|
@ -120,24 +108,16 @@ public enum AppleFM {
|
|||
import Foundation
|
||||
|
||||
public enum AppleFM {
|
||||
/// Global toggle to enable Apple Foundation Models features at runtime.
|
||||
/// Defaults to `false` so code completion/AI features are disabled by default.
|
||||
public static var isEnabled: Bool = false
|
||||
|
||||
/// Stub health check implementation when Foundation Models is not available.
|
||||
/// - Throws: Always throws an error indicating the feature is unavailable.
|
||||
public static func appleFMHealthCheck() async throws -> String {
|
||||
throw NSError(domain: "AppleFM", code: -1, userInfo: [NSLocalizedDescriptionKey: "Foundation Models feature is not enabled."])
|
||||
}
|
||||
|
||||
/// Stub completion implementation when Foundation Models is not available.
|
||||
/// - Parameter prompt: The prompt string.
|
||||
/// - Returns: Placeholder string indicating unavailable feature.
|
||||
public static func appleFMComplete(prompt: String) async throws -> String {
|
||||
return "Completion unavailable: Foundation Models feature not enabled."
|
||||
}
|
||||
|
||||
/// Stub streaming implementation when Foundation Models is not available.
|
||||
public static func appleFMStream(prompt: String) -> AsyncStream<String> {
|
||||
return AsyncStream { continuation in
|
||||
continuation.finish()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import Foundation
|
|||
import Combine
|
||||
import StoreKit
|
||||
|
||||
///MARK: - Support Purchase Manager
|
||||
// Handles optional support purchase and entitlement state via StoreKit.
|
||||
@MainActor
|
||||
final class SupportPurchaseManager: ObservableObject {
|
||||
static let supportProductID = "h3p.neon-vision-editor.support.optional"
|
||||
|
|
@ -17,6 +19,7 @@ final class SupportPurchaseManager: ObservableObject {
|
|||
private var transactionUpdatesTask: Task<Void, Never>?
|
||||
private let bypassDefaultsKey = "SupportPurchaseBypassEnabled"
|
||||
|
||||
// Allows bypass in simulator/debug environments for testing purchase-gated UI.
|
||||
private func shouldAllowTestingBypass(environment: AppStore.Environment) -> Bool {
|
||||
#if targetEnvironment(simulator)
|
||||
return true
|
||||
|
|
@ -47,12 +50,14 @@ final class SupportPurchaseManager: ObservableObject {
|
|||
allowsTestingBypass
|
||||
}
|
||||
|
||||
// Refreshes StoreKit capability, product metadata, and entitlement state.
|
||||
func refreshStoreState() async {
|
||||
await refreshBypassEligibility()
|
||||
await refreshProducts(showStatusOnFailure: false)
|
||||
await refreshSupportEntitlement()
|
||||
}
|
||||
|
||||
// Enables testing bypass where allowed.
|
||||
func bypassForTesting() {
|
||||
guard canBypassInCurrentBuild else { return }
|
||||
UserDefaults.standard.set(true, forKey: bypassDefaultsKey)
|
||||
|
|
@ -60,11 +65,13 @@ final class SupportPurchaseManager: ObservableObject {
|
|||
statusMessage = "Support purchase bypass enabled for TestFlight/Sandbox testing."
|
||||
}
|
||||
|
||||
// Clears testing bypass and re-evaluates current entitlement.
|
||||
func clearBypassForTesting() {
|
||||
UserDefaults.standard.removeObject(forKey: bypassDefaultsKey)
|
||||
Task { await refreshSupportEntitlement() }
|
||||
}
|
||||
|
||||
// Loads support product metadata from App Store.
|
||||
func refreshProducts(showStatusOnFailure: Bool = true) async {
|
||||
guard canUseInAppPurchases else {
|
||||
supportProduct = nil
|
||||
|
|
@ -86,6 +93,7 @@ final class SupportPurchaseManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
// Starts purchase flow for the optional support product.
|
||||
func purchaseSupport() async {
|
||||
guard canUseInAppPurchases else {
|
||||
statusMessage = "In-app purchase is only available in App Store/TestFlight builds."
|
||||
|
|
@ -119,6 +127,7 @@ final class SupportPurchaseManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
// Triggers App Store restore flow and refreshes entitlement state.
|
||||
func restorePurchases() async {
|
||||
guard canUseInAppPurchases else {
|
||||
statusMessage = "Restore is only available in App Store/TestFlight builds."
|
||||
|
|
@ -134,6 +143,7 @@ final class SupportPurchaseManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
// Recomputes support entitlement from current verified transactions.
|
||||
private func refreshSupportEntitlement() async {
|
||||
if canBypassInCurrentBuild && UserDefaults.standard.bool(forKey: bypassDefaultsKey) {
|
||||
hasSupported = true
|
||||
|
|
@ -150,6 +160,7 @@ final class SupportPurchaseManager: ObservableObject {
|
|||
hasSupported = supported
|
||||
}
|
||||
|
||||
// Detects whether this build/environment can use in-app purchases.
|
||||
private func refreshBypassEligibility() async {
|
||||
do {
|
||||
let appTransactionResult = try await AppTransaction.shared
|
||||
|
|
@ -167,6 +178,7 @@ final class SupportPurchaseManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
// Listens for transaction updates and applies verified changes.
|
||||
private func observeTransactionUpdates() -> Task<Void, Never> {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
|
@ -184,6 +196,7 @@ final class SupportPurchaseManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
// Enforces StoreKit verification before using transaction payloads.
|
||||
private func verify<T>(_ result: VerificationResult<T>) throws -> T {
|
||||
switch result {
|
||||
case .verified(let safe):
|
||||
|
|
@ -194,6 +207,7 @@ final class SupportPurchaseManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
///MARK: - StoreKit Errors
|
||||
enum SupportPurchaseError: LocalizedError {
|
||||
case failedVerification
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
//
|
||||
// AIModel.swift
|
||||
// Neon Vision Editor
|
||||
//
|
||||
// Created by Hilthart Pedersen on 06.02.26.
|
||||
//
|
||||
|
||||
|
||||
import Foundation
|
||||
|
|
|
|||
209
Neon Vision Editor/UI/AppUpdaterDialog.swift
Normal file
209
Neon Vision Editor/UI/AppUpdaterDialog.swift
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import SwiftUI
|
||||
|
||||
struct AppUpdaterDialog: View {
|
||||
@EnvironmentObject private var appUpdateManager: AppUpdateManager
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
private var releaseTitle: String {
|
||||
appUpdateManager.latestRelease?.title ?? "Latest Release"
|
||||
}
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
header
|
||||
bodyContent
|
||||
actionRow
|
||||
}
|
||||
.padding(20)
|
||||
.frame(minWidth: 520, idealWidth: 560)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath.circle.fill")
|
||||
.font(.system(size: 38, weight: .semibold))
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(
|
||||
LinearGradient(colors: [Color.blue, Color.cyan], startPoint: .topLeading, endPoint: .bottomTrailing),
|
||||
Color.white.opacity(0.9)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Software Update")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Checks GitHub releases for Neon Vision Editor updates.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var bodyContent: some View {
|
||||
switch appUpdateManager.status {
|
||||
case .idle, .checking:
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Checking for updates…")
|
||||
.font(.headline)
|
||||
Text("Current version: \(appUpdateManager.currentVersion)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
|
||||
case .upToDate:
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label("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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ struct NeonSettingsView: View {
|
|||
let supportsOpenInTabs: Bool
|
||||
let supportsTranslucency: Bool
|
||||
@EnvironmentObject private var supportPurchaseManager: SupportPurchaseManager
|
||||
@EnvironmentObject private var appUpdateManager: AppUpdateManager
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@AppStorage("SettingsOpenInTabs") private var openInTabs: String = "system"
|
||||
@AppStorage("SettingsEditorFontName") private var editorFontName: String = ""
|
||||
|
|
@ -21,6 +22,9 @@ struct NeonSettingsView: View {
|
|||
@AppStorage("SettingsDefaultNewFileLanguage") private var defaultNewFileLanguage: String = "plain"
|
||||
@AppStorage("SettingsConfirmCloseDirtyTab") private var confirmCloseDirtyTab: Bool = true
|
||||
@AppStorage("SettingsConfirmClearEditor") private var confirmClearEditor: Bool = true
|
||||
@AppStorage(AppUpdateManager.autoCheckEnabledKey) private var autoCheckForUpdates: Bool = true
|
||||
@AppStorage(AppUpdateManager.updateIntervalKey) private var updateCheckIntervalRaw: String = AppUpdateCheckInterval.daily.rawValue
|
||||
@AppStorage(AppUpdateManager.autoDownloadEnabledKey) private var autoDownloadUpdates: Bool = false
|
||||
|
||||
@AppStorage("SettingsShowLineNumbers") private var showLineNumbers: Bool = true
|
||||
@AppStorage("SettingsHighlightCurrentLine") private var highlightCurrentLine: Bool = false
|
||||
|
|
@ -45,10 +49,10 @@ struct NeonSettingsView: View {
|
|||
@State private var fontPicker = FontPickerController()
|
||||
#endif
|
||||
|
||||
@State private var grokAPIToken: String = SecureTokenStore.token(for: .grok)
|
||||
@State private var openAIAPIToken: String = SecureTokenStore.token(for: .openAI)
|
||||
@State private var geminiAPIToken: String = SecureTokenStore.token(for: .gemini)
|
||||
@State private var anthropicAPIToken: String = SecureTokenStore.token(for: .anthropic)
|
||||
@State private var grokAPIToken: String = ""
|
||||
@State private var openAIAPIToken: String = ""
|
||||
@State private var geminiAPIToken: String = ""
|
||||
@State private var anthropicAPIToken: String = ""
|
||||
@State private var showSupportPurchaseDialog: Bool = false
|
||||
@State private var availableEditorFonts: [String] = []
|
||||
private let privacyPolicyURL = URL(string: "https://github.com/h3pdesign/Neon-Vision-Editor/blob/main/PRIVACY.md")
|
||||
|
|
@ -151,6 +155,13 @@ struct NeonSettingsView: View {
|
|||
supportTab
|
||||
.tabItem { Label("Support", systemImage: "heart") }
|
||||
.tag("support")
|
||||
#if os(macOS)
|
||||
if ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution {
|
||||
updatesTab
|
||||
.tabItem { Label("Updates", systemImage: "arrow.triangle.2.circlepath.circle") }
|
||||
.tag("updates")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 900, idealWidth: 980, minHeight: 820, idealHeight: 880)
|
||||
|
|
@ -168,6 +179,9 @@ struct NeonSettingsView: View {
|
|||
if supportPurchaseManager.supportProduct == nil {
|
||||
Task { await supportPurchaseManager.refreshStoreState() }
|
||||
}
|
||||
appUpdateManager.setAutoCheckEnabled(autoCheckForUpdates)
|
||||
appUpdateManager.setUpdateInterval(selectedUpdateInterval)
|
||||
appUpdateManager.setAutoDownloadEnabled(autoDownloadUpdates)
|
||||
#if os(macOS)
|
||||
fontPicker.onChange = { selected in
|
||||
useSystemFont = false
|
||||
|
|
@ -198,6 +212,20 @@ struct NeonSettingsView: View {
|
|||
highlightScopeBackground = false
|
||||
}
|
||||
}
|
||||
.onChange(of: autoCheckForUpdates) { _, enabled in
|
||||
appUpdateManager.setAutoCheckEnabled(enabled)
|
||||
}
|
||||
.onChange(of: updateCheckIntervalRaw) { _, _ in
|
||||
appUpdateManager.setUpdateInterval(selectedUpdateInterval)
|
||||
}
|
||||
.onChange(of: autoDownloadUpdates) { _, enabled in
|
||||
appUpdateManager.setAutoDownloadEnabled(enabled)
|
||||
}
|
||||
.onChange(of: settingsActiveTab) { _, newValue in
|
||||
if newValue == "ai" {
|
||||
loadAPITokensIfNeeded()
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Support Neon Vision Editor", isPresented: $showSupportPurchaseDialog, titleVisibility: .visible) {
|
||||
Button("Support \(supportPurchaseManager.supportPriceLabel)") {
|
||||
Task { await supportPurchaseManager.purchaseSupport() }
|
||||
|
|
@ -222,6 +250,13 @@ struct NeonSettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func loadAPITokensIfNeeded() {
|
||||
if grokAPIToken.isEmpty { grokAPIToken = SecureTokenStore.token(for: .grok) }
|
||||
if openAIAPIToken.isEmpty { openAIAPIToken = SecureTokenStore.token(for: .openAI) }
|
||||
if geminiAPIToken.isEmpty { geminiAPIToken = SecureTokenStore.token(for: .gemini) }
|
||||
if anthropicAPIToken.isEmpty { anthropicAPIToken = SecureTokenStore.token(for: .anthropic) }
|
||||
}
|
||||
|
||||
private var preferredColorSchemeOverride: ColorScheme? {
|
||||
ReleaseRuntimePolicy.preferredColorScheme(for: appearance)
|
||||
}
|
||||
|
|
@ -449,6 +484,10 @@ struct NeonSettingsView: View {
|
|||
selectedFontValue = useSystemFont ? systemFontSentinel : (editorFontName.isEmpty ? systemFontSentinel : editorFontName)
|
||||
}
|
||||
|
||||
private var selectedUpdateInterval: AppUpdateCheckInterval {
|
||||
AppUpdateCheckInterval(rawValue: updateCheckIntervalRaw) ?? .daily
|
||||
}
|
||||
|
||||
private var editorTab: some View {
|
||||
settingsContainer(maxWidth: 760) {
|
||||
GroupBox("Editor") {
|
||||
|
|
@ -768,6 +807,63 @@ struct NeonSettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private var updatesTab: some View {
|
||||
settingsContainer(maxWidth: 620) {
|
||||
GroupBox("GitHub Release Updates") {
|
||||
VStack(alignment: .leading, spacing: UI.space12) {
|
||||
Toggle("Automatically check for updates", isOn: $autoCheckForUpdates)
|
||||
|
||||
HStack(alignment: .center, spacing: UI.space12) {
|
||||
Text("Check Interval")
|
||||
.frame(width: isCompactSettingsLayout ? nil : 140, alignment: .leading)
|
||||
Picker("", selection: $updateCheckIntervalRaw) {
|
||||
ForEach(AppUpdateCheckInterval.allCases) { interval in
|
||||
Text(interval.title).tag(interval.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(maxWidth: isCompactSettingsLayout ? .infinity : 220, alignment: .leading)
|
||||
}
|
||||
.disabled(!autoCheckForUpdates)
|
||||
|
||||
Toggle("Automatically install updates when available", isOn: $autoDownloadUpdates)
|
||||
.disabled(!autoCheckForUpdates)
|
||||
|
||||
HStack(spacing: UI.space8) {
|
||||
Button("Check Now") {
|
||||
Task { await appUpdateManager.checkForUpdates(source: .manual) }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
if let checkedAt = appUpdateManager.lastCheckedAt {
|
||||
Text("Last checked: \(checkedAt.formatted(date: .abbreviated, time: .shortened))")
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: UI.space6) {
|
||||
Text("Last check result: \(appUpdateManager.lastCheckResultSummary)")
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if let pausedUntil = appUpdateManager.pausedUntil, pausedUntil > Date() {
|
||||
Text("Auto-check pause active until \(pausedUntil.formatted(date: .abbreviated, time: .shortened)) (\(appUpdateManager.consecutiveFailureCount) consecutive failures).")
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Uses GitHub release assets only. App Store Connect releases are not used by this updater.")
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(UI.groupPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func settingsContainer<Content: View>(maxWidth: CGFloat = 560, @ViewBuilder _ content: () -> Content) -> some View {
|
||||
ScrollView {
|
||||
VStack(alignment: isCompactSettingsLayout ? .leading : .center, spacing: UI.space20) {
|
||||
|
|
|
|||
63
Neon Vision EditorTests/AppUpdateManagerTests.swift
Normal file
63
Neon Vision EditorTests/AppUpdateManagerTests.swift
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import XCTest
|
||||
@testable import Neon_Vision_Editor
|
||||
|
||||
final class AppUpdateManagerTests: XCTestCase {
|
||||
func testHostAllowlistBehavior() {
|
||||
XCTAssertTrue(AppUpdateManager.isTrustedGitHubHost("github.com"))
|
||||
XCTAssertTrue(AppUpdateManager.isTrustedGitHubHost("objects.githubusercontent.com"))
|
||||
XCTAssertTrue(AppUpdateManager.isTrustedGitHubHost("github-releases.githubusercontent.com"))
|
||||
XCTAssertFalse(AppUpdateManager.isTrustedGitHubHost("api.github.com"))
|
||||
XCTAssertFalse(AppUpdateManager.isTrustedGitHubHost("github.com.evil.example"))
|
||||
XCTAssertFalse(AppUpdateManager.isTrustedGitHubHost(nil))
|
||||
}
|
||||
|
||||
func testAssetChooserPrecedence() {
|
||||
let names = [
|
||||
"Neon-Vision-Editor-macOS.zip",
|
||||
"Neon.Vision.Editor.app.zip",
|
||||
"Neon-App-Preview.zip"
|
||||
]
|
||||
XCTAssertEqual(AppUpdateManager.selectPreferredAssetName(from: names), "Neon.Vision.Editor.app.zip")
|
||||
|
||||
let appZipFallback = [
|
||||
"NeonVisionEditor.app.zip",
|
||||
"Neon-Vision-Editor-macOS.zip"
|
||||
]
|
||||
XCTAssertEqual(AppUpdateManager.selectPreferredAssetName(from: appZipFallback), "NeonVisionEditor.app.zip")
|
||||
|
||||
let neonZipFallback = [
|
||||
"Neon-Vision-Editor-macOS.zip",
|
||||
"SomethingElse.zip"
|
||||
]
|
||||
XCTAssertEqual(AppUpdateManager.selectPreferredAssetName(from: neonZipFallback), "Neon-Vision-Editor-macOS.zip")
|
||||
}
|
||||
|
||||
func testSkipVersionBehavior() {
|
||||
XCTAssertTrue(AppUpdateManager.isVersionSkipped("1.2.3", skippedValue: "1.2.3"))
|
||||
XCTAssertFalse(AppUpdateManager.isVersionSkipped("1.2.3", skippedValue: nil))
|
||||
XCTAssertFalse(AppUpdateManager.isVersionSkipped("1.2.3", skippedValue: "1.2.4"))
|
||||
XCTAssertFalse(AppUpdateManager.isVersionSkipped("1.2.3", skippedValue: "v1.2.3"))
|
||||
}
|
||||
|
||||
func testNormalizeVersionStripsPrefixAndPrerelease() {
|
||||
XCTAssertEqual(AppUpdateManager.normalizedVersion(from: "v1.2.3"), "1.2.3")
|
||||
XCTAssertEqual(AppUpdateManager.normalizedVersion(from: "V2.0.0-beta.1"), "2.0.0")
|
||||
}
|
||||
|
||||
func testVersionComparison() {
|
||||
XCTAssertEqual(AppUpdateManager.compareVersions("1.2.0", "1.1.9"), .orderedDescending)
|
||||
XCTAssertEqual(AppUpdateManager.compareVersions("1.2", "1.2.0"), .orderedSame)
|
||||
XCTAssertEqual(AppUpdateManager.compareVersions("1.2.0", "1.2.1"), .orderedAscending)
|
||||
}
|
||||
|
||||
func testStableIsNewerThanPrereleaseWithSameCoreVersion() {
|
||||
XCTAssertEqual(AppUpdateManager.compareVersions("1.2.0-beta.1", "1.2.0"), .orderedAscending)
|
||||
XCTAssertEqual(AppUpdateManager.compareVersions("1.2.0", "1.2.0-beta.1"), .orderedDescending)
|
||||
}
|
||||
|
||||
func testPrereleaseVsStableEdgeCases() {
|
||||
XCTAssertEqual(AppUpdateManager.compareVersions("1.10.0-beta.1", "1.9.9"), .orderedDescending)
|
||||
XCTAssertEqual(AppUpdateManager.compareVersions("v1.2.0", "1.2.0-rc.1"), .orderedDescending)
|
||||
XCTAssertEqual(AppUpdateManager.compareVersions("1.2.0-rc.1", "1.2.0-beta.4"), .orderedSame)
|
||||
}
|
||||
}
|
||||
|
|
@ -82,6 +82,59 @@ The workflow performs:
|
|||
5. Staple ticket
|
||||
6. Zip app and upload to GitHub release asset (`--clobber`)
|
||||
|
||||
## 6b) Self-Hosted Runner Setup (Required when hosted runner lacks Xcode 17+)
|
||||
|
||||
Use this when GitHub-hosted runners only provide Xcode 16.x and release icon requirements need Xcode 17+.
|
||||
|
||||
Where to run:
|
||||
|
||||
- Run on the physical Mac that will be your runner, in Terminal.
|
||||
- Use a dedicated directory (recommended): `~/actions-runner`.
|
||||
- Do not run the runner from the app repository folder.
|
||||
|
||||
Get the correct token:
|
||||
|
||||
- Open: `https://github.com/h3pdesign/Neon-Vision-Editor/settings/actions/runners/new`
|
||||
- Use the short-lived **runner registration token** shown on that page.
|
||||
- Do not use a Personal Access Token for `./config.sh --token`.
|
||||
|
||||
Install and configure:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/actions-runner
|
||||
cd ~/actions-runner
|
||||
curl -o actions-runner-osx-arm64.tar.gz -L <github-runner-download-url-from-page>
|
||||
tar xzf actions-runner-osx-arm64.tar.gz
|
||||
./config.sh --url https://github.com/h3pdesign/Neon-Vision-Editor --token <runner-token-from-page> --labels self-hosted,macOS
|
||||
```
|
||||
|
||||
Start as a service:
|
||||
|
||||
```bash
|
||||
sudo ./svc.sh install
|
||||
sudo ./svc.sh start
|
||||
```
|
||||
|
||||
Verify prerequisites on runner:
|
||||
|
||||
```bash
|
||||
xcodebuild -version
|
||||
xcode-select -p
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Xcode major version `17` or higher.
|
||||
- Runner appears online in repo settings with labels: `self-hosted`, `macOS`.
|
||||
|
||||
Trigger self-hosted notarized release:
|
||||
|
||||
```bash
|
||||
gh workflow run release-notarized-selfhosted.yml -f tag=v0.4.12 -f use_self_hosted=true
|
||||
gh run list --workflow release-notarized-selfhosted.yml --limit 5
|
||||
gh run watch <RUN_ID> --exit-status
|
||||
```
|
||||
|
||||
## 7) Monitor and Inspect
|
||||
|
||||
```bash
|
||||
|
|
|
|||
90
scripts/run_selfhosted_notarized_release.sh
Executable file
90
scripts/run_selfhosted_notarized_release.sh
Executable file
|
|
@ -0,0 +1,90 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Run notarized release using the self-hosted macOS workflow.
|
||||
|
||||
Usage:
|
||||
scripts/run_selfhosted_notarized_release.sh <tag>
|
||||
|
||||
Examples:
|
||||
scripts/run_selfhosted_notarized_release.sh v0.4.12
|
||||
scripts/run_selfhosted_notarized_release.sh 0.4.12
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TAG="$1"
|
||||
if [[ "$TAG" != v* ]]; then
|
||||
TAG="v$TAG"
|
||||
fi
|
||||
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
echo "gh CLI is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! gh auth status >/dev/null 2>&1; then
|
||||
echo "gh is not authenticated. Run: gh auth login" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_SLUG="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')"
|
||||
if [[ -z "$REPO_SLUG" ]]; then
|
||||
echo "Could not resolve repository slug from gh." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag ${TAG} does not exist locally. Create/push tag first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
|
||||
echo "Tag ${TAG} not found on origin. Push tag first: git push origin ${TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RUNNER_LINE="$(
|
||||
gh api "repos/${REPO_SLUG}/actions/runners" \
|
||||
--jq '.runners[] | select(.status == "online") | [.name, ([.labels[].name] | join(","))] | @tsv' \
|
||||
| awk -F '\t' 'index($2, "self-hosted") && index($2, "macOS") { print; exit }'
|
||||
)"
|
||||
|
||||
if [[ -z "$RUNNER_LINE" ]]; then
|
||||
echo "No online self-hosted macOS runner found for ${REPO_SLUG}." >&2
|
||||
echo "Check: https://github.com/${REPO_SLUG}/settings/actions/runners" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RUNNER_NAME="$(echo "$RUNNER_LINE" | awk -F '\t' '{print $1}')"
|
||||
echo "Using online runner: ${RUNNER_NAME}"
|
||||
|
||||
echo "Triggering self-hosted notarized workflow for ${TAG}..."
|
||||
gh workflow run release-notarized-selfhosted.yml -f tag="$TAG" -f use_self_hosted=true
|
||||
|
||||
sleep 6
|
||||
RUN_ID="$(
|
||||
gh run list --workflow release-notarized-selfhosted.yml --limit 20 \
|
||||
--json databaseId,displayTitle \
|
||||
--jq ".[] | select(.displayTitle | contains(\"${TAG}\")) | .databaseId" \
|
||||
| head -n1
|
||||
)"
|
||||
|
||||
if [[ -z "$RUN_ID" ]]; then
|
||||
echo "Could not find self-hosted workflow run for ${TAG}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Watching run ${RUN_ID}..."
|
||||
gh run watch "$RUN_ID" --exit-status
|
||||
|
||||
echo "Verifying published release asset payload..."
|
||||
scripts/ci/verify_release_asset.sh "$TAG"
|
||||
|
||||
echo "Self-hosted notarized release completed for ${TAG}."
|
||||
Loading…
Reference in a new issue